mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-13 04:57:29 +00:00
feat(torrents): add live upload limit control
This commit is contained in:
+13
-1
@@ -6288,6 +6288,18 @@ async fn set_download_speed_limit(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_torrent_upload_limit(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
limit: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.queue_manager
|
||||
.set_aria2_torrent_upload_limit(&id, limit)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
|
||||
let trimmed = limit.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -10611,7 +10623,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_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_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,
|
||||
|
||||
@@ -258,6 +258,16 @@ pub trait SidecarSpawner: Send + Sync + 'static {
|
||||
Err("live aria2 speed limits are unavailable".to_string())
|
||||
}
|
||||
|
||||
/// Change one active BitTorrent transfer's runtime upload cap without
|
||||
/// replacing its GID or queue permit.
|
||||
async fn set_torrent_upload_limit(
|
||||
&self,
|
||||
_gid: &str,
|
||||
_limit: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
Err("live torrent upload limits 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(
|
||||
@@ -797,6 +807,76 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change an active Torrent's upload cap without replacing its GID or
|
||||
/// queue permit. The control lock and post-RPC ownership check fence a
|
||||
/// late response from a terminal or replaced lifecycle.
|
||||
pub async fn set_aria2_torrent_upload_limit(
|
||||
&self,
|
||||
id: &str,
|
||||
limit: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let normalized_limit = match limit.as_deref().map(str::trim) {
|
||||
None | Some("") => None,
|
||||
Some(raw) => Some(
|
||||
crate::normalize_speed_limit_for_aria2(raw)
|
||||
.ok_or_else(|| "invalid torrent upload limit".to_string())?,
|
||||
),
|
||||
};
|
||||
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_upload_limit(&gid, normalized_limit.as_deref())
|
||||
.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 upload limit".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_upload_limit = normalized_limit;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pop the next task, or None if empty.
|
||||
pub async fn pop_front(&self) -> Option<QueuedTask> {
|
||||
self.pending.lock().await.pop_front()
|
||||
@@ -3185,6 +3265,30 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_torrent_upload_limit(
|
||||
&self,
|
||||
gid: &str,
|
||||
limit: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let state = self.app_handle.state::<crate::AppState>();
|
||||
let limit = limit.unwrap_or("0");
|
||||
let result = crate::rpc_call(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret,
|
||||
"aria2.changeOption",
|
||||
serde_json::json!([gid, {"max-upload-limit": 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,
|
||||
|
||||
@@ -15,6 +15,11 @@ struct CountingSpawner {
|
||||
media_calls: AtomicUsize,
|
||||
speed_limit_calls: AtomicUsize,
|
||||
last_speed_limit: std::sync::Mutex<Option<String>>,
|
||||
torrent_upload_limit_calls: AtomicUsize,
|
||||
last_torrent_upload_limit: std::sync::Mutex<Option<String>>,
|
||||
block_torrent_upload_limit: std::sync::atomic::AtomicBool,
|
||||
torrent_upload_limit_started: tokio::sync::Notify,
|
||||
torrent_upload_limit_release: tokio::sync::Notify,
|
||||
add_speed_limits: std::sync::Mutex<Vec<Option<String>>>,
|
||||
block_speed_limit: std::sync::atomic::AtomicBool,
|
||||
speed_limit_started: tokio::sync::Notify,
|
||||
@@ -188,6 +193,11 @@ impl CountingSpawner {
|
||||
media_calls: AtomicUsize::new(0),
|
||||
speed_limit_calls: AtomicUsize::new(0),
|
||||
last_speed_limit: std::sync::Mutex::new(None),
|
||||
torrent_upload_limit_calls: AtomicUsize::new(0),
|
||||
last_torrent_upload_limit: std::sync::Mutex::new(None),
|
||||
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(),
|
||||
add_speed_limits: std::sync::Mutex::new(Vec::new()),
|
||||
block_speed_limit: std::sync::atomic::AtomicBool::new(false),
|
||||
speed_limit_started: tokio::sync::Notify::new(),
|
||||
@@ -286,6 +296,22 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn set_torrent_upload_limit(
|
||||
&self,
|
||||
_gid: &str,
|
||||
limit: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
self.torrent_upload_limit_calls.fetch_add(1, Ordering::SeqCst);
|
||||
*self.last_torrent_upload_limit.lock().unwrap() = limit.map(str::to_string);
|
||||
if self
|
||||
.block_torrent_upload_limit
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
self.torrent_upload_limit_started.notify_one();
|
||||
self.torrent_upload_limit_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(())
|
||||
@@ -543,6 +569,173 @@ async fn live_aria2_speed_limit_does_not_update_payload_after_gid_replacement()
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_torrent_upload_limit_updates_current_gid_and_payload() {
|
||||
let (manager, spawner) = make_manager(1);
|
||||
let manager = Arc::new(manager);
|
||||
let mut task = aria2_task("torrent-upload-limit");
|
||||
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-upload-limit").is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("aria2 dispatch should register a Torrent gid");
|
||||
|
||||
manager
|
||||
.set_aria2_torrent_upload_limit(
|
||||
"torrent-upload-limit",
|
||||
Some("512K".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
spawner.torrent_upload_limit_calls.load(Ordering::SeqCst),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
spawner
|
||||
.last_torrent_upload_limit
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("512K")
|
||||
);
|
||||
manager
|
||||
.set_aria2_torrent_upload_limit("torrent-upload-limit", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
spawner.torrent_upload_limit_calls.load(Ordering::SeqCst),
|
||||
2
|
||||
);
|
||||
assert!(spawner
|
||||
.last_torrent_upload_limit
|
||||
.lock()
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
manager.clear_aria2_retry_state("torrent-upload-limit").await;
|
||||
manager.forget_aria2_gid("torrent-upload-limit").await;
|
||||
manager.release_permit("torrent-upload-limit").await;
|
||||
manager.release_registered_id("torrent-upload-limit").await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_torrent_upload_limit_rejects_non_torrents_and_invalid_values() {
|
||||
let (manager, spawner) = make_manager(1);
|
||||
|
||||
assert!(manager
|
||||
.set_aria2_torrent_upload_limit("missing", Some("512K".to_string()))
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
let manager = Arc::new(manager);
|
||||
let task = aria2_task("ordinary-download");
|
||||
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("ordinary-download").is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("aria2 dispatch should register a gid");
|
||||
|
||||
assert!(manager
|
||||
.set_aria2_torrent_upload_limit("ordinary-download", Some("not-a-rate".to_string()))
|
||||
.await
|
||||
.is_err());
|
||||
assert!(manager
|
||||
.set_aria2_torrent_upload_limit("ordinary-download", Some("512K".to_string()))
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(spawner.torrent_upload_limit_calls.load(Ordering::SeqCst), 0);
|
||||
|
||||
manager
|
||||
.apply_completion(
|
||||
"ordinary-download",
|
||||
firelink_lib::queue::PendingOutcome::Complete,
|
||||
)
|
||||
.await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_torrent_upload_limit_does_not_update_after_gid_replacement() {
|
||||
let (manager, spawner) = make_manager(1);
|
||||
let manager = Arc::new(manager);
|
||||
let mut task = aria2_task("torrent-upload-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-upload-stale").is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("aria2 dispatch should register a Torrent gid");
|
||||
|
||||
spawner
|
||||
.block_torrent_upload_limit
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let started = spawner.torrent_upload_limit_started.notified();
|
||||
let setter = {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move {
|
||||
manager
|
||||
.set_aria2_torrent_upload_limit(
|
||||
"torrent-upload-stale",
|
||||
Some("512K".to_string()),
|
||||
)
|
||||
.await
|
||||
})
|
||||
};
|
||||
timeout(Duration::from_secs(1), started)
|
||||
.await
|
||||
.expect("Torrent upload RPC should start");
|
||||
|
||||
manager
|
||||
.remember_gid(
|
||||
"torrent-upload-stale".to_string(),
|
||||
"gid-replaced".to_string(),
|
||||
)
|
||||
.await;
|
||||
spawner.torrent_upload_limit_release.notify_one();
|
||||
assert!(setter.await.unwrap().is_err());
|
||||
|
||||
manager.clear_aria2_retry_state("torrent-upload-stale").await;
|
||||
manager.forget_aria2_gid("torrent-upload-stale").await;
|
||||
manager.release_permit("torrent-upload-stale").await;
|
||||
manager.release_registered_id("torrent-upload-stale").await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_readds_aria2_with_the_latest_live_speed_limit() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
Reference in New Issue
Block a user