mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-19 07:36:17 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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);
|
||||
|
||||
+78
-35
@@ -42,6 +42,7 @@ import { formatDownloadBytes } from './utils/downloadProgress';
|
||||
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
|
||||
import { createMainWindowSizePersistence } from './utils/mainWindowState';
|
||||
import type { MainWindowSize } from './bindings/MainWindowSize';
|
||||
import { beginSchedulerControl, isSchedulerControlCurrent } from './utils/schedulerControl';
|
||||
|
||||
const loadSettingsView = () => import('./components/SettingsView');
|
||||
const loadSchedulerView = () => import('./components/SchedulerView');
|
||||
@@ -106,7 +107,6 @@ const PageLoadingFallback = () => {
|
||||
};
|
||||
|
||||
let automaticUpdateCheckStarted = false;
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
let powerPreferencesSync: Promise<void> = Promise.resolve();
|
||||
|
||||
const waitForSettingsHydration = (): Promise<void> => {
|
||||
@@ -264,7 +264,6 @@ function App() {
|
||||
const preventsDisplaySleepWhileDownloading = useSettingsStore(
|
||||
state => state.preventsDisplaySleepWhileDownloading
|
||||
);
|
||||
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
const { addToast, removeToast } = useToast();
|
||||
const isMacUserAgent = navigator.userAgent.includes('Mac');
|
||||
const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent);
|
||||
@@ -314,6 +313,55 @@ function App() {
|
||||
const actionLabel = t($ => $.scheduler.postActions[action]);
|
||||
let timerId: number | null = null;
|
||||
let toastId: string | null = null;
|
||||
const showForceActionToast = () => {
|
||||
let forceToastId: string | null = null;
|
||||
const proceed = () => {
|
||||
if (forceToastId !== null) {
|
||||
removeToast(forceToastId);
|
||||
forceToastId = null;
|
||||
}
|
||||
invoke('perform_system_action', { action, force: true }).catch(error => {
|
||||
console.error('Forced scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: t($ => $.app.systemActionFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
};
|
||||
forceToastId = addToast({
|
||||
variant: 'warning',
|
||||
isActionable: true,
|
||||
duration: 0,
|
||||
message: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{t($ => $.app.systemActionCancelled)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-2 py-1"
|
||||
onClick={proceed}
|
||||
>
|
||||
{t($ => $.app.systemActionProceedAnyway)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
};
|
||||
const perform = (force: boolean) => {
|
||||
invoke('perform_system_action', { action, force }).catch(error => {
|
||||
const detail = String(error);
|
||||
if (!force && detail.includes('active or queued')) {
|
||||
showForceActionToast();
|
||||
return;
|
||||
}
|
||||
console.error('Scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: t($ => $.app.systemActionFailed, { detail }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
};
|
||||
const cancel = () => {
|
||||
clearPendingPostActionTimer();
|
||||
timerId = null;
|
||||
@@ -355,24 +403,13 @@ function App() {
|
||||
isActiveDownloadStatus(download.status)
|
||||
);
|
||||
if (activeTransfers) {
|
||||
addToast({
|
||||
message: t($ => $.app.systemActionCancelled),
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
showForceActionToast();
|
||||
return;
|
||||
}
|
||||
invoke('perform_system_action', { action }).catch(error => {
|
||||
console.error('Scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: t($ => $.app.systemActionFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
perform(false);
|
||||
}, 10_000);
|
||||
pendingPostActionTimer.current = timerId;
|
||||
}, [addToast, clearPendingPostActionTimer, removeToast]);
|
||||
}, [addToast, clearPendingPostActionTimer, removeToast, t]);
|
||||
|
||||
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -402,12 +439,6 @@ function App() {
|
||||
return clearPendingPostActionTimer;
|
||||
}, [clearPendingPostActionTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTransferCount > 0) {
|
||||
clearPendingPostActionTimer();
|
||||
}
|
||||
}, [activeTransferCount, clearPendingPostActionTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
initMediaDomains();
|
||||
window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth));
|
||||
@@ -841,6 +872,11 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
// Scope duplicate suppression to this listener instance. A module-level
|
||||
// set can retain a key across a webview/listener restart while the old
|
||||
// async handler is still unwinding, causing the replacement listener to
|
||||
// drop the only retry for that scheduled action.
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
const unlisten = listen('schedule-trigger', async (event) => {
|
||||
const state = useSettingsStore.getState();
|
||||
const payload = event.payload;
|
||||
@@ -848,6 +884,7 @@ function App() {
|
||||
processingScheduleKeys.add(payload.key);
|
||||
try {
|
||||
if (payload.action === 'start') {
|
||||
const generation = beginSchedulerControl();
|
||||
clearPendingPostActionTimer();
|
||||
const scheduledQueueIds = getScheduledQueueIds();
|
||||
if (scheduledQueueIds.length === 0) {
|
||||
@@ -866,6 +903,13 @@ function App() {
|
||||
scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
);
|
||||
const acceptedIds = startedResults.flat();
|
||||
if (!isSchedulerControlCurrent(generation)) {
|
||||
await Promise.allSettled(
|
||||
acceptedIds.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
||||
return;
|
||||
}
|
||||
const scheduledQueueSet = new Set(scheduledQueueIds);
|
||||
const trackedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
@@ -879,14 +923,18 @@ function App() {
|
||||
state.setSchedulerRunning(activeIds.length > 0);
|
||||
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
||||
} else if (payload.action === 'stop') {
|
||||
const generation = beginSchedulerControl();
|
||||
// A stop event can race with the completion effect's post-action
|
||||
// countdown after it has already cleared the tracked IDs. Always
|
||||
// cancel that pending action before applying the stop transition.
|
||||
clearPendingPostActionTimer();
|
||||
const trackedIds = state.schedulerActiveDownloadIds;
|
||||
if (trackedIds.length > 0) {
|
||||
clearPendingPostActionTimer();
|
||||
const pauseResults = await Promise.allSettled(
|
||||
trackedIds.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
|
||||
if (failedPauses > 0) {
|
||||
if (failedPauses > 0 && isSchedulerControlCurrent(generation)) {
|
||||
addToast({
|
||||
message: failedPauses === 1
|
||||
? t($ => $.app.schedulerPauseOneFailed)
|
||||
@@ -896,8 +944,10 @@ function App() {
|
||||
});
|
||||
}
|
||||
}
|
||||
state.setSchedulerActiveDownloadIds([]);
|
||||
state.setSchedulerRunning(false);
|
||||
if (isSchedulerControlCurrent(generation)) {
|
||||
state.setSchedulerActiveDownloadIds([]);
|
||||
state.setSchedulerRunning(false);
|
||||
}
|
||||
await invoke('ack_schedule_trigger', { action: 'stop', key: payload.key });
|
||||
}
|
||||
} finally {
|
||||
@@ -906,6 +956,7 @@ function App() {
|
||||
});
|
||||
|
||||
return () => {
|
||||
beginSchedulerControl();
|
||||
unlisten.then(f => f()).catch(console.error);
|
||||
};
|
||||
}, [addToast, clearPendingPostActionTimer, coreReady]);
|
||||
@@ -929,15 +980,7 @@ function App() {
|
||||
isActionable: true
|
||||
});
|
||||
} else if (settings.scheduler.postQueueAction !== 'none') {
|
||||
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
|
||||
addToast({
|
||||
message: t($ => $.app.scheduledActionSkippedActive),
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
} else {
|
||||
schedulePostQueueAction(settings.scheduler.postQueueAction);
|
||||
}
|
||||
schedulePostQueueAction(settings.scheduler.postQueueAction);
|
||||
}
|
||||
}, [
|
||||
addToast,
|
||||
|
||||
@@ -12,4 +12,4 @@ import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
import type { WindowControlStyle } from "./WindowControlStyle";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, isFoldersCollapsed: boolean, mainWindowSize?: MainWindowSize, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, isFoldersCollapsed: boolean, mainWindowSize?: MainWindowSize, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerTriggeredStartKey?: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useToast } from '../contexts/ToastContext';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDateTime } from '../utils/dateTime';
|
||||
import { beginSchedulerControl, isSchedulerControlCurrent } from '../utils/schedulerControl';
|
||||
|
||||
const days = [
|
||||
{ value: 0, key: 'su' },
|
||||
@@ -30,7 +31,8 @@ const postActions: { value: PostQueueAction; icon: typeof Moon }[] = [
|
||||
{ value: 'shutdown', icon: Power },
|
||||
];
|
||||
|
||||
const minuteOfDay = (value: string) => {
|
||||
const minuteOfDay = (value: string): number | null => {
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return null;
|
||||
const [hour, minute] = value.split(':').map(Number);
|
||||
return hour * 60 + minute;
|
||||
};
|
||||
@@ -38,7 +40,10 @@ const minuteOfDay = (value: string) => {
|
||||
function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'none' {
|
||||
if (!settings.enabled) return 'disabled';
|
||||
|
||||
const [hour, minute] = settings.startTime.split(':').map(Number);
|
||||
const startMinute = minuteOfDay(settings.startTime);
|
||||
if (startMinute === null) return 'none';
|
||||
const hour = Math.floor(startMinute / 60);
|
||||
const minute = startMinute % 60;
|
||||
const now = new Date();
|
||||
|
||||
for (let offset = 0; offset < 8; offset += 1) {
|
||||
@@ -136,7 +141,13 @@ export default function SchedulerView() {
|
||||
addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) === minuteOfDay(draft.startTime)) {
|
||||
const startMinute = minuteOfDay(draft.startTime);
|
||||
const stopMinute = minuteOfDay(draft.stopTime);
|
||||
if (draft.enabled && (startMinute === null || (draft.stopTimeEnabled && stopMinute === null))) {
|
||||
addToast({ message: t($ => $.scheduler.validationTime), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (draft.enabled && draft.stopTimeEnabled && stopMinute === startMinute) {
|
||||
addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
@@ -151,11 +162,18 @@ export default function SchedulerView() {
|
||||
};
|
||||
|
||||
const runNow = async () => {
|
||||
const generation = beginSchedulerControl();
|
||||
const previouslyTrackedIds = new Set(useSettingsStore.getState().schedulerActiveDownloadIds);
|
||||
const results = await Promise.all(
|
||||
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
);
|
||||
const acceptedIds = results.flat();
|
||||
if (!isSchedulerControlCurrent(generation)) {
|
||||
await Promise.allSettled(
|
||||
acceptedIds.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
return;
|
||||
}
|
||||
const selectedQueueSet = new Set(effectiveSelectedQueueIds);
|
||||
const trackedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
@@ -180,9 +198,11 @@ export default function SchedulerView() {
|
||||
};
|
||||
|
||||
const pauseNow = async () => {
|
||||
const generation = beginSchedulerControl();
|
||||
const counts = await Promise.all(
|
||||
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
|
||||
);
|
||||
if (!isSchedulerControlCurrent(generation)) return;
|
||||
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
|
||||
useSettingsStore.getState().setSchedulerRunning(false);
|
||||
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
|
||||
|
||||
@@ -196,6 +196,7 @@ const common = {
|
||||
linuxActionsDescription: 'Sleep, restart, and shut down use your Linux desktop and system policy. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.',
|
||||
validationDay: 'Select at least one day for the scheduler',
|
||||
validationQueue: 'Select at least one queue for the scheduler',
|
||||
validationTime: 'Enter valid times in HH:MM format',
|
||||
validationStopTime: 'Stop time must be later than start time',
|
||||
saved: 'Scheduler settings saved',
|
||||
trackingOne: 'Tracking 1 scheduled download',
|
||||
@@ -513,6 +514,7 @@ const common = {
|
||||
settingsSaveFailed: 'Could not save settings. Check storage permissions and try again.',
|
||||
systemActionCountdown: '{{action}} in 10 seconds.',
|
||||
systemActionCancelled: 'System action cancelled because another download is active or queued.',
|
||||
systemActionProceedAnyway: 'Proceed anyway',
|
||||
systemActionFailed: 'Scheduled system action failed: {{detail}}',
|
||||
downloadCompleteTitle: 'Download Complete',
|
||||
downloadCompleteBody: '{{fileName}} has finished downloading.',
|
||||
|
||||
@@ -196,6 +196,7 @@ const fa = {
|
||||
linuxActionsDescription: 'خوابیدن، راهاندازی مجدد و خاموش کردن از دسکتاپ و سیاستهای سیستم Linux استفاده میکنند. Firelink هنگام اجرا هر اقدام ردشدهای را گزارش میدهد؛ هیچ مجوز دائمی از قبل درخواست نمیشود.',
|
||||
validationDay: 'حداقل یک روز برای زمانبند انتخاب کنید',
|
||||
validationQueue: 'حداقل یک صف برای زمانبند انتخاب کنید',
|
||||
validationTime: 'زمانها را با قالب معتبر HH:MM وارد کنید',
|
||||
validationStopTime: 'زمان پایان باید بعد از زمان شروع باشد',
|
||||
saved: 'تنظیمات زمانبند ذخیره شد',
|
||||
trackingOne: 'در حال پیگیری ۱ دانلود زمانبندیشده',
|
||||
@@ -513,6 +514,7 @@ const fa = {
|
||||
settingsSaveFailed: 'تنظیمات ذخیره نشدند. دسترسیهای ذخیرهسازی را بررسی کرده و دوباره امتحان کنید.',
|
||||
systemActionCountdown: '{{action}} در ۱۰ ثانیه.',
|
||||
systemActionCancelled: 'اقدام سیستم لغو شد زیرا دانلود دیگری فعال یا در صف است.',
|
||||
systemActionProceedAnyway: 'ادامه دادن به هر حال',
|
||||
systemActionFailed: 'اقدام سیستم زمانبندیشده ناموفق بود: {{detail}}',
|
||||
downloadCompleteTitle: 'دانلود تکمیلشده',
|
||||
downloadCompleteBody: 'دانلود {{fileName}} به پایان رسید.',
|
||||
|
||||
@@ -196,6 +196,7 @@ const he = {
|
||||
linuxActionsDescription: 'שינה, הפעלה מחדש וכיבוי משתמשים בשולחן העבודה ובמדיניות המערכת של Linux. Firelink מדווח על כל פעולה שנדחתה בעת ביצועה; לא נדרשת הרשאה קבועה מראש.',
|
||||
validationDay: 'יש לבחור לפחות יום אחד למתזמן',
|
||||
validationQueue: 'יש לבחור לפחות תור אחד למתזמן',
|
||||
validationTime: 'יש להזין שעות תקינות בתבנית HH:MM',
|
||||
validationStopTime: 'שעת הסיום חייבת להיות מאוחרת משעת ההתחלה',
|
||||
saved: 'הגדרות המתזמן נשמרו',
|
||||
trackingOne: 'עוקב אחר הורדה מתוזמנת אחת',
|
||||
@@ -513,6 +514,7 @@ const he = {
|
||||
settingsSaveFailed: 'לא ניתן לשמור הגדרות. בדוק הרשאות אחסון ונסה שוב.',
|
||||
systemActionCountdown: '{{action}} בעוד 10 שניות.',
|
||||
systemActionCancelled: 'פעולת המערכת בוטלה מכיוון שהורדה אחרת פעילה או בתור.',
|
||||
systemActionProceedAnyway: 'להמשיך בכל זאת',
|
||||
systemActionFailed: 'פעולת מערכת מתוזמנת נכשלה: {{detail}}',
|
||||
downloadCompleteTitle: 'ההורדה הושלמה',
|
||||
downloadCompleteBody: 'הורדת {{fileName}} הסתיימה.',
|
||||
|
||||
@@ -196,6 +196,7 @@ const ru = {
|
||||
linuxActionsDescription: 'Спящий режим, перезагрузка и выключение используют системную политику и рабочий стол Linux. Firelink сообщит о любых отклонённых действиях при их запуске; постоянное разрешение заранее не запрашивается.',
|
||||
validationDay: 'Выберите хотя бы один день для планировщика',
|
||||
validationQueue: 'Выберите хотя бы одну очередь для планировщика',
|
||||
validationTime: 'Введите корректное время в формате HH:MM',
|
||||
validationStopTime: 'Время окончания должно быть позже времени начала',
|
||||
saved: 'Настройки планировщика сохранены',
|
||||
trackingOne: 'Отслеживается 1 запланированная загрузка',
|
||||
@@ -513,6 +514,7 @@ const ru = {
|
||||
settingsSaveFailed: 'Не удалось сохранить настройки. Проверьте разрешения хранилища и попробуйте снова.',
|
||||
systemActionCountdown: '{{action}} через 10 секунд.',
|
||||
systemActionCancelled: 'Системное действие отменено, так как есть активная или запланированная загрузка.',
|
||||
systemActionProceedAnyway: 'Всё равно продолжить',
|
||||
systemActionFailed: 'Не удалось выполнить запланированное системное действие: {{detail}}',
|
||||
downloadCompleteTitle: 'Загрузка завершена',
|
||||
downloadCompleteBody: 'Загрузка {{fileName}} завершена.',
|
||||
|
||||
@@ -196,6 +196,7 @@ const uk = {
|
||||
linuxActionsDescription: 'Сон, перезавантаження та вимкнення використовують ваш робочий стіл Linux та системну політику. Firelink повідомляє про будь-яку відхилену дію під час її виконання; жодні постійні дозволи заздалегідь не вимагаються.',
|
||||
validationDay: 'Виберіть принаймні один день для планувальника',
|
||||
validationQueue: 'Виберіть принаймні одну чергу для планувальника',
|
||||
validationTime: 'Введіть коректний час у форматі HH:MM',
|
||||
validationStopTime: 'Час зупинки має бути пізнішим за час початку',
|
||||
saved: 'Налаштування планувальника збережено',
|
||||
trackingOne: 'Відстеження 1 запланованого завантаження',
|
||||
@@ -513,6 +514,7 @@ const uk = {
|
||||
settingsSaveFailed: 'Не вдалося зберегти налаштування. Перевірте дозволи сховища та спробуйте ще раз.',
|
||||
systemActionCountdown: '{{action}} через 10 секунд.',
|
||||
systemActionCancelled: 'Системна дія скасована, оскільки активне або в черзі інше завантаження.',
|
||||
systemActionProceedAnyway: 'Продовжити попри це',
|
||||
systemActionFailed: 'Не вдалося виконати заплановану системну дію: {{detail}}',
|
||||
downloadCompleteTitle: 'Завантаження завершено',
|
||||
downloadCompleteBody: 'Завантаження {{fileName}} завершено.',
|
||||
|
||||
@@ -196,6 +196,7 @@ const zhCN = {
|
||||
linuxActionsDescription: '睡眠、重启和关机使用您的 Linux 桌面和系统策略。Firelink 在运行时会报告任何被拒绝的操作;不会提前声明任何永久权限。',
|
||||
validationDay: '至少为计划任务选择一天',
|
||||
validationQueue: '至少为计划任务选择一个队列',
|
||||
validationTime: '请输入 HH:MM 格式的有效时间',
|
||||
validationStopTime: '停止时间必须晚于开始时间',
|
||||
saved: '计划任务设置已保存',
|
||||
trackingOne: '正在跟踪 1 个计划的下载',
|
||||
@@ -513,6 +514,7 @@ const zhCN = {
|
||||
settingsSaveFailed: '无法保存设置。请检查存储权限并重试。',
|
||||
systemActionCountdown: '10 秒后{{action}}。',
|
||||
systemActionCancelled: '系统操作已取消,因为有其他下载正在进行或已排队。',
|
||||
systemActionProceedAnyway: '仍然继续',
|
||||
systemActionFailed: '计划的系统操作失败:{{detail}}',
|
||||
downloadCompleteTitle: '下载完成',
|
||||
downloadCompleteBody: '{{fileName}} 已下载完成。',
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ type CommandMap = {
|
||||
args: { preventSystemSleep: boolean; preventDisplaySleep: boolean };
|
||||
result: void;
|
||||
};
|
||||
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
||||
perform_system_action: { args: { action: PostQueueAction; force: boolean }; result: void };
|
||||
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
|
||||
set_concurrent_limit: { args: { limit: number }; result: void };
|
||||
set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void };
|
||||
|
||||
@@ -250,6 +250,17 @@ describe('useSettingsStore global speed limit persistence', () => {
|
||||
expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M');
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_global_speed_limit', { limit: '3M' });
|
||||
});
|
||||
|
||||
it('rejects malformed limits before changing native or local state', async () => {
|
||||
await expect(useSettingsStore.getState().setGlobalSpeedLimit('not-a-rate'))
|
||||
.rejects.toThrow('Global speed limit is invalid');
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
|
||||
'set_global_speed_limit',
|
||||
expect.anything()
|
||||
);
|
||||
expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSettingsStore Torrent overall upload limit persistence', () => {
|
||||
|
||||
@@ -517,11 +517,15 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
});
|
||||
},
|
||||
setGlobalSpeedLimit: async (limit) => {
|
||||
const normalized = normalizeSpeedLimitForBackend(limit);
|
||||
if (limit.trim() && !normalized) {
|
||||
return Promise.reject(new Error('Global speed limit is invalid'));
|
||||
}
|
||||
await invoke('set_global_speed_limit', {
|
||||
limit: normalizeSpeedLimitForBackend(limit)
|
||||
limit: normalized
|
||||
});
|
||||
info('Settings updated: globalSpeedLimit');
|
||||
set({ globalSpeedLimit: limit });
|
||||
set({ globalSpeedLimit: normalized ?? '' });
|
||||
},
|
||||
setTorrentOverallUploadLimit: (limit) => {
|
||||
const normalizedLimit = normalizeSpeedLimitForBackend(limit);
|
||||
@@ -1026,6 +1030,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
torrentOverallUploadLimit: typeof persisted.torrentOverallUploadLimit === 'string'
|
||||
? normalizeSpeedLimitForBackend(persisted.torrentOverallUploadLimit) ?? ''
|
||||
: currentState.torrentOverallUploadLimit,
|
||||
globalSpeedLimit: typeof persisted.globalSpeedLimit === 'string'
|
||||
? normalizeSpeedLimitForBackend(persisted.globalSpeedLimit) ?? ''
|
||||
: currentState.globalSpeedLimit,
|
||||
perServerConnections: clampSettingInteger(
|
||||
persisted.perServerConnections,
|
||||
1,
|
||||
|
||||
@@ -15,6 +15,10 @@ const download = (id: string, status: DownloadItem['status']): DownloadItem => (
|
||||
});
|
||||
|
||||
describe('schedulerCompletionState', () => {
|
||||
it('does not treat an empty scheduler set as completed', () => {
|
||||
expect(schedulerCompletionState([], [])).toBe('incomplete');
|
||||
});
|
||||
|
||||
it('stays active while any tracked scheduler download can still progress', () => {
|
||||
expect(schedulerCompletionState([
|
||||
download('a', 'completed'),
|
||||
|
||||
@@ -7,6 +7,8 @@ export const schedulerCompletionState = (
|
||||
downloads: DownloadItem[],
|
||||
schedulerActiveDownloadIds: string[],
|
||||
): SchedulerCompletionState => {
|
||||
if (schedulerActiveDownloadIds.length === 0) return 'incomplete';
|
||||
|
||||
const scheduledItems = schedulerActiveDownloadIds.map(id =>
|
||||
downloads.find(download => download.id === id)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beginSchedulerControl, isSchedulerControlCurrent } from './schedulerControl';
|
||||
|
||||
describe('scheduler control generation', () => {
|
||||
it('invalidates an older asynchronous scheduler operation', () => {
|
||||
const first = beginSchedulerControl();
|
||||
expect(isSchedulerControlCurrent(first)).toBe(true);
|
||||
|
||||
const second = beginSchedulerControl();
|
||||
expect(isSchedulerControlCurrent(first)).toBe(false);
|
||||
expect(isSchedulerControlCurrent(second)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
let schedulerControlGeneration = 0;
|
||||
|
||||
/**
|
||||
* Start a new scheduler control lifecycle. A later manual pause or scheduler
|
||||
* event invalidates earlier asynchronous queue operations before they can
|
||||
* publish stale running state.
|
||||
*/
|
||||
export const beginSchedulerControl = (): number => {
|
||||
schedulerControlGeneration += 1;
|
||||
return schedulerControlGeneration;
|
||||
};
|
||||
|
||||
export const isSchedulerControlCurrent = (generation: number): boolean =>
|
||||
schedulerControlGeneration === generation;
|
||||
Reference in New Issue
Block a user