fix(scheduler): harden scheduled actions and speed limits

- Persist scheduler dispatch markers and retry unacknowledged events across renderer and process restarts.
- Fence queue admission, Torrent moves, and permit activation during system actions with an explicit force path.
- Preserve Aria2 zero-limit overrides and normalize global limits across startup, queued, retry, and live paths.
- Guard scheduler lifecycle races, completion post-actions, settings bindings, and regression coverage.
This commit is contained in:
NimBold
2026-08-12 18:57:31 +03:30
parent 885e3d0100
commit e9ad226b93
22 changed files with 528 additions and 71 deletions
+3
View File
@@ -698,6 +698,9 @@ pub struct PersistedSettings {
pub scheduler_running: bool,
pub scheduler_active_download_ids: Vec<String>,
pub scheduler_last_start_key: String,
#[serde(default)]
#[ts(optional)]
pub scheduler_triggered_start_key: Option<String>,
pub scheduler_last_stop_key: String,
pub last_custom_speed_limit_ki_b: u32,
#[serde(default = "default_speed_limit_unit")]
+25 -8
View File
@@ -6305,12 +6305,17 @@ pub(crate) fn execute_system_action(action: crate::ipc::PostQueueAction) -> Resu
}
#[tauri::command]
fn perform_system_action(
async fn perform_system_action(
caller: tauri::WebviewWindow,
state: tauri::State<'_, AppState>,
action: crate::ipc::PostQueueAction,
force: bool,
) -> Result<(), String> {
properties_window::ensure_main_window(&caller)?;
execute_system_action(action)
state.queue_manager.begin_system_action(force).await?;
let result = execute_system_action(action);
state.queue_manager.end_system_action();
result
}
#[tauri::command]
@@ -6325,6 +6330,7 @@ fn ack_schedule_trigger(
crate::settings::update_settings_state(&app_handle, |state| match action.as_str() {
"start" => {
state.insert("schedulerLastStartKey".to_string(), serde_json::json!(key));
state.insert("schedulerTriggeredStartKey".to_string(), serde_json::json!(""));
}
"stop" => {
state.insert("schedulerLastStopKey".to_string(), serde_json::json!(key));
@@ -6337,6 +6343,7 @@ fn ack_schedule_trigger(
if let Some(settings) = cached.as_mut() {
if action == "start" {
settings.scheduler_last_start_key = key;
settings.scheduler_triggered_start_key = None;
} else {
settings.scheduler_last_stop_key = key;
}
@@ -8593,11 +8600,15 @@ async fn move_torrent_data(
}
if let Some(session_id) = properties_session_id.as_deref() {
properties.with_current_session(caller.label(), session_id, || {
state.queue_manager.begin_torrent_move(&id);
Ok(())
})?;
} else {
state.queue_manager.begin_torrent_move(&id);
}
state.queue_manager.begin_torrent_move(&id).await?;
if let Some(session_id) = properties_session_id.as_deref() {
if let Err(error) = properties.with_current_session(caller.label(), session_id, || Ok(())) {
state.queue_manager.finish_torrent_move(&id);
return Err(error);
}
}
if let Err(error) = write_torrent_move_journal(
&journal,
@@ -8693,6 +8704,7 @@ async fn move_torrent_data(
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
let _ = tokio::fs::remove_file(&journal).await;
state.queue_manager.finish_torrent_move(&id);
let _ = app_handle.emit("download-state", move_restore_event());
return Err("Torrent data changed during relocation".to_string());
}
@@ -8702,6 +8714,7 @@ async fn move_torrent_data(
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
let _ = tokio::fs::remove_file(&journal).await;
state.queue_manager.finish_torrent_move(&id);
let _ = app_handle.emit("download-state", move_restore_event());
return Err("Torrent data changed during relocation".to_string());
}
@@ -9610,9 +9623,13 @@ async fn set_global_speed_limit(
limit: Option<String>,
) -> Result<(), String> {
properties_window::ensure_main_window(&caller)?;
let normalized_limit = limit
.as_deref()
.and_then(normalize_speed_limit_for_aria2);
let normalized_limit = match limit.as_deref().map(str::trim) {
None | Some("") => None,
Some(raw) => Some(
normalize_speed_limit_for_aria2(raw)
.ok_or_else(|| "Global speed limit is invalid".to_string())?,
),
};
let limit_str = normalized_limit.clone().unwrap_or_else(|| "0".to_string());
rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
+121 -15
View File
@@ -52,6 +52,19 @@ pub const DEFAULT_ARIA2_DISK_CACHE: &str = "16M";
pub const MAX_ARIA2_DISK_CACHE_MIB: u64 = 1024;
pub const MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB: u32 = 1_048_576;
/// A per-download zero is different from an empty/global limit: Aria2 uses
/// `0` to remove the item cap, which intentionally lets an item override the
/// daemon-wide limit. Keep that sentinel through payloads, retries, and live
/// GID updates instead of normalizing it to `None`.
fn normalize_download_speed_limit(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed == "0" {
Some("0".to_string())
} else {
crate::normalize_speed_limit_for_aria2(trimmed)
}
}
pub fn normalize_minimum_normal_download_speed_kib(value: u32) -> Result<u32, String> {
if value > MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB {
return Err(format!(
@@ -928,6 +941,10 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
/// Serializes queue-slot selection with global permit acquisition and
/// ownership transitions.
admission_gate: Mutex<()>,
/// Prevents new enqueue/admission work after a system action has passed
/// its final safety check. The flag is set while holding admission_gate so
/// the check and the fence are one state transition.
system_action_pending: AtomicBool,
/// Last queue selected by the dispatcher. Selection starts after this
/// queue when multiple queues have eligible work.
dispatch_cursor: Mutex<Option<String>>,
@@ -942,6 +959,7 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
/// are scoped to the current GID and control epoch and never leave this
/// process as durable state.
torrent_telemetry: Mutex<HashMap<String, TorrentTelemetryState>>,
torrent_moves: StdMutex<HashSet<String>>,
torrent_move_cancellations: StdMutex<HashSet<String>>,
/// aria2 gid -> download id map (shared with the WS poller).
@@ -1034,6 +1052,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
queue_limits: Mutex::new(HashMap::new()),
queue_permit_ownership: Mutex::new(HashMap::new()),
admission_gate: Mutex::new(()),
system_action_pending: AtomicBool::new(false),
dispatch_cursor: Mutex::new(None),
target_capacity: AtomicUsize::new(capacity),
slots_to_retire: AtomicUsize::new(0),
@@ -1045,6 +1064,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
}),
seed_budgets: StdMutex::new(HashMap::new()),
torrent_telemetry: Mutex::new(HashMap::new()),
torrent_moves: StdMutex::new(HashSet::new()),
torrent_move_cancellations: StdMutex::new(HashSet::new()),
aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())),
pending_completion: Arc::new(Mutex::new(HashMap::new())),
@@ -1135,11 +1155,20 @@ impl<R: tauri::Runtime> QueueManager<R> {
true
}
pub fn begin_torrent_move(&self, id: &str) {
pub async fn begin_torrent_move(&self, id: &str) -> Result<(), String> {
let _admission_gate = self.admission_gate.lock().await;
if self.system_action_pending.load(Ordering::Acquire) {
return Err("System action is already being performed".to_string());
}
self.torrent_moves
.lock()
.expect("Torrent move lock poisoned")
.insert(id.to_string());
self.torrent_move_cancellations
.lock()
.expect("Torrent move cancellation lock poisoned")
.remove(id);
Ok(())
}
pub fn cancel_torrent_move(&self, id: &str) {
@@ -1157,12 +1186,24 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
pub fn finish_torrent_move(&self, id: &str) {
self.torrent_moves
.lock()
.expect("Torrent move lock poisoned")
.remove(id);
self.torrent_move_cancellations
.lock()
.expect("Torrent move cancellation lock poisoned")
.remove(id);
}
pub fn has_torrent_moves(&self) -> bool {
!self
.torrent_moves
.lock()
.expect("Torrent move lock poisoned")
.is_empty()
}
/// Drop counters after terminal cleanup/removal. Persisted lifetime
/// totals remain owned by the DownloadItem row; this only removes raw
/// process-local lifecycle state.
@@ -1737,6 +1778,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
mut task: QueuedTask,
generation: u64,
) -> Result<(), String> {
let _admission_gate = self.admission_gate.lock().await;
if self.system_action_pending.load(Ordering::Acquire) {
return Err("System action is already being performed".to_string());
}
let id = task.id.clone();
let cancellations = self.enqueue_cancellations.lock().await;
if cancellations
@@ -2391,22 +2436,22 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
pub async fn aria2_speed_limited(&self, id: &str) -> bool {
if self
.aria2_global_speed_limit
.lock()
.unwrap_or_else(|error| error.into_inner())
.is_some()
{
return true;
}
self.aria2_payloads
let item_limit = self
.aria2_payloads
.lock()
.await
.get(id)
.and_then(|payload| payload.speed_limit.as_deref())
.and_then(crate::normalize_speed_limit_for_aria2)
.is_some()
.and_then(normalize_download_speed_limit);
if item_limit.as_deref() == Some("0") {
return false;
}
item_limit.is_some()
|| self
.aria2_global_speed_limit
.lock()
.unwrap_or_else(|error| error.into_inner())
.is_some()
}
/// Change an active aria2 transfer's speed cap without replacing its GID
@@ -2421,7 +2466,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
let normalized_limit = match limit.as_deref().map(str::trim) {
None | Some("") => None,
Some(raw) => Some(
crate::normalize_speed_limit_for_aria2(raw)
normalize_download_speed_limit(raw)
.ok_or_else(|| "invalid download speed limit".to_string())?,
),
};
@@ -3004,6 +3049,9 @@ impl<R: tauri::Runtime> QueueManager<R> {
lifecycle_generation: u64,
) -> Option<OwnedSemaphorePermit> {
let _admission_gate = self.admission_gate.lock().await;
if self.system_action_pending.load(Ordering::Acquire) {
return None;
}
let mut ownership = self.queue_permit_ownership.lock().await;
if ownership.contains_key(id)
|| self.active_permits.lock().await.contains_key(id)
@@ -3063,6 +3111,21 @@ impl<R: tauri::Runtime> QueueManager<R> {
permit: OwnedSemaphorePermit,
) -> bool {
let _admission_gate = self.admission_gate.lock().await;
if self.system_action_pending.load(Ordering::Acquire) {
let removed = self
.queue_permit_ownership
.lock()
.await
.get(id)
.is_some_and(|entry| {
entry.lifecycle_generation == lifecycle_generation && !entry.active
});
if removed {
self.queue_permit_ownership.lock().await.remove(id);
self.notify.notify_waiters();
}
return false;
}
let mut ownership = self.queue_permit_ownership.lock().await;
let owned = ownership
.get(id)
@@ -3090,6 +3153,9 @@ impl<R: tauri::Runtime> QueueManager<R> {
async fn try_admit_next_task(&self) -> Option<(OwnedSemaphorePermit, QueuedTask)> {
let _admission_gate = self.admission_gate.lock().await;
if self.system_action_pending.load(Ordering::Acquire) {
return None;
}
let mut pending = self.pending.lock().await;
if pending.is_empty() {
return None;
@@ -3160,6 +3226,9 @@ impl<R: tauri::Runtime> QueueManager<R> {
permit: OwnedSemaphorePermit,
) {
let _admission_gate = self.admission_gate.lock().await;
if self.system_action_pending.load(Ordering::Acquire) {
return;
}
self.active_permits
.lock()
.await
@@ -3203,6 +3272,18 @@ impl<R: tauri::Runtime> QueueManager<R> {
.get(id)
.copied();
let mut ownership = self.queue_permit_ownership.lock().await;
if self.system_action_pending.load(Ordering::Acquire) {
let remove_reservation = ownership.get(id).is_some_and(|entry| {
entry.queue_id == queue_id
&& entry.lifecycle_generation == lifecycle_generation
&& !entry.active
});
if remove_reservation {
ownership.remove(id);
self.notify.notify_waiters();
}
return false;
}
let has_matching_reservation = ownership.get(id).is_some_and(|entry| {
entry.queue_id == queue_id
&& entry.lifecycle_generation == lifecycle_generation
@@ -3362,6 +3443,31 @@ impl<R: tauri::Runtime> QueueManager<R> {
self.active_permits.lock().await.contains_key(id)
}
/// Atomically fence new transfer admission after checking all backend-owned
/// work. The frontend performs the same check for a useful user message,
/// but this backend transition closes the check-to-action race.
pub async fn begin_system_action(&self, force: bool) -> Result<(), String> {
let _admission_gate = self.admission_gate.lock().await;
if self.system_action_pending.load(Ordering::Acquire) {
return Err("Another system action is already pending".to_string());
}
if !force
&& (!self.pending.lock().await.is_empty()
|| !self.queue_permit_ownership.lock().await.is_empty()
|| !self.active_permits.lock().await.is_empty()
|| self.has_torrent_moves())
{
return Err("System action was skipped because downloads are still active or queued".to_string());
}
self.system_action_pending.store(true, Ordering::Release);
Ok(())
}
pub fn end_system_action(&self) {
self.system_action_pending.store(false, Ordering::Release);
self.notify.notify_waiters();
}
/// Clear all permits belonging to aria2. Useful when aria2 WS connection drops.
pub async fn clear_aria2_permits(&self) {
let ids_to_fail: Vec<String> = {
@@ -6532,7 +6638,7 @@ impl SidecarSpawner for ProductionSpawner {
if let Some(speed) = payload
.speed_limit
.as_deref()
.and_then(crate::normalize_speed_limit_for_aria2)
.and_then(normalize_download_speed_limit)
{
options.insert("max-download-limit".to_string(), serde_json::json!(speed));
}
+109 -5
View File
@@ -16,13 +16,14 @@ fn stop_is_due(
stop_minute: Option<u32>,
current_minute: u32,
last_start_key: &str,
triggered_start_key: &str,
start_key: &str,
last_stop_key: &str,
stop_key: &str,
) -> bool {
stop_time_enabled
&& stop_minute.is_some_and(|stop| current_minute >= stop)
&& last_start_key == start_key
&& (last_start_key == start_key || triggered_start_key == start_key)
&& last_stop_key != stop_key
}
@@ -33,6 +34,7 @@ struct OvernightStopCheck<'a> {
current_minute: u32,
previous_day_allowed: bool,
last_start_key: &'a str,
triggered_start_key: &'a str,
previous_start_key: &'a str,
last_stop_key: &'a str,
stop_key: &'a str,
@@ -46,6 +48,7 @@ fn overnight_stop_is_due(check: OvernightStopCheck<'_>) -> bool {
current_minute,
previous_day_allowed,
last_start_key,
triggered_start_key,
previous_start_key,
last_stop_key,
stop_key,
@@ -55,10 +58,31 @@ fn overnight_stop_is_due(check: OvernightStopCheck<'_>) -> bool {
&& start_minute.zip(stop_minute).is_some_and(|(start, stop)| {
stop < start && current_minute >= stop && current_minute < start
})
&& last_start_key == previous_start_key
&& (last_start_key == previous_start_key || triggered_start_key == previous_start_key)
&& last_stop_key != stop_key
}
fn persist_scheduler_start_trigger(
app_handle: &tauri::AppHandle,
settings_cache: &Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
key: &str,
) {
if let Err(error) = crate::settings::update_settings_state(app_handle, |state| {
state.insert(
"schedulerTriggeredStartKey".to_string(),
serde_json::json!(key),
);
}) {
log::warn!("Failed to persist scheduler start trigger: {error}");
}
if let Ok(mut settings) = settings_cache.write() {
if let Some(settings) = settings.as_mut() {
settings.scheduler_triggered_start_key = Some(key.to_string());
}
}
}
pub fn spawn_scheduler(
app_handle: tauri::AppHandle,
settings_cache: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
@@ -66,6 +90,11 @@ pub fn spawn_scheduler(
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
let mut last_emit: HashMap<&'static str, std::time::Instant> = HashMap::new();
// Renderer acknowledgement remains the durable completion record, but
// a native dispatch marker also survives a closed/unmounted webview so
// an overnight stop does not become permanently ineligible. The
// process-local start key also covers the same-loop event/stop check.
let mut triggered_start_key = String::new();
loop {
interval.tick().await;
@@ -74,11 +103,21 @@ pub fn spawn_scheduler(
(
settings.scheduler.clone(),
settings.scheduler_last_start_key.clone(),
settings
.scheduler_triggered_start_key
.clone()
.unwrap_or_default(),
settings.scheduler_last_stop_key.clone(),
)
})
});
if let Some((scheduler, scheduler_last_start_key, scheduler_last_stop_key)) = settings {
if let Some((
scheduler,
scheduler_last_start_key,
persisted_triggered_start_key,
scheduler_last_stop_key,
)) = settings
{
if !scheduler.enabled {
continue;
}
@@ -108,13 +147,29 @@ pub fn spawn_scheduler(
.get("start")
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
{
let _ = app_handle.emit(
if persisted_triggered_start_key != start_key
&& triggered_start_key != start_key
{
// Record the dispatch intent before emitting so a
// crash between the native event and renderer ack
// still makes an overnight stop eligible. Start
// events remain retryable until the renderer acks
// them, which covers startup/listener races.
persist_scheduler_start_trigger(
&app_handle,
&settings_cache,
&start_key,
);
}
if app_handle.emit(
"schedule-trigger",
serde_json::json!({
"action": "start",
"key": start_key
}),
);
).is_ok() {
triggered_start_key = start_key.clone();
}
last_emit.insert("start", std::time::Instant::now());
}
@@ -125,6 +180,13 @@ pub fn spawn_scheduler(
stop_minute,
current_minute,
&scheduler_last_start_key,
if triggered_start_key == start_key {
start_key.as_str()
} else if persisted_triggered_start_key == start_key {
start_key.as_str()
} else {
""
},
&start_key,
&scheduler_last_stop_key,
&stop_key,
@@ -146,6 +208,13 @@ pub fn spawn_scheduler(
current_minute,
previous_day_allowed,
last_start_key: &scheduler_last_start_key,
triggered_start_key: if triggered_start_key == previous_start_key {
previous_start_key.as_str()
} else if persisted_triggered_start_key == previous_start_key {
previous_start_key.as_str()
} else {
""
},
previous_start_key: &previous_start_key,
last_stop_key: &scheduler_last_stop_key,
stop_key: &stop_key,
@@ -195,6 +264,7 @@ mod tests {
Some(480),
600,
"",
"",
"2026-06-22-start",
"",
"2026-06-22-stop",
@@ -204,12 +274,43 @@ mod tests {
Some(480),
600,
"2026-06-22-start",
"",
"2026-06-22-start",
"",
"2026-06-22-stop",
));
}
#[test]
fn stop_accepts_process_local_start_when_renderer_ack_is_missing() {
assert!(stop_is_due(
true,
Some(480),
600,
"",
"2026-06-22-start",
"2026-06-22-start",
"",
"2026-06-22-stop",
));
}
#[test]
fn overnight_stop_accepts_persisted_start_trigger_when_app_restarts() {
assert!(overnight_stop_is_due(OvernightStopCheck {
stop_time_enabled: true,
start_minute: Some(1320),
stop_minute: Some(360),
current_minute: 420,
previous_day_allowed: true,
last_start_key: "",
triggered_start_key: "2026-06-22-start",
previous_start_key: "2026-06-22-start",
last_stop_key: "",
stop_key: "2026-06-23-stop",
}));
}
#[test]
fn overnight_stop_uses_the_previous_day_start() {
assert!(overnight_stop_is_due(OvernightStopCheck {
@@ -219,6 +320,7 @@ mod tests {
current_minute: 420,
previous_day_allowed: true,
last_start_key: "2026-06-22-start",
triggered_start_key: "",
previous_start_key: "2026-06-22-start",
last_stop_key: "",
stop_key: "2026-06-23-stop",
@@ -230,6 +332,7 @@ mod tests {
current_minute: 1380,
previous_day_allowed: true,
last_start_key: "2026-06-22-start",
triggered_start_key: "",
previous_start_key: "2026-06-22-start",
last_stop_key: "",
stop_key: "2026-06-22-stop",
@@ -241,6 +344,7 @@ mod tests {
current_minute: 420,
previous_day_allowed: false,
last_start_key: "2026-06-22-start",
triggered_start_key: "",
previous_start_key: "2026-06-22-start",
last_stop_key: "",
stop_key: "2026-06-23-stop",
+27 -1
View File
@@ -271,7 +271,11 @@ pub fn preserve_scheduler_runtime_keys(
};
let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?;
let incoming_state = settings_state_mut(&mut incoming_document)?;
for key in ["schedulerLastStartKey", "schedulerLastStopKey"] {
for key in [
"schedulerLastStartKey",
"schedulerTriggeredStartKey",
"schedulerLastStopKey",
] {
if let Some(value) = existing_state.get(key) {
incoming_state.insert(key.to_string(), value.clone());
}
@@ -606,6 +610,8 @@ fn validate_settings(settings: &mut PersistedSettings) {
settings.minimum_normal_download_speed_ki_b,
)
.unwrap_or_default();
settings.global_speed_limit = crate::normalize_speed_limit_for_aria2(&settings.global_speed_limit)
.unwrap_or_default();
settings.torrent_overall_upload_limit = crate::normalize_speed_limit_for_aria2(
&settings.torrent_overall_upload_limit,
)
@@ -875,6 +881,7 @@ fn default_settings() -> PersistedSettings {
scheduler_running: false,
scheduler_active_download_ids: Vec::new(),
scheduler_last_start_key: String::new(),
scheduler_triggered_start_key: None,
scheduler_last_stop_key: String::new(),
last_custom_speed_limit_ki_b: 1024,
last_custom_speed_limit_unit: "MB/s".to_string(),
@@ -940,6 +947,7 @@ mod tests {
let existing = json!({
"state": {
"schedulerLastStartKey": "2026-06-22-start",
"schedulerTriggeredStartKey": "2026-06-22-start",
"schedulerLastStopKey": "2026-06-22-stop"
},
"version": 3
@@ -948,6 +956,7 @@ mod tests {
let incoming = json!({
"state": {
"schedulerLastStartKey": "",
"schedulerTriggeredStartKey": "",
"schedulerLastStopKey": "",
"theme": "system"
},
@@ -958,6 +967,10 @@ mod tests {
let merged = preserve_scheduler_runtime_keys(Some(&existing), &incoming).unwrap();
let merged: Value = serde_json::from_str(&merged).unwrap();
assert_eq!(merged["state"]["schedulerLastStartKey"], "2026-06-22-start");
assert_eq!(
merged["state"]["schedulerTriggeredStartKey"],
"2026-06-22-start"
);
assert_eq!(merged["state"]["schedulerLastStopKey"], "2026-06-22-stop");
}
@@ -1051,6 +1064,19 @@ mod tests {
assert!(settings.torrent_overall_upload_limit.is_empty());
}
#[test]
fn normalizes_invalid_global_speed_limit_to_unlimited() {
let stored = json!({
"state": {
"globalSpeedLimit": "not-a-rate"
}
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert!(settings.global_speed_limit.is_empty());
}
#[test]
fn migrates_legacy_location_settings_and_preserves_custom_overrides() {
let stored = json!({
+75
View File
@@ -558,6 +558,81 @@ async fn live_aria2_speed_limit_updates_the_current_gid_and_payload() {
dispatcher.abort();
}
#[tokio::test]
async fn explicit_zero_download_limit_overrides_the_global_limit_and_survives_admission() {
let (manager, spawner) = make_manager(1);
let manager = Arc::new(manager);
manager.set_aria2_global_speed_limit(Some("2M".to_string()));
let mut task = aria2_task("speed-unlimited-override");
task.payload.speed_limit = Some("0".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 manager
.aria2_gid_for_download("speed-unlimited-override")
.is_some()
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aria2 dispatch should register a gid");
assert_eq!(
spawner.add_speed_limits.lock().unwrap().as_slice(),
&[Some("0".to_string())]
);
assert!(!manager
.aria2_speed_limited("speed-unlimited-override")
.await);
manager
.apply_completion(
"speed-unlimited-override",
firelink_lib::queue::PendingOutcome::Complete,
)
.await;
dispatcher.abort();
}
#[tokio::test]
async fn system_action_fence_rejects_new_work_and_force_bypasses_only_firelink_safety() {
let (manager, _spawner) = make_manager(1);
let mut queued = aria2_task("queued-before-action");
manager.push(queued.clone()).await.unwrap();
assert!(manager.begin_system_action(false).await.is_err());
assert!(manager.remove_from_pending("queued-before-action").await);
assert!(manager.ensure_aria2_permit("active-before-action").await);
assert!(manager.begin_system_action(false).await.is_err());
manager.release_permit("active-before-action").await;
manager
.begin_torrent_move("moving-before-action")
.await
.unwrap();
assert!(manager.begin_system_action(false).await.is_err());
manager.finish_torrent_move("moving-before-action");
manager.begin_system_action(true).await.unwrap();
let candidate = manager.acquire_aria2_permit_candidate().await.unwrap();
assert!(!manager
.park_aria2_permit_if_missing("candidate-during-action", candidate)
.await);
assert!(!manager.has_active_permit("candidate-during-action").await);
queued.id = "queued-during-action".to_string();
assert!(manager.push(queued).await.is_err());
manager.end_system_action();
manager.push(aria2_task("queued-after-action")).await.unwrap();
}
#[tokio::test]
async fn live_aria2_speed_limit_rejects_invalid_and_non_active_requests() {
let (manager, spawner) = make_manager(1);