feat(torrents): add live peer controls

This commit is contained in:
NimBold
2026-08-01 22:27:32 +03:30
parent 52082c1e1d
commit ab6c3c372e
18 changed files with 970 additions and 11 deletions
+6
View File
@@ -185,6 +185,12 @@ pub struct DownloadItem {
#[serde(default)]
#[ts(optional)]
pub torrent_upload_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_max_peers: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_peer_speed_limit: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
+14 -1
View File
@@ -6300,6 +6300,19 @@ async fn set_torrent_upload_limit(
.await
}
#[tauri::command]
async fn set_torrent_peer_options(
state: tauri::State<'_, AppState>,
id: String,
max_peers: Option<i64>,
peer_speed_limit: Option<String>,
) -> Result<(), String> {
state
.queue_manager
.set_aria2_torrent_peer_options(&id, max_peers, peer_speed_limit)
.await
}
pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
let trimmed = limit.trim();
if trimmed.is_empty() {
@@ -10679,7 +10692,7 @@ pub fn run() {
authorize_keychain_access,
acknowledge_pairing_token_change,
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path,
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, set_global_speed_limit, remove_download, get_download_primary_path,
detach_download_for_reconfigure,
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
commands::reveal_in_file_manager, commands::open_downloaded_file,
+196
View File
@@ -212,6 +212,8 @@ pub struct SpawnPayload {
pub torrent_seed_time: Option<f64>,
pub torrent_seed_ratio: Option<f64>,
pub torrent_upload_limit: Option<String>,
pub torrent_max_peers: Option<u32>,
pub torrent_peer_speed_limit: Option<String>,
}
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
@@ -268,6 +270,17 @@ pub trait SidecarSpawner: Send + Sync + 'static {
Err("live torrent upload limits are unavailable".to_string())
}
/// Change the peer cap and low-speed peer expansion threshold without
/// replacing the Torrent GID or queue permit.
async fn set_torrent_peer_options(
&self,
_gid: &str,
_max_peers: u32,
_peer_speed_limit: &str,
) -> Result<(), String> {
Err("live torrent peer options are unavailable".to_string())
}
/// Run a media download to completion. The permit is parked for the full
/// duration; release is handled by QueueManager on the runner's exit.
async fn run_media(
@@ -877,6 +890,78 @@ impl<R: tauri::Runtime> QueueManager<R> {
Ok(())
}
/// Change active Torrent peer settings without replacing the GID or queue
/// permit. Clearing a setting restores Aria2's documented default while
/// keeping the persisted payload override-free for future retries.
pub async fn set_aria2_torrent_peer_options(
&self,
id: &str,
max_peers: Option<i64>,
peer_speed_limit: Option<String>,
) -> Result<(), String> {
let normalized_max_peers = normalize_torrent_max_peers(max_peers)?;
let normalized_peer_speed_limit =
normalize_torrent_peer_speed_limit(peer_speed_limit.as_deref())?;
let rpc_max_peers = normalized_max_peers.unwrap_or(ARIA2_DEFAULT_TORRENT_MAX_PEERS);
let rpc_peer_speed_limit = normalized_peer_speed_limit
.as_deref()
.unwrap_or(ARIA2_DEFAULT_TORRENT_PEER_SPEED_LIMIT);
let _control_guard = self.acquire_aria2_control(id).await;
if !self.is_registered(id).await
|| !matches!(self.active_kind(id).await, Some(TaskKind::Aria2))
{
return Err("download is not an active aria2 transfer".to_string());
}
let is_torrent = self
.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.is_torrent);
if !is_torrent {
return Err("download is not a Torrent transfer".to_string());
}
let gid = self
.aria2_gid_for_download(id)
.ok_or_else(|| "active Torrent transfer has no gid".to_string())?;
let expected_mapping = self
.aria2_gid_mapping(&gid)
.ok_or_else(|| "active Torrent transfer has no current gid mapping".to_string())?;
if expected_mapping.id != id {
return Err("aria2 gid belongs to another download".to_string());
}
if !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("active Torrent transfer has a stale control epoch".to_string());
}
self.spawner
.set_torrent_peer_options(&gid, rpc_max_peers, rpc_peer_speed_limit)
.await?;
let still_current = self.is_registered(id).await
&& matches!(self.active_kind(id).await, Some(TaskKind::Aria2))
&& self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
&& self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
&& self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str());
if !still_current {
return Err("Torrent lifecycle changed while setting peer options".to_string());
}
let mut payloads = self.aria2_payloads.lock().await;
let payload = payloads
.get_mut(id)
.ok_or_else(|| "active Torrent transfer payload is unavailable".to_string())?;
payload.torrent_max_peers = normalized_max_peers;
payload.torrent_peer_speed_limit = normalized_peer_speed_limit;
Ok(())
}
/// Pop the next task, or None if empty.
pub async fn pop_front(&self) -> Option<QueuedTask> {
self.pending.lock().await.pop_front()
@@ -2958,6 +3043,9 @@ pub struct ProductionSpawner {
const ARIA2_MIN_SPLIT_SIZE: &str = "1M";
const ARIA2_STREAM_PIECE_SELECTOR: &str = "inorder";
const ARIA2_DEFAULT_TORRENT_MAX_PEERS: u32 = 55;
const ARIA2_DEFAULT_TORRENT_PEER_SPEED_LIMIT: &str = "50K";
const MAX_TORRENT_MAX_PEERS: u32 = 1000;
fn apply_aria2_connection_options(
options: &mut serde_json::Map<String, serde_json::Value>,
@@ -2996,6 +3084,27 @@ fn format_aria2_torrent_number(value: f64, field: &str) -> Result<String, String
Ok(value.to_string())
}
fn normalize_torrent_max_peers(value: Option<i64>) -> Result<Option<u32>, String> {
let Some(value) = value else {
return Ok(None);
};
if !(0..=i64::from(MAX_TORRENT_MAX_PEERS)).contains(&value) {
return Err(format!(
"torrent maximum peers must be between 0 and {MAX_TORRENT_MAX_PEERS}"
));
}
Ok(Some(value as u32))
}
fn normalize_torrent_peer_speed_limit(value: Option<&str>) -> Result<Option<String>, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
crate::normalize_speed_limit_for_aria2(value)
.map(Some)
.ok_or_else(|| "torrent peer speed limit must be greater than zero".to_string())
}
fn apply_aria2_torrent_options(
options: &mut serde_json::Map<String, serde_json::Value>,
payload: &SpawnPayload,
@@ -3033,6 +3142,27 @@ fn apply_aria2_torrent_options(
.ok_or_else(|| "torrent upload limit must be greater than zero".to_string())?;
options.insert("max-upload-limit".to_string(), serde_json::json!(normalized));
}
if let Some(max_peers) = payload.torrent_max_peers {
if max_peers > MAX_TORRENT_MAX_PEERS {
return Err(format!(
"torrent maximum peers must be between 0 and {MAX_TORRENT_MAX_PEERS}"
));
}
options.insert(
"bt-max-peers".to_string(),
serde_json::json!(max_peers.to_string()),
);
}
if let Some(peer_speed_limit) = payload.torrent_peer_speed_limit.as_deref() {
let normalized = normalize_torrent_peer_speed_limit(Some(peer_speed_limit))
?.ok_or_else(|| "torrent peer speed limit must be greater than zero".to_string())?;
options.insert(
"bt-request-peer-speed-limit".to_string(),
serde_json::json!(normalized),
);
}
Ok(())
}
@@ -3289,6 +3419,33 @@ impl SidecarSpawner for ProductionSpawner {
}
}
async fn set_torrent_peer_options(
&self,
gid: &str,
max_peers: u32,
peer_speed_limit: &str,
) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.changeOption",
serde_json::json!([gid, {
"bt-max-peers": max_peers.to_string(),
"bt-request-peer-speed-limit": peer_speed_limit,
}]),
)
.await
.map_err(|error| format!("aria2.changeOption failed for gid {gid}: {error}"))?;
match result.as_str() {
Some("OK") => Ok(()),
Some(value) => Err(format!(
"aria2.changeOption returned unexpected result {value} for gid {gid}"
)),
None => Err("aria2.changeOption returned a non-string result".to_string()),
}
}
async fn recreate_uri(
&self,
id: &str,
@@ -3589,6 +3746,12 @@ pub struct EnqueueItem {
pub torrent_upload_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_max_peers: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_peer_speed_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>,
}
@@ -3634,6 +3797,8 @@ impl EnqueueItem {
torrent_seed_time: self.torrent_seed_time,
torrent_seed_ratio: self.torrent_seed_ratio,
torrent_upload_limit: self.torrent_upload_limit,
torrent_max_peers: self.torrent_max_peers,
torrent_peer_speed_limit: self.torrent_peer_speed_limit,
},
}
}
@@ -3708,6 +3873,8 @@ mod tests {
torrent_seed_time: Some(30.0),
torrent_seed_ratio: Some(1.5),
torrent_upload_limit: Some("2 MiB/s".to_string()),
torrent_max_peers: Some(120),
torrent_peer_speed_limit: Some("2M".to_string()),
..Default::default()
};
@@ -3719,6 +3886,14 @@ mod tests {
options.get("max-upload-limit"),
Some(&serde_json::json!("2M"))
);
assert_eq!(
options.get("bt-max-peers"),
Some(&serde_json::json!("120"))
);
assert_eq!(
options.get("bt-request-peer-speed-limit"),
Some(&serde_json::json!("2M"))
);
assert!(torrent_seeding_requested(&payload));
}
@@ -3751,6 +3926,27 @@ mod tests {
assert!(error.contains("seed time"));
}
#[test]
fn torrent_options_reject_invalid_peer_values() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_max_peers: Some(MAX_TORRENT_MAX_PEERS + 1),
..Default::default()
};
let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err();
assert!(error.contains("maximum peers"));
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_peer_speed_limit: Some("0".to_string()),
..Default::default()
};
let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err();
assert!(error.contains("peer speed limit"));
}
#[tokio::test]
async fn seeding_outcome_keeps_torrent_ownership_and_permit_live() {
let app = tauri::test::mock_builder()
+242
View File
@@ -20,7 +20,13 @@ struct CountingSpawner {
block_torrent_upload_limit: std::sync::atomic::AtomicBool,
torrent_upload_limit_started: tokio::sync::Notify,
torrent_upload_limit_release: tokio::sync::Notify,
torrent_peer_options_calls: AtomicUsize,
last_torrent_peer_options: std::sync::Mutex<Option<(u32, String)>>,
block_torrent_peer_options: std::sync::atomic::AtomicBool,
torrent_peer_options_started: tokio::sync::Notify,
torrent_peer_options_release: tokio::sync::Notify,
add_speed_limits: std::sync::Mutex<Vec<Option<String>>>,
add_peer_options: std::sync::Mutex<Vec<(Option<u32>, Option<String>)>>,
block_speed_limit: std::sync::atomic::AtomicBool,
speed_limit_started: tokio::sync::Notify,
speed_limit_release: tokio::sync::Notify,
@@ -198,7 +204,13 @@ impl CountingSpawner {
block_torrent_upload_limit: std::sync::atomic::AtomicBool::new(false),
torrent_upload_limit_started: tokio::sync::Notify::new(),
torrent_upload_limit_release: tokio::sync::Notify::new(),
torrent_peer_options_calls: AtomicUsize::new(0),
last_torrent_peer_options: std::sync::Mutex::new(None),
block_torrent_peer_options: std::sync::atomic::AtomicBool::new(false),
torrent_peer_options_started: tokio::sync::Notify::new(),
torrent_peer_options_release: tokio::sync::Notify::new(),
add_speed_limits: std::sync::Mutex::new(Vec::new()),
add_peer_options: std::sync::Mutex::new(Vec::new()),
block_speed_limit: std::sync::atomic::AtomicBool::new(false),
speed_limit_started: tokio::sync::Notify::new(),
speed_limit_release: tokio::sync::Notify::new(),
@@ -275,6 +287,13 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
.lock()
.unwrap()
.push(payload.speed_limit.clone());
self.add_peer_options
.lock()
.unwrap()
.push((
payload.torrent_max_peers,
payload.torrent_peer_speed_limit.clone(),
));
Ok(format!("gid-{call}"))
}
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
@@ -312,6 +331,24 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
}
Ok(())
}
async fn set_torrent_peer_options(
&self,
_gid: &str,
max_peers: u32,
peer_speed_limit: &str,
) -> Result<(), String> {
self.torrent_peer_options_calls.fetch_add(1, Ordering::SeqCst);
*self.last_torrent_peer_options.lock().unwrap() =
Some((max_peers, peer_speed_limit.to_string()));
if self
.block_torrent_peer_options
.load(std::sync::atomic::Ordering::SeqCst)
{
self.torrent_peer_options_started.notify_one();
self.torrent_peer_options_release.notified().await;
}
Ok(())
}
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
self.media_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
@@ -736,6 +773,137 @@ async fn live_torrent_upload_limit_does_not_update_after_gid_replacement() {
dispatcher.abort();
}
#[tokio::test]
async fn live_torrent_peer_options_update_current_gid_and_payload() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
let mut task = aria2_task("torrent-peer-options");
task.payload.is_torrent = true;
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("torrent-peer-options").is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aria2 dispatch should register a Torrent gid");
manager
.set_aria2_torrent_peer_options(
"torrent-peer-options",
Some(120),
Some("2M".to_string()),
)
.await
.unwrap();
assert_eq!(spawner.torrent_peer_options_calls.load(Ordering::SeqCst), 1);
assert_eq!(
spawner.last_torrent_peer_options.lock().unwrap().as_ref(),
Some(&(120, "2M".to_string()))
);
manager
.set_aria2_torrent_peer_options("torrent-peer-options", None, None)
.await
.unwrap();
assert_eq!(spawner.torrent_peer_options_calls.load(Ordering::SeqCst), 2);
assert_eq!(
spawner.last_torrent_peer_options.lock().unwrap().as_ref(),
Some(&(55, "50K".to_string()))
);
manager.clear_aria2_retry_state("torrent-peer-options").await;
manager.forget_aria2_gid("torrent-peer-options").await;
manager.release_permit("torrent-peer-options").await;
manager.release_registered_id("torrent-peer-options").await;
dispatcher.abort();
}
#[tokio::test]
async fn live_torrent_peer_options_reject_invalid_values_and_stale_gid() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
let mut task = aria2_task("torrent-peer-options-stale");
task.payload.is_torrent = true;
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if manager
.aria2_gid_for_download("torrent-peer-options-stale")
.is_some()
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aria2 dispatch should register a Torrent gid");
assert!(manager
.set_aria2_torrent_peer_options(
"torrent-peer-options-stale",
Some(1001),
Some("2M".to_string()),
)
.await
.is_err());
assert!(manager
.set_aria2_torrent_peer_options(
"torrent-peer-options-stale",
Some(100),
Some("not-a-rate".to_string()),
)
.await
.is_err());
assert_eq!(spawner.torrent_peer_options_calls.load(Ordering::SeqCst), 0);
spawner
.block_torrent_peer_options
.store(true, std::sync::atomic::Ordering::SeqCst);
let started = spawner.torrent_peer_options_started.notified();
let setter = {
let manager = Arc::clone(&manager);
tokio::spawn(async move {
manager
.set_aria2_torrent_peer_options(
"torrent-peer-options-stale",
Some(100),
Some("2M".to_string()),
)
.await
})
};
timeout(Duration::from_secs(1), started)
.await
.expect("Torrent peer options RPC should start");
manager
.remember_gid(
"torrent-peer-options-stale".to_string(),
"gid-replaced".to_string(),
)
.await;
spawner.torrent_peer_options_release.notify_one();
assert!(setter.await.unwrap().is_err());
manager.clear_aria2_retry_state("torrent-peer-options-stale").await;
manager.forget_aria2_gid("torrent-peer-options-stale").await;
manager.release_permit("torrent-peer-options-stale").await;
manager.release_registered_id("torrent-peer-options-stale").await;
dispatcher.abort();
}
#[tokio::test]
async fn retry_readds_aria2_with_the_latest_live_speed_limit() {
use firelink_lib::queue::PendingOutcome;
@@ -798,6 +966,80 @@ async fn retry_readds_aria2_with_the_latest_live_speed_limit() {
dispatcher.abort();
}
#[tokio::test]
async fn retry_readds_torrent_with_the_latest_live_peer_options() {
use firelink_lib::queue::PendingOutcome;
let (mgr, spawner) = make_manager(1);
let manager = Arc::new(mgr);
let mut task = aria2_task("peer-options-retry");
task.payload.is_torrent = true;
task.payload.max_tries = Some(1);
task.payload.torrent_max_peers = Some(120);
task.payload.torrent_peer_speed_limit = Some("1M".to_string());
manager.push(task).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), async {
loop {
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 1 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("initial Torrent add should run");
manager
.handle_aria2_event(
"gid-1",
PendingOutcome::Error(
"aria2 error code 1: Failed to receive data, cause: protocol error".to_string(),
),
)
.await;
manager
.set_aria2_torrent_peer_options(
"peer-options-retry",
Some(240),
Some("2M".to_string()),
)
.await
.unwrap();
timeout(Duration::from_secs(4), async {
loop {
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 2 {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await
.expect("Torrent retry should re-add after backoff");
assert_eq!(
spawner.add_peer_options.lock().unwrap().as_slice(),
&[
(Some(120), Some("1M".to_string())),
(Some(240), Some("2M".to_string()))
]
);
manager
.clear_aria2_retry_state("peer-options-retry")
.await;
manager
.forget_aria2_gid("peer-options-retry")
.await;
manager.release_permit("peer-options-retry").await;
manager.release_registered_id("peer-options-retry").await;
dispatcher.abort();
}
#[tokio::test]
async fn stale_aria2_permit_candidate_cannot_replace_current_permit() {
let (mgr, _spawner) = make_manager(2);