From f3d0e0be13295bdcf544e31a412a2d3bc3140d08 Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 16 Jul 2026 14:28:59 +0330 Subject: [PATCH] fix(downloads): recover stalls and honor connection defaults (#19, #20) --- src-tauri/src/lib.rs | 515 ++++++++++++++++++++++++----- src-tauri/src/queue.rs | 32 ++ src/components/PropertiesModal.tsx | 37 ++- src/components/SettingsView.tsx | 2 +- src/store/useDownloadStore.ts | 7 +- src/utils/downloads.test.ts | 16 +- src/utils/downloads.ts | 31 ++ 7 files changed, 555 insertions(+), 85 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a04527c..6e0e829 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4855,10 +4855,10 @@ async fn set_global_speed_limit( state: tauri::State<'_, AppState>, limit: Option, ) -> Result<(), String> { - let limit_str = limit + let normalized_limit = limit .as_deref() - .and_then(normalize_speed_limit_for_aria2) - .unwrap_or_else(|| "0".to_string()); + .and_then(normalize_speed_limit_for_aria2); + let limit_str = normalized_limit.clone().unwrap_or_else(|| "0".to_string()); rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, @@ -4866,7 +4866,11 @@ async fn set_global_speed_limit( serde_json::json!([{"max-overall-download-limit": limit_str}]), ) .await - .map(|_| ()) + .map(|_| { + state + .queue_manager + .set_aria2_global_speed_limit(normalized_limit); + }) .map_err(|e| { eprintln!("Failed to set global speed limit: {}", e); e @@ -5700,10 +5704,267 @@ mod tests { should_send_metadata_credentials, collect_log_files, FirelinkDeepLink, percent_decode_metadata_value, MediaProgress, MediaProgressEmitterState, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX, + observe_aria2_connections, Aria2ConnectionObservation, Aria2RecoveryReason, }; use serde_json::json; use std::time::{Duration, Instant}; + #[test] + fn slow_nonzero_aria2_throughput_recovers_after_a_sustained_degradation() { + let start = Instant::now(); + let mut observation = Aria2ConnectionObservation::default(); + + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 10 * 1024 * 1024, + 10.0 * 1024.0 * 1024.0, + 16, + 16, + false, + start, + ), + None + ); + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 11 * 1024 * 1024, + 10.0 * 1024.0 * 1024.0, + 16, + 16, + false, + start + Duration::from_secs(1), + ); + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 12 * 1024 * 1024, + 10.0 * 1024.0 * 1024.0, + 16, + 16, + false, + start + Duration::from_secs(2), + ); + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 11 * 1024 * 1024, + 1024.0 * 1024.0, + 16, + 16, + false, + start + Duration::from_secs(31), + ), + None, + "the first slow sample starts the degradation timer" + ); + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 12 * 1024 * 1024, + 1024.0 * 1024.0, + 16, + 16, + false, + start + Duration::from_secs(62), + ), + Some(Aria2RecoveryReason::SlowThroughput) + ); + } + + #[test] + fn aria2_recovery_uses_a_cooldown_instead_of_a_one_shot_latch() { + let start = Instant::now(); + let mut observation = Aria2ConnectionObservation::default(); + let sample = |observation: &mut Aria2ConnectionObservation, now: Instant| { + observe_aria2_connections( + observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 12 * 1024 * 1024, + 1024.0 * 1024.0, + 16, + 16, + false, + now, + ) + }; + + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 12 * 1024 * 1024, + 10.0 * 1024.0 * 1024.0, + 16, + 16, + false, + start, + ); + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 13 * 1024 * 1024, + 10.0 * 1024.0 * 1024.0, + 16, + 16, + false, + start + Duration::from_secs(1), + ); + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 14 * 1024 * 1024, + 10.0 * 1024.0 * 1024.0, + 16, + 16, + false, + start + Duration::from_secs(2), + ); + sample(&mut observation, start + Duration::from_secs(31)); + assert_eq!( + sample(&mut observation, start + Duration::from_secs(62)), + Some(Aria2RecoveryReason::SlowThroughput) + ); + assert_eq!(sample(&mut observation, start + Duration::from_secs(93)), None); + assert_eq!( + sample(&mut observation, start + Duration::from_secs(108)), + Some(Aria2RecoveryReason::SlowThroughput), + "a later degradation can recover again after the cooldown" + ); + } + + #[test] + fn slow_recovery_requires_a_real_multi_connection_transfer() { + let start = Instant::now(); + let mut observation = Aria2ConnectionObservation::default(); + + for (offset, speed) in [(0, 10.0 * 1024.0 * 1024.0), (31, 1024.0 * 1024.0), (62, 1024.0 * 1024.0)] { + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 10 * 1024 * 1024 + offset * 1024, + speed, + 1, + 16, + false, + start + Duration::from_secs(offset as u64), + ), + None + ); + } + } + + #[test] + fn slow_recovery_ignores_intentional_speed_limits() { + let start = Instant::now(); + let mut observation = Aria2ConnectionObservation::default(); + for offset in 0..3 { + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + (10 + offset) * 1024 * 1024, + 10.0 * 1024.0 * 1024.0, + 16, + 16, + true, + start + Duration::from_secs(offset), + ); + } + + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 14 * 1024 * 1024, + 1024.0 * 1024.0, + 16, + 16, + true, + start + Duration::from_secs(31), + ), + None + ); + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 15 * 1024 * 1024, + 1024.0 * 1024.0, + 16, + 16, + true, + start + Duration::from_secs(62), + ), + None + ); + } + + #[test] + fn one_startup_spike_does_not_arm_slow_recovery() { + let start = Instant::now(); + let mut observation = Aria2ConnectionObservation::default(); + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 10 * 1024 * 1024, + 10.0 * 1024.0 * 1024.0, + 16, + 16, + false, + start, + ); + + for offset in [31_u64, 62, 93] { + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 100 * 1024 * 1024, + 10 * 1024 * 1024 + offset as u64 * 1024, + 1024.0 * 1024.0, + 16, + 16, + false, + start + Duration::from_secs(offset), + ), + None + ); + } + } + #[test] fn media_metadata_fallback_lets_ytdlp_choose_extension() { let destination = std::path::Path::new("/tmp/firelink"); @@ -6677,6 +6938,154 @@ mod tests { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Aria2RecoveryReason { + ZeroProgress, + SlowThroughput, + ConnectionPoolCollapse, +} + +impl Aria2RecoveryReason { + fn as_str(self) -> &'static str { + match self { + Self::ZeroProgress => "zero-stall", + Self::SlowThroughput => "slow-throughput", + Self::ConnectionPoolCollapse => "connection-collapse", + } + } +} + +#[derive(Default)] +struct Aria2ConnectionObservation { + gid: String, + saw_multiple_connections: bool, + healthy_speed_samples: u8, + degraded_since: Option, + no_progress_since: Option, + last_refreshed_at: Option, + peak_speed_bytes: f64, + last_completed: u64, +} + +const ARIA2_CONNECTION_RECOVERY_DELAY: Duration = Duration::from_secs(30); +const ARIA2_CONNECTION_RECOVERY_COOLDOWN: Duration = Duration::from_secs(45); +const ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY: u64 = 1024 * 1024; +const ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY: f64 = 64.0 * 1024.0; +const ARIA2_DEGRADED_SPEED_FRACTION: f64 = 0.20; +const ARIA2_MIN_HEALTHY_SPEED_SAMPLES: u8 = 3; + +fn observe_aria2_connections( + observation: &mut Aria2ConnectionObservation, + gid: &str, + status: &str, + total: u64, + completed: u64, + speed_bytes: f64, + active_connections: i32, + requested_connections: i32, + speed_limited: bool, + now: Instant, +) -> Option { + if observation.gid != gid { + *observation = Aria2ConnectionObservation { + gid: gid.to_string(), + last_completed: completed, + ..Default::default() + }; + } + + let remaining = total.saturating_sub(completed); + let mut reason = None; + if status == "active" && total > completed { + let speed_bytes = speed_bytes.max(0.0); + // A decaying peak keeps the trigger sensitive to a recent healthy + // rate without permanently comparing against a brief startup burst. + observation.peak_speed_bytes = (observation.peak_speed_bytes * 0.995).max(speed_bytes); + + if completed > observation.last_completed { + observation.no_progress_since = None; + } else if speed_bytes <= 0.0 { + let stalled_since = observation.no_progress_since.get_or_insert(now); + if now.duration_since(*stalled_since) >= ARIA2_CONNECTION_RECOVERY_DELAY { + reason = Some(Aria2RecoveryReason::ZeroProgress); + } + } else { + observation.no_progress_since = None; + } + + let multi_connection_candidate = requested_connections > 1 + && remaining >= ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY; + if multi_connection_candidate { + if active_connections > 1 + && speed_bytes >= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY + && speed_bytes >= observation.peak_speed_bytes * 0.5 + { + observation.healthy_speed_samples = observation + .healthy_speed_samples + .saturating_add(1); + } + let slow_throughput = !speed_limited + && observation.saw_multiple_connections + && observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES + && observation.peak_speed_bytes + >= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY + && speed_bytes > 0.0 + && speed_bytes < observation.peak_speed_bytes * ARIA2_DEGRADED_SPEED_FRACTION; + if slow_throughput { + let degraded_since = observation.degraded_since.get_or_insert(now); + if now.duration_since(*degraded_since) >= ARIA2_CONNECTION_RECOVERY_DELAY + && reason.is_none() + { + reason = Some(Aria2RecoveryReason::SlowThroughput); + } + } else { + observation.degraded_since = None; + } + + if active_connections > 1 { + observation.saw_multiple_connections = true; + } else if !speed_limited && observation.saw_multiple_connections { + let degraded_since = observation.degraded_since.get_or_insert(now); + if now.duration_since(*degraded_since) >= ARIA2_CONNECTION_RECOVERY_DELAY + && reason.is_none() + { + reason = Some(Aria2RecoveryReason::ConnectionPoolCollapse); + } + } + } else { + observation.degraded_since = None; + } + } else { + observation.degraded_since = None; + observation.no_progress_since = None; + } + + observation.last_completed = completed; + + let recovery_allowed = observation.last_refreshed_at.map_or(true, |last| { + now.duration_since(last) >= ARIA2_CONNECTION_RECOVERY_COOLDOWN + }); + if !recovery_allowed { + return None; + } + + if let Some(reason) = reason { + observation.last_refreshed_at = Some(now); + // Start a fresh observation window after every attempt. This permits + // repeated healing while preventing a refresh loop every poll tick. + match reason { + Aria2RecoveryReason::ZeroProgress => observation.no_progress_since = Some(now), + Aria2RecoveryReason::SlowThroughput + | Aria2RecoveryReason::ConnectionPoolCollapse => { + observation.degraded_since = Some(now) + } + } + Some(reason) + } else { + None + } +} + static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); static LOG_STREAM_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); @@ -6827,6 +7236,10 @@ pub fn run() { let scheduler_settings = Arc::new(RwLock::new(persisted_settings.clone())); let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent)); + let initial_global_speed_limit = persisted_settings + .as_ref() + .and_then(|settings| normalize_speed_limit_for_aria2(&settings.global_speed_limit)); + queue_manager.set_aria2_global_speed_limit(initial_global_speed_limit); let dispatcher_mgr = Arc::clone(&queue_manager); tauri::async_runtime::spawn(async move { dispatcher_mgr.run_dispatcher().await; @@ -7108,18 +7521,6 @@ pub fn run() { let poll_secret = aria2_secret.clone(); let poll_mgr = Arc::clone(&queue_manager_poll); tauri::async_runtime::spawn(async move { - #[derive(Default)] - struct Aria2ConnectionObservation { - gid: String, - saw_multiple_connections: bool, - degraded_since: Option, - no_progress_since: Option, - refreshed: bool, - last_completed: u64, - } - - const RECOVERY_DELAY: Duration = Duration::from_secs(30); - const MIN_REMAINING_FOR_CONNECTION_RECOVERY: u64 = 1024 * 1024; let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000)); let mut observations: HashMap = HashMap::new(); loop { @@ -7150,64 +7551,21 @@ pub fn run() { .await .unwrap_or(1) .max(1); + let speed_limited = poll_mgr.aria2_speed_limited(&id).await; let now = Instant::now(); let observation = observations.entry(id.clone()).or_default(); - if observation.gid != gid { - *observation = Aria2ConnectionObservation { - gid: gid.to_string(), - last_completed: completed, - ..Default::default() - }; - } - - let remaining = total.saturating_sub(completed); - let mut should_refresh = false; - if status == "active" && total > completed { - if completed > observation.last_completed { - observation.no_progress_since = None; - } else if speed_bytes <= 0.0 { - let stalled_since = observation.no_progress_since.get_or_insert(now); - if now.duration_since(*stalled_since) >= RECOVERY_DELAY - && !observation.refreshed - { - observation.refreshed = true; - should_refresh = true; - } - } else { - observation.no_progress_since = None; - } - - if requested_connections > 1 && active_connections > 1 { - observation.saw_multiple_connections = true; - observation.degraded_since = None; - if !should_refresh { - observation.refreshed = false; - } - } else if requested_connections > 1 - && observation.saw_multiple_connections - && active_connections <= 1 - && remaining >= MIN_REMAINING_FOR_CONNECTION_RECOVERY - { - let degraded_since = observation.degraded_since.get_or_insert(now); - if now.duration_since(*degraded_since) >= RECOVERY_DELAY - && !observation.refreshed - { - observation.refreshed = true; - should_refresh = true; - log::warn!( - "aria2 connection pool degraded [{}]: gid {} has {} connection(s), requested {}; refreshing", - id, - gid, - active_connections, - requested_connections - ); - } - } - } else { - observation.degraded_since = None; - observation.no_progress_since = None; - } - observation.last_completed = completed; + let recovery_reason = observe_aria2_connections( + observation, + gid, + status, + total, + completed, + speed_bytes, + active_connections, + requested_connections, + speed_limited, + now, + ); let fraction = if total > 0 { completed as f64 / total as f64 } else { 0.0 }; let speed = crate::download::format_speed(speed_bytes); @@ -7235,10 +7593,19 @@ pub fn run() { total_is_estimate: Some(false), }); - if should_refresh { + if let Some(reason) = recovery_reason { + log::warn!( + "aria2 connection recovery [{}]: gid {} reason={} speed={}B/s active_connections={} requested_connections={}", + id, + gid, + reason.as_str(), + speed_bytes, + active_connections, + requested_connections + ); if let Err(error) = poll_mgr.refresh_aria2_connections(&id, gid).await { log::warn!( - "aria2 connection refresh [{}] for gid {} failed: {}", + "aria2 connection recovery [{}] for gid {} failed: {}", id, gid, error diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index e8c03e1..ac7fba1 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -156,6 +156,11 @@ pub struct QueueManager { /// download id -> spawn payload for aria2 transient-error re-addUri retries. aria2_payloads: Mutex>, + /// The daemon-wide download cap currently applied to aria2. This mirrors + /// successful RPC changes so the poller can avoid treating an intentional + /// cap as a degraded connection pool. + aria2_global_speed_limit: Arc>>, + /// 0-based transient-error strike counter per aria2 download id. aria2_retry_strikes: Mutex>, @@ -221,6 +226,7 @@ impl QueueManager { aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())), pending_completion: Arc::new(Mutex::new(HashMap::new())), aria2_payloads: Mutex::new(HashMap::new()), + aria2_global_speed_limit: Arc::new(StdMutex::new(None)), aria2_retry_strikes: Mutex::new(HashMap::new()), aria2_retry_cancelled: Mutex::new(HashSet::new()), aria2_retry_inflight: Mutex::new(HashMap::new()), @@ -467,6 +473,32 @@ impl QueueManager { .and_then(|payload| payload.connections) } + pub fn set_aria2_global_speed_limit(&self, limit: Option) { + *self + .aria2_global_speed_limit + .lock() + .unwrap_or_else(|error| error.into_inner()) = limit; + } + + 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 + .lock() + .await + .get(id) + .and_then(|payload| payload.speed_limit.as_deref()) + .and_then(crate::normalize_speed_limit_for_aria2) + .is_some() + } + /// Pop the next task, or None if empty. pub async fn pop_front(&self) -> Option { self.pending.lock().await.pop_front() diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 3d04424..992a00f 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -15,6 +15,7 @@ import { formatDownloadTotal, resolveDownloadSizeDisplay } from '../utils/downloadProgress'; +import { resolveDownloadConnections } from '../utils/downloads'; type LoginMode = 'matching' | 'custom' | 'none'; @@ -46,7 +47,8 @@ export const PropertiesModal = () => { const [url, setUrl] = useState(''); const [fileName, setFileName] = useState(''); const [saveLocation, setSaveLocation] = useState(''); - const [connections, setConnections] = useState(16); + const [connections, setConnections] = useState(() => resolveDownloadConnections(undefined, perServerConnections)); + const [connectionsDirty, setConnectionsDirty] = useState(false); const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false); const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s @@ -79,7 +81,8 @@ export const PropertiesModal = () => { activeItem.category ).then(setSaveLocation); } - setConnections(activeItem.connections || 16); + setConnections(resolveDownloadConnections(activeItem.connections, perServerConnections)); + setConnectionsDirty(false); if (activeItem.speedLimit) { setSpeedLimitEnabled(true); @@ -117,7 +120,15 @@ export const PropertiesModal = () => { setSelectedPropertiesDownloadId(null); } } - }, [selectedPropertiesDownloadId, baseDownloadFolder, setSelectedPropertiesDownloadId]); + }, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]); + + useEffect(() => { + if (!selectedPropertiesDownloadId || connectionsDirty) return; + const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId); + if (activeItem && activeItem.connections === undefined) { + setConnections(resolveDownloadConnections(undefined, perServerConnections)); + } + }, [selectedPropertiesDownloadId, perServerConnections, connectionsDirty]); useEffect(() => { if (!selectedPropertiesDownloadId) return; @@ -160,7 +171,6 @@ export const PropertiesModal = () => { url, fileName, destination: saveLocation, - connections: Number(connections), speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : undefined, username: loginMode === 'custom' ? username.trim() : undefined, password: loginMode === 'custom' ? password.trim() : undefined, @@ -168,6 +178,9 @@ export const PropertiesModal = () => { checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined, cookies: cookies.trim() || undefined, mirrors: mirrors.trim() || undefined, + ...(connectionsDirty + ? { connections: resolveDownloadConnections(connections, perServerConnections) } + : {}), }; try { @@ -259,7 +272,7 @@ export const PropertiesModal = () => {
Speed{displayedSpeed}
ETA{displayedEta}
-
Connections{item.connections || perServerConnections || '-'}
+
Connections{resolveDownloadConnections(item.connections, perServerConnections)}{item.connections !== undefined ? ' (saved)' : ' (default)'}
Speed cap{item.speedLimit || '-'}
Category{item.category}
Last try{formatLastTry(item.lastTry)}
@@ -311,8 +324,20 @@ export const PropertiesModal = () => {
- setConnections(Number(e.target.value))} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" /> + { setConnections(Number(e.target.value)); setConnectionsDirty(true); }} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" /> per file + {!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && ( + + )} +
+
+ Saved per download. The Settings default applies to new downloads.
diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index ed4e74f..990217d 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -625,7 +625,7 @@ runEngineChecks(false);
Default connections: - For new downloads + New downloads; existing items keep their saved value
{ url: item.url, destination, filename: item.fileName, - connections: item.connections || settings.perServerConnections || null, + connections: resolveDownloadConnections(item.connections, settings.perServerConnections), speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia), username: item.username || (login ? login.username : null), password: item.password || keychainPassword, @@ -694,6 +694,7 @@ export const useDownloadStore = create((set, get) => ({ const queuePosition = maxPos + 1; const ownedItem: DownloadItem = { ...item, + connections: resolveDownloadConnections(item.connections, settings.perServerConnections), destination: destPath, status: action.type === 'add-to-queue' ? 'staged' : 'ready', queueId, @@ -1177,7 +1178,7 @@ export const useDownloadStore = create((set, get) => ({ url: item.url, destination: destPath, filename: item.fileName, - connections: item.connections || settings.perServerConnections || null, + connections: resolveDownloadConnections(item.connections, settings.perServerConnections), speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia), username: item.username || (login ? login.username : null), password: item.password || keychainPassword, diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index 1fbd234..4f5e836 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { DownloadItem } from '../bindings/DownloadItem'; -import { redactDownloadForPersistence } from './downloads'; +import { redactDownloadForPersistence, resolveDownloadConnections } from './downloads'; const item = (status: DownloadItem['status']): DownloadItem => ({ id: 'download-1', @@ -42,3 +42,17 @@ describe('download persistence progress snapshots', () => { } ); }); + +describe('download connection resolution', () => { + it('uses a clamped fallback for legacy rows without a saved value', () => { + expect(resolveDownloadConnections(undefined, 8)).toBe(8); + expect(resolveDownloadConnections(undefined, 0)).toBe(1); + expect(resolveDownloadConnections(undefined, Number.NaN)).toBe(16); + }); + + it('clamps malformed saved values before dispatch', () => { + expect(resolveDownloadConnections(0, 8)).toBe(1); + expect(resolveDownloadConnections(17, 8)).toBe(16); + expect(resolveDownloadConnections(Number.NaN, 8)).toBe(8); + }); +}); diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index ee1fb6e..4712a50 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -40,6 +40,37 @@ export const isActiveDownloadStatus = (status: DownloadStatus): boolean => export const isTransferActiveStatus = (status: DownloadStatus): boolean => status === 'downloading' || status === 'processing' || status === 'retrying'; +export const DOWNLOAD_CONNECTIONS_MIN = 1; +export const DOWNLOAD_CONNECTIONS_MAX = 16; + +/** + * Resolve persisted/user-entered connection values before they cross into the + * backend. Older rows may omit the value, while malformed rows can contain + * zero, NaN, or an out-of-range number. + */ +export const resolveDownloadConnections = (value: unknown, fallback: unknown): number => { + const toFiniteInteger = (candidate: unknown): number | undefined => { + if (typeof candidate === 'number') { + return Number.isFinite(candidate) ? Math.trunc(candidate) : undefined; + } + if (typeof candidate === 'string' && candidate.trim() !== '') { + const parsed = Number(candidate); + return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined; + } + return undefined; + }; + const normalizedFallback = toFiniteInteger(fallback) ?? DOWNLOAD_CONNECTIONS_MAX; + const safeFallback = Math.min( + DOWNLOAD_CONNECTIONS_MAX, + Math.max(DOWNLOAD_CONNECTIONS_MIN, normalizedFallback) + ); + const candidate = toFiniteInteger(value) ?? safeFallback; + return Math.min( + DOWNLOAD_CONNECTIONS_MAX, + Math.max(DOWNLOAD_CONNECTIONS_MIN, candidate) + ); +}; + export const normalizeSpeedLimitForBackend = (value?: string | null): string | null => { const trimmed = value?.trim(); if (!trimmed) return null;