From 3bcad639e24430e9946ea4f792128676735d1419 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 22 Aug 2026 04:11:02 +0330 Subject: [PATCH] fix(persistence): harden cross-layer ownership - Validate and sanitize renderer event payloads before UI projection - Fence stale enqueue cleanup by native lifecycle generation - Canonicalize empty startup hydration and harden SQLite backup durability - Add real-postcondition IPC, restart, storage, and queue regressions --- src-tauri/src/db.rs | 167 ++++++++++++++++++++++++++--- src-tauri/src/lib.rs | 53 ++++++++- src-tauri/tests/queue_manager.rs | 25 +++++ src/ipc.ts | 10 +- src/store/downloadStore.test.ts | 70 ++++++++++++ src/store/downloadStore.ts | 73 +++++++++++-- src/store/useDownloadStore.test.ts | 28 +++++ src/store/useDownloadStore.ts | 20 ++-- src/utils/downloads.ts | 20 ++++ 9 files changed, 432 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index f2b7142..ab5b285 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -66,7 +66,28 @@ fn init_at_path_internal( fs::create_dir_all(app_data_dir) .map_err(|error| format!("failed to create app data directory: {error}"))?; let database_path = app_data_dir.join(DATABASE_NAME); - let existed = database_path.exists(); + let existed = match fs::symlink_metadata(&database_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "persistence database is a symbolic link: '{}'", + database_path.display() + )); + } + Ok(metadata) if !metadata.is_file() => { + return Err(format!( + "persistence database is not a regular file: '{}'", + database_path.display() + )); + } + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => { + return Err(format!( + "failed to inspect persistence database '{}': {error}", + database_path.display() + )); + } + }; let mut connection = Connection::open(&database_path) .map_err(|error| format!("failed to open database: {error}"))?; @@ -289,6 +310,27 @@ fn import_legacy_data( } fn sanitize_legacy_source(path: &Path, remove_pairing_token: bool) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "legacy persistence source is a symbolic link: '{}'", + path.display() + )); + } + Ok(metadata) if !metadata.is_file() => { + return Err(format!( + "legacy persistence source is not a regular file: '{}'", + path.display() + )); + } + Ok(_) => {} + Err(error) => { + return Err(format!( + "failed to inspect legacy persistence source '{}': {error}", + path.display() + )); + } + } if is_database_path(path) { let mut connection = Connection::open(path).map_err(|error| { format!( @@ -389,12 +431,27 @@ fn write_sanitized_legacy_store( path.display() ) })?; + temporary.as_file().sync_all().map_err(|error| { + format!( + "failed to synchronize temporary sanitized legacy store beside '{}': {error}", + path.display() + ) + })?; temporary.persist(path).map_err(|error| { format!( "failed to replace legacy store '{}' without losing the original: {}", path.display(), error.error ) })?; + #[cfg(unix)] + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| { + format!( + "failed to synchronize legacy store directory '{}': {error}", + parent.display() + ) + })?; Ok(()) } @@ -591,6 +648,18 @@ fn query_string_column(connection: &Connection, query: &str) -> Result Result { + let source_metadata = fs::symlink_metadata(path).map_err(|error| { + format!( + "failed to inspect persistence file '{}': {error}", + path.display() + ) + })?; + if source_metadata.file_type().is_symlink() || !source_metadata.is_file() { + return Err(format!( + "persistence backup source is not a regular file: '{}'", + path.display() + )); + } let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); let file_name = path .file_name() @@ -599,6 +668,7 @@ fn backup_file(path: &Path, reason: &str) -> Result { let backup_prefix = format!("{file_name}.backup-{reason}-"); if let Some(existing) = path.parent().and_then(|parent| { fs::read_dir(parent).ok()?.flatten().find_map(|entry| { + entry.file_type().ok().filter(|kind| kind.is_file())?; entry .file_name() .to_string_lossy() @@ -608,17 +678,50 @@ fn backup_file(path: &Path, reason: &str) -> Result { }) { return Ok(existing); } - let backup_path = path.with_file_name(format!("{file_name}.backup-{reason}-{timestamp}")); - if backup_path.exists() { - return Ok(backup_path); + let backup_path = path.with_file_name(format!( + "{file_name}.backup-{reason}-{timestamp}-{}", + uuid::Uuid::new_v4().simple() + )); + let result = (|| { + use std::io::{copy, BufReader}; + + let source = fs::File::open(path).map_err(|error| { + format!( + "failed to open persistence file '{}' for backup: {error}", + path.display() + ) + })?; + let mut source = BufReader::new(source); + let destination = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&backup_path) + .map_err(|error| { + format!( + "failed to create persistence backup '{}': {error}", + backup_path.display() + ) + })?; + let mut destination = destination; + copy(&mut source, &mut destination).map_err(|error| { + format!( + "failed to back up persistence file '{}' to '{}': {error}", + path.display(), + backup_path.display() + ) + })?; + destination.sync_all().map_err(|error| { + format!( + "failed to synchronize persistence backup '{}': {error}", + backup_path.display() + ) + })?; + Ok::<(), String>(()) + })(); + if let Err(error) = result { + let _ = fs::remove_file(&backup_path); + return Err(error); } - fs::copy(path, &backup_path).map_err(|error| { - format!( - "failed to back up persistence file '{}' to '{}': {error}", - path.display(), - backup_path.display() - ) - })?; Ok(backup_path) } @@ -628,7 +731,10 @@ fn backup_database(connection: &Connection, path: &Path, reason: &str) -> Result .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| format!("invalid database path '{}'", path.display()))?; - let backup_path = path.with_file_name(format!("{file_name}.backup-{reason}-{timestamp}")); + let backup_path = path.with_file_name(format!( + "{file_name}.backup-{reason}-{timestamp}-{}", + uuid::Uuid::new_v4().simple() + )); connection .execute("VACUUM INTO ?1", params![backup_path.to_string_lossy()]) .map_err(|error| { @@ -2349,6 +2455,43 @@ mod tests { })); } + #[cfg(unix)] + #[test] + fn refuses_to_open_a_database_symlink() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let target = temp.path().join("outside.sqlite"); + symlink(&target, temp.path().join(DATABASE_NAME)).unwrap(); + + let result = init_at_path(temp.path()); + let error = result.err().expect("database symlink must fail closed"); + assert!(error.contains("symbolic link")); + assert!(!target.exists()); + } + + #[cfg(unix)] + #[test] + fn ignores_symlinked_legacy_backup_candidates() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let source = temp.path().join(LEGACY_STORE_NAME); + let outside = temp.path().join("outside-store.bin"); + let linked_backup = temp + .path() + .join("store.bin.backup-legacy-import-attacker"); + fs::write(&source, b"trusted-source").unwrap(); + fs::write(&outside, b"outside-content").unwrap(); + symlink(&outside, &linked_backup).unwrap(); + + let backup = backup_file(&source, "legacy-import").unwrap(); + assert_ne!(backup, linked_backup); + assert!(fs::symlink_metadata(&backup).unwrap().is_file()); + assert_eq!(fs::read(&backup).unwrap(), b"trusted-source"); + assert_eq!(fs::read(&outside).unwrap(), b"outside-content"); + } + #[test] fn portable_migration_does_not_create_raw_schema_backup() { let temp = TempDir::new().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e3cddb..8a98826 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5911,6 +5911,16 @@ async fn resume_download( } } +fn stale_lifecycle_cleanup_is_noop( + expected_generation: Option, + current_generation: Option, +) -> bool { + matches!( + (expected_generation, current_generation), + (Some(expected), Some(current)) if expected != current + ) +} + #[tauri::command] async fn remove_download( caller: tauri::WebviewWindow, @@ -5919,10 +5929,18 @@ async fn remove_download( id: String, delete_assets: bool, preserve_resumable: Option, + expected_lifecycle_generation: Option, ) -> Result<(), String> { properties_window::ensure_main_window(&caller)?; log::info!("remove_download called for id: {}", id); let preserve_resumable = preserve_resumable.unwrap_or(false); + let expected_lifecycle_generation = expected_lifecycle_generation + .map(|generation| { + generation + .parse::() + .map_err(|_| "Invalid expected download lifecycle generation".to_string()) + }) + .transpose()?; let control_guard = state.queue_manager.acquire_aria2_control(&id).await; let active_kind = state.queue_manager.active_kind(&id).await; @@ -5930,8 +5948,28 @@ async fn remove_download( .queue_manager .registered_lifecycle_generation(&id) .await; - let media_lifecycle_generation = registered_lifecycle_generation.unwrap_or_default(); - if let Some(generation) = registered_lifecycle_generation { + if stale_lifecycle_cleanup_is_noop( + expected_lifecycle_generation, + registered_lifecycle_generation, + ) { + // A renderer cleanup worker may be delayed until after the old native + // lifecycle has already completed and a newer generation has claimed + // the same download id. It must never remove that newer owner. + return Ok(()); + } + if let Some(expected_generation) = expected_lifecycle_generation { + // Registration normally exists before a pending task is committed. + // If a delayed cleanup observes a transient registry/pending gap, + // remove only the exact expected task and do not perform a broad + // download removal against an unowned lifecycle. + state + .queue_manager + .remove_from_pending_for_generation(&id, expected_generation) + .await; + if registered_lifecycle_generation.is_none() { + return Ok(()); + } + } else if let Some(generation) = registered_lifecycle_generation { state .queue_manager .remove_from_pending_for_generation(&id, generation) @@ -5939,6 +5977,7 @@ async fn remove_download( } else { state.queue_manager.remove_from_pending(&id).await; } + let media_lifecycle_generation = registered_lifecycle_generation.unwrap_or_default(); let gid = state.queue_manager.aria2_gid_for_download(&id); if let Some(gid) = gid.as_deref() { @@ -11778,7 +11817,7 @@ mod tests { retained_torrent_id_from_persisted_record, retained_torrent_info_hash_from_persisted_record, merge_durable_torrent_telemetry, torrent_identity_magnet, torrent_move_path_pair, - Aria2DaemonGuard, + Aria2DaemonGuard, stale_lifecycle_cleanup_is_noop, }; #[cfg(target_os = "macos")] use super::should_apply_dock_badge_update; @@ -11803,6 +11842,14 @@ mod tests { assert!(!aria2_gid_not_found("aria2 error code 3: Resource not found")); } + #[test] + fn stale_lifecycle_cleanup_only_targets_the_expected_native_owner() { + assert!(!stale_lifecycle_cleanup_is_noop(Some(7), Some(7))); + assert!(stale_lifecycle_cleanup_is_noop(Some(7), Some(8))); + assert!(!stale_lifecycle_cleanup_is_noop(Some(7), None)); + assert!(!stale_lifecycle_cleanup_is_noop(None, Some(8))); + } + #[test] fn terminal_aria2_status_preserves_exact_progress_snapshot() { let snapshot = aria2_download_state_progress(Some(&json!({ diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index 58afe46..b91f71d 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -460,6 +460,31 @@ async fn accepted_generation_cannot_be_replayed_after_registry_release() { .expect("only a newer lifecycle may reuse the id"); } +#[tokio::test] +async fn generation_fenced_pending_removal_handles_a_registry_gap() { + let (mgr, _spawner) = make_manager(2); + mgr.push_with_generation(sample_task("pending-gap"), 7) + .await + .expect("the pending lifecycle should be accepted"); + assert_eq!( + mgr.registered_lifecycle_generation("pending-gap").await, + Some(7) + ); + + // Model a delayed cleanup observing the pending task after its registry + // marker has already been released. Only the exact lifecycle may be + // removed; a newer generation must remain untouched. + mgr.release_registered_id("pending-gap").await; + assert!(!mgr + .remove_from_pending_for_generation("pending-gap", 8) + .await); + assert_eq!(mgr.pending_order(None).await, vec!["pending-gap".to_string()]); + assert!(mgr + .remove_from_pending_for_generation("pending-gap", 7) + .await); + assert!(mgr.pending_order(None).await.is_empty()); +} + #[tokio::test] async fn release_permit_is_idempotent() { let (mgr, _spawner) = make_manager(2); diff --git a/src/ipc.ts b/src/ipc.ts index 760742f..fa187e5 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -70,7 +70,15 @@ type CommandMap = { open_downloaded_file: { args: { path: string }; result: void }; pause_download: { args: { id: string }; result: void }; resume_download: { args: { id: string; queueId: string }; result: boolean }; - remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void }; + remove_download: { + args: { + id: string; + deleteAssets: boolean; + preserveResumable?: boolean; + expectedLifecycleGeneration?: string; + }; + result: void; + }; get_download_primary_path: { args: { id: string }; result: string | null }; detach_download_for_reconfigure: { args: { id: string }; result: void }; clear_torrent_removal_paths: { args: { id: string }; result: void }; diff --git a/src/store/downloadStore.test.ts b/src/store/downloadStore.test.ts index 7ba1e64..494834d 100644 --- a/src/store/downloadStore.test.ts +++ b/src/store/downloadStore.test.ts @@ -92,6 +92,38 @@ describe('useDownloadProgressStore', () => { release(); }); + it('ignores malformed state events instead of projecting an unknown status', async () => { + const handlers: Record void> = {}; + vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { + handlers[event] = handler as (event: any) => void; + return Promise.resolve(vi.fn()); + }); + useDownloadStore.setState({ + downloads: [{ + id: 'malformed-state', + url: 'https://example.com/file', + fileName: 'file.bin', + status: 'downloading', + category: 'Other', + dateAdded: '' + }] + }); + + const release = await initDownloadListener(); + handlers['download-state']({ payload: { + id: 'malformed-state', + status: 'not-a-download-status', + error: { secret: 'must not enter the store' } + } }); + + expect(useDownloadStore.getState().downloads[0]).toMatchObject({ + id: 'malformed-state', + status: 'downloading' + }); + expect(useDownloadStore.getState().downloads[0]).not.toHaveProperty('lastError'); + release(); + }); + it('removes a row from backend pending order when its lifecycle becomes active or retrying', async () => { const handlers: Record void> = {}; vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { @@ -167,6 +199,37 @@ describe('useDownloadProgressStore', () => { release(); }); + it('accepts a progress frame that omits the optional size value', async () => { + const handlers: Record void> = {}; + vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { + handlers[event] = handler as (event: any) => void; + return Promise.resolve(vi.fn()); + }); + useDownloadStore.setState({ + downloads: [{ + id: 'omitted-size', + url: 'https://example.com/file.bin', + fileName: 'file.bin', + status: 'downloading', + category: 'Other', + dateAdded: '' + }] + }); + + const release = await initDownloadListener(); + handlers['download-progress']({ payload: { + id: 'omitted-size', + fraction: 0.25, + speed: '1 MB/s', + eta: '1s', + size_is_final: false + } }); + + expect(useDownloadProgressStore.getState().progressMap['omitted-size']) + .toMatchObject({ id: 'omitted-size', fraction: 0.25, size: null }); + release(); + }); + it('keeps the last valid live frame when a malformed fraction arrives', async () => { const handlers: Record void> = {}; vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { @@ -231,6 +294,13 @@ describe('useDownloadProgressStore', () => { }); const release = await initDownloadListener(); + handlers['download-allocation']({ payload: { + id: 'native-allocation', + pending: true, + lifecycleGeneration: 'not-a-generation' + } }); + expect(useDownloadStore.getState().allocationPendingIds.has('native-allocation')).toBe(false); + handlers['download-allocation']({ payload: { id: 'native-allocation', pending: true, diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index a728656..eff9710 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -1,9 +1,11 @@ import type { UnlistenFn } from '@tauri-apps/api/event'; import type { DownloadStatus } from '../bindings/DownloadStatus'; +import type { DownloadStateEvent } from '../bindings/DownloadStateEvent'; +import type { DownloadErrorKind } from '../bindings/DownloadErrorKind'; import { listenEvent as listen } from '../ipc'; import type { DownloadItem } from '../bindings/DownloadItem'; import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent'; -import { categoryForDownload } from '../utils/downloads'; +import { categoryForDownload, isDownloadStatus } from '../utils/downloads'; import { useDownloadProgressStore } from './downloadProgressStore'; import { @@ -35,9 +37,53 @@ type ProgressFields = { const finiteNonNegative = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0; +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const isDownloadErrorKind = (value: unknown): value is DownloadErrorKind => + value === 'nameResolution' || value === 'destinationAccess'; + +const isLifecycleGeneration = (value: unknown): value is string => + typeof value === 'string' && /^\d+$/.test(value); + +type SanitizedDownloadStateEvent = Omit & { + status: DownloadStatus; +}; + +const sanitizeStatePayload = (value: unknown): SanitizedDownloadStateEvent | null => { + if (!isRecord(value) || typeof value.id !== 'string' || !isDownloadStatus(value.status)) { + return null; + } + + return { + id: value.id, + status: value.status, + error: typeof value.error === 'string' ? value.error : null, + ...(isDownloadErrorKind(value.errorKind) ? { errorKind: value.errorKind } : {}), + ...(typeof value.resolverFallback === 'boolean' + ? { resolverFallback: value.resolverFallback } + : {}), + ...(typeof value.fileName === 'string' ? { fileName: value.fileName } : {}), + ...(typeof value.destination === 'string' ? { destination: value.destination } : {}), + ...(finiteNonNegative(value.torrentSeedRemaining) + ? { torrentSeedRemaining: value.torrentSeedRemaining } + : {}), + ...(value.progress !== undefined ? { progress: value.progress as DownloadStateEvent['progress'] } : {}) + }; +}; + const sanitizeProgressPayload = ( - payload: DownloadProgressEvent, + value: unknown, ): DownloadProgressEvent | null => { + if (!isRecord(value) + || typeof value.id !== 'string' + || typeof value.speed !== 'string' + || typeof value.eta !== 'string' + || (value.size !== undefined && value.size !== null && typeof value.size !== 'string') + || typeof value.size_is_final !== 'boolean') { + return null; + } + const payload = { ...value, size: value.size ?? null } as unknown as DownloadProgressEvent; if (typeof payload.fraction !== 'number' || !Number.isFinite(payload.fraction) || payload.fraction < 0 @@ -65,6 +111,9 @@ const sanitizeProgressPayload = ( && typeof sanitized.total_is_estimate !== 'boolean') { delete sanitized.total_is_estimate; } + if (sanitized.upload_speed !== undefined && typeof sanitized.upload_speed !== 'string') { + delete sanitized.upload_speed; + } return sanitized; }; @@ -151,7 +200,8 @@ const disposeDownloadListeners = () => { const startDownloadListeners = async () => { const registrations = await Promise.allSettled([ listen('download-progress', (event) => { - const payload = event.payload; + const payload = sanitizeProgressPayload(event.payload); + if (!payload) return; const mainStore = useDownloadStore.getState(); const current = mainStore.downloads.find(d => d.id === payload.id); if (!current) { @@ -167,10 +217,7 @@ const startDownloadListeners = async () => { useDownloadProgressStore.getState().clearDownloadProgress(payload.id); return; } - const sanitizedPayload = sanitizeProgressPayload(payload); - if (!sanitizedPayload) { - return; - } + const sanitizedPayload = payload; useDownloadProgressStore.getState().updateDownloadProgress(payload.id, sanitizedPayload); const shouldUpdateSize = Boolean(sanitizedPayload.size && (!current.isMedia || sanitizedPayload.size_is_final)); const updates: Partial = {}; @@ -232,6 +279,12 @@ const startDownloadListeners = async () => { }), listen('download-allocation', (event) => { const payload = event.payload; + if (!isRecord(payload) + || typeof payload.id !== 'string' + || typeof payload.pending !== 'boolean' + || !isLifecycleGeneration(payload.lifecycleGeneration)) { + return; + } const mainStore = useDownloadStore.getState(); const current = mainStore.downloads.find(download => download.id === payload.id); if (!current) { @@ -258,14 +311,15 @@ const startDownloadListeners = async () => { ); }), listen('download-state', async (event) => { - const payload = event.payload; + const payload = sanitizeStatePayload(event.payload); + if (!payload) return; const mainStore = useDownloadStore.getState(); const current = mainStore.downloads.find(d => d.id === payload.id); if (!current) { useDownloadProgressStore.getState().resetDownloadProgress(payload.id); return; } - const status = payload.status as DownloadStatus; + const status = payload.status; // A move terminal event carries its authoritative destination. Older // lifecycle events do not, so they must not overwrite an active move or // clear its progress while the native relocation still owns the row. @@ -462,6 +516,7 @@ const startDownloadListeners = async () => { }), listen('torrent-move-progress', (event) => { const payload = event.payload; + if (!isRecord(payload) || typeof payload.id !== 'string') return; const current = useDownloadStore.getState().downloads.find(d => d.id === payload.id); if (!current || current.status !== 'moving') { useDownloadProgressStore.getState().clearMoveProgress(payload.id); diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index ca9aed2..9c833ad 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -772,6 +772,28 @@ describe('useDownloadStore', () => { ); }); + it('replaces stale in-memory downloads when startup loads an empty persisted snapshot', async () => { + useDownloadStore.setState({ + downloads: [{ + id: 'stale-memory-row', + url: 'https://example.com/stale.bin', + fileName: 'stale.bin', + status: 'completed', + category: 'Other', + dateAdded: '' + }] as any[] + }); + vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { + if (cmd === 'db_get_all_queues') return []; + if (cmd === 'db_get_all_downloads') return []; + return undefined; + }); + + await useDownloadStore.getState().initDB(); + + expect(useDownloadStore.getState().downloads).toEqual([]); + }); + it('remaps persisted downloads when queue records are malformed or missing', async () => { vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { if (cmd === 'db_get_all_queues') { @@ -1336,6 +1358,12 @@ describe('useDownloadStore', () => { expect( vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'remove_download') ).toHaveLength(2); + expect( + vi.mocked(ipc.invokeCommand).mock.calls.some(([command, args]) => + command === 'remove_download' + && (args as { expectedLifecycleGeneration?: string })?.expectedLifecycleGeneration === '0' + ) + ).toBe(true); }); it('does not expose allocation while admission is merely blocked', async () => { diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 886bbe5..672787c 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -256,9 +256,13 @@ const isCurrentDownloadLifecycle = (id: string, generation: bigint): boolean => currentDownloadLifecycle(id) === generation && useDownloadStore.getState().downloads.some(download => download.id === id); -const removeStaleBackendDispatch = async (id: string): Promise => { +const removeStaleBackendDispatch = async (id: string, lifecycleGeneration: bigint): Promise => { try { - await invoke('remove_download', { id, deleteAssets: false }); + await invoke('remove_download', { + id, + deleteAssets: false, + expectedLifecycleGeneration: lifecycleGeneration.toString() + }); } catch (error) { // The original remove request may already have won this race. Either way, // never allow a stale enqueue to make the deleted row live again. @@ -452,7 +456,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): const accepted = await invoke('enqueue_download', { item: enqueueItem }); backendAccepted = true; if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) { - await removeStaleBackendDispatch(id); + await removeStaleBackendDispatch(id, lifecycleGeneration); return false; } @@ -469,7 +473,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): } const order = await invoke('get_pending_order', { queueId: item.queueId || MAIN_QUEUE_ID }); if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) { - await removeStaleBackendDispatch(id); + await removeStaleBackendDispatch(id, lifecycleGeneration); return false; } @@ -483,7 +487,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): } catch (e) { console.error(`Failed to dispatch ${id}:`, e); if (backendAccepted && lifecycleGeneration !== null) { - await removeStaleBackendDispatch(id); + await removeStaleBackendDispatch(id, lifecycleGeneration); } if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) { const proxyBlocked = isSystemProxyConfigurationError(e); @@ -2940,11 +2944,9 @@ export const useDownloadStore = create((set, get) => { return normalizePersistedDownloadProgress({ ...download, queueId }); }); - set(state => ({ + set(() => ({ queues, - downloads: downloads.length > 0 - ? normalizeQueuePositions(downloads) - : state.downloads + downloads: normalizeQueuePositions(downloads) })); // A process can die after Aria2 has removed the unselected files but diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index c886da8..0ee2598 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -38,6 +38,26 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet = new Set([ 'moving', ]); +const DOWNLOAD_STATUSES: ReadonlySet = new Set([ + 'ready', + 'staged', + 'downloading', + 'processing', + 'seeding', + 'waitingToSeed', + 'paused', + 'completed', + 'failed', + 'queued', + 'retrying', + 'verifying', + 'moving', +]); + +/** Runtime guard for values arriving from the untyped Tauri event channel. */ +export const isDownloadStatus = (status: unknown): status is DownloadStatus => + typeof status === 'string' && DOWNLOAD_STATUSES.has(status); + export const isActiveDownloadStatus = (status: DownloadStatus): boolean => ACTIVE_DOWNLOAD_STATUSES.has(status);