From 54cbf6a58894df3cbde599210dbfd62a2d1e8801 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:09:27 +0100 Subject: [PATCH 01/89] fix: remove cancelled segmented partials off GTK --- src/download.rs | 52 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/download.rs b/src/download.rs index 474401817..e7a827d22 100644 --- a/src/download.rs +++ b/src/download.rs @@ -814,6 +814,34 @@ impl DownloadManager { } } + /// Delete a segmented partial away from GTK. Reserve its exact path until + /// unlink completes so an immediate retry cannot recreate it mid-cleanup. + fn schedule_partial_cleanup(self: &Rc, path: std::path::PathBuf) { + self.reserve_dest(&path); + let weak = Rc::downgrade(self); + glib::spawn_future_local(async move { + let worker_path = path.clone(); + match gio::spawn_blocking(move || std::fs::remove_file(worker_path)).await { + Ok(Ok(())) => {} + Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => {} + Ok(Err(e)) => tracing::warn!( + path = %path.display(), + error = %e, + "could not remove cancelled segmented partial" + ), + Err(e) => tracing::warn!( + path = %path.display(), + error = ?e, + "partial cleanup worker failed" + ), + } + if let Some(manager) = weak.upgrade() { + manager.release_dest(&path); + manager.start_next(); + } + }); + } + /// Whether the row is currently capturing a live stream (stop-and-keep applies). pub fn is_live_video(&self, id: u64) -> bool { self.live_rows.borrow().contains(&id) @@ -2776,7 +2804,9 @@ impl DownloadManager { /// Cancel a download; retry with [`DownloadManager::retry`]. pub fn cancel(self: &Rc, id: u64) { - self.cancel_inner(id, false, Stop::Preserve); + if let Some(path) = self.cancel_inner(id, false, Stop::Preserve) { + self.schedule_partial_cleanup(path); + } self.persist_queue(); self.changed(); self.start_next(); @@ -2833,7 +2863,7 @@ impl DownloadManager { } } - fn cancel_inner(&self, id: u64, keep_partial: bool, stop: Stop) { + fn cancel_inner(&self, id: u64, keep_partial: bool, stop: Stop) -> Option { // Live rows keep what's recorded (Stop, not Cancel): signal the worker and skip the abort plus the Cancelled preset, so its Finished still lands. Removal is the one caller that discards. let live = self.live_rows.borrow().contains(&id); // Only a *video* worker can be told to discard: plain rows have no `video_abort` sender, so a Discard-only path would leak the engine and its slot. @@ -2852,10 +2882,12 @@ impl DownloadManager { self.pending_names.borrow_mut().remove(&id); let had_segments = self.segment_state.borrow_mut().remove(&id).is_some(); self.torrent_pieces.borrow_mut().remove(&id); + let mut partial_to_remove = None; if let Some(item) = self.find(id) { // Segmented partials have holes: without the bitmap cancel drops the file — unless the row may come back via Undo. + // Return the path so callers can reserve it and unlink off the GTK thread. if had_segments && !keep_partial { - let _ = std::fs::remove_file(item.file_path()); + partial_to_remove = Some(item.file_path()); } // Unfinished torrent rows drop their session entry but keep partial files, so cancel/retry and remove/Undo resume. if crate::torrent::is_torrent(&item.url()) && item.status() != DownloadStatus::Done { @@ -2869,7 +2901,7 @@ impl DownloadManager { item.set_detail(item.status().label()); } } - } + partial_to_remove /// Re-queue a failed or cancelled download. pub fn retry(self: &Rc, id: u64) { @@ -2900,7 +2932,7 @@ impl DownloadManager { /// Cancel and drop a row; restore with [`DownloadManager::unremove`]. The partial file is kept so Undo can resume. pub fn remove(self: &Rc, id: u64) { - self.cancel_inner(id, true, Stop::Discard); + let _ = self.cancel_inner(id, true, Stop::Discard); self.epoch.borrow_mut().remove(&id); self.started.borrow_mut().remove(&id); // Video rows defer the sweep to `finish_discard`, which waits for the worker: a finalize path still inside its rename would otherwise deliver a file for a row that no longer exists. @@ -3148,6 +3180,7 @@ impl DownloadManager { /// Cancel every queued, downloading or paused item. pub fn cancel_all(self: &Rc) { // One persist at the end and no per-row start_next: cancel() would briefly spawn the next queued row only to cancel it right after. + let mut partials_to_remove = Vec::new(); self.for_matching( |s| { matches!( @@ -3155,8 +3188,15 @@ impl DownloadManager { DownloadStatus::Queued | DownloadStatus::Downloading | DownloadStatus::Paused ) }, - |m, id| m.cancel_inner(id, false, Stop::Preserve), + |m, id| { + if let Some(path) = m.cancel_inner(id, false, Stop::Preserve) { + partials_to_remove.push(path); + } + }, ); + for path in partials_to_remove { + self.schedule_partial_cleanup(path); + } self.persist_queue(); self.changed(); self.start_next(); From 8088f425768f7597332939f2803e623ffe1d60b8 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:12:30 +0100 Subject: [PATCH 02/89] fix: move pump filesystem work off GTK --- src/download.rs | 131 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 101 insertions(+), 30 deletions(-) diff --git a/src/download.rs b/src/download.rs index e7a827d22..03cab888f 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1679,7 +1679,7 @@ impl DownloadManager { /// Whether a finished-name candidate is taken (file on disk, reserved stem, or live row holding it). Used by the Finished claim loop. fn is_name_taken(&self, dir: &str, existing: &[String], n: &str) -> bool { - std::path::Path::new(dir).join(n).exists() + existing.iter().any(|entry| entry == n) || crate::video_staging::stem_reserved_in(existing, name_stem(n)) // A discard still tearing down owns this destination and its // stem: the finalizer's sweep (and the orphan removal when the @@ -1810,14 +1810,31 @@ impl DownloadManager { let name = this.finalize_filename(&name); let current = item.filename().to_string(); let dir = item.dest_dir().to_string(); - let existing = crate::video_staging::dir_file_names( - std::path::Path::new(&dir), - ); + let listing_dir = dir.clone(); + let existing = match gio::spawn_blocking(move || { + crate::video_staging::dir_file_names(std::path::Path::new(&listing_dir)) + }) + .await + { + Ok(names) => names, + Err(error) => { + tracing::warn!(error = ?error, dir = %dir, "finished-name directory listing failed"); + Vec::new() + } + }; + // The main context was yielded while the directory was read. Do not finalize a row that was + // paused/cancelled, removed, or replaced while the worker ran. + if item.status() == DownloadStatus::Cancelled + || item.status() == DownloadStatus::Paused + { + done = true; + break; + } let taken = |n: &str| this.is_name_taken(&dir, &existing, n); // Claim-then-move so a file appearing between dedupe and rename is never clobbered. let old_path = item.file_path(); let mut final_name = dedupe_filename(&name, taken); - let mut moved = !old_path.exists(); + let mut moved = !existing.iter().any(|entry| entry == ¤t); for _ in 0..8 { if moved || old_path == std::path::Path::new(&dir).join(&final_name) @@ -1846,32 +1863,54 @@ impl DownloadManager { // pre-rename one. Paints the engine's size now, // refines with the measured size when it lands. this.set_finished_detail(&item, size); - // Server file date, when asked: best-effort, never fails the row. + // Server-date stamping and torrent directory cleanup can perform filesystem + // metadata reads and recursive walks; snapshot plain inputs and defer them. let mtime = this.server_mtime.borrow_mut().remove(&id); - if this.settings.keep_server_date() - && let Some(t) = mtime - && let Ok(f) = std::fs::File::open(item.file_path()) - { - let _ = f.set_modified(t); - } + let keep_server_date = this.settings.keep_server_date(); + let cleanup_torrent = crate::torrent::is_torrent(&url) + && !this.settings.torrent_seed_finished(); + let finished_path = item.file_path(); + let torrent_dest_dir = item.dest_dir().to_string(); + let torrent_output_dir = item.output_dir().to_string(); + let torrent_filename = item.filename().to_string(); item.set_progress(1.0); item.set_status(DownloadStatus::Done); // One finished record per URL (Parabolic parity): re-downloads replace instead of stacking. this.drop_finished_duplicates(&url, id); this.segment_state.borrow_mut().remove(&id); this.torrent_pieces.borrow_mut().remove(&id); - // Filtered torrents: drop untoggled files now that every selected byte is on disk. Skipped while seeding (serving still reads those spans). - if crate::torrent::is_torrent(&url) - && !this.settings.torrent_seed_finished() - { - let folder = Self::torrent_folder(&item); - if folder.is_dir() { - crate::torrent::cleanup_unselected(&folder, &url); - } + if cleanup_torrent { // The intake filter is spent: keeping it would pin the index list for a done row. crate::torrent::drop_selection(&url); } - this.notify_finished(&item, Ok(())); + if this.find(id).is_some() { + this.notify_finished(&item, Ok(())); + } + if (keep_server_date && mtime.is_some()) || cleanup_torrent { + let worker_url = url.clone(); + glib::spawn_future_local(async move { + let _ = gio::spawn_blocking(move || { + if keep_server_date + && let Some(time) = mtime + && let Ok(file) = std::fs::File::open(&finished_path) + { + let _ = file.set_modified(time); + } + if cleanup_torrent { + let folder = DownloadManager::torrent_folder_from_parts( + &torrent_dest_dir, + &worker_url, + &torrent_output_dir, + &torrent_filename, + ); + if folder.is_dir() { + crate::torrent::cleanup_unselected(&folder, &worker_url); + } + } + }) + .await; + }); + } } done = true; break; @@ -1969,11 +2008,17 @@ impl DownloadManager { // get 200 on every piece, and fail again permanently. // Explicitly delete the partial file: its bytes belong to the dead version and must not survive // into a retry (previously this was only emergent via 416/hole handling). - let _ = std::fs::remove_file(item.file_path()); + let invalid_path = item.file_path(); this.segment_state.borrow_mut().remove(&id); this.pending_names.borrow_mut().remove(&id); item.set_etag(String::new()); item.set_last_modified(String::new()); + this.persist_queue(); + this.reserve_dest(&invalid_path); + let worker_path = invalid_path.clone(); + let _ = gio::spawn_blocking(move || std::fs::remove_file(worker_path)).await; + this.release_dest(&invalid_path); + this.start_next(); if item.status() != DownloadStatus::Cancelled && item.status() != DownloadStatus::Paused { @@ -2013,18 +2058,44 @@ impl DownloadManager { } } EngineMsg::TruncatePrefix => { - if let Some(st) = this.segment_state.borrow_mut().get_mut(&id) { - truncate_to_prefix(&item.file_path(), st); - // The file lost everything past the prefix; the bitmap must forget it too or a later resume skips missing pieces. - st.forget_beyond_prefix(); + // Commit the matching bitmap first: Undo/retry during the worker must never see a + // prefix-truncated file paired with bits beyond the first gap. + let state = { + let mut states = this.segment_state.borrow_mut(); + states.get_mut(&id).map(|st| { + st.forget_beyond_prefix(); + st.clone() + }) + }; + if let Some(state) = state { + this.persist_queue(); + let path = item.file_path(); + this.reserve_dest(&path); + let worker_path = path.clone(); + let _ = gio::spawn_blocking(move || { + truncate_to_prefix(&worker_path, &state) + }) + .await; + this.release_dest(&path); + this.start_next(); } } EngineMsg::FallbackSingle { ack } => { - // Server throttled parallel connections: shrink to the completed prefix and forget the bitmap on the main thread. The engine waits for this ack before appending at EOF. - if let Some(st) = this.segment_state.borrow().get(&id) { - truncate_to_prefix(&item.file_path(), st); + // Drop and persist the bitmap before yielding. The worker owns only the + // file mutation; a retry/Undo waits on the path reservation until it completes. + let state = this.segment_state.borrow_mut().remove(&id); + this.persist_queue(); + if let Some(state) = state { + let path = item.file_path(); + this.reserve_dest(&path); + let worker_path = path.clone(); + let _ = gio::spawn_blocking(move || { + truncate_to_prefix(&worker_path, &state) + }) + .await; + this.release_dest(&path); + this.start_next(); } - this.segment_state.borrow_mut().remove(&id); ack.send(()).await.ok(); } EngineMsg::SuggestName(name) => { From a1c9d2e93c6a2833b8111ddfd45c456428c31346 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:15:07 +0100 Subject: [PATCH 03/89] perf: serialize queue snapshots off GTK --- src/download.rs | 89 ++++++++++++++++++++++++++----------------- src/download_store.rs | 2 +- 2 files changed, 55 insertions(+), 36 deletions(-) diff --git a/src/download.rs b/src/download.rs index 03cab888f..44eff958a 100644 --- a/src/download.rs +++ b/src/download.rs @@ -569,9 +569,12 @@ impl DownloadManager { // so the last persist completes before shutdown awaits the task. #[cfg(not(test))] let (persist_tx, persist_task) = { - let (tx, rx) = tokio::sync::watch::channel(None::); - let task = tokio_rt().spawn(Self::persist_task_loop(rx, |text: String| { - Self::write_queue_file(&text) + let (tx, rx) = tokio::sync::watch::channel(None::); + let task = tokio_rt().spawn(Self::persist_task_loop(rx, |queue: StoredQueue| { + match serde_json::to_string(&queue) { + Ok(text) => Self::write_queue_file(&text), + Err(error) => tracing::error!("could not serialize download queue: {error}"), + } })); (Some(tx), Some(task)) }; @@ -3506,8 +3509,11 @@ impl DownloadManager { Self::write_queue_file(&text); } #[cfg(not(test))] - if let (Some(text), Some(tx)) = (self.serialize_queue(), &*self.persist_tx.borrow()) { - tx.send_replace(Some(text)); + if let (Some(queue), Some(tx)) = ( + self.snapshot_queue_inner(false), + &*self.persist_tx.borrow(), + ) { + tx.send_replace(Some(queue)); } } @@ -3515,17 +3521,20 @@ impl DownloadManager { /// Extracted for testing: `watch::changed()` delivers the final value before /// the closed-channel Err, so dropping the sender after a final send guarantees /// the last write lands. - pub(crate) async fn persist_task_loop( - mut rx: tokio::sync::watch::Receiver>, - write: impl Fn(String) + Clone + Send + 'static, - ) { + pub(crate) async fn persist_task_loop( + mut rx: tokio::sync::watch::Receiver>, + write: impl Fn(T) + Clone + Send + 'static, + ) + where + T: Clone + Send + 'static, + { while rx.changed().await.is_ok() { - let Some(text) = rx.borrow_and_update().clone() else { + let Some(value) = rx.borrow_and_update().clone() else { continue; }; - // File I/O must not block the async runtime. + // Queue serialization and disk I/O must not block GTK or the async runtime. let write = write.clone(); - let _ = tokio::task::spawn_blocking(move || write(text)).await; + let _ = tokio::task::spawn_blocking(move || write(value)).await; } } @@ -3546,10 +3555,11 @@ impl DownloadManager { tracing::warn!( "shutdown: queue restore did not complete; preserving the saved queue file" ); - } else if let (Some(text), Some(tx)) = - (self.serialize_queue_inner(true), &*self.persist_tx.borrow()) - { - tx.send_replace(Some(text)); + } else if let (Some(queue), Some(tx)) = ( + self.snapshot_queue_inner(true), + &*self.persist_tx.borrow(), + ) { + tx.send_replace(Some(queue)); } drop(self.persist_tx.borrow_mut().take()); if let Some(task) = self.persist_task.borrow_mut().take() { @@ -3561,14 +3571,9 @@ impl DownloadManager { } } - /// Serialize the queue to JSON. Returns None if batching or on serialize error. - fn serialize_queue(&self) -> Option { - self.serialize_queue_inner(false) - } - - /// Serialize the queue to JSON, bypassing the batch gate when `force` is true. - /// Shutdown uses `force=true`: quitting mid-batch must persist the final state. - fn serialize_queue_inner(&self, force: bool) -> Option { + /// Build a thread-safe snapshot from GTK-owned rows. The expensive JSON + /// encoding is done by the background persistence worker. + fn snapshot_queue_inner(&self, force: bool) -> Option { if !force && self.batch.get() > 0 { return None; } @@ -3637,22 +3642,36 @@ impl DownloadManager { }); } } - let data = StoredQueue { + Some(StoredQueue { version: QUEUE_VERSION, items, - }; - // Compact output cuts queue size ~3x but does not enforce the 10 MB - // restore cap; a very large paused set can still be quarantined. - let text = match serde_json::to_string(&data) { - Ok(text) => text, - Err(e) => { - tracing::error!("could not serialize download queue: {e}"); - return None; + }) + } + + /// Test helper for assertions over the serialized queue representation. + #[cfg(test)] + fn serialize_queue(&self) -> Option { + self.serialize_queue_inner(false) + } + + /// Synchronous serialization is used only by deterministic test persistence. + #[cfg(test)] + fn serialize_queue_inner(&self, force: bool) -> Option { + let queue = self.snapshot_queue_inner(force)?; + Self::serialize_snapshot(queue) + } + + fn serialize_snapshot(queue: StoredQueue) -> Option { + match serde_json::to_string(&queue) { + Ok(text) => Some(text), + Err(error) => { + tracing::error!("could not serialize download queue: {error}"); + None } - }; - Some(text) + } } + /// Write the serialized queue atomically: temp file + sync_all + rename + /// dir sync. The persist task is the sole writer, so writes are ordered by /// construction. Runs via `spawn_blocking`, never on the async runtime or diff --git a/src/download_store.rs b/src/download_store.rs index b92377b22..4190aead1 100644 --- a/src/download_store.rs +++ b/src/download_store.rs @@ -89,7 +89,7 @@ pub(crate) struct StoredItem { pub(crate) gallery: Option, } -#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub(crate) struct StoredQueue { pub(crate) version: u32, pub(crate) items: Vec, From ce0c05acc8cc4313347eeb1575ac2e10961b7326 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:17:34 +0100 Subject: [PATCH 04/89] fix: keep synchronous shutdown fallback nonblocking --- src/download.rs | 57 ++++++++++++------------------------------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/src/download.rs b/src/download.rs index 44eff958a..a25014935 100644 --- a/src/download.rs +++ b/src/download.rs @@ -3562,12 +3562,11 @@ impl DownloadManager { tx.send_replace(Some(queue)); } drop(self.persist_tx.borrow_mut().take()); - if let Some(task) = self.persist_task.borrow_mut().take() { - // Bound the shutdown wait: a stalled disk must not hang quit. - let _ = tokio_rt().block_on(async { - tokio::time::timeout(std::time::Duration::from_secs(5), task).await - }); - } + // This is the synchronous fallback reached only when the normal + // close/Quit path could not run its async shutdown. Detach the writer + // instead of blocking GTK; the ordinary path awaits it in + // persist_for_shutdown_async before the application quits. + drop(self.persist_task.borrow_mut().take()); } } @@ -4360,10 +4359,10 @@ impl DownloadManager { #[cfg(not(test))] { if self.restore_state.get() == RestoreState::Ready { - if let (Some(text), Some(tx)) = - (self.serialize_queue_inner(true), &*self.persist_tx.borrow()) + if let (Some(queue), Some(tx)) = + (self.snapshot_queue_inner(true), &*self.persist_tx.borrow()) { - tx.send_replace(Some(text)); + tx.send_replace(Some(queue)); } } else { tracing::warn!( @@ -4392,50 +4391,22 @@ impl DownloadManager { } } - pub(crate) fn shutdown_with_timeout(&self, timeout: Duration) { + pub(crate) fn shutdown_with_timeout(&self, _timeout: Duration) { self.stop_scheduler(); self.draining.set(true); let handles: Vec<_> = self.running.borrow_mut().drain().map(|(_, h)| h).collect(); - // Discard workers first through their retained abort handles: aborting a finalizer would detach its worker instead of stopping it. Finalizers are awaited (not aborted) so cleanup still runs. let finals: Vec = self.discards.borrow_mut().drain().map(|(_, p)| p).collect(); - for handle in handles.iter() { + for handle in &handles { handle.abort(); } for pending in &finals { pending.worker_abort.abort(); } - // Bound the join: on timeout the remaining handles are dropped - // (detached) and partial files are left alone — the next launch - // reconciles them via the length-vs-prefix check in spawn(). - let joined = tokio_rt() - .block_on(async { - tokio::time::timeout(timeout, async { - for handle in handles { - let _ = handle.await; - } - for pending in finals { - let _ = pending.finalizer.await; - } - }) - .await - }) - .is_ok(); - if !joined { - tracing::warn!("shutdown: join timed out; persisting without truncating partial files"); - self.persist_for_shutdown(); - return; - } - let ids: Vec = self.segment_state.borrow().keys().cloned().collect(); - for id in ids { - if let Some(item) = self.find(id) - && let Some(st) = self.segment_state.borrow_mut().get_mut(&id) - { - truncate_to_prefix(&item.file_path(), st); - st.forget_beyond_prefix(); - } - } - // Persist BEFORE returning: pump tails exit silently once draining is set, so this is the only persist that matters. + // This synchronous fallback is only for shutdown paths that bypass the + // normal GLib async close flow. Do not block GTK joining workers or + // truncating files. Preserve the bitmap as-is; the next launch can + // reconcile stale partial lengths without risking a false bitmap. self.persist_for_shutdown(); } } From 5d924e6c0179c89bbce49e7a4b76eee91c490047 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:19:58 +0100 Subject: [PATCH 05/89] perf: prepare download destinations off GTK --- src/download.rs | 253 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 169 insertions(+), 84 deletions(-) diff --git a/src/download.rs b/src/download.rs index a25014935..95075e812 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1504,33 +1504,162 @@ impl DownloadManager { } fn spawn(self: &Rc, item: DownloadItem) { - let opts = DownloadOptions::from_settings(&self.settings); - // Re-stash per attempt: a stale Last-Modified must never apply when the new run's server sends none. - self.server_mtime.borrow_mut().remove(&item.id()); + let id = item.id(); let dest = item.file_path(); - if let Some(parent) = dest.parent() { - // A planted symlink in any parent component would divert the - // download outside the destination: refuse instead of following - // it. (Filenames can't contain separators, so the parent is the - // destination itself today; this pins the invariant.) - // A mkdir failure is reported distinctly: it is not a containment - // refusal, so "escapes the download folder" would mislead. - let blocked = match ensure_contained_parent(parent, &item.dest_dir()) { - Ok(true) => None, - Ok(false) => Some(gettext("Download path escapes the download folder")), - Err(e) => Some( - gettext("Could not create the download folder: {error}") - .replace("{error}", &e.to_string()), - ), + let dest_dir = item.dest_dir().to_string(); + let url = item.url().to_string(); + let is_torrent = crate::torrent::is_torrent(&url); + let is_video = matches!( + self.video_source(id), + Some(crate::media_types::VideoSource::Page { .. }) + ); + let segments = self.segment_state.borrow().get(&id).cloned(); + let started = self.started.borrow().contains(&id); + // Reserve the target while filesystem validation is in flight. This + // keeps another queued row or a rapid retry from claiming the same path. + self.reserve_dest(&dest); + let weak = Rc::downgrade(self); + glib::spawn_future_local(async move { + let worker_dest = dest.clone(); + let worker_dir = dest_dir.clone(); + let worker_url = url.clone(); + let worker_recorded = item.output_dir().to_string(); + let worker_filename = item.filename().to_string(); + let worker_segments = segments.clone(); + let preparation = gio::spawn_blocking(move || { + let parent = worker_dest.parent(); + if let Some(parent) = parent { + match ensure_contained_parent(parent, &worker_dir) { + Ok(true) => {} + Ok(false) => { + return Err(gettext("Download path escapes the download folder")); + } + Err(error) => { + return Err( + gettext("Could not create the download folder: {error}") + .replace("{error}", &error.to_string()), + ); + } + } + } + + let mut torrent_dest = None; + let mut torrent_output_dir = None; + if is_torrent { + let dir = std::path::PathBuf::from(&worker_dir); + let recorded_path = std::path::PathBuf::from(&worker_recorded); + let output = if worker_recorded.is_empty() { + dir.clone() + } else { + let guarded = + crate::torrent::guard_output_folder(&dir, recorded_path.clone()); + if guarded != recorded_path { + torrent_output_dir = Some(guarded.to_string_lossy().into_owned()); + } else if guarded.parent() != Some(dir.as_path()) { + std::fs::create_dir_all(&guarded).map_err(|error| { + gettext("Could not create the download folder: {error}") + .replace("{error}", &error.to_string()) + })?; + } + guarded + }; + if worker_recorded.is_empty() { + std::fs::create_dir_all(&dir).map_err(|error| { + gettext("Could not create the download folder: {error}") + .replace("{error}", &error.to_string()) + })?; + } + torrent_dest = Some(output); + } + + let mut drop_segments = false; + let mode = if is_torrent || is_video { + None + } else { + Some(match worker_segments { + Some(state) => { + // Drop an invalid bitmap and stale partial together. Do both on + // the worker so resume validation does not block GTK. + let len = std::fs::metadata(&worker_dest) + .map(|metadata| metadata.len()) + .unwrap_or(0); + if len < state.prefix_len() || len > state.total { + let _ = std::fs::remove_file(&worker_dest); + drop_segments = true; + StartMode::Fresh + } else { + StartMode::Resume(state) + } + } + None => match std::fs::metadata(&worker_dest) { + Err(_) => StartMode::Fresh, + Ok(_) if !started => { + return Err(DEST_EXISTS.to_string()); + } + Ok(_) => StartMode::Single, + }, + }) + }; + Ok((mode, drop_segments, torrent_dest, torrent_output_dir)) + }) + .await; + + let Some(manager) = weak.upgrade() else { + return; }; - if let Some(detail) = blocked { - item.set_detail(detail.clone()); - item.set_status(DownloadStatus::Failed); - self.changed(); - self.notify_finished(&item, Err(detail)); + manager.release_dest(&dest); + let Some(current) = manager.find(id) else { + manager.start_next(); + return; + }; + if current.status() != DownloadStatus::Queued || current.file_path() != dest { + manager.start_next(); return; } - } + // A cancel/retry can happen while the blocking task runs. If any input + // changed, discard its answer and let the scheduler take a fresh snapshot. + let current_segments = manager.segment_state.borrow().get(&id).cloned(); + let same_segments = match (&segments, ¤t_segments) { + (None, None) => true, + (Some(before), Some(after)) => { + before.total == after.total && before.done == after.done + } + _ => false, + }; + if !same_segments || manager.started.borrow().contains(&id) != started { + manager.start_next(); + return; + } + + let (mode, drop_segments, torrent_dest, torrent_output_dir) = match preparation { + Ok(Ok(prepared)) => prepared, + Ok(Err(detail)) => { + current.set_detail(detail.clone()); + current.set_status(DownloadStatus::Failed); + manager.changed(); + manager.notify_finished(¤t, Err(detail)); + manager.start_next(); + return; + } + Err(error) => { + let detail = gettext("Could not prepare download destination: {error}") + .replace("{error}", &format!("{error:?}")); + current.set_detail(detail.clone()); + current.set_status(DownloadStatus::Failed); + manager.changed(); + manager.notify_finished(¤t, Err(detail)); + manager.start_next(); + return; + } + }; + if drop_segments { + manager.segment_state.borrow_mut().remove(&id); + manager.persist_queue(); + } + manager.spawn_prepared(current, mode, torrent_dest, torrent_output_dir); + }); + } + // Fail fast on junk; later edits apply without re-queueing. let limit = opts.limit_rate.trim(); if !limit.is_empty() && limit != "0" && parse_rate(limit).is_none() { @@ -1542,7 +1671,16 @@ impl DownloadManager { publish_rate_limit(&self.settings); let url = item.url().to_string(); if crate::torrent::is_torrent(&url) { - return self.spawn_torrent(item, url); + let Some(dest) = torrent_dest else { + item.set_detail(gettext("Could not prepare the torrent output folder")); + item.set_status(DownloadStatus::Failed); + self.changed(); + return; + }; + if let Some(output_dir) = torrent_output_dir { + item.set_output_dir(output_dir); + } + return self.spawn_torrent(item, url, dest); } if let Some(crate::media_types::VideoSource::Page { page_url, @@ -1567,43 +1705,7 @@ impl DownloadManager { let connections = (opts.connections.max(1) as usize).min(16); let timeout = Duration::from_secs(30); // A saved bitmap means non-contiguous pieces: only a segmented resume is correct. - let mode = match self.segment_state.borrow().get(&item.id()).cloned() { - Some(st) => { - // Drop the bitmap if the file no longer holds the completed prefix (deleted/truncated/replaced while paused). - let len = std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(0); - if len < st.prefix_len() || len > st.total { - self.segment_state.borrow_mut().remove(&item.id()); - let _ = std::fs::remove_file(&dest); - StartMode::Fresh - } else { - let frac = - (st.completed_bytes() as f64 / st.total.max(1) as f64).clamp(0.0, 1.0); - item.set_progress(frac); - StartMode::Resume(st) - } - } - None => match std::fs::metadata(&dest) { - // A missing destination is a fresh download. An existing - // destination — even zero bytes — is refused: it may not be - // ours, and silently deleting it could destroy foreign data. - Err(_) => StartMode::Fresh, - Ok(_) => { - // Parabolic-style dedup: refuse an existing destination - // instead of truncating or renaming it. Restored/retried - // rows carry `started` and resume their own partials - // below (progress alone can't tell: a crash before the - // first tick restores at 0.0 with bytes on disk). - if !self.started.borrow().contains(&item.id()) { - item.set_detail(DEST_EXISTS.to_string()); - item.set_status(DownloadStatus::Failed); - self.changed(); - self.notify_finished(&item, Err(DEST_EXISTS.to_string())); - return; - } - StartMode::Single - } - }, - }; + let mode = mode.unwrap_or(StartMode::Fresh); let generation = self.epoch.borrow().get(&item.id()).cloned().unwrap_or(0) + 1; self.epoch.borrow_mut().insert(item.id(), generation); let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); @@ -2200,29 +2302,12 @@ impl DownloadManager { } /// Spawn the torrent engine for a magnet row. Mirrors `spawn`'s contract so pause/cancel/retry and the stale-pump guard keep working. - fn spawn_torrent(self: &Rc, item: DownloadItem, magnet: String) { - let dir = std::path::PathBuf::from(item.dest_dir().to_string()); - // Magnets recorded their subfolder at enqueue; archived torrents recompute theirs from metadata. - let recorded = item.output_dir().to_string(); - let dest = if recorded.is_empty() { - let _ = std::fs::create_dir_all(&dir); - dir.clone() - } else { - // Re-guard the recorded subfolder at spawn: a symlink planted - // after intake (a wide window for queued torrents) would - // otherwise divert the engine's writes. A squatted name dedupes - // to a fresh folder, like collection subfolders. - let recorded_path = std::path::PathBuf::from(&recorded); - let guarded = crate::torrent::guard_output_folder(&dir, recorded_path.clone()); - if guarded != recorded_path { - item.set_output_dir(guarded.to_string_lossy().into_owned()); - } else if guarded.parent() != Some(dir.as_path()) { - // Not under the dest dir (shouldn't happen: every recorded - // path is dir-joined): keep the old ensure-exists behavior. - let _ = std::fs::create_dir_all(&guarded); - } - guarded - }; + fn spawn_torrent( + self: &Rc, + item: DownloadItem, + magnet: String, + dest: std::path::PathBuf, + ) { let settings = &self.settings; let seed_finished = settings.torrent_seed_finished(); let peer_limit: Option = None; // rqbit default From d487ea1af17fb4d83758ae5dd33b84d18e456d5c Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:21:35 +0100 Subject: [PATCH 06/89] fix: complete async spawn preparation refactor --- src/download.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/download.rs b/src/download.rs index 95075e812..86fb37a02 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1522,9 +1522,7 @@ impl DownloadManager { glib::spawn_future_local(async move { let worker_dest = dest.clone(); let worker_dir = dest_dir.clone(); - let worker_url = url.clone(); let worker_recorded = item.output_dir().to_string(); - let worker_filename = item.filename().to_string(); let worker_segments = segments.clone(); let preparation = gio::spawn_blocking(move || { let parent = worker_dest.parent(); @@ -1660,6 +1658,17 @@ impl DownloadManager { }); } + fn spawn_prepared( + self: &Rc, + item: DownloadItem, + mode: Option, + torrent_dest: Option, + torrent_output_dir: Option, + ) { + let opts = DownloadOptions::from_settings(&self.settings); + // Re-stash per attempt: a stale Last-Modified must never apply when the new run's server sends none. + self.server_mtime.borrow_mut().remove(&item.id()); + let dest = item.file_path(); // Fail fast on junk; later edits apply without re-queueing. let limit = opts.limit_rate.trim(); if !limit.is_empty() && limit != "0" && parse_rate(limit).is_none() { @@ -2308,6 +2317,7 @@ impl DownloadManager { magnet: String, dest: std::path::PathBuf, ) { + let recorded = item.output_dir().to_string(); let settings = &self.settings; let seed_finished = settings.torrent_seed_finished(); let peer_limit: Option = None; // rqbit default From c2823ee88b21a6fbba52d9223f67c7a8088e6137 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:32:51 +0100 Subject: [PATCH 07/89] perf: move download intake filesystem work off GTK --- src/download.rs | 150 ++++++++++++++++++++++++++++++------------------ 1 file changed, 94 insertions(+), 56 deletions(-) diff --git a/src/download.rs b/src/download.rs index 86fb37a02..5092dca91 100644 --- a/src/download.rs +++ b/src/download.rs @@ -922,29 +922,50 @@ impl DownloadManager { /// /// # Errors /// Returns a display-ready message when the bytes are not a valid torrent. - pub fn enqueue_torrent_file( + /// Archive and enqueue a .torrent without performing disk I/O on GTK. + pub async fn enqueue_torrent_file_async( self: &Rc, bytes: Vec, file_name: &str, dest_dir: Option<&str>, only_files: Option>, ) -> Result { - let pseudo = crate::torrent::archive_torrent_file(file_name, &bytes)?; - if let Some(sel) = only_files { - crate::torrent::stage_selection(&pseudo, sel); + let file_name = file_name.to_string(); + let dir = self.resolve_dir(dest_dir); + let worker_file_name = file_name.clone(); + let worker_dir = dir.clone(); + let (pseudo, base, existing) = gio::spawn_blocking(move || { + // Parse metadata before archiving so invalid input never leaves an archive behind. + let base = crate::torrent::intake_plan(&bytes).map(|(base, _multi)| base); + let pseudo = crate::torrent::archive_torrent_file(&worker_file_name, &bytes)?; + let existing = + crate::video_staging::dir_file_names(std::path::Path::new(&worker_dir)); + Ok::<_, String>((pseudo, base, existing)) + }) + .await + .map_err(|error| format!("Could not prepare torrent file: {error:?}"))??; + + if let Some(selection) = only_files { + crate::torrent::stage_selection(&pseudo, selection); } - let stub = crate::torrent::stub_name_for_file(file_name); + let stub = crate::torrent::stub_name_for_file(&file_name); let item = self.enqueue(&pseudo, dest_dir, Some(&stub))?; - // The engine writes with overwrite:true, so record a deduped subfolder the engine then uses as-is. - if let Some((base, _)) = crate::torrent::intake_plan(&bytes) { - let dir = self.resolve_dir(dest_dir); - if std::path::Path::new(&dir).join(&base).exists() { - let name = dedupe_filename(&base, |n| { - let p = std::path::Path::new(&dir).join(n); - p.exists() - // Only video-row finished names reserve stems (see plain `enqueue`). - || self.items() - .any(|it| it.output_dir() == p.to_string_lossy()) + + // The engine writes with overwrite:true, so reserve a collision-free + // output folder from the listing snapshot before it can be spawned. + if let Some(base) = base { + let candidate = std::path::Path::new(&dir).join(&base); + let candidate_s = candidate.to_string_lossy().into_owned(); + let disk_taken = existing.iter().any(|entry| entry == &base); + let row_taken = self.items().any(|row| row.output_dir() == candidate_s); + if disk_taken || row_taken || self.dest_reserved(&candidate) { + let name = dedupe_filename(&base, |name| { + let path = std::path::Path::new(&dir).join(name); + existing.iter().any(|entry| entry == name) + || self.dest_reserved(&path) + || self + .items() + .any(|row| row.output_dir() == path.to_string_lossy()) }); item.set_output_dir( std::path::Path::new(&dir) @@ -975,45 +996,49 @@ impl DownloadManager { /// /// # Errors /// Returns a display-ready message when the URL or filename is invalid. - pub fn enqueue_video( + /// Enqueue a video page after preparing its directory listing off GTK. + pub async fn enqueue_video_async( self: &Rc, page_url: &str, dest_dir: Option<&str>, filename: Option<&str>, choices: crate::media_types::VideoChoices, ) -> Result { - // One readdir per intake. let dir = self.resolve_dir(dest_dir); - let existing = crate::video_staging::dir_file_names(std::path::Path::new(&dir)); + let listing_dir = dir.clone(); + let existing = gio::spawn_blocking(move || { + crate::video_staging::dir_file_names(std::path::Path::new(&listing_dir)) + }) + .await + .map_err(|error| format!("Could not inspect download folder: {error:?}"))?; self.enqueue_video_staged(page_url, &dir, filename, choices, &existing) } /// Enqueue a gallery download (image gallery via gallery-dl). /// Creates a DownloadItem with gallery flags set; the actual download /// is driven by the gallery runner (phase 12). - pub fn enqueue_gallery( + /// Enqueue a gallery after listing its destination off GTK. + pub async fn enqueue_gallery_async( self: &Rc, job: &crate::gallery_argv::GalleryJob, ) -> Result { let url = normalize_url(&job.page_url)?; - // Resolve the destination: empty/"." fall back to the download dir; - // relative paths are rejected (would resolve against the launcher CWD). let dir = self.resolve_dir(Some(&job.dest_dir.to_string_lossy())); - // Gallery title is the folder name; sanitize it (probe falls back to - // the URL, which contains `/` and would fail restore validation). let title = crate::file_names::sanitize_folder_name(&job.title); - // Ensure the gallery directory is unique and provably ours: if a - // directory with the sanitized title already exists on disk - // (pre-existing user folder), or another gallery row already claims - // that name (same title enqueued twice, neither downloaded yet), - // append -2, -3, etc. until a free name is found. This prevents the - // delete path from trashing unrelated files. + let listing_dir = dir.clone(); + let existing = gio::spawn_blocking(move || { + crate::video_staging::dir_file_names(std::path::Path::new(&listing_dir)) + }) + .await + .map_err(|error| format!("Could not inspect gallery destination: {error:?}"))?; + let mut unique_title = title.clone(); let mut counter = 1; - while std::path::Path::new(&dir).join(&unique_title).exists() + while existing.iter().any(|entry| entry == &unique_title) || self .items() .any(|it| it.dest_dir() == dir && it.filename() == unique_title) + || self.dest_reserved(&std::path::Path::new(&dir).join(&unique_title)) { counter += 1; unique_title = format!("{title}-{counter}"); @@ -1029,10 +1054,7 @@ impl DownloadManager { item.set_gallery_files_done(0); item.set_gallery_url(url.clone()); item.set_detail(format!("Gallery • {} files", job.file_count.unwrap_or(0))); - // Gallery files go into a subfolder named after the (unique) title. let gallery_dir = std::path::Path::new(&dir).join(&unique_title); - // Store a resolved copy: absolute dest_dir (the gallery subfolder) - // and the real row ID. (The caller's job keeps its placeholder ID.) let mut stored_job = job.clone(); stored_job.dest_dir = gallery_dir; stored_job.item_id = item.id(); @@ -1096,7 +1118,8 @@ impl DownloadManager { } /// Queue one row per playlist entry for collection-shaped resolves with no picked entry. Returns (added, reported total). One persist for the whole import. - fn expand_playlist_rows( + /// Queue collection children after creating/listing their destination off GTK. + async fn expand_playlist_rows_async( self: &Rc, id: u64, item: &DownloadItem, @@ -1110,31 +1133,51 @@ impl DownloadManager { }) => (quality, audio_only), _ => return (0, pl.total, 0), }; - let dest_dir = self.resolve_dir(Some(&item.dest_dir())); - let _batch = self.batch_guard(); - // Multi-item collections land in a titled subfolder, torrent-style; a - // lone item keeps the flat behavior. - let dest_dir = if pl.total > 1 { - crate::file_names::collection_subdir(&dest_dir, &pl.title) - } else { - dest_dir + let base_dir = self.resolve_dir(Some(&item.dest_dir())); + let multi = pl.total > 1; + let title = pl.title.clone(); + let prepared = gio::spawn_blocking(move || { + let dest_dir = if multi { + crate::file_names::collection_subdir(&base_dir, &title) + } else { + base_dir + }; + let existing = + crate::video_staging::dir_file_names(std::path::Path::new(&dest_dir)); + (dest_dir, existing) + }) + .await; + let (dest_dir, existing) = match prepared { + Ok(prepared) => prepared, + Err(error) => { + tracing::warn!( + error = ?error, + parent_id = id, + "playlist import directory preparation failed" + ); + return (0, pl.total, 0); + } }; - // The shared network-policy helper enforces a collection-local host - // cap for untrusted browser handoffs and remembers accepted hosts. + + // The main context yielded while the folder was prepared. Do not + // enqueue stale children after pause/cancel/remove. + let Some(current) = self.find(id) else { + return (0, pl.total, 0); + }; + if current.status() != DownloadStatus::Downloading { + return (0, pl.total, 0); + } + + let _batch = self.batch_guard(); let mut guarded_child_hosts = HashSet::new(); let mut skipped_by_host_limit = 0usize; - // One readdir for the whole expansion instead of one per entry. - let existing = crate::video_staging::dir_file_names(std::path::Path::new(&dest_dir)); - let mut added = 0; + let mut added = 0usize; for entry in &pl.items { let Some(url) = crate::video_probe::expand_child_target(&item.url(), &pl.page_url, entry) else { continue; }; - // Collection results are untrusted input. Keep the shared helper - // responsible for both policy inheritance and the distinct-host - // limit, so picker and automatic expansion behave identically. match crate::download_net::inherit_public_resolution_for_collection( &item.url(), &url, @@ -1147,12 +1190,9 @@ impl DownloadManager { } Err(crate::download_net::CollectionHostError::UnsafeDestination) => continue, } - // Match the interactive picker: carry each entry's title into its - // output filename, falling back to an entry URL slug when needed. let remux = crate::video_prefs::remux_video_active(&self.settings.remux_video()); let name = crate::video::default_video_filename(&entry.title, audio_only, remux.as_deref()); - // Live streams queued from a playlist take the VOD path; each child re-resolves its own page. if self .enqueue_video_staged( &url, @@ -1172,8 +1212,6 @@ impl DownloadManager { added += 1; } } - // Reported total, not attempted ("500 of 600"). Include a separate - // count for the security cap so the carrier row can explain omissions. (added, pl.total, skipped_by_host_limit) } @@ -2039,7 +2077,7 @@ impl DownloadManager { } // Playlist expansion runs on the main thread where queueing is legal; the new rows are the feedback. let (added, total, skipped_by_host_limit) = - this.expand_playlist_rows(id, &item, &pl); + this.expand_playlist_rows_async(id, &item, &pl).await; let skipped_detail = || { ngettext( "{skipped} item skipped because the collection exceeded the limit of {limit} distinct hosts", From e5bc4aea8e871b585248bd1d0913043f2cac6a76 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:35:08 +0100 Subject: [PATCH 08/89] refactor: enqueue media imports asynchronously --- src/inline_add.rs | 119 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 88 insertions(+), 31 deletions(-) diff --git a/src/inline_add.rs b/src/inline_add.rs index b4674f347..f5e4eee8c 100644 --- a/src/inline_add.rs +++ b/src/inline_add.rs @@ -844,9 +844,9 @@ fn submit_probed_single( .is_empty() .then(|| default_name_for(settings, &v.title, audio_only)); let name = if typed.is_empty() { - auto.as_deref() + auto } else { - Some(typed.as_str()) + Some(typed.clone()) }; // Exact picks pin the format with its height as fallback, so a dropped pin still // degrades to the chosen height; audio-only rows drop the pin, and Automatic (no @@ -867,28 +867,35 @@ fn submit_probed_single( .unwrap_or_else(|| manager.settings().video_quality()), None => manager.settings().video_quality(), }; - enqueue_and_close( + let page_url = v.page_url.clone(); + let is_live = v.is_live; + let enqueue_manager = Rc::clone(manager); + let error_step = Rc::clone(step); + let error_lookup_add = Rc::clone(lookup_add); + enqueue_and_close_async( manager, dest, close_card, scheduled_at, - |d| { - manager.enqueue_video( - &v.page_url, - d, - name, - crate::media_types::VideoChoices { - quality, - audio_only, - video_format_id: format_id, - is_live: v.is_live, - playlist_item_id: None, - }, - ) + move |d| async move { + enqueue_manager + .enqueue_video_async( + &page_url, + d.as_deref(), + name.as_deref(), + crate::media_types::VideoChoices { + quality, + audio_only, + video_format_id: format_id, + is_live, + playlist_item_id: None, + }, + ) + .await }, - |e| { - show_video_error(step, e); - set_lookup_add(lookup_add, true); + move |e| { + show_video_error(&error_step, e); + set_lookup_add(&error_lookup_add, true); }, ); } @@ -999,19 +1006,22 @@ fn submit_gallery_all( speed_limit: crate::gallery_runner::gallery_speed_limit(settings), range: None, }; - enqueue_and_close( + let enqueue_manager = Rc::clone(manager); + let error_step = Rc::clone(step); + let error_lookup_add = Rc::clone(lookup_add); + enqueue_and_close_async( manager, dest, close_card, scheduled_at, - |d| { + move |d| async move { let mut job = job; - job.dest_dir = std::path::PathBuf::from(d.unwrap_or(".")); - manager.enqueue_gallery(&job) + job.dest_dir = std::path::PathBuf::from(d.unwrap_or_else(|| ".".to_string())); + enqueue_manager.enqueue_gallery_async(&job).await }, - |e| { - show_video_error(step, e); - set_lookup_add(lookup_add, true); + move |e| { + show_video_error(&error_step, e); + set_lookup_add(&error_lookup_add, true); }, ); } @@ -1062,6 +1072,36 @@ pub(crate) fn enqueue_and_close( } } +pub(crate) fn enqueue_and_close_async( + manager: &Rc, + dest: &Rc>, + close_card: &Rc, + scheduled_at: Option, + enqueue: impl FnOnce(Option) -> F + 'static, + on_err: impl FnOnce(&str) + 'static, +) +where + F: std::future::Future> + 'static, +{ + let manager = Rc::clone(manager); + let dest = Rc::clone(dest); + let close_card = Rc::clone(close_card); + glib::spawn_future_local(async move { + // Own the destination value before awaiting; close_card re-borrows the + // destination when it resets the form after a successful enqueue. + let destination = Some(dest.borrow().clone()); + // Keep the new row parked until its scheduled timestamp is installed. + let _guard = manager.batch_guard(); + match enqueue(destination).await { + Ok(item) => { + apply_scheduled_at(&item, scheduled_at); + close_card(); + } + Err(error) => on_err(&error), + } + }); +} + /// Queue a probed link as a plain file and collapse the card: the fallback when /// extraction finds no playable media on an unlisted page. `Err` when plain intake rejects the URL. fn queue_plain( @@ -1506,12 +1546,24 @@ fn push_torrent_picker_page( let only = (selected.len() < grid.len()).then_some(selected); let show_picker_on_error = fallback_to_picker.clone(); let error_caption_on_error = error_caption.clone(); - enqueue_and_close( + let enqueue_manager = Rc::clone(&manager); + let enqueue_bytes = bytes.clone(); + let enqueue_name = file_name.clone(); + enqueue_and_close_async( &manager, &dest_dir, &close_card, scheduled_at.get(), - |d| manager.enqueue_torrent_file(bytes.clone(), &file_name, d, only), + move |d| async move { + enqueue_manager + .enqueue_torrent_file_async( + enqueue_bytes, + &enqueue_name, + d.as_deref(), + only, + ) + .await + }, move |e| { show_picker_on_error(); error_caption_on_error.set_text(e); @@ -1583,13 +1635,18 @@ fn wire_torrent_picker( } }; if entries.len() <= 1 { - enqueue_and_close( + let enqueue_manager = Rc::clone(&m); + enqueue_and_close_async( &m, &dd, &close_card, scheduled_at.get(), - |d| m.enqueue_torrent_file(bytes, &name, d, None), - |e| { + move |d| async move { + enqueue_manager + .enqueue_torrent_file_async(bytes, &name, d.as_deref(), None) + .await + }, + move |e| { set_field_error(&torrent_row, e); }, ); From 605aa71ffcbd005ebe8ea1e9b0a00cfb7bd481f2 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:35:43 +0100 Subject: [PATCH 09/89] refactor: await async enqueue from UI and file handlers --- src/application.rs | 7 ++++--- src/inline_gallery.rs | 40 ++++++++++++++++++++++------------------ 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/application.rs b/src/application.rs index 824e30e91..3f3407a21 100644 --- a/src/application.rs +++ b/src/application.rs @@ -157,7 +157,7 @@ async fn intake_remote_torrent( Routed::OpenedCard } Ok(_) => { - if let Err(e) = manager.enqueue_torrent_file(bytes, &stem, None, None) { + if let Err(e) = manager.enqueue_torrent_file_async(bytes, &stem, None, None).await { fail(e); return Routed::Failed; } @@ -560,8 +560,9 @@ fn handle_open_files(s: Rc, files: Vec) { add_card.open_torrent_picker(file_name, bytes, entries); } Ok(_) => { - if let Err(e) = - manager.enqueue_torrent_file(bytes, &stem, None, None) + if let Err(e) = manager + .enqueue_torrent_file_async(bytes, &stem, None, None) + .await { toasts.add_toast(adw::Toast::new(&crate::ui_util::esc_markup( &e, diff --git a/src/inline_gallery.rs b/src/inline_gallery.rs index 7d1cb9735..6d967fcc8 100644 --- a/src/inline_gallery.rs +++ b/src/inline_gallery.rs @@ -347,25 +347,29 @@ pub(crate) fn push_gallery_picker_page( speed_limit: crate::gallery_runner::gallery_speed_limit(settings), range: Some(range), }; - // Batch guard defers start_next until the Scheduled status is set. - let _guard = manager.batch_guard(); - let mut job = base_job.clone(); - job.dest_dir = std::path::PathBuf::from(dest_dir.borrow().clone()); - match manager.enqueue_gallery(&job) { - Ok(item) => { - apply_scheduled_at(&item, scheduled_at); - // Don't destroy the currently emitting button/page in its - // own clicked callback. close_card already pops back to form; - // defer that teardown until the signal has unwound. - let close = close_card.clone(); - gtk4::glib::idle_add_local_once(move || close()); + let manager = Rc::clone(&manager); + let dest_dir = Rc::clone(&dest_dir); + let close_card = Rc::clone(&close_card); + let error_caption = error_caption.clone(); + let add_btn_guard = add_btn_guard.clone(); + gtk4::glib::spawn_future_local(async move { + let _guard = manager.batch_guard(); + let mut job = base_job; + job.dest_dir = std::path::PathBuf::from(dest_dir.borrow().clone()); + match manager.enqueue_gallery_async(&job).await { + Ok(item) => { + apply_scheduled_at(&item, scheduled_at); + // Defer picker teardown until the clicked signal unwinds. + let close = close_card.clone(); + gtk4::glib::idle_add_local_once(move || close()); + } + Err(e) => { + error_caption.set_text(&e); + error_caption.set_visible(true); + add_btn_guard.set_sensitive(true); + } } - Err(e) => { - error_caption.set_text(&e); - error_caption.set_visible(true); - add_btn_guard.set_sensitive(true); - } - } + }); }); } From 3c3e662d7bc315b95386450b8b2783ce68012965 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:37:51 +0100 Subject: [PATCH 10/89] test: exercise async intake APIs and guard playlist cancellation --- src/download.rs | 5 ++- src/download_tests.rs | 96 ++++++++++++++++++------------------------- 2 files changed, 44 insertions(+), 57 deletions(-) diff --git a/src/download.rs b/src/download.rs index 5092dca91..29fe7fe47 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1164,7 +1164,10 @@ impl DownloadManager { let Some(current) = self.find(id) else { return (0, pl.total, 0); }; - if current.status() != DownloadStatus::Downloading { + if matches!( + current.status(), + DownloadStatus::Cancelled | DownloadStatus::Paused + ) { return (0, pl.total, 0); } diff --git a/src/download_tests.rs b/src/download_tests.rs index 2674d9825..20139edfb 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -1058,8 +1058,7 @@ fn enqueue_video_spawns_and_fails_without_tools() { .join("grab-video-enqueue") .to_string_lossy() .into_owned(); - let item = manager - .enqueue_video( + let item = glib::MainContext::default().block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest), Some("My Video.mp4"), @@ -1070,7 +1069,7 @@ fn enqueue_video_spawns_and_fails_without_tools() { is_live: false, playlist_item_id: None, }, - ) + )) .expect("video enqueue"); let id = item.id(); // The worker runs and fails fast on the missing tools (no network). @@ -1105,8 +1104,7 @@ fn enqueue_video_restrict_filenames_folds_name() { let dest = std::env::temp_dir().join("grab-video-restrict"); std::fs::create_dir_all(&dest).unwrap(); let dest = dest.to_string_lossy().into_owned(); - let item = manager - .enqueue_video( + let item = glib::MainContext::default().block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest), Some("Café & Croissants.mp4"), @@ -1117,7 +1115,7 @@ fn enqueue_video_restrict_filenames_folds_name() { is_live: false, playlist_item_id: None, }, - ) + )) .expect("video enqueue"); assert_eq!(item.filename(), "Cafe_Croissants.mp4"); let id = item.id(); @@ -1299,8 +1297,7 @@ fn video_source_survives_restore_and_retry() { let id = { let settings = test_settings(); let manager = DownloadManager::new(gio::ListStore::new::(), settings); - let item = manager - .enqueue_video( + let item = glib::MainContext::default().block_on(manager.enqueue_video_async( "https://vimeo.com/123456", Some(&dest), Some("Clip.mp4"), @@ -1311,7 +1308,7 @@ fn video_source_survives_restore_and_retry() { is_live: false, playlist_item_id: None, }, - ) + )) .expect("video enqueue"); // Persisted with the Page marker (not silently dropped). let text = std::fs::read_to_string(&qf).unwrap(); @@ -1461,8 +1458,7 @@ fn unremove_restores_video_source() { .join("grab-video-unremove") .to_string_lossy() .into_owned(); - let item = manager - .enqueue_video( + let item = glib::MainContext::default().block_on(manager.enqueue_video_async( "https://vimeo.com/123456", Some(&dest), Some("Clip.mp4"), @@ -1473,7 +1469,7 @@ fn unremove_restores_video_source() { is_live: false, playlist_item_id: None, }, - ) + )) .expect("video enqueue"); let id = item.id(); drain_engine(&manager, id); @@ -1513,14 +1509,13 @@ fn unremove_restores_video_source() { #[test] fn queue_file_never_carries_cookies() { // Secrets must not reach the persisted queue, whatever the settings. - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let (_lock, _loop) = test_locks(); let _notools = NoVideoTools::apply(); let qf = test_queue_file("video-cookies-persist"); let settings = test_settings(); settings.set_string("cookies-browser", "firefox").unwrap(); let manager = DownloadManager::new(gio::ListStore::new::(), settings); - let item = manager - .enqueue_video( + let item = glib::MainContext::default().block_on(manager.enqueue_video_async( "https://vimeo.com/123456", Some("/tmp/dl"), Some("Clip.mp4"), @@ -1531,7 +1526,7 @@ fn queue_file_never_carries_cookies() { is_live: false, playlist_item_id: None, }, - ) + )) .expect("video enqueue"); // Drain the worker here so no woken pump tail trips another test's thread guard. drain_engine(&manager, item.id()); @@ -1802,7 +1797,7 @@ fn staged_selection_survives_respawn() { #[test] fn torrent_file_enqueue_uses_stem_stub() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let (_lock, _loop) = test_locks(); let _qf = test_queue_file("torrent-enqueue"); let settings = test_settings(); // Occupy the only slot so nothing spawns a real engine below. @@ -1813,13 +1808,12 @@ fn torrent_file_enqueue_uses_stem_stub() { }); manager.running.borrow_mut().insert(99, holder); let stem = format!("grab-enqueue-{}", std::process::id()); - let item = manager - .enqueue_torrent_file( + let item = glib::MainContext::default().block_on(manager.enqueue_torrent_file_async( single_torrent_bytes(), &format!("{stem}.torrent"), None, None, - ) + )) .unwrap(); assert_eq!(item.status(), DownloadStatus::Queued); assert!(crate::torrent::is_torrent_url(&item.url())); @@ -1834,7 +1828,7 @@ fn torrent_file_enqueue_uses_stem_stub() { #[test] fn staged_selection_reaches_spawn_take() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let (_lock, _loop) = test_locks(); let _qf = test_queue_file("torrent-take"); let settings = test_settings(); // Occupy the only slot so nothing spawns a real engine below. @@ -1845,13 +1839,12 @@ fn staged_selection_reaches_spawn_take() { }); manager.running.borrow_mut().insert(99, holder); let stem = format!("grab-take-{}", std::process::id()); - let item = manager - .enqueue_torrent_file( + let item = glib::MainContext::default().block_on(manager.enqueue_torrent_file_async( single_torrent_bytes(), &format!("{stem}.torrent"), None, Some(vec![0]), - ) + )) .unwrap(); // Must survive the archive → normalize → store round-trip identically. assert_eq!( @@ -1905,7 +1898,7 @@ fn delete_download_trashes_torrent_files() { #[test] fn remove_keeps_torrent_archive_for_undo() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let (_lock, _loop) = test_locks(); let _qf = test_queue_file("torrent-remove"); let settings = test_settings(); // Occupy the only slot so nothing spawns a real engine below. @@ -1916,13 +1909,12 @@ fn remove_keeps_torrent_archive_for_undo() { }); manager.running.borrow_mut().insert(99, holder); let stem = format!("grab-undo-{}", std::process::id()); - let item = manager - .enqueue_torrent_file( + let item = glib::MainContext::default().block_on(manager.enqueue_torrent_file_async( single_torrent_bytes(), &format!("{stem}.torrent"), None, None, - ) + )) .unwrap(); let id = item.id(); let url = item.url().to_string(); @@ -2423,8 +2415,7 @@ fn enqueue_video_keeps_name_when_file_exists() { std::fs::create_dir_all(&dest).unwrap(); std::fs::write(dest.join("clip.mp4"), b"someone else").unwrap(); let dest_s = dest.to_string_lossy().into_owned(); - let item = manager - .enqueue_video( + let item = glib::MainContext::default().block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest_s), Some("clip.mp4"), @@ -2435,7 +2426,7 @@ fn enqueue_video_keeps_name_when_file_exists() { is_live: false, playlist_item_id: None, }, - ) + )) .expect("video enqueue"); assert_eq!(item.filename(), "clip.mp4"); assert_eq!( @@ -2589,8 +2580,7 @@ fn expand_playlist_rows_use_titled_subfolder() { let _ = std::fs::remove_dir_all(&dest); std::fs::create_dir_all(&dest).unwrap(); let dest_s = dest.to_string_lossy().into_owned(); - let parent = manager - .enqueue_video( + let parent = glib::MainContext::default().block_on(manager.enqueue_video_async( "https://www.youtube.com/playlist?list=PLx", Some(&dest_s), Some("parent.mp4"), @@ -2601,7 +2591,7 @@ fn expand_playlist_rows_use_titled_subfolder() { is_live: false, playlist_item_id: None, }, - ) + )) .expect("parent enqueue"); // Stage a Page source so the expansion reads quality/audio_only. manager.video_sources.borrow_mut().insert( @@ -2638,7 +2628,7 @@ fn expand_playlist_rows_use_titled_subfolder() { .into_boxed_slice(), }; let (added, total, skipped_by_host_limit) = - manager.expand_playlist_rows(parent.id(), &parent, &pl); + glib::MainContext::default().block_on(manager.expand_playlist_rows_async(parent.id(), &parent, &pl)); assert_eq!((added, total, skipped_by_host_limit), (2, 2, 0)); let sub = dest.join("My Mix: Vol. 1"); assert!(sub.is_dir()); @@ -5398,8 +5388,7 @@ fn enqueue_video_reserves_part_namespaced_stems() { std::fs::create_dir_all(&dest).unwrap(); std::fs::write(dest.join("Clip.video.mp4"), b"foreign").unwrap(); let dest_s = dest.to_string_lossy().into_owned(); - let item = manager - .enqueue_video( + let item = glib::MainContext::default().block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest_s), Some("Clip.mp4"), @@ -5410,7 +5399,7 @@ fn enqueue_video_reserves_part_namespaced_stems() { is_live: false, playlist_item_id: None, }, - ) + )) .expect("video enqueue"); assert_eq!(item.filename(), "Clip (1).mp4"); // The foreign file is untouched and the fresh stem is unreserved. @@ -5517,8 +5506,7 @@ fn enqueue_video_reserves_subtitle_sidecar_stems() { std::fs::create_dir_all(&dest).unwrap(); std::fs::write(dest.join("Clip.en.srt"), b"foreign").unwrap(); let dest_s = dest.to_string_lossy().into_owned(); - let item = manager - .enqueue_video( + let item = glib::MainContext::default().block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest_s), Some("Clip.mp4"), @@ -5529,7 +5517,7 @@ fn enqueue_video_reserves_subtitle_sidecar_stems() { is_live: false, playlist_item_id: None, }, - ) + )) .expect("video enqueue"); assert_eq!(item.filename(), "Clip (1).mp4"); assert_eq!(std::fs::read(dest.join("Clip.en.srt")).unwrap(), b"foreign"); @@ -5553,18 +5541,16 @@ fn enqueue_video_accepts_unlisted_url() { is_live: false, playlist_item_id: None, }; - let item = manager - .enqueue_video( + let item = glib::MainContext::default().block_on(manager.enqueue_video_async( "https://example.com/f.iso", Some("/tmp/dl"), None, choices.clone(), - ) + )) .expect("probed intake"); assert_eq!(item.filename(), "f.iso"); assert!( - manager - .enqueue_video("not a url", Some("/tmp/dl"), None, choices) + glib::MainContext::default().block_on(manager.enqueue_video_async("not a url", Some("/tmp/dl"), None, choices)) .is_err() ); // Drain the spawned worker (fails fast without tools) so no woken @@ -5866,8 +5852,7 @@ fn a_reserved_destination_forces_video_intake_to_dedupe() { std::fs::create_dir_all(&dest).unwrap(); let dest_s = dest.to_string_lossy().into_owned(); manager.reserve_dest(&dest.join("Clip.mp4")); - let item = manager - .enqueue_video( + let item = glib::MainContext::default().block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest_s), Some("Clip.mp4"), @@ -5878,7 +5863,7 @@ fn a_reserved_destination_forces_video_intake_to_dedupe() { is_live: false, playlist_item_id: None, }, - ) + )) .expect("video enqueue"); assert_eq!( item.filename(), @@ -5932,8 +5917,7 @@ fn a_reserved_stem_forces_video_intake_to_dedupe() { std::fs::create_dir_all(&dest).unwrap(); let dest_s = dest.to_string_lossy().into_owned(); manager.reserve_dest(&dest.join("Clip.mp4")); - let item = manager - .enqueue_video( + let item = glib::MainContext::default().block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest_s), Some("Clip.m4a"), @@ -5944,7 +5928,7 @@ fn a_reserved_stem_forces_video_intake_to_dedupe() { is_live: false, playlist_item_id: None, }, - ) + )) .expect("video enqueue"); assert_eq!( item.filename(), @@ -7578,7 +7562,7 @@ fn enqueue_gallery_resolves_dest_and_id() { let mut job = gallery_job_fixture(); job.dest_dir = std::path::PathBuf::from(dest); job.item_id = 0; // placeholder - let item = m.enqueue_gallery(&job).expect("enqueue"); + let item = glib::MainContext::default().block_on(m.enqueue_gallery_async(&job)).expect("enqueue"); // Destination resolved to an absolute path. assert!(std::path::Path::new(&item.dest_dir()).is_absolute()); assert!(!item.dest_dir().is_empty()); @@ -7657,7 +7641,7 @@ fn gallery_remove_discards_handoff_cookies() { crate::extension_server::store_handoff_cookies(&job.page_url, path, None); assert!(crate::extension_server::live_handoff_cookie_path(&job.page_url, "none").is_some()); // Enqueue the gallery, then remove it. - let item = m.enqueue_gallery(&job).expect("enqueue"); + let item = glib::MainContext::default().block_on(m.enqueue_gallery_async(&job)).expect("enqueue"); let id = item.id(); // Drain the spawned task to avoid tripping the glib thread guard. drain_engine(&m, id); @@ -7780,7 +7764,7 @@ fn gallery_enqueue_uses_unique_dir_when_title_collides() { let mut job = gallery_job_fixture(); job.dest_dir = tmp.clone(); job.title = "Test Gallery".to_string(); - let item = m.enqueue_gallery(&job).expect("enqueue"); + let item = glib::MainContext::default().block_on(m.enqueue_gallery_async(&job)).expect("enqueue"); // Must NOT use the pre-existing directory. assert_ne!(item.filename(), "Test Gallery"); assert!(item.filename().starts_with("Test Gallery-")); @@ -7793,7 +7777,7 @@ fn gallery_enqueue_uses_unique_dir_when_title_collides() { assert!(preexisting.join("unrelated.txt").exists()); // A second gallery with the same title (neither downloaded yet, so no // dir on disk) must get a *different* unique name — in-memory collision. - let item2 = m.enqueue_gallery(&job).expect("enqueue second"); + let item2 = glib::MainContext::default().block_on(m.enqueue_gallery_async(&job)).expect("enqueue second"); assert_ne!(item2.filename(), item.filename()); assert_ne!(item2.filename(), "Test Gallery"); // Drain both spawned engines so no woken tail trips a later test's From 94ee174b87b58ac7d3ecc6173ae65d62b89d5902 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:40:18 +0100 Subject: [PATCH 11/89] fix: close async cancel cleanup helper --- src/download.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/download.rs b/src/download.rs index 29fe7fe47..87fc04106 100644 --- a/src/download.rs +++ b/src/download.rs @@ -3112,6 +3112,7 @@ impl DownloadManager { } } partial_to_remove + } /// Re-queue a failed or cancelled download. pub fn retry(self: &Rc, id: u64) { From 1ef3dcc3412e1d5aac5eba3421a945b3d1d30deb Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:48:24 +0100 Subject: [PATCH 12/89] style: apply rustfmt to async filesystem changes --- src/application.rs | 5 +++- src/download.rs | 42 +++++++++++++------------- src/download_tests.rs | 68 +++++++++++++++++++++++++++++-------------- src/inline_add.rs | 3 +- 4 files changed, 71 insertions(+), 47 deletions(-) diff --git a/src/application.rs b/src/application.rs index 3f3407a21..41c7dfdde 100644 --- a/src/application.rs +++ b/src/application.rs @@ -157,7 +157,10 @@ async fn intake_remote_torrent( Routed::OpenedCard } Ok(_) => { - if let Err(e) = manager.enqueue_torrent_file_async(bytes, &stem, None, None).await { + if let Err(e) = manager + .enqueue_torrent_file_async(bytes, &stem, None, None) + .await + { fail(e); return Routed::Failed; } diff --git a/src/download.rs b/src/download.rs index 87fc04106..c01f9f03b 100644 --- a/src/download.rs +++ b/src/download.rs @@ -938,8 +938,7 @@ impl DownloadManager { // Parse metadata before archiving so invalid input never leaves an archive behind. let base = crate::torrent::intake_plan(&bytes).map(|(base, _multi)| base); let pseudo = crate::torrent::archive_torrent_file(&worker_file_name, &bytes)?; - let existing = - crate::video_staging::dir_file_names(std::path::Path::new(&worker_dir)); + let existing = crate::video_staging::dir_file_names(std::path::Path::new(&worker_dir)); Ok::<_, String>((pseudo, base, existing)) }) .await @@ -1142,8 +1141,7 @@ impl DownloadManager { } else { base_dir }; - let existing = - crate::video_staging::dir_file_names(std::path::Path::new(&dest_dir)); + let existing = crate::video_staging::dir_file_names(std::path::Path::new(&dest_dir)); (dest_dir, existing) }) .await; @@ -1574,10 +1572,8 @@ impl DownloadManager { return Err(gettext("Download path escapes the download folder")); } Err(error) => { - return Err( - gettext("Could not create the download folder: {error}") - .replace("{error}", &error.to_string()), - ); + return Err(gettext("Could not create the download folder: {error}") + .replace("{error}", &error.to_string())); } } } @@ -1967,7 +1963,9 @@ impl DownloadManager { let dir = item.dest_dir().to_string(); let listing_dir = dir.clone(); let existing = match gio::spawn_blocking(move || { - crate::video_staging::dir_file_names(std::path::Path::new(&listing_dir)) + crate::video_staging::dir_file_names(std::path::Path::new( + &listing_dir, + )) }) .await { @@ -2059,7 +2057,10 @@ impl DownloadManager { &torrent_filename, ); if folder.is_dir() { - crate::torrent::cleanup_unselected(&folder, &worker_url); + crate::torrent::cleanup_unselected( + &folder, + &worker_url, + ); } } }) @@ -2171,7 +2172,8 @@ impl DownloadManager { this.persist_queue(); this.reserve_dest(&invalid_path); let worker_path = invalid_path.clone(); - let _ = gio::spawn_blocking(move || std::fs::remove_file(worker_path)).await; + let _ = + gio::spawn_blocking(move || std::fs::remove_file(worker_path)).await; this.release_dest(&invalid_path); this.start_next(); if item.status() != DownloadStatus::Cancelled @@ -3646,10 +3648,9 @@ impl DownloadManager { Self::write_queue_file(&text); } #[cfg(not(test))] - if let (Some(queue), Some(tx)) = ( - self.snapshot_queue_inner(false), - &*self.persist_tx.borrow(), - ) { + if let (Some(queue), Some(tx)) = + (self.snapshot_queue_inner(false), &*self.persist_tx.borrow()) + { tx.send_replace(Some(queue)); } } @@ -3661,8 +3662,7 @@ impl DownloadManager { pub(crate) async fn persist_task_loop( mut rx: tokio::sync::watch::Receiver>, write: impl Fn(T) + Clone + Send + 'static, - ) - where + ) where T: Clone + Send + 'static, { while rx.changed().await.is_ok() { @@ -3692,10 +3692,9 @@ impl DownloadManager { tracing::warn!( "shutdown: queue restore did not complete; preserving the saved queue file" ); - } else if let (Some(queue), Some(tx)) = ( - self.snapshot_queue_inner(true), - &*self.persist_tx.borrow(), - ) { + } else if let (Some(queue), Some(tx)) = + (self.snapshot_queue_inner(true), &*self.persist_tx.borrow()) + { tx.send_replace(Some(queue)); } drop(self.persist_tx.borrow_mut().take()); @@ -3807,7 +3806,6 @@ impl DownloadManager { } } - /// Write the serialized queue atomically: temp file + sync_all + rename + /// dir sync. The persist task is the sole writer, so writes are ordered by /// construction. Runs via `spawn_blocking`, never on the async runtime or diff --git a/src/download_tests.rs b/src/download_tests.rs index 20139edfb..e6e699567 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -1058,7 +1058,8 @@ fn enqueue_video_spawns_and_fails_without_tools() { .join("grab-video-enqueue") .to_string_lossy() .into_owned(); - let item = glib::MainContext::default().block_on(manager.enqueue_video_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest), Some("My Video.mp4"), @@ -1104,7 +1105,8 @@ fn enqueue_video_restrict_filenames_folds_name() { let dest = std::env::temp_dir().join("grab-video-restrict"); std::fs::create_dir_all(&dest).unwrap(); let dest = dest.to_string_lossy().into_owned(); - let item = glib::MainContext::default().block_on(manager.enqueue_video_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest), Some("Café & Croissants.mp4"), @@ -1297,7 +1299,8 @@ fn video_source_survives_restore_and_retry() { let id = { let settings = test_settings(); let manager = DownloadManager::new(gio::ListStore::new::(), settings); - let item = glib::MainContext::default().block_on(manager.enqueue_video_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_video_async( "https://vimeo.com/123456", Some(&dest), Some("Clip.mp4"), @@ -1458,7 +1461,8 @@ fn unremove_restores_video_source() { .join("grab-video-unremove") .to_string_lossy() .into_owned(); - let item = glib::MainContext::default().block_on(manager.enqueue_video_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_video_async( "https://vimeo.com/123456", Some(&dest), Some("Clip.mp4"), @@ -1515,7 +1519,8 @@ fn queue_file_never_carries_cookies() { let settings = test_settings(); settings.set_string("cookies-browser", "firefox").unwrap(); let manager = DownloadManager::new(gio::ListStore::new::(), settings); - let item = glib::MainContext::default().block_on(manager.enqueue_video_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_video_async( "https://vimeo.com/123456", Some("/tmp/dl"), Some("Clip.mp4"), @@ -1808,7 +1813,8 @@ fn torrent_file_enqueue_uses_stem_stub() { }); manager.running.borrow_mut().insert(99, holder); let stem = format!("grab-enqueue-{}", std::process::id()); - let item = glib::MainContext::default().block_on(manager.enqueue_torrent_file_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_torrent_file_async( single_torrent_bytes(), &format!("{stem}.torrent"), None, @@ -1839,7 +1845,8 @@ fn staged_selection_reaches_spawn_take() { }); manager.running.borrow_mut().insert(99, holder); let stem = format!("grab-take-{}", std::process::id()); - let item = glib::MainContext::default().block_on(manager.enqueue_torrent_file_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_torrent_file_async( single_torrent_bytes(), &format!("{stem}.torrent"), None, @@ -1909,7 +1916,8 @@ fn remove_keeps_torrent_archive_for_undo() { }); manager.running.borrow_mut().insert(99, holder); let stem = format!("grab-undo-{}", std::process::id()); - let item = glib::MainContext::default().block_on(manager.enqueue_torrent_file_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_torrent_file_async( single_torrent_bytes(), &format!("{stem}.torrent"), None, @@ -2415,7 +2423,8 @@ fn enqueue_video_keeps_name_when_file_exists() { std::fs::create_dir_all(&dest).unwrap(); std::fs::write(dest.join("clip.mp4"), b"someone else").unwrap(); let dest_s = dest.to_string_lossy().into_owned(); - let item = glib::MainContext::default().block_on(manager.enqueue_video_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest_s), Some("clip.mp4"), @@ -2580,7 +2589,8 @@ fn expand_playlist_rows_use_titled_subfolder() { let _ = std::fs::remove_dir_all(&dest); std::fs::create_dir_all(&dest).unwrap(); let dest_s = dest.to_string_lossy().into_owned(); - let parent = glib::MainContext::default().block_on(manager.enqueue_video_async( + let parent = glib::MainContext::default() + .block_on(manager.enqueue_video_async( "https://www.youtube.com/playlist?list=PLx", Some(&dest_s), Some("parent.mp4"), @@ -2627,8 +2637,8 @@ fn expand_playlist_rows_use_titled_subfolder() { ] .into_boxed_slice(), }; - let (added, total, skipped_by_host_limit) = - glib::MainContext::default().block_on(manager.expand_playlist_rows_async(parent.id(), &parent, &pl)); + let (added, total, skipped_by_host_limit) = glib::MainContext::default() + .block_on(manager.expand_playlist_rows_async(parent.id(), &parent, &pl)); assert_eq!((added, total, skipped_by_host_limit), (2, 2, 0)); let sub = dest.join("My Mix: Vol. 1"); assert!(sub.is_dir()); @@ -5388,7 +5398,8 @@ fn enqueue_video_reserves_part_namespaced_stems() { std::fs::create_dir_all(&dest).unwrap(); std::fs::write(dest.join("Clip.video.mp4"), b"foreign").unwrap(); let dest_s = dest.to_string_lossy().into_owned(); - let item = glib::MainContext::default().block_on(manager.enqueue_video_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest_s), Some("Clip.mp4"), @@ -5506,7 +5517,8 @@ fn enqueue_video_reserves_subtitle_sidecar_stems() { std::fs::create_dir_all(&dest).unwrap(); std::fs::write(dest.join("Clip.en.srt"), b"foreign").unwrap(); let dest_s = dest.to_string_lossy().into_owned(); - let item = glib::MainContext::default().block_on(manager.enqueue_video_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest_s), Some("Clip.mp4"), @@ -5541,7 +5553,8 @@ fn enqueue_video_accepts_unlisted_url() { is_live: false, playlist_item_id: None, }; - let item = glib::MainContext::default().block_on(manager.enqueue_video_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_video_async( "https://example.com/f.iso", Some("/tmp/dl"), None, @@ -5550,7 +5563,8 @@ fn enqueue_video_accepts_unlisted_url() { .expect("probed intake"); assert_eq!(item.filename(), "f.iso"); assert!( - glib::MainContext::default().block_on(manager.enqueue_video_async("not a url", Some("/tmp/dl"), None, choices)) + glib::MainContext::default() + .block_on(manager.enqueue_video_async("not a url", Some("/tmp/dl"), None, choices)) .is_err() ); // Drain the spawned worker (fails fast without tools) so no woken @@ -5852,7 +5866,8 @@ fn a_reserved_destination_forces_video_intake_to_dedupe() { std::fs::create_dir_all(&dest).unwrap(); let dest_s = dest.to_string_lossy().into_owned(); manager.reserve_dest(&dest.join("Clip.mp4")); - let item = glib::MainContext::default().block_on(manager.enqueue_video_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest_s), Some("Clip.mp4"), @@ -5917,7 +5932,8 @@ fn a_reserved_stem_forces_video_intake_to_dedupe() { std::fs::create_dir_all(&dest).unwrap(); let dest_s = dest.to_string_lossy().into_owned(); manager.reserve_dest(&dest.join("Clip.mp4")); - let item = glib::MainContext::default().block_on(manager.enqueue_video_async( + let item = glib::MainContext::default() + .block_on(manager.enqueue_video_async( "https://www.youtube.com/watch?v=gXtp6C-3JKo", Some(&dest_s), Some("Clip.m4a"), @@ -7562,7 +7578,9 @@ fn enqueue_gallery_resolves_dest_and_id() { let mut job = gallery_job_fixture(); job.dest_dir = std::path::PathBuf::from(dest); job.item_id = 0; // placeholder - let item = glib::MainContext::default().block_on(m.enqueue_gallery_async(&job)).expect("enqueue"); + let item = glib::MainContext::default() + .block_on(m.enqueue_gallery_async(&job)) + .expect("enqueue"); // Destination resolved to an absolute path. assert!(std::path::Path::new(&item.dest_dir()).is_absolute()); assert!(!item.dest_dir().is_empty()); @@ -7641,7 +7659,9 @@ fn gallery_remove_discards_handoff_cookies() { crate::extension_server::store_handoff_cookies(&job.page_url, path, None); assert!(crate::extension_server::live_handoff_cookie_path(&job.page_url, "none").is_some()); // Enqueue the gallery, then remove it. - let item = glib::MainContext::default().block_on(m.enqueue_gallery_async(&job)).expect("enqueue"); + let item = glib::MainContext::default() + .block_on(m.enqueue_gallery_async(&job)) + .expect("enqueue"); let id = item.id(); // Drain the spawned task to avoid tripping the glib thread guard. drain_engine(&m, id); @@ -7764,7 +7784,9 @@ fn gallery_enqueue_uses_unique_dir_when_title_collides() { let mut job = gallery_job_fixture(); job.dest_dir = tmp.clone(); job.title = "Test Gallery".to_string(); - let item = glib::MainContext::default().block_on(m.enqueue_gallery_async(&job)).expect("enqueue"); + let item = glib::MainContext::default() + .block_on(m.enqueue_gallery_async(&job)) + .expect("enqueue"); // Must NOT use the pre-existing directory. assert_ne!(item.filename(), "Test Gallery"); assert!(item.filename().starts_with("Test Gallery-")); @@ -7777,7 +7799,9 @@ fn gallery_enqueue_uses_unique_dir_when_title_collides() { assert!(preexisting.join("unrelated.txt").exists()); // A second gallery with the same title (neither downloaded yet, so no // dir on disk) must get a *different* unique name — in-memory collision. - let item2 = glib::MainContext::default().block_on(m.enqueue_gallery_async(&job)).expect("enqueue second"); + let item2 = glib::MainContext::default() + .block_on(m.enqueue_gallery_async(&job)) + .expect("enqueue second"); assert_ne!(item2.filename(), item.filename()); assert_ne!(item2.filename(), "Test Gallery"); // Drain both spawned engines so no woken tail trips a later test's diff --git a/src/inline_add.rs b/src/inline_add.rs index f5e4eee8c..0c74a738f 100644 --- a/src/inline_add.rs +++ b/src/inline_add.rs @@ -1079,8 +1079,7 @@ pub(crate) fn enqueue_and_close_async( scheduled_at: Option, enqueue: impl FnOnce(Option) -> F + 'static, on_err: impl FnOnce(&str) + 'static, -) -where +) where F: std::future::Future> + 'static, { let manager = Rc::clone(manager); From 937d59a8360da2ffae38ca23a3d71f365e6fe947 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:49:57 +0100 Subject: [PATCH 13/89] fix: align queue persistence channel type --- src/download.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/download.rs b/src/download.rs index c01f9f03b..b39464f9a 100644 --- a/src/download.rs +++ b/src/download.rs @@ -350,7 +350,7 @@ pub struct DownloadManager { /// sole writer, which makes writes ordered by construction. /// Tests write synchronously and never use the channel. #[cfg_attr(test, allow(dead_code))] - persist_tx: RefCell>>>, + persist_tx: RefCell>>>, /// Handle for the queue persist task; awaited on shutdown after the final /// state is sent. Tests never spawn it. #[cfg_attr(test, allow(dead_code))] From 07c9787faba5da30cd96a93e250b44ee15b345ec Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:54:18 +0100 Subject: [PATCH 14/89] fix: use async queue serializer and test-only folder helper --- src/download.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/download.rs b/src/download.rs index b39464f9a..edea82ec5 100644 --- a/src/download.rs +++ b/src/download.rs @@ -571,9 +571,8 @@ impl DownloadManager { let (persist_tx, persist_task) = { let (tx, rx) = tokio::sync::watch::channel(None::); let task = tokio_rt().spawn(Self::persist_task_loop(rx, |queue: StoredQueue| { - match serde_json::to_string(&queue) { - Ok(text) => Self::write_queue_file(&text), - Err(error) => tracing::error!("could not serialize download queue: {error}"), + if let Some(text) = Self::serialize_snapshot(queue) { + Self::write_queue_file(&text); } })); (Some(tx), Some(task)) @@ -3243,6 +3242,7 @@ impl DownloadManager { /// recorded branch refuses a path that is itself a symlink. Either way, a /// symlink planted after the download finished can't divert the /// delete/trash onto its target. + #[cfg(test)] fn torrent_folder(item: &DownloadItem) -> std::path::PathBuf { let dest_dir = item.dest_dir().to_string(); let url = item.url().to_string(); From ee057ff291f33b2180209a63fcfc92f146ddd0e4 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:04:52 +0100 Subject: [PATCH 15/89] test: wait for async video preparation --- src/download_tests.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index e6e699567..278aa9356 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -36,8 +36,12 @@ fn test_locks() -> ( std::sync::MutexGuard<'static, ()>, ) { // Recover from poison: a panicking test shouldn't cascade into 28 identical failures. - let q = QUEUE_FILE_LOCK.lock().unwrap(); - let l = MAIN_LOOP_LOCK.lock().unwrap(); + let q = QUEUE_FILE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let l = MAIN_LOOP_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); (q, l) } @@ -5618,11 +5622,23 @@ fn a_video_attempt_gets_a_gate_and_a_plain_row_does_not() { backend: None, }); let item = manager.restore_existing(&row).expect("video row"); + // Destination validation is asynchronous now. Wait until the attempt has + // passed preparation and installed its delivery gate before asserting the + // fail-fast proxy path; don't assume GTK setup runs in the enqueue call. + let context = glib::MainContext::default(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while manager.gate_for(item.id()).is_none() + && item.status() == DownloadStatus::Queued + && std::time::Instant::now() < deadline + { + context.iteration(false); + std::thread::sleep(std::time::Duration::from_millis(1)); + } assert!( manager.gate_for(item.id()).is_some(), - "a video row with no gate cannot arbitrate delivery, so a removal \ - could not stop it placing a file" + "a video attempt must install its delivery gate before engine validation" ); + quiesce(&context); manager .settings() From 8ba97722292fb26a3b5880ec324818983b6a087c Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:10:58 +0100 Subject: [PATCH 16/89] test: drain asynchronous destination preparation --- src/download_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/download_tests.rs b/src/download_tests.rs index 278aa9356..f07817004 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -88,6 +88,9 @@ fn drain_engine(manager: &DownloadManager, id: u64) { // per completion, and it outlives the engine that triggered it. while (manager.running.borrow().contains_key(&id) || manager.pump_handles.borrow().contains_key(&id) + || manager + .find(id) + .is_some_and(|item| manager.dest_reserved(&item.file_path())) || manager.sizing_inflight.get() > 0) && std::time::Instant::now() < deadline { From 9de5aa82a32cd50e5976edae727b62b116a0d1fa Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:13:52 +0100 Subject: [PATCH 17/89] test: await async queue scheduling in lifecycle cases --- src/download_tests.rs | 123 +++++++++++++++++++++++------------------- 1 file changed, 69 insertions(+), 54 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index f07817004..18fd2364d 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -30,21 +30,44 @@ static QUEUE_FILE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// wrong thread. Take AFTER QUEUE_FILE_LOCK, always that order. static MAIN_LOOP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +/// Both locks in the safe order (queue, then loop); reversed order deadlocks the suite. +fn lock_queue_file() -> std::sync::MutexGuard<'static, ()> { + QUEUE_FILE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn lock_main_loop() -> std::sync::MutexGuard<'static, ()> { + MAIN_LOOP_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// Both locks in the safe order (queue, then loop); reversed order deadlocks the suite. fn test_locks() -> ( std::sync::MutexGuard<'static, ()>, std::sync::MutexGuard<'static, ()>, ) { - // Recover from poison: a panicking test shouldn't cascade into 28 identical failures. - let q = QUEUE_FILE_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let l = MAIN_LOOP_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - (q, l) + (lock_queue_file(), lock_main_loop()) } +/// Wait for the asynchronous spawn preparation to hand a queued row to its engine. +fn wait_for_started(manager: &DownloadManager, id: u64, timeout: Duration) -> bool { + let ctx = glib::MainContext::default(); + let deadline = std::time::Instant::now() + timeout; + while !manager.started.borrow().contains(&id) + && manager + .find(id) + .is_some_and(|item| item.status() == DownloadStatus::Queued) + && std::time::Instant::now() < deadline + { + ctx.iteration(false); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + manager.started.borrow().contains(&id) +} + + /// Minimal persisted row for `restore_existing` fixtures (no id => pre-v3 allocate path). fn stored_row(url: &str, dest_dir: &str, filename: &str, status: DownloadStatus) -> StoredItem { StoredItem { @@ -440,7 +463,7 @@ fn dedupe_caps_iterations() { #[test] fn corrupt_queue_is_quarantined() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let qf = test_queue_file("quarantine"); std::fs::write(&qf, b"{not json").unwrap(); let settings = test_settings(); @@ -458,7 +481,7 @@ fn invalid_utf8_queue_is_quarantined() { // read_to_string fails on invalid UTF-8: must quarantine like the other // corrupt-queue branches instead of silently starting empty (a later // persist would then overwrite the unreadable file). - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let qf = test_queue_file("quarantine-utf8"); std::fs::write(&qf, b"{\xff\xfe not utf-8").unwrap(); let settings = test_settings(); @@ -475,7 +498,7 @@ fn invalid_utf8_queue_is_quarantined() { fn shutdown_with_timeout_returns_promptly_with_stuck_finalizer() { // A wedged finalizer must not hang the GTK thread on quit: the join is // bounded, partial files are left for the next launch to reconcile. - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("shutdown-timeout"); let settings = test_settings(); let m = DownloadManager::new(gio::ListStore::new::(), settings); @@ -835,7 +858,7 @@ fn parses_rates() { #[test] fn rate_limit_follows_settings_live() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("rate-live"); let settings = test_settings(); // Watch publishes; cap follows with no re-queue, junk reads as unlimited. @@ -867,7 +890,7 @@ fn notification_toggles() { #[test] fn transferring_ignores_paused() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("transferring"); let settings = test_settings(); let manager = DownloadManager::new(gio::ListStore::new::(), settings); @@ -1346,7 +1369,7 @@ fn video_source_survives_restore_and_retry() { #[test] fn mismatched_video_source_dropped_on_restore() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let qf = test_queue_file("video-mismatch"); // Hand-edited queue: the Page marker names another video. let queue = StoredQueue { @@ -1555,7 +1578,7 @@ fn queue_file_never_carries_cookies() { #[test] fn active_count_covers_cancel_all_scope() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("active-count"); let settings = test_settings(); let manager = DownloadManager::new(gio::ListStore::new::(), settings); @@ -1578,7 +1601,7 @@ fn active_count_covers_cancel_all_scope() { #[test] fn error_banner_ignores_cancelled() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("error-banner"); let settings = test_settings(); let manager = DownloadManager::new(gio::ListStore::new::(), settings); @@ -1639,7 +1662,7 @@ fn path_size_async_measures_off_thread() { // The async wrapper must actually measure, not just resolve: a stub that // returns None without scanning would leave finished rows unsized. // MAIN_LOOP_LOCK: glib futures abort if polled from the wrong thread. - let _loop = MAIN_LOOP_LOCK.lock().unwrap(); + let _loop = lock_main_loop(); let dir = std::env::temp_dir().join(format!("grab-path-size-async-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let sub = dir.join("sub"); @@ -1949,7 +1972,7 @@ fn remove_keeps_torrent_archive_for_undo() { #[test] fn rejects_relative_download_dir() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("relative-dir"); let settings = test_settings(); // Occupy the only slot so nothing spawns a real engine below. @@ -2722,7 +2745,7 @@ fn restrict_filename_ascii_folds_to_ascii() { #[test] fn restore_keeps_exact_filename() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("restore-exact"); test_settings(); let dir = std::env::temp_dir().join(format!("grab-restore-{}", std::process::id())); @@ -2854,7 +2877,7 @@ fn history_restore_roundtrip() { #[test] fn selection_survives_persist_restore() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let qf = test_queue_file("selection-roundtrip"); let settings = test_settings(); settings.set_int("max-concurrent", 1).unwrap(); @@ -2915,7 +2938,7 @@ fn sane_filenames() { #[test] fn restore_rejects_bad_filenames() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("restore-bad"); let settings = test_settings(); let manager = DownloadManager::new(gio::ListStore::new::(), settings); @@ -3005,15 +3028,11 @@ fn cancel_frees_slot_immediately() { manager.cancel(41); assert_eq!(a.status(), DownloadStatus::Cancelled); assert!(!manager.running.borrow().contains_key(&41)); - assert_eq!(b.status(), DownloadStatus::Downloading); - assert!(manager.running.borrow().contains_key(&42)); - // Drain B's UI future here: already queued, or another test's loop aborts on thread-affinity. - let ctx = glib::MainContext::default(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - while manager.running.borrow().contains_key(&42) && std::time::Instant::now() < deadline { - ctx.iteration(false); - std::thread::sleep(std::time::Duration::from_millis(1)); - } + assert!( + wait_for_started(&manager, 42, Duration::from_secs(10)), + "cancel must release the slot and start the next queued row" + ); + drain_engine(&manager, 42); assert!(!manager.running.borrow().contains_key(&42)); assert_eq!(b.status(), DownloadStatus::Failed); } @@ -3037,15 +3056,13 @@ fn pause_starts_next_queued() { manager.pause(61); assert_eq!(a.status(), DownloadStatus::Paused); assert!(!manager.running.borrow().contains_key(&61)); - assert_eq!(b.status(), DownloadStatus::Downloading); - assert!(manager.running.borrow().contains_key(&62)); - let ctx = glib::MainContext::default(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - while manager.running.borrow().contains_key(&62) && std::time::Instant::now() < deadline { - ctx.iteration(false); - std::thread::sleep(std::time::Duration::from_millis(1)); - } + assert!( + wait_for_started(&manager, 62, Duration::from_secs(10)), + "pause must release the slot and start the next queued row" + ); + drain_engine(&manager, 62); assert!(!manager.running.borrow().contains_key(&62)); + assert_eq!(b.status(), DownloadStatus::Failed); } #[test] @@ -3067,22 +3084,20 @@ fn raising_max_concurrent_starts_queued() { assert_eq!(b.status(), DownloadStatus::Queued); // The preferences SpinRow writes this key; the queued row must start with no other event. settings.set_int("max-concurrent", 2).unwrap(); - assert_eq!(b.status(), DownloadStatus::Downloading); - assert!(manager.running.borrow().contains_key(&64)); - let ctx = glib::MainContext::default(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - while manager.running.borrow().contains_key(&64) && std::time::Instant::now() < deadline { - ctx.iteration(false); - std::thread::sleep(std::time::Duration::from_millis(1)); - } + assert!( + wait_for_started(&manager, 64, Duration::from_secs(10)), + "raising the concurrency limit must start the newly eligible row" + ); + drain_engine(&manager, 64); assert!(!manager.running.borrow().contains_key(&64)); + assert_eq!(b.status(), DownloadStatus::Failed); // Restore: the memory backend is shared across tests. settings.set_int("max-concurrent", 3).unwrap(); } #[test] fn defer_yields_slot_and_goes_last() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("defer"); let settings = test_settings(); settings.set_int("max-concurrent", 1).unwrap(); @@ -3118,7 +3133,7 @@ fn defer_yields_slot_and_goes_last() { #[test] fn lowering_max_concurrent_parks_newest() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("preempt"); let settings = test_settings(); settings.set_int("max-concurrent", 2).unwrap(); @@ -3208,7 +3223,7 @@ fn restore_preserves_intent() { #[test] fn restored_status_mapping() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("restored-mapping"); let settings = test_settings(); // Occupy the only slot so restored rows never spawn an engine. @@ -3242,7 +3257,7 @@ fn restored_status_mapping() { #[test] fn retry_persists_immediately() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let qf = test_queue_file("retry-persist"); let settings = test_settings(); settings.set_int("max-concurrent", 1).unwrap(); @@ -3818,7 +3833,7 @@ fn segmented_pause_resume_keeps_bytes() { #[test] fn bitmap_persists_across_managers() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let qf = test_queue_file("segpersist"); let settings = test_settings(); let m1 = DownloadManager::new(gio::ListStore::new::(), settings.clone()); @@ -4287,7 +4302,7 @@ fn aggregate_downsamples_by_half() { #[test] fn piece_bitmap_prefers_segment_then_torrent_then_bytes() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("piece-bitmap"); let settings = test_settings(); let manager = DownloadManager::new(gio::ListStore::new::(), settings); @@ -5258,7 +5273,7 @@ exit 0 #[test] fn clear_finished_drops_only_done() { - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let qf = test_queue_file("clear-finished"); let settings = test_settings(); let m = DownloadManager::new(gio::ListStore::new::(), settings); @@ -5360,7 +5375,7 @@ fn restore_dedups_finished_urls() { fn drop_finished_duplicates_keeps_active_and_newest() { // The finish hook's policy, directly: the just-finished row stays, // older Done rows for the URL go, everything else is user intent. - let _lock = QUEUE_FILE_LOCK.lock().unwrap(); + let _lock = lock_queue_file(); let _qf = test_queue_file("history-dedup-live"); let settings = test_settings(); let m = DownloadManager::new(gio::ListStore::new::(), settings); From 46adb66f236947854ffe9283ef75a15855ab5f10 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:17:09 +0100 Subject: [PATCH 18/89] style: format asynchronous lifecycle tests --- src/download_tests.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 18fd2364d..311bd8925 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -30,7 +30,6 @@ static QUEUE_FILE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// wrong thread. Take AFTER QUEUE_FILE_LOCK, always that order. static MAIN_LOOP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); -/// Both locks in the safe order (queue, then loop); reversed order deadlocks the suite. fn lock_queue_file() -> std::sync::MutexGuard<'static, ()> { QUEUE_FILE_LOCK .lock() @@ -67,7 +66,6 @@ fn wait_for_started(manager: &DownloadManager, id: u64, timeout: Duration) -> bo manager.started.borrow().contains(&id) } - /// Minimal persisted row for `restore_existing` fixtures (no id => pre-v3 allocate path). fn stored_row(url: &str, dest_dir: &str, filename: &str, status: DownloadStatus) -> StoredItem { StoredItem { From fee81286a4241ee2ae0cd0bb8b041359b43d581a Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:40:56 +0100 Subject: [PATCH 19/89] test: isolate GLib contexts for async deletion tests --- src/download_tests.rs | 50 +++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 311bd8925..ce39993e4 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -50,6 +50,17 @@ fn test_locks() -> ( (lock_queue_file(), lock_main_loop()) } +/// Run a GLib future on a context owned by this test thread. A private +/// context prevents GLib task-source finalizers from being dispatched by a +/// different test thread when the global default context is reused. +fn block_on_test_context(future: F) -> F::Output { + let context = glib::MainContext::new(); + context + .with_thread_default(|| context.block_on(future)) + .expect("test thread should be able to own its private GLib context") +} + + /// Wait for the asynchronous spawn preparation to hand a queued row to its engine. fn wait_for_started(manager: &DownloadManager, id: u64, timeout: Duration) -> bool { let ctx = glib::MainContext::default(); @@ -1917,8 +1928,7 @@ fn delete_download_trashes_torrent_files() { // Explicit delete trashes real files and drops the row; no session entry offline. assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(7)) + block_on_test_context(manager.delete_download_async(7)) .is_ok() ); assert!(!file.exists()); @@ -2058,8 +2068,7 @@ fn delete_download_removes_file_and_row() { store.append(&item); assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(7)) + block_on_test_context(manager.delete_download_async(7)) .is_ok() ); assert!(!file.exists()); @@ -2077,8 +2086,7 @@ fn delete_download_removes_file_and_row() { item2.set_status(DownloadStatus::Done); store.append(&item2); assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(8)) + block_on_test_context(manager.delete_download_async(8)) .is_ok() ); assert_eq!(store.n_items(), 0); @@ -2122,8 +2130,7 @@ fn magnet_delete_trashes_recorded_subfolder() { item.set_filename("real-name.bin"); item.set_status(DownloadStatus::Done); assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(item.id())) + block_on_test_context(manager.delete_download_async(item.id())) .is_ok() ); // Metadata-less magnet: output directory ownership is unknown. @@ -2184,8 +2191,7 @@ fn delete_download_trashes_torrent_subfolder() { store.append(&item); assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(9)) + block_on_test_context(manager.delete_download_async(9)) .is_ok() ); // Owned file trashed, unrelated file survives, folder left in place. @@ -2226,8 +2232,7 @@ fn delete_download_magnet_leaves_preexisting_folder() { store.append(&item); assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(10)) + block_on_test_context(manager.delete_download_async(10)) .is_ok() ); // Folder and unrelated file must survive. @@ -2271,8 +2276,7 @@ fn torrent_delete_refuses_symlinked_subdirectory() { store.append(&item); assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(11)) + block_on_test_context(manager.delete_download_async(11)) .is_ok() ); // Precious file and symlink must survive. @@ -5500,13 +5504,11 @@ fn delete_download_trashes_video_sidecars() { std::fs::write(dir.join(n), b"x").unwrap(); } assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(11)) + block_on_test_context(manager.delete_download_async(11)) .is_ok() ); assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(12)) + block_on_test_context(manager.delete_download_async(12)) .is_ok() ); // Video + its sidecars trashed; plain row's same-named srt stays. @@ -8024,8 +8026,7 @@ fn delete_download_legacy_gallery_row_keeps_unrelated_folder() { .insert(7, test_gallery_job(dir.clone())); assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(7)) + block_on_test_context(manager.delete_download_async(7)) .is_ok() ); assert_eq!(store.n_items(), 0); @@ -8051,8 +8052,7 @@ fn delete_download_gallery_row_without_job_keeps_folder() { // No job record at all: nothing is provably ours. assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(7)) + block_on_test_context(manager.delete_download_async(7)) .is_ok() ); assert_eq!(store.n_items(), 0); @@ -8080,8 +8080,7 @@ fn delete_download_new_gallery_row_trashes_subfolder() { .insert(7, test_gallery_job(sub.clone())); assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(7)) + block_on_test_context(manager.delete_download_async(7)) .is_ok() ); assert_eq!(store.n_items(), 0); @@ -8116,8 +8115,7 @@ fn delete_download_after_gallery_rename_trashes_renamed_folder() { assert!(manager.rename_download(7, "renamed").is_ok()); assert!(dir.join("renamed").is_dir()); assert!( - glib::MainContext::default() - .block_on(manager.delete_download_async(7)) + block_on_test_context(manager.delete_download_async(7)) .is_ok() ); assert_eq!(store.n_items(), 0); From 635a9d4279434549109fbee5cfb5a55ba77d8e0e Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:41:26 +0100 Subject: [PATCH 20/89] style: normalize test helper spacing --- src/download_tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index ce39993e4..0a9ed6565 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -60,7 +60,6 @@ fn block_on_test_context(future: F) -> F::Output { .expect("test thread should be able to own its private GLib context") } - /// Wait for the asynchronous spawn preparation to hand a queued row to its engine. fn wait_for_started(manager: &DownloadManager, id: u64, timeout: Duration) -> bool { let ctx = glib::MainContext::default(); From d11a47d3bb984e7a2d637b3ca34abfec265679ae Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:42:27 +0100 Subject: [PATCH 21/89] style: format async deletion assertions --- src/download_tests.rs | 65 +++++++++---------------------------------- 1 file changed, 13 insertions(+), 52 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 0a9ed6565..79624ef12 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -1926,10 +1926,7 @@ fn delete_download_trashes_torrent_files() { store.append(&item); // Explicit delete trashes real files and drops the row; no session entry offline. - assert!( - block_on_test_context(manager.delete_download_async(7)) - .is_ok() - ); + assert!(block_on_test_context(manager.delete_download_async(7)).is_ok()); assert!(!file.exists()); assert_eq!(store.n_items(), 0); assert!(crate::torrent::archive_path_for_url(&pseudo).is_none()); @@ -2066,10 +2063,7 @@ fn delete_download_removes_file_and_row() { item.set_status(DownloadStatus::Done); store.append(&item); - assert!( - block_on_test_context(manager.delete_download_async(7)) - .is_ok() - ); + assert!(block_on_test_context(manager.delete_download_async(7)).is_ok()); assert!(!file.exists()); assert_eq!(store.n_items(), 0); // Undo the test's own Trash litter. @@ -2084,10 +2078,7 @@ fn delete_download_removes_file_and_row() { ); item2.set_status(DownloadStatus::Done); store.append(&item2); - assert!( - block_on_test_context(manager.delete_download_async(8)) - .is_ok() - ); + assert!(block_on_test_context(manager.delete_download_async(8)).is_ok()); assert_eq!(store.n_items(), 0); let _ = std::fs::remove_dir_all(&dir); } @@ -2128,10 +2119,7 @@ fn magnet_delete_trashes_recorded_subfolder() { std::fs::write(folder.join("real-name.bin"), b"data").unwrap(); item.set_filename("real-name.bin"); item.set_status(DownloadStatus::Done); - assert!( - block_on_test_context(manager.delete_download_async(item.id())) - .is_ok() - ); + assert!(block_on_test_context(manager.delete_download_async(item.id())).is_ok()); // Metadata-less magnet: output directory ownership is unknown. // Delete removes the queue row but leaves the directory untouched. assert!(folder.exists()); @@ -2189,10 +2177,7 @@ fn delete_download_trashes_torrent_subfolder() { item.set_status(DownloadStatus::Done); store.append(&item); - assert!( - block_on_test_context(manager.delete_download_async(9)) - .is_ok() - ); + assert!(block_on_test_context(manager.delete_download_async(9)).is_ok()); // Owned file trashed, unrelated file survives, folder left in place. assert!(!folder.join("a.txt").exists()); assert!(folder.join("unrelated.txt").exists()); @@ -2230,10 +2215,7 @@ fn delete_download_magnet_leaves_preexisting_folder() { item.set_status(DownloadStatus::Done); store.append(&item); - assert!( - block_on_test_context(manager.delete_download_async(10)) - .is_ok() - ); + assert!(block_on_test_context(manager.delete_download_async(10)).is_ok()); // Folder and unrelated file must survive. assert!(folder.exists()); assert_eq!( @@ -2274,10 +2256,7 @@ fn torrent_delete_refuses_symlinked_subdirectory() { item.set_status(DownloadStatus::Done); store.append(&item); - assert!( - block_on_test_context(manager.delete_download_async(11)) - .is_ok() - ); + assert!(block_on_test_context(manager.delete_download_async(11)).is_ok()); // Precious file and symlink must survive. assert_eq!(std::fs::read(outside.join("b.txt")).unwrap(), b"precious"); assert!(folder.join("sub").is_symlink()); @@ -5502,14 +5481,8 @@ fn delete_download_trashes_video_sidecars() { ] { std::fs::write(dir.join(n), b"x").unwrap(); } - assert!( - block_on_test_context(manager.delete_download_async(11)) - .is_ok() - ); - assert!( - block_on_test_context(manager.delete_download_async(12)) - .is_ok() - ); + assert!(block_on_test_context(manager.delete_download_async(11)).is_ok()); + assert!(block_on_test_context(manager.delete_download_async(12)).is_ok()); // Video + its sidecars trashed; plain row's same-named srt stays. for n in ["Clip.mp4", "Clip.en.srt", "Clip.fr.srt", "Other.mp4"] { assert!(!dir.join(n).exists(), "{n} must be trashed"); @@ -8024,10 +7997,7 @@ fn delete_download_legacy_gallery_row_keeps_unrelated_folder() { .borrow_mut() .insert(7, test_gallery_job(dir.clone())); - assert!( - block_on_test_context(manager.delete_download_async(7)) - .is_ok() - ); + assert!(block_on_test_context(manager.delete_download_async(7)).is_ok()); assert_eq!(store.n_items(), 0); // The unrelated folder and its content survive. assert!(decoy.join("keep.txt").exists()); @@ -8050,10 +8020,7 @@ fn delete_download_gallery_row_without_job_keeps_folder() { store.append(&test_gallery_item(7, &dir, "title")); // No job record at all: nothing is provably ours. - assert!( - block_on_test_context(manager.delete_download_async(7)) - .is_ok() - ); + assert!(block_on_test_context(manager.delete_download_async(7)).is_ok()); assert_eq!(store.n_items(), 0); assert!(folder.join("keep.txt").exists()); let _ = std::fs::remove_dir_all(&dir); @@ -8078,10 +8045,7 @@ fn delete_download_new_gallery_row_trashes_subfolder() { .borrow_mut() .insert(7, test_gallery_job(sub.clone())); - assert!( - block_on_test_context(manager.delete_download_async(7)) - .is_ok() - ); + assert!(block_on_test_context(manager.delete_download_async(7)).is_ok()); assert_eq!(store.n_items(), 0); assert!(!sub.exists()); // Undo the test's own Trash litter. @@ -8113,10 +8077,7 @@ fn delete_download_after_gallery_rename_trashes_renamed_folder() { assert!(manager.rename_download(7, "renamed").is_ok()); assert!(dir.join("renamed").is_dir()); - assert!( - block_on_test_context(manager.delete_download_async(7)) - .is_ok() - ); + assert!(block_on_test_context(manager.delete_download_async(7)).is_ok()); assert_eq!(store.n_items(), 0); // The renamed folder — the one Grab owns — must be trashed. assert!(!dir.join("renamed").exists()); From cc845df7a20d95057b47f06931747d57a89ec33d Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:09:11 +0100 Subject: [PATCH 22/89] test: run async deletion on the shared GLib context --- src/download_tests.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 79624ef12..1d999ed7b 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -1926,7 +1926,7 @@ fn delete_download_trashes_torrent_files() { store.append(&item); // Explicit delete trashes real files and drops the row; no session entry offline. - assert!(block_on_test_context(manager.delete_download_async(7)).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(7)).is_ok()); assert!(!file.exists()); assert_eq!(store.n_items(), 0); assert!(crate::torrent::archive_path_for_url(&pseudo).is_none()); @@ -2063,7 +2063,7 @@ fn delete_download_removes_file_and_row() { item.set_status(DownloadStatus::Done); store.append(&item); - assert!(block_on_test_context(manager.delete_download_async(7)).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(7)).is_ok()); assert!(!file.exists()); assert_eq!(store.n_items(), 0); // Undo the test's own Trash litter. @@ -2078,7 +2078,7 @@ fn delete_download_removes_file_and_row() { ); item2.set_status(DownloadStatus::Done); store.append(&item2); - assert!(block_on_test_context(manager.delete_download_async(8)).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(8)).is_ok()); assert_eq!(store.n_items(), 0); let _ = std::fs::remove_dir_all(&dir); } @@ -2119,7 +2119,7 @@ fn magnet_delete_trashes_recorded_subfolder() { std::fs::write(folder.join("real-name.bin"), b"data").unwrap(); item.set_filename("real-name.bin"); item.set_status(DownloadStatus::Done); - assert!(block_on_test_context(manager.delete_download_async(item.id())).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(item.id())).is_ok()); // Metadata-less magnet: output directory ownership is unknown. // Delete removes the queue row but leaves the directory untouched. assert!(folder.exists()); @@ -2177,7 +2177,7 @@ fn delete_download_trashes_torrent_subfolder() { item.set_status(DownloadStatus::Done); store.append(&item); - assert!(block_on_test_context(manager.delete_download_async(9)).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(9)).is_ok()); // Owned file trashed, unrelated file survives, folder left in place. assert!(!folder.join("a.txt").exists()); assert!(folder.join("unrelated.txt").exists()); @@ -2215,7 +2215,7 @@ fn delete_download_magnet_leaves_preexisting_folder() { item.set_status(DownloadStatus::Done); store.append(&item); - assert!(block_on_test_context(manager.delete_download_async(10)).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(10)).is_ok()); // Folder and unrelated file must survive. assert!(folder.exists()); assert_eq!( @@ -2256,7 +2256,7 @@ fn torrent_delete_refuses_symlinked_subdirectory() { item.set_status(DownloadStatus::Done); store.append(&item); - assert!(block_on_test_context(manager.delete_download_async(11)).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(11)).is_ok()); // Precious file and symlink must survive. assert_eq!(std::fs::read(outside.join("b.txt")).unwrap(), b"precious"); assert!(folder.join("sub").is_symlink()); @@ -5481,8 +5481,8 @@ fn delete_download_trashes_video_sidecars() { ] { std::fs::write(dir.join(n), b"x").unwrap(); } - assert!(block_on_test_context(manager.delete_download_async(11)).is_ok()); - assert!(block_on_test_context(manager.delete_download_async(12)).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(11)).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(12)).is_ok()); // Video + its sidecars trashed; plain row's same-named srt stays. for n in ["Clip.mp4", "Clip.en.srt", "Clip.fr.srt", "Other.mp4"] { assert!(!dir.join(n).exists(), "{n} must be trashed"); @@ -7997,7 +7997,7 @@ fn delete_download_legacy_gallery_row_keeps_unrelated_folder() { .borrow_mut() .insert(7, test_gallery_job(dir.clone())); - assert!(block_on_test_context(manager.delete_download_async(7)).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(7)).is_ok()); assert_eq!(store.n_items(), 0); // The unrelated folder and its content survive. assert!(decoy.join("keep.txt").exists()); @@ -8020,7 +8020,7 @@ fn delete_download_gallery_row_without_job_keeps_folder() { store.append(&test_gallery_item(7, &dir, "title")); // No job record at all: nothing is provably ours. - assert!(block_on_test_context(manager.delete_download_async(7)).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(7)).is_ok()); assert_eq!(store.n_items(), 0); assert!(folder.join("keep.txt").exists()); let _ = std::fs::remove_dir_all(&dir); @@ -8045,7 +8045,7 @@ fn delete_download_new_gallery_row_trashes_subfolder() { .borrow_mut() .insert(7, test_gallery_job(sub.clone())); - assert!(block_on_test_context(manager.delete_download_async(7)).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(7)).is_ok()); assert_eq!(store.n_items(), 0); assert!(!sub.exists()); // Undo the test's own Trash litter. @@ -8077,7 +8077,7 @@ fn delete_download_after_gallery_rename_trashes_renamed_folder() { assert!(manager.rename_download(7, "renamed").is_ok()); assert!(dir.join("renamed").is_dir()); - assert!(block_on_test_context(manager.delete_download_async(7)).is_ok()); + assert!(glib::MainContext::default().block_on(manager.delete_download_async(7)).is_ok()); assert_eq!(store.n_items(), 0); // The renamed folder — the one Grab owns — must be trashed. assert!(!dir.join("renamed").exists()); From 3775f9442d58e727682a440c24e645534a2d35c5 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:22:59 +0100 Subject: [PATCH 23/89] test: fix formatting and remove unused context helper --- src/download_tests.rs | 88 ++++++++++++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 1d999ed7b..311bd8925 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -50,16 +50,6 @@ fn test_locks() -> ( (lock_queue_file(), lock_main_loop()) } -/// Run a GLib future on a context owned by this test thread. A private -/// context prevents GLib task-source finalizers from being dispatched by a -/// different test thread when the global default context is reused. -fn block_on_test_context(future: F) -> F::Output { - let context = glib::MainContext::new(); - context - .with_thread_default(|| context.block_on(future)) - .expect("test thread should be able to own its private GLib context") -} - /// Wait for the asynchronous spawn preparation to hand a queued row to its engine. fn wait_for_started(manager: &DownloadManager, id: u64, timeout: Duration) -> bool { let ctx = glib::MainContext::default(); @@ -1926,7 +1916,11 @@ fn delete_download_trashes_torrent_files() { store.append(&item); // Explicit delete trashes real files and drops the row; no session entry offline. - assert!(glib::MainContext::default().block_on(manager.delete_download_async(7)).is_ok()); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(7)) + .is_ok() + ); assert!(!file.exists()); assert_eq!(store.n_items(), 0); assert!(crate::torrent::archive_path_for_url(&pseudo).is_none()); @@ -2063,7 +2057,11 @@ fn delete_download_removes_file_and_row() { item.set_status(DownloadStatus::Done); store.append(&item); - assert!(glib::MainContext::default().block_on(manager.delete_download_async(7)).is_ok()); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(7)) + .is_ok() + ); assert!(!file.exists()); assert_eq!(store.n_items(), 0); // Undo the test's own Trash litter. @@ -2078,7 +2076,11 @@ fn delete_download_removes_file_and_row() { ); item2.set_status(DownloadStatus::Done); store.append(&item2); - assert!(glib::MainContext::default().block_on(manager.delete_download_async(8)).is_ok()); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(8)) + .is_ok() + ); assert_eq!(store.n_items(), 0); let _ = std::fs::remove_dir_all(&dir); } @@ -2119,7 +2121,11 @@ fn magnet_delete_trashes_recorded_subfolder() { std::fs::write(folder.join("real-name.bin"), b"data").unwrap(); item.set_filename("real-name.bin"); item.set_status(DownloadStatus::Done); - assert!(glib::MainContext::default().block_on(manager.delete_download_async(item.id())).is_ok()); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(item.id())) + .is_ok() + ); // Metadata-less magnet: output directory ownership is unknown. // Delete removes the queue row but leaves the directory untouched. assert!(folder.exists()); @@ -2177,7 +2183,11 @@ fn delete_download_trashes_torrent_subfolder() { item.set_status(DownloadStatus::Done); store.append(&item); - assert!(glib::MainContext::default().block_on(manager.delete_download_async(9)).is_ok()); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(9)) + .is_ok() + ); // Owned file trashed, unrelated file survives, folder left in place. assert!(!folder.join("a.txt").exists()); assert!(folder.join("unrelated.txt").exists()); @@ -2215,7 +2225,11 @@ fn delete_download_magnet_leaves_preexisting_folder() { item.set_status(DownloadStatus::Done); store.append(&item); - assert!(glib::MainContext::default().block_on(manager.delete_download_async(10)).is_ok()); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(10)) + .is_ok() + ); // Folder and unrelated file must survive. assert!(folder.exists()); assert_eq!( @@ -2256,7 +2270,11 @@ fn torrent_delete_refuses_symlinked_subdirectory() { item.set_status(DownloadStatus::Done); store.append(&item); - assert!(glib::MainContext::default().block_on(manager.delete_download_async(11)).is_ok()); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(11)) + .is_ok() + ); // Precious file and symlink must survive. assert_eq!(std::fs::read(outside.join("b.txt")).unwrap(), b"precious"); assert!(folder.join("sub").is_symlink()); @@ -5481,8 +5499,16 @@ fn delete_download_trashes_video_sidecars() { ] { std::fs::write(dir.join(n), b"x").unwrap(); } - assert!(glib::MainContext::default().block_on(manager.delete_download_async(11)).is_ok()); - assert!(glib::MainContext::default().block_on(manager.delete_download_async(12)).is_ok()); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(11)) + .is_ok() + ); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(12)) + .is_ok() + ); // Video + its sidecars trashed; plain row's same-named srt stays. for n in ["Clip.mp4", "Clip.en.srt", "Clip.fr.srt", "Other.mp4"] { assert!(!dir.join(n).exists(), "{n} must be trashed"); @@ -7997,7 +8023,11 @@ fn delete_download_legacy_gallery_row_keeps_unrelated_folder() { .borrow_mut() .insert(7, test_gallery_job(dir.clone())); - assert!(glib::MainContext::default().block_on(manager.delete_download_async(7)).is_ok()); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(7)) + .is_ok() + ); assert_eq!(store.n_items(), 0); // The unrelated folder and its content survive. assert!(decoy.join("keep.txt").exists()); @@ -8020,7 +8050,11 @@ fn delete_download_gallery_row_without_job_keeps_folder() { store.append(&test_gallery_item(7, &dir, "title")); // No job record at all: nothing is provably ours. - assert!(glib::MainContext::default().block_on(manager.delete_download_async(7)).is_ok()); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(7)) + .is_ok() + ); assert_eq!(store.n_items(), 0); assert!(folder.join("keep.txt").exists()); let _ = std::fs::remove_dir_all(&dir); @@ -8045,7 +8079,11 @@ fn delete_download_new_gallery_row_trashes_subfolder() { .borrow_mut() .insert(7, test_gallery_job(sub.clone())); - assert!(glib::MainContext::default().block_on(manager.delete_download_async(7)).is_ok()); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(7)) + .is_ok() + ); assert_eq!(store.n_items(), 0); assert!(!sub.exists()); // Undo the test's own Trash litter. @@ -8077,7 +8115,11 @@ fn delete_download_after_gallery_rename_trashes_renamed_folder() { assert!(manager.rename_download(7, "renamed").is_ok()); assert!(dir.join("renamed").is_dir()); - assert!(glib::MainContext::default().block_on(manager.delete_download_async(7)).is_ok()); + assert!( + glib::MainContext::default() + .block_on(manager.delete_download_async(7)) + .is_ok() + ); assert_eq!(store.n_items(), 0); // The renamed folder — the one Grab owns — must be trashed. assert!(!dir.join("renamed").exists()); From ae81f5d75a38a1c12e79c82e102573be4f964d8f Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:32:01 +0100 Subject: [PATCH 24/89] test: drain GLib deletion tasks before releasing context --- src/download_tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/download_tests.rs b/src/download_tests.rs index 311bd8925..21ae41dd4 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -1921,6 +1921,7 @@ fn delete_download_trashes_torrent_files() { .block_on(manager.delete_download_async(7)) .is_ok() ); + quiesce(&glib::MainContext::default()); assert!(!file.exists()); assert_eq!(store.n_items(), 0); assert!(crate::torrent::archive_path_for_url(&pseudo).is_none()); @@ -2062,6 +2063,7 @@ fn delete_download_removes_file_and_row() { .block_on(manager.delete_download_async(7)) .is_ok() ); + quiesce(&glib::MainContext::default()); assert!(!file.exists()); assert_eq!(store.n_items(), 0); // Undo the test's own Trash litter. @@ -2081,6 +2083,7 @@ fn delete_download_removes_file_and_row() { .block_on(manager.delete_download_async(8)) .is_ok() ); + quiesce(&glib::MainContext::default()); assert_eq!(store.n_items(), 0); let _ = std::fs::remove_dir_all(&dir); } @@ -2188,6 +2191,7 @@ fn delete_download_trashes_torrent_subfolder() { .block_on(manager.delete_download_async(9)) .is_ok() ); + quiesce(&glib::MainContext::default()); // Owned file trashed, unrelated file survives, folder left in place. assert!(!folder.join("a.txt").exists()); assert!(folder.join("unrelated.txt").exists()); @@ -2230,6 +2234,7 @@ fn delete_download_magnet_leaves_preexisting_folder() { .block_on(manager.delete_download_async(10)) .is_ok() ); + quiesce(&glib::MainContext::default()); // Folder and unrelated file must survive. assert!(folder.exists()); assert_eq!( @@ -2275,6 +2280,7 @@ fn torrent_delete_refuses_symlinked_subdirectory() { .block_on(manager.delete_download_async(11)) .is_ok() ); + quiesce(&glib::MainContext::default()); // Precious file and symlink must survive. assert_eq!(std::fs::read(outside.join("b.txt")).unwrap(), b"precious"); assert!(folder.join("sub").is_symlink()); @@ -5504,11 +5510,13 @@ fn delete_download_trashes_video_sidecars() { .block_on(manager.delete_download_async(11)) .is_ok() ); + quiesce(&glib::MainContext::default()); assert!( glib::MainContext::default() .block_on(manager.delete_download_async(12)) .is_ok() ); + quiesce(&glib::MainContext::default()); // Video + its sidecars trashed; plain row's same-named srt stays. for n in ["Clip.mp4", "Clip.en.srt", "Clip.fr.srt", "Other.mp4"] { assert!(!dir.join(n).exists(), "{n} must be trashed"); @@ -8028,6 +8036,7 @@ fn delete_download_legacy_gallery_row_keeps_unrelated_folder() { .block_on(manager.delete_download_async(7)) .is_ok() ); + quiesce(&glib::MainContext::default()); assert_eq!(store.n_items(), 0); // The unrelated folder and its content survive. assert!(decoy.join("keep.txt").exists()); @@ -8055,6 +8064,7 @@ fn delete_download_gallery_row_without_job_keeps_folder() { .block_on(manager.delete_download_async(7)) .is_ok() ); + quiesce(&glib::MainContext::default()); assert_eq!(store.n_items(), 0); assert!(folder.join("keep.txt").exists()); let _ = std::fs::remove_dir_all(&dir); @@ -8084,6 +8094,7 @@ fn delete_download_new_gallery_row_trashes_subfolder() { .block_on(manager.delete_download_async(7)) .is_ok() ); + quiesce(&glib::MainContext::default()); assert_eq!(store.n_items(), 0); assert!(!sub.exists()); // Undo the test's own Trash litter. @@ -8120,6 +8131,7 @@ fn delete_download_after_gallery_rename_trashes_renamed_folder() { .block_on(manager.delete_download_async(7)) .is_ok() ); + quiesce(&glib::MainContext::default()); assert_eq!(store.n_items(), 0); // The renamed folder — the one Grab owns — must be trashed. assert!(!dir.join("renamed").exists()); From d3e0a6674a04894958dd0f43d8f2b053cdb7c1ad Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:40:29 +0100 Subject: [PATCH 25/89] Drain cancellation cleanup in queue-order test --- src/download_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 21ae41dd4..c089859db 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -3101,7 +3101,7 @@ fn raising_max_concurrent_starts_queued() { #[test] fn defer_yields_slot_and_goes_last() { - let _lock = lock_queue_file(); + let (_lock, _loop) = test_locks(); let _qf = test_queue_file("defer"); let settings = test_settings(); settings.set_int("max-concurrent", 1).unwrap(); @@ -3132,6 +3132,9 @@ fn defer_yields_slot_and_goes_last() { // Cancel first so no queued row remains for the backend restore to spawn. manager.cancel_all(); assert!(!manager.running.borrow().contains_key(&72)); + // cancel_all schedules partial-file cleanup on the GLib context. Drain + // those local futures before this test's context is owned by another thread. + quiesce(&glib::MainContext::default()); settings.set_int("max-concurrent", 3).unwrap(); } From 38ac4c49f84ad3ed4d72ea376734848e7f526424 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:41:05 +0100 Subject: [PATCH 26/89] Wait for cancellation cleanup workers in tests --- src/download_tests.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index c089859db..4b1e92da9 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -121,6 +121,25 @@ fn drain_engine(manager: &DownloadManager, id: u64) { quiesce(&ctx); } +/// Wait for cancel_all's path reservations to clear, proving its blocking +/// cleanup workers and their main-context continuations have completed. +fn drain_cancel_cleanups(manager: &DownloadManager) { + let ctx = glib::MainContext::default(); + let deadline = std::time::Instant::now() + Duration::from_secs(15); + while manager + .items() + .any(|item| manager.dest_reserved(&item.file_path())) + { + assert!( + std::time::Instant::now() < deadline, + "timed out draining cancellation cleanup workers" + ); + ctx.iteration(false); + std::thread::sleep(Duration::from_millis(1)); + } + quiesce(&ctx); +} + /// Pump until quiet (50 idle rounds) so no woken tail trips another test's loop. fn quiesce(ctx: &glib::MainContext) { let mut idle_rounds = 0; @@ -3133,8 +3152,8 @@ fn defer_yields_slot_and_goes_last() { manager.cancel_all(); assert!(!manager.running.borrow().contains_key(&72)); // cancel_all schedules partial-file cleanup on the GLib context. Drain - // those local futures before this test's context is owned by another thread. - quiesce(&glib::MainContext::default()); + // the workers and their continuations before another test owns the context. + drain_cancel_cleanups(&manager); settings.set_int("max-concurrent", 3).unwrap(); } From 56f83479f2209741a18f4bb0e56e1878fc356210 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:03:38 +0100 Subject: [PATCH 27/89] Count async preparation against download concurrency limit --- src/download.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/download.rs b/src/download.rs index edea82ec5..e47e8caec 100644 --- a/src/download.rs +++ b/src/download.rs @@ -278,6 +278,8 @@ pub struct DownloadManager { store: gio::ListStore, settings: crate::settings::AppSettings, running: RefCell>>, + /// Rows whose filesystem preparation is in flight; these consume scheduler slots too. + preparing: RefCell>, /// Pump task handles by download ID, so tests can wait for pump completion (a `spawn_future_local` task reaped on another thread trips glib's thread guard). pub(crate) pump_handles: RefCell>>, next_id: Cell, @@ -585,6 +587,7 @@ impl DownloadManager { store, settings, running: RefCell::new(HashMap::new()), + preparing: RefCell::new(HashSet::new()), pump_handles: RefCell::new(HashMap::new()), next_id: Cell::new(1), on_change: RefCell::new(None), @@ -1521,7 +1524,7 @@ impl DownloadManager { self.discards .borrow_mut() .retain(|_, pending| !pending.finalizer.is_finished()); - while self.running.borrow().len() < self.max_concurrent() { + while self.running.borrow().len() + self.preparing.borrow().len() < self.max_concurrent() { let next = self .items() // A discard still tearing down owns its destination: the row stays Queued until the reservation clears. @@ -1553,9 +1556,11 @@ impl DownloadManager { ); let segments = self.segment_state.borrow().get(&id).cloned(); let started = self.started.borrow().contains(&id); - // Reserve the target while filesystem validation is in flight. This - // keeps another queued row or a rapid retry from claiming the same path. + // Reserve the target and a scheduler slot while filesystem validation is in flight. + // This keeps another queued row or a rapid retry from claiming the same path, + // and prevents async preparation from bypassing max_concurrent. self.reserve_dest(&dest); + self.preparing.borrow_mut().insert(id); let weak = Rc::downgrade(self); glib::spawn_future_local(async move { let worker_dest = dest.clone(); @@ -1641,6 +1646,7 @@ impl DownloadManager { let Some(manager) = weak.upgrade() else { return; }; + manager.preparing.borrow_mut().remove(&id); manager.release_dest(&dest); let Some(current) = manager.find(id) else { manager.start_next(); From 9aa8026340744a7a4e6b75d3ebeda1a889c94b22 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:03:45 +0100 Subject: [PATCH 28/89] Pass torrent selection snapshot into deferred cleanup --- src/torrent.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/torrent.rs b/src/torrent.rs index daa91af6d..c5ec339c0 100644 --- a/src/torrent.rs +++ b/src/torrent.rs @@ -396,10 +396,11 @@ pub(crate) fn single_file_path(dest: &std::path::Path, url: &str) -> Option = selected.into_iter().collect(); + let keep: std::collections::HashSet = selected.iter().copied().collect(); for (i, entry) in entries.iter().enumerate() { if let Some(target) = deletion_target(folder, i, &keep, entry) { remove_file_and_prune_parents(&target, folder); From 5a12eab5469a6cb9376339cd23a48154b5aed559 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:03:52 +0100 Subject: [PATCH 29/89] Snapshot torrent filter before deferred cleanup --- src/download.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/download.rs b/src/download.rs index e47e8caec..5a3c18d32 100644 --- a/src/download.rs +++ b/src/download.rs @@ -2027,6 +2027,11 @@ impl DownloadManager { let keep_server_date = this.settings.keep_server_date(); let cleanup_torrent = crate::torrent::is_torrent(&url) && !this.settings.torrent_seed_finished(); + // Snapshot the filter before dropping its row-scoped state; the + // filesystem cleanup runs later on a blocking worker. + let cleanup_selection = cleanup_torrent + .then(|| crate::torrent::get_selection(&url)) + .flatten(); let finished_path = item.file_path(); let torrent_dest_dir = item.dest_dir().to_string(); let torrent_output_dir = item.output_dir().to_string(); @@ -2054,7 +2059,7 @@ impl DownloadManager { { let _ = file.set_modified(time); } - if cleanup_torrent { + if let Some(selected) = cleanup_selection { let folder = DownloadManager::torrent_folder_from_parts( &torrent_dest_dir, &worker_url, @@ -2065,6 +2070,7 @@ impl DownloadManager { crate::torrent::cleanup_unselected( &folder, &worker_url, + &selected, ); } } From a09a30cc73ab68147c522de2f850b28ce0689e52 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:03:58 +0100 Subject: [PATCH 30/89] Update torrent cleanup test for explicit selection snapshot --- src/download_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 4b1e92da9..ec56bf7ab 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -2173,7 +2173,8 @@ fn finish_cleans_untoggled_placeholders() { std::fs::create_dir_all(folder.join("sub")).unwrap(); std::fs::write(folder.join("a.txt"), b"kept").unwrap(); std::fs::write(folder.join("sub").join("b.txt"), b"").unwrap(); - crate::torrent::cleanup_unselected(&folder, &pseudo); + let selected = crate::torrent::get_selection(&pseudo).unwrap(); + crate::torrent::cleanup_unselected(&folder, &pseudo, &selected); assert!(folder.join("a.txt").exists()); assert!(!folder.join("sub").exists()); assert!(folder.exists()); From 0ddca80932fc3ddfab631d5741e6696b33e8da73 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:04:15 +0100 Subject: [PATCH 31/89] Preserve segmented resume and shutdown persistence ordering --- src/download.rs | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/download.rs b/src/download.rs index 5a3c18d32..4ec61932f 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1755,6 +1755,13 @@ impl DownloadManager { } let connections = (opts.connections.max(1) as usize).min(16); let timeout = Duration::from_secs(30); + // Restore the visible fraction from the saved bitmap before starting the + // resumed engine; preparation runs off-thread but UI state remains main-thread-owned. + if let Some(StartMode::Resume(state)) = mode.as_ref() { + let fraction = (state.completed_bytes() as f64 / state.total.max(1) as f64) + .clamp(0.0, 1.0); + item.set_progress(fraction); + } // A saved bitmap means non-contiguous pieces: only a segmented resume is correct. let mode = mode.unwrap_or(StartMode::Fresh); let generation = self.epoch.borrow().get(&item.id()).cloned().unwrap_or(0) + 1; @@ -2249,11 +2256,10 @@ impl DownloadManager { } } EngineMsg::FallbackSingle { ack } => { - // Drop and persist the bitmap before yielding. The worker owns only the - // file mutation; a retry/Undo waits on the path reservation until it completes. - let state = this.segment_state.borrow_mut().remove(&id); - this.persist_queue(); - if let Some(state) = state { + // Keep the bitmap persisted until the sparse/segmented file is + // actually truncated. A crash during the worker must still restore + // with the bitmap rather than append to a file with holes. + if let Some(state) = this.segment_state.borrow().get(&id).cloned() { let path = item.file_path(); this.reserve_dest(&path); let worker_path = path.clone(); @@ -2262,7 +2268,12 @@ impl DownloadManager { }) .await; this.release_dest(&path); + this.segment_state.borrow_mut().remove(&id); + this.persist_queue(); this.start_next(); + } else { + this.segment_state.borrow_mut().remove(&id); + this.persist_queue(); } ack.send(()).await.ok(); } @@ -3710,11 +3721,13 @@ impl DownloadManager { tx.send_replace(Some(queue)); } drop(self.persist_tx.borrow_mut().take()); - // This is the synchronous fallback reached only when the normal - // close/Quit path could not run its async shutdown. Detach the writer - // instead of blocking GTK; the ordinary path awaits it in - // persist_for_shutdown_async before the application quits. - drop(self.persist_task.borrow_mut().take()); + // This fallback runs after the main loop has exited, when blocking + // cannot freeze GTK. Bound the wait so a stalled disk cannot hang exit. + if let Some(task) = self.persist_task.borrow_mut().take() { + let _ = tokio_rt().block_on(async { + tokio::time::timeout(Duration::from_secs(5), task).await + }); + } } } From 1ac8eac4ebc74f54b7f0f0c0ed64ea84b3164e3c Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:04:26 +0100 Subject: [PATCH 32/89] Avoid retrying stale finished-name claims --- src/download.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/download.rs b/src/download.rs index 4ec61932f..49a129eaf 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1995,7 +1995,13 @@ impl DownloadManager { done = true; break; } - let taken = |n: &str| this.is_name_taken(&dir, &existing, n); + // A listing is only a snapshot: remember every candidate that + // lost an atomic rename race so the next attempt cannot repeat it. + let lost_claims = RefCell::new(Vec::::new()); + let taken = |n: &str| { + lost_claims.borrow().iter().any(|lost| lost == n) + || this.is_name_taken(&dir, &existing, n) + }; // Claim-then-move so a file appearing between dedupe and rename is never clobbered. let old_path = item.file_path(); let mut final_name = dedupe_filename(&name, taken); @@ -2014,6 +2020,7 @@ impl DownloadManager { moved = true; } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + lost_claims.borrow_mut().push(final_name); final_name = dedupe_filename(&name, taken); } Err(_) => break, From a645dbf2d10a3026417abf4b41fc95662834b201 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:04:47 +0100 Subject: [PATCH 33/89] Prevent duplicate torrent enqueue submissions --- src/inline_add.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/inline_add.rs b/src/inline_add.rs index 0c74a738f..b33528c35 100644 --- a/src/inline_add.rs +++ b/src/inline_add.rs @@ -1545,6 +1545,8 @@ fn push_torrent_picker_page( let only = (selected.len() < grid.len()).then_some(selected); let show_picker_on_error = fallback_to_picker.clone(); let error_caption_on_error = error_caption.clone(); + add_btn_click.set_sensitive(false); + let add_btn_on_error = add_btn_click.clone(); let enqueue_manager = Rc::clone(&manager); let enqueue_bytes = bytes.clone(); let enqueue_name = file_name.clone(); @@ -1564,6 +1566,7 @@ fn push_torrent_picker_page( .await }, move |e| { + add_btn_on_error.set_sensitive(true); show_picker_on_error(); error_caption_on_error.set_text(e); error_caption_on_error.set_visible(true); @@ -1634,7 +1637,9 @@ fn wire_torrent_picker( } }; if entries.len() <= 1 { + torrent_btn.set_sensitive(false); let enqueue_manager = Rc::clone(&m); + let torrent_btn_on_error = torrent_btn.clone(); enqueue_and_close_async( &m, &dd, @@ -1646,6 +1651,7 @@ fn wire_torrent_picker( .await }, move |e| { + torrent_btn_on_error.set_sensitive(true); set_field_error(&torrent_row, e); }, ); From b887f9905d5257cdae293a1c2cdfe3a48197c948 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:04:56 +0100 Subject: [PATCH 34/89] Own torrent submit button across async picker flow --- src/inline_add.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/inline_add.rs b/src/inline_add.rs index b33528c35..573d3921d 100644 --- a/src/inline_add.rs +++ b/src/inline_add.rs @@ -1590,8 +1590,10 @@ fn wire_torrent_picker( scheduled_at: Rc>>, ) { let nav = nav.clone(); + let torrent_btn = torrent_btn.clone(); let scheduled_at_c = Rc::clone(&scheduled_at); torrent_btn.connect_clicked(move |_| { + let torrent_btn = torrent_btn.clone(); let m = manager.clone(); let dd = dest_dir.clone(); let close_card = close_card.clone(); From 8f3efea86aed241d63584e49b1d697df8abf3994 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:05:20 +0100 Subject: [PATCH 35/89] Restore filesystem and intake invariant rationale --- src/download.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/download.rs b/src/download.rs index 49a129eaf..28d50bab1 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1024,6 +1024,9 @@ impl DownloadManager { job: &crate::gallery_argv::GalleryJob, ) -> Result { let url = normalize_url(&job.page_url)?; + // Resolve the destination before recording it: empty/relative paths must never + // become dependent on the launcher CWD. The sanitized title is used as a + // restore-safe folder component, not as an arbitrary path from metadata. let dir = self.resolve_dir(Some(&job.dest_dir.to_string_lossy())); let title = crate::file_names::sanitize_folder_name(&job.title); let listing_dir = dir.clone(); @@ -1033,6 +1036,8 @@ impl DownloadManager { .await .map_err(|error| format!("Could not inspect gallery destination: {error:?}"))?; + // Only claim a folder that this row can prove is new. A pre-existing user + // directory or another queued gallery must never become a trash target. let mut unique_title = title.clone(); let mut counter = 1; while existing.iter().any(|entry| entry == &unique_title) @@ -1161,6 +1166,8 @@ impl DownloadManager { // The main context yielded while the folder was prepared. Do not // enqueue stale children after pause/cancel/remove. + // Collection metadata is untrusted. Re-check the carrier after yielding so + // removed/cancelled rows cannot create children from stale resolver output. let Some(current) = self.find(id) else { return (0, pl.total, 0); }; @@ -1570,6 +1577,8 @@ impl DownloadManager { let preparation = gio::spawn_blocking(move || { let parent = worker_dest.parent(); if let Some(parent) = parent { + // Re-check containment on the worker immediately before touching the + // path: a symlink planted after intake must not redirect the write. match ensure_contained_parent(parent, &worker_dir) { Ok(true) => {} Ok(false) => { @@ -1590,6 +1599,8 @@ impl DownloadManager { let output = if worker_recorded.is_empty() { dir.clone() } else { + // Re-guard the recorded folder at spawn time; a symlink may have + // been planted after intake. Never follow it outside the destination. let guarded = crate::torrent::guard_output_folder(&dir, recorded_path.clone()); if guarded != recorded_path { @@ -1630,6 +1641,8 @@ impl DownloadManager { StartMode::Resume(state) } } + // Existing destination is refused for a fresh row; never overwrite a + // file that may belong to the user or another application. None => match std::fs::metadata(&worker_dest) { Err(_) => StartMode::Fresh, Ok(_) if !started => { From 06f8db9f52d9980116cbe73ef52ff006ed3c61ab Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:05:41 +0100 Subject: [PATCH 36/89] Revalidate finished-name generation after filesystem await --- src/download.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/download.rs b/src/download.rs index 28d50bab1..c311aaefb 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1987,6 +1987,10 @@ impl DownloadManager { let current = item.filename().to_string(); let dir = item.dest_dir().to_string(); let listing_dir = dir.clone(); + // Do not keep a strong manager Rc inside this thread-bound local + // task while filesystem work is pending; re-upgrade and validate + // the generation before applying the result. + drop(this); let existing = match gio::spawn_blocking(move || { crate::video_staging::dir_file_names(std::path::Path::new( &listing_dir, @@ -2000,6 +2004,14 @@ impl DownloadManager { Vec::new() } }; + let Some(this) = weak.upgrade() else { + done = true; + break; + }; + if !this.is_current(id, generation) { + done = true; + break; + } // The main context was yielded while the directory was read. Do not finalize a row that was // paused/cancelled, removed, or replaced while the worker ran. if item.status() == DownloadStatus::Cancelled From c7a9e792d2ee2fd1fba548ed848d56d491dbafb0 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:05:47 +0100 Subject: [PATCH 37/89] Revalidate failed-version cleanup after filesystem await --- src/download.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/download.rs b/src/download.rs index c311aaefb..d6794a86e 100644 --- a/src/download.rs +++ b/src/download.rs @@ -2222,8 +2222,17 @@ impl DownloadManager { this.persist_queue(); this.reserve_dest(&invalid_path); let worker_path = invalid_path.clone(); + drop(this); let _ = gio::spawn_blocking(move || std::fs::remove_file(worker_path)).await; + let Some(this) = weak.upgrade() else { + done = true; + break; + }; + if !this.is_current(id, generation) { + done = true; + break; + } this.release_dest(&invalid_path); this.start_next(); if item.status() != DownloadStatus::Cancelled From aa701c62a98d8d9716ea6c501c02860cc6ca6687 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:05:54 +0100 Subject: [PATCH 38/89] Release failed-version reservation before stale exit --- src/download.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/download.rs b/src/download.rs index d6794a86e..adefb8067 100644 --- a/src/download.rs +++ b/src/download.rs @@ -2229,11 +2229,11 @@ impl DownloadManager { done = true; break; }; + this.release_dest(&invalid_path); if !this.is_current(id, generation) { done = true; break; } - this.release_dest(&invalid_path); this.start_next(); if item.status() != DownloadStatus::Cancelled && item.status() != DownloadStatus::Paused From 37668d539988a7d665e73ce9561daebe477e5b8a Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:06:08 +0100 Subject: [PATCH 39/89] Avoid retaining manager across truncation awaits --- src/download.rs | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/download.rs b/src/download.rs index adefb8067..cc933c2fa 100644 --- a/src/download.rs +++ b/src/download.rs @@ -2288,11 +2288,20 @@ impl DownloadManager { let path = item.file_path(); this.reserve_dest(&path); let worker_path = path.clone(); + drop(this); let _ = gio::spawn_blocking(move || { truncate_to_prefix(&worker_path, &state) }) .await; + let Some(this) = weak.upgrade() else { + done = true; + break; + }; this.release_dest(&path); + if !this.is_current(id, generation) { + done = true; + break; + } this.start_next(); } } @@ -2304,14 +2313,31 @@ impl DownloadManager { let path = item.file_path(); this.reserve_dest(&path); let worker_path = path.clone(); - let _ = gio::spawn_blocking(move || { + drop(this); + let truncated = gio::spawn_blocking(move || { truncate_to_prefix(&worker_path, &state) }) - .await; + .await + .is_ok_and(Result::is_ok); + let Some(this) = weak.upgrade() else { + done = true; + break; + }; this.release_dest(&path); - this.segment_state.borrow_mut().remove(&id); - this.persist_queue(); - this.start_next(); + if !this.is_current(id, generation) { + done = true; + break; + } + if truncated { + this.segment_state.borrow_mut().remove(&id); + this.persist_queue(); + this.start_next(); + } else { + tracing::warn!(path = %path.display(), "could not truncate segmented file before single-stream fallback"); + // Dropping ack tells the engine that fallback cannot safely proceed. + done = true; + break; + } } else { this.segment_state.borrow_mut().remove(&id); this.persist_queue(); From a6ece7991f919c9d67ea41cdeceae1017e9984fd Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:07:01 +0100 Subject: [PATCH 40/89] Separate async intake preparation from queue commit --- src/download.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/src/download.rs b/src/download.rs index cc933c2fa..51871f2a9 100644 --- a/src/download.rs +++ b/src/download.rs @@ -932,6 +932,20 @@ impl DownloadManager { dest_dir: Option<&str>, only_files: Option>, ) -> Result { + let (item, guard) = self + .enqueue_torrent_file_async_batched(bytes, file_name, dest_dir, only_files) + .await?; + drop(guard); + Ok(item) + } + + pub(crate) async fn enqueue_torrent_file_async_batched( + self: &Rc, + bytes: Vec, + file_name: &str, + dest_dir: Option<&str>, + only_files: Option>, + ) -> Result<(DownloadItem, BatchGuard), String> { let file_name = file_name.to_string(); let dir = self.resolve_dir(dest_dir); let worker_file_name = file_name.clone(); @@ -946,6 +960,8 @@ impl DownloadManager { .await .map_err(|error| format!("Could not prepare torrent file: {error:?}"))??; + // Hold the queue only during the synchronous commit, not during disk I/O. + let guard = self.batch_guard(); if let Some(selection) = only_files { crate::torrent::stage_selection(&pseudo, selection); } @@ -976,7 +992,7 @@ impl DownloadManager { ); } } - Ok(item) + Ok((item, guard)) } /// Final filename policy, shared by intake naming and late server/container suggestions: shorten, then the opt-in ASCII fold. Dedupe stays at the call sites. @@ -1005,6 +1021,20 @@ impl DownloadManager { filename: Option<&str>, choices: crate::media_types::VideoChoices, ) -> Result { + let (item, guard) = self + .enqueue_video_async_batched(page_url, dest_dir, filename, choices) + .await?; + drop(guard); + Ok(item) + } + + pub(crate) async fn enqueue_video_async_batched( + self: &Rc, + page_url: &str, + dest_dir: Option<&str>, + filename: Option<&str>, + choices: crate::media_types::VideoChoices, + ) -> Result<(DownloadItem, BatchGuard), String> { let dir = self.resolve_dir(dest_dir); let listing_dir = dir.clone(); let existing = gio::spawn_blocking(move || { @@ -1012,7 +1042,9 @@ impl DownloadManager { }) .await .map_err(|error| format!("Could not inspect download folder: {error:?}"))?; - self.enqueue_video_staged(page_url, &dir, filename, choices, &existing) + let guard = self.batch_guard(); + let item = self.enqueue_video_staged(page_url, &dir, filename, choices, &existing)?; + Ok((item, guard)) } /// Enqueue a gallery download (image gallery via gallery-dl). @@ -1023,6 +1055,15 @@ impl DownloadManager { self: &Rc, job: &crate::gallery_argv::GalleryJob, ) -> Result { + let (item, guard) = self.enqueue_gallery_async_batched(job).await?; + drop(guard); + Ok(item) + } + + pub(crate) async fn enqueue_gallery_async_batched( + self: &Rc, + job: &crate::gallery_argv::GalleryJob, + ) -> Result<(DownloadItem, BatchGuard), String> { let url = normalize_url(&job.page_url)?; // Resolve the destination before recording it: empty/relative paths must never // become dependent on the launcher CWD. The sanitized title is used as a @@ -1036,6 +1077,8 @@ impl DownloadManager { .await .map_err(|error| format!("Could not inspect gallery destination: {error:?}"))?; + // Only hold the queue during the synchronous commit, never across listing I/O. + let guard = self.batch_guard(); // Only claim a folder that this row can prove is new. A pre-existing user // directory or another queued gallery must never become a trash target. let mut unique_title = title.clone(); @@ -1065,7 +1108,7 @@ impl DownloadManager { stored_job.dest_dir = gallery_dir; stored_job.item_id = item.id(); self.gallery_jobs.borrow_mut().insert(item.id(), stored_job); - Ok(self.insert(item)) + Ok((self.insert(item), guard)) } /// [`DownloadManager::enqueue_video`] with a precomputed directory listing, From 913e99d427b3f45f18db47c35c72a7ff884abcdc Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:07:09 +0100 Subject: [PATCH 41/89] Keep async intake I/O outside queue batch guard --- src/inline_add.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/inline_add.rs b/src/inline_add.rs index 573d3921d..769ada190 100644 --- a/src/inline_add.rs +++ b/src/inline_add.rs @@ -879,7 +879,7 @@ fn submit_probed_single( scheduled_at, move |d| async move { enqueue_manager - .enqueue_video_async( + .enqueue_video_async_batched( &page_url, d.as_deref(), name.as_deref(), @@ -1017,7 +1017,7 @@ fn submit_gallery_all( move |d| async move { let mut job = job; job.dest_dir = std::path::PathBuf::from(d.unwrap_or_else(|| ".".to_string())); - enqueue_manager.enqueue_gallery_async(&job).await + enqueue_manager.enqueue_gallery_async_batched(&job).await }, move |e| { show_video_error(&error_step, e); @@ -1079,22 +1079,23 @@ pub(crate) fn enqueue_and_close_async( scheduled_at: Option, enqueue: impl FnOnce(Option) -> F + 'static, on_err: impl FnOnce(&str) + 'static, -) where - F: std::future::Future> + 'static, + ) where + F: std::future::Future> + 'static, { - let manager = Rc::clone(manager); let dest = Rc::clone(dest); let close_card = Rc::clone(close_card); glib::spawn_future_local(async move { // Own the destination value before awaiting; close_card re-borrows the // destination when it resets the form after a successful enqueue. let destination = Some(dest.borrow().clone()); - // Keep the new row parked until its scheduled timestamp is installed. - let _guard = manager.batch_guard(); + // Preparation runs without a batch open. The intake API opens one only + // for its synchronous commit and returns the guard so scheduling is set + // before the queue is allowed to start the row. match enqueue(destination).await { - Ok(item) => { + Ok((item, guard)) => { apply_scheduled_at(&item, scheduled_at); close_card(); + drop(guard); } Err(error) => on_err(&error), } @@ -1557,7 +1558,7 @@ fn push_torrent_picker_page( scheduled_at.get(), move |d| async move { enqueue_manager - .enqueue_torrent_file_async( + .enqueue_torrent_file_async_batched( enqueue_bytes, &enqueue_name, d.as_deref(), From 27459a1c0e6b5168f2f5dccc23aee59b2bcb8d12 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:07:18 +0100 Subject: [PATCH 42/89] Avoid holding queue batch across gallery filesystem preparation --- src/inline_gallery.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/inline_gallery.rs b/src/inline_gallery.rs index 6d967fcc8..1121473c1 100644 --- a/src/inline_gallery.rs +++ b/src/inline_gallery.rs @@ -353,15 +353,15 @@ pub(crate) fn push_gallery_picker_page( let error_caption = error_caption.clone(); let add_btn_guard = add_btn_guard.clone(); gtk4::glib::spawn_future_local(async move { - let _guard = manager.batch_guard(); let mut job = base_job; job.dest_dir = std::path::PathBuf::from(dest_dir.borrow().clone()); - match manager.enqueue_gallery_async(&job).await { - Ok(item) => { + match manager.enqueue_gallery_async_batched(&job).await { + Ok((item, guard)) => { apply_scheduled_at(&item, scheduled_at); // Defer picker teardown until the clicked signal unwinds. let close = close_card.clone(); gtk4::glib::idle_add_local_once(move || close()); + drop(guard); } Err(e) => { error_caption.set_text(&e); From 292e411981b4d3e63e2398730134f06742f993af Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:07:28 +0100 Subject: [PATCH 43/89] Use batched intake for single-file torrent submit --- src/inline_add.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/inline_add.rs b/src/inline_add.rs index 769ada190..44a4146d8 100644 --- a/src/inline_add.rs +++ b/src/inline_add.rs @@ -1073,7 +1073,7 @@ pub(crate) fn enqueue_and_close( } pub(crate) fn enqueue_and_close_async( - manager: &Rc, + _manager: &Rc, dest: &Rc>, close_card: &Rc, scheduled_at: Option, @@ -1650,7 +1650,7 @@ fn wire_torrent_picker( scheduled_at.get(), move |d| async move { enqueue_manager - .enqueue_torrent_file_async(bytes, &name, d.as_deref(), None) + .enqueue_torrent_file_async_batched(bytes, &name, d.as_deref(), None) .await }, move |e| { From 200bbb986a61932826ce84f8eafeb4d8a7be1389 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:07:40 +0100 Subject: [PATCH 44/89] Test async preparation respects concurrency cap --- src/download_tests.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/download_tests.rs b/src/download_tests.rs index ec56bf7ab..b44ee9a52 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -8199,3 +8199,41 @@ fn restoring_public_only_row_reinstates_its_host_policy() { let _ = std::fs::remove_file(queue_file); } + + +#[test] +fn start_next_counts_preparing_rows_against_the_cap() { + let (_lock, _loop) = test_locks(); + let _qf = test_queue_file("preparing-cap"); + let settings = test_settings(); + settings.set_int("max-concurrent", 2).unwrap(); + let manager = DownloadManager::new(gio::ListStore::new::(), settings); + + for i in 0..5 { + manager + .enqueue( + &format!("http://127.0.0.1:9/f{i}"), + Some("/tmp"), + None, + ) + .unwrap(); + } + + assert!( + manager.preparing.borrow().len() + manager.running.borrow().len() <= 2, + "filesystem preparation must consume a scheduler slot" + ); + assert_eq!(manager.preparing.borrow().len(), 2); + manager.cancel_all(); + + let context = glib::MainContext::default(); + let deadline = std::time::Instant::now() + Duration::from_secs(15); + while !manager.preparing.borrow().is_empty() + && std::time::Instant::now() < deadline + { + context.iteration(false); + std::thread::sleep(Duration::from_millis(1)); + } + assert!(manager.preparing.borrow().is_empty(), "preparation workers must drain"); + quiesce(&context); +} From 6a06c1fe1b4a708770ee55040d3b6dccc66b09b0 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:07:46 +0100 Subject: [PATCH 45/89] Cover torrent cleanup after selection state is dropped --- src/download_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/download_tests.rs b/src/download_tests.rs index b44ee9a52..f9b57b7f9 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -2174,6 +2174,9 @@ fn finish_cleans_untoggled_placeholders() { std::fs::write(folder.join("a.txt"), b"kept").unwrap(); std::fs::write(folder.join("sub").join("b.txt"), b"").unwrap(); let selected = crate::torrent::get_selection(&pseudo).unwrap(); + // Finished drops the URL-scoped selection before its deferred worker runs. + // Passing the snapshot must still remove untoggled placeholders. + crate::torrent::drop_selection(&pseudo); crate::torrent::cleanup_unselected(&folder, &pseudo, &selected); assert!(folder.join("a.txt").exists()); assert!(!folder.join("sub").exists()); From 868ff471eaae1d0606bdb2301ed4511d13e56b7d Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:08:10 +0100 Subject: [PATCH 46/89] Release manager before playlist directory preparation --- src/download.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/download.rs b/src/download.rs index 51871f2a9..b561eac5e 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1169,12 +1169,15 @@ impl DownloadManager { /// Queue one row per playlist entry for collection-shaped resolves with no picked entry. Returns (added, reported total). One persist for the whole import. /// Queue collection children after creating/listing their destination off GTK. async fn expand_playlist_rows_async( - self: &Rc, + weak: std::rc::Weak, id: u64, item: &DownloadItem, pl: &crate::media_types::PlaylistInfo, ) -> (usize, usize, usize) { - let (quality, audio_only) = match self.video_source(id) { + let Some(this) = weak.upgrade() else { + return (0, pl.total, 0); + }; + let (quality, audio_only) = match this.video_source(id) { Some(crate::media_types::VideoSource::Page { quality, audio_only, @@ -1182,9 +1185,12 @@ impl DownloadManager { }) => (quality, audio_only), _ => return (0, pl.total, 0), }; - let base_dir = self.resolve_dir(Some(&item.dest_dir())); + let base_dir = this.resolve_dir(Some(&item.dest_dir())); let multi = pl.total > 1; let title = pl.title.clone(); + // The local task must not keep a strong manager Rc pinned while the + // directory worker is pending; upgrade again before touching queue state. + drop(this); let prepared = gio::spawn_blocking(move || { let dest_dir = if multi { crate::file_names::collection_subdir(&base_dir, &title) @@ -1207,11 +1213,14 @@ impl DownloadManager { } }; + let Some(this) = weak.upgrade() else { + return (0, pl.total, 0); + }; // The main context yielded while the folder was prepared. Do not // enqueue stale children after pause/cancel/remove. // Collection metadata is untrusted. Re-check the carrier after yielding so // removed/cancelled rows cannot create children from stale resolver output. - let Some(current) = self.find(id) else { + let Some(current) = this.find(id) else { return (0, pl.total, 0); }; if matches!( @@ -1221,7 +1230,7 @@ impl DownloadManager { return (0, pl.total, 0); } - let _batch = self.batch_guard(); + let _batch = this.batch_guard(); let mut guarded_child_hosts = HashSet::new(); let mut skipped_by_host_limit = 0usize; let mut added = 0usize; @@ -1243,10 +1252,10 @@ impl DownloadManager { } Err(crate::download_net::CollectionHostError::UnsafeDestination) => continue, } - let remux = crate::video_prefs::remux_video_active(&self.settings.remux_video()); + let remux = crate::video_prefs::remux_video_active(&this.settings.remux_video()); let name = crate::video::default_video_filename(&entry.title, audio_only, remux.as_deref()); - if self + if this .enqueue_video_staged( &url, &dest_dir, @@ -2173,8 +2182,18 @@ impl DownloadManager { break; } // Playlist expansion runs on the main thread where queueing is legal; the new rows are the feedback. + let weak = Rc::downgrade(&this); + drop(this); let (added, total, skipped_by_host_limit) = - this.expand_playlist_rows_async(id, &item, &pl).await; + Self::expand_playlist_rows_async(weak.clone(), id, &item, &pl).await; + let Some(this) = weak.upgrade() else { + done = true; + break; + }; + if !this.is_current(id, generation) { + done = true; + break; + } let skipped_detail = || { ngettext( "{skipped} item skipped because the collection exceeded the limit of {limit} distinct hosts", From a1e09d6911bd4233be483a0672f50c4725988fef Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:08:19 +0100 Subject: [PATCH 47/89] Fail explicitly when plain download preparation lacks a mode --- src/download.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/download.rs b/src/download.rs index b561eac5e..a4019f1e0 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1827,8 +1827,20 @@ impl DownloadManager { .clamp(0.0, 1.0); item.set_progress(fraction); } - // A saved bitmap means non-contiguous pieces: only a segmented resume is correct. - let mode = mode.unwrap_or(StartMode::Fresh); + // Torrent and video rows returned above. A plain row must always carry + // a prepared mode; never silently turn an invariant violation into Fresh, + // which could overwrite or corrupt an existing partial. + let mode = match mode { + Some(mode) => mode, + None => { + let detail = gettext("Could not prepare download mode"); + item.set_detail(detail); + item.set_status(DownloadStatus::Failed); + self.changed(); + self.start_next(); + return; + } + }; let generation = self.epoch.borrow().get(&item.id()).cloned().unwrap_or(0) + 1; self.epoch.borrow_mut().insert(item.id(), generation); let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); From 29c52ffb11fc05e1cc34d10bf756ec8292b300e6 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:08:55 +0100 Subject: [PATCH 48/89] Keep finished-name manager binding valid after await --- src/download.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/download.rs b/src/download.rs index a4019f1e0..dae03a184 100644 --- a/src/download.rs +++ b/src/download.rs @@ -2051,10 +2051,8 @@ impl DownloadManager { let current = item.filename().to_string(); let dir = item.dest_dir().to_string(); let listing_dir = dir.clone(); - // Do not keep a strong manager Rc inside this thread-bound local - // task while filesystem work is pending; re-upgrade and validate - // the generation before applying the result. - drop(this); + // The listing yields the main context; validate the generation + // after the await before applying the result. let existing = match gio::spawn_blocking(move || { crate::video_staging::dir_file_names(std::path::Path::new( &listing_dir, @@ -2068,10 +2066,6 @@ impl DownloadManager { Vec::new() } }; - let Some(this) = weak.upgrade() else { - done = true; - break; - }; if !this.is_current(id, generation) { done = true; break; From 1f3faaf1620799815a1c1ab9a732bad0d4357478 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:09:20 +0100 Subject: [PATCH 49/89] Extract atomic finished-name adoption with race-safe retries --- src/download.rs | 76 +++++++++++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/src/download.rs b/src/download.rs index dae03a184..94d8bcd3b 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1917,6 +1917,36 @@ impl DownloadManager { self.pump(item, item_id, generation, rx); } + /// Atomically adopt a finished filename. The directory listing can go stale between + /// deduplication and rename, so remember each candidate that lost EEXIST rather + /// than retrying the same name from the stale snapshot. + fn adopt_final_name( + old_path: &std::path::Path, + dir: &std::path::Path, + wanted: &str, + is_taken: impl Fn(&str) -> bool, + ) -> Option { + let lost_claims = RefCell::new(Vec::::new()); + let taken = |name: &str| { + lost_claims.borrow().iter().any(|lost| lost == name) || is_taken(name) + }; + for _ in 0..8 { + let candidate = dedupe_filename(wanted, taken); + let target = dir.join(&candidate); + if old_path == target { + return Some(candidate); + } + match rename_noreplace(old_path, &target) { + Ok(()) => return Some(candidate), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + lost_claims.borrow_mut().push(candidate); + } + Err(_) => return None, + } + } + None + } + /// Whether a finished-name candidate is taken (file on disk, reserved stem, or live row holding it). Used by the Finished claim loop. fn is_name_taken(&self, dir: &str, existing: &[String], n: &str) -> bool { existing.iter().any(|entry| entry == n) @@ -2078,37 +2108,27 @@ impl DownloadManager { done = true; break; } - // A listing is only a snapshot: remember every candidate that - // lost an atomic rename race so the next attempt cannot repeat it. - let lost_claims = RefCell::new(Vec::::new()); - let taken = |n: &str| { - lost_claims.borrow().iter().any(|lost| lost == n) - || this.is_name_taken(&dir, &existing, n) - }; // Claim-then-move so a file appearing between dedupe and rename is never clobbered. let old_path = item.file_path(); - let mut final_name = dedupe_filename(&name, taken); - let mut moved = !existing.iter().any(|entry| entry == ¤t); - for _ in 0..8 { - if moved - || old_path == std::path::Path::new(&dir).join(&final_name) - { - break; - } - match rename_noreplace( - &old_path, - &std::path::Path::new(&dir).join(&final_name), - ) { - Ok(()) => { - moved = true; - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - lost_claims.borrow_mut().push(final_name); - final_name = dedupe_filename(&name, taken); + let (final_name, moved) = + if !existing.iter().any(|entry| entry == ¤t) { + ( + dedupe_filename(&name, |n| { + this.is_name_taken(&dir, &existing, n) + }), + true, + ) + } else { + match Self::adopt_final_name( + &old_path, + std::path::Path::new(&dir), + &name, + |n| this.is_name_taken(&dir, &existing, n), + ) { + Some(name) => (name, true), + None => (current.clone(), false), } - Err(_) => break, - } - } + }; if moved && final_name != current { item.set_filename(final_name); } From ca0cfc838a2da3289e6a3c435777dd407b35bde2 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:09:29 +0100 Subject: [PATCH 50/89] Test finished-name adoption retries stale rename collisions --- src/download_tests.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/download_tests.rs b/src/download_tests.rs index f9b57b7f9..917193445 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -8240,3 +8240,33 @@ fn start_next_counts_preparing_rows_against_the_cap() { assert!(manager.preparing.borrow().is_empty(), "preparation workers must drain"); quiesce(&context); } + + +#[test] +fn finished_name_adoption_retries_after_stale_listing_collision() { + let dir = std::env::temp_dir().join(format!( + "grab-finished-name-race-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("tmp.bin"); + let occupied = dir.join("a.mp4"); + std::fs::write(&source, b"downloaded").unwrap(); + std::fs::write(&occupied, b"foreign").unwrap(); + + // Simulate a stale listing that says every name is free. The atomic rename + // must reject a.mp4 and retry with a distinct name, never overwrite it. + let adopted = DownloadManager::adopt_final_name( + &source, + &dir, + "a.mp4", + |_| false, + ) + .expect("a later collision-free candidate should be adopted"); + assert_ne!(adopted, "a.mp4"); + assert!(dir.join(&adopted).exists()); + assert_eq!(std::fs::read(&occupied).unwrap(), b"foreign"); + assert_eq!(std::fs::read(dir.join(adopted)).unwrap(), b"downloaded"); + let _ = std::fs::remove_dir_all(&dir); +} From 9e9e5f9613d5bdec6d4fc5ece3da2985bad9bce0 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:09:52 +0100 Subject: [PATCH 51/89] Restore collection expansion policy rationale --- src/download.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/download.rs b/src/download.rs index 94d8bcd3b..a5dc583a5 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1231,6 +1231,9 @@ impl DownloadManager { } let _batch = this.batch_guard(); + // Collection results are untrusted input. Keep policy inheritance and the + // distinct-host cap in the shared helper so automatic expansion and the picker + // enforce the same destination/network rules. let mut guarded_child_hosts = HashSet::new(); let mut skipped_by_host_limit = 0usize; let mut added = 0usize; @@ -1252,9 +1255,12 @@ impl DownloadManager { } Err(crate::download_net::CollectionHostError::UnsafeDestination) => continue, } + // Match the interactive picker by carrying each entry title into its filename. let remux = crate::video_prefs::remux_video_active(&this.settings.remux_video()); let name = crate::video::default_video_filename(&entry.title, audio_only, remux.as_deref()); + // Live streams discovered inside a collection use the VOD path; each child + // resolves its own page rather than inheriting the carrier's live-capture state. if this .enqueue_video_staged( &url, @@ -1274,6 +1280,8 @@ impl DownloadManager { added += 1; } } + // Report the collection's stated total, not merely attempted entries, so the + // carrier can accurately show "added of total" alongside host-cap omissions. (added, pl.total, skipped_by_host_limit) } From 9cc7202407373fec2ccb0321d202454a8036cbd7 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:10:18 +0100 Subject: [PATCH 52/89] Represent prepared download modes with an explicit enum --- src/download.rs | 85 ++++++++++++++++++++++++++++--------------------- 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/src/download.rs b/src/download.rs index a5dc583a5..788eb372f 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1651,27 +1651,25 @@ impl DownloadManager { } } - let mut torrent_dest = None; - let mut torrent_output_dir = None; if is_torrent { let dir = std::path::PathBuf::from(&worker_dir); let recorded_path = std::path::PathBuf::from(&worker_recorded); - let output = if worker_recorded.is_empty() { - dir.clone() + let (output, output_dir) = if worker_recorded.is_empty() { + (dir.clone(), None) } else { // Re-guard the recorded folder at spawn time; a symlink may have // been planted after intake. Never follow it outside the destination. let guarded = crate::torrent::guard_output_folder(&dir, recorded_path.clone()); - if guarded != recorded_path { - torrent_output_dir = Some(guarded.to_string_lossy().into_owned()); - } else if guarded.parent() != Some(dir.as_path()) { + let output_dir = (guarded != recorded_path) + .then(|| guarded.to_string_lossy().into_owned()); + if output_dir.is_none() && guarded.parent() != Some(dir.as_path()) { std::fs::create_dir_all(&guarded).map_err(|error| { gettext("Could not create the download folder: {error}") .replace("{error}", &error.to_string()) })?; } - guarded + (guarded, output_dir) }; if worker_recorded.is_empty() { std::fs::create_dir_all(&dir).map_err(|error| { @@ -1679,40 +1677,39 @@ impl DownloadManager { .replace("{error}", &error.to_string()) })?; } - torrent_dest = Some(output); + return Ok(PreparedSpawn::Torrent { dest: output, output_dir }); + } + if is_video { + return Ok(PreparedSpawn::Video); } let mut drop_segments = false; - let mode = if is_torrent || is_video { - None - } else { - Some(match worker_segments { - Some(state) => { - // Drop an invalid bitmap and stale partial together. Do both on - // the worker so resume validation does not block GTK. - let len = std::fs::metadata(&worker_dest) - .map(|metadata| metadata.len()) - .unwrap_or(0); - if len < state.prefix_len() || len > state.total { - let _ = std::fs::remove_file(&worker_dest); - drop_segments = true; - StartMode::Fresh - } else { - StartMode::Resume(state) - } + let mode = match worker_segments { + Some(state) => { + // Drop an invalid bitmap and stale partial together. Do both on + // the worker so resume validation does not block GTK. + let len = std::fs::metadata(&worker_dest) + .map(|metadata| metadata.len()) + .unwrap_or(0); + if len < state.prefix_len() || len > state.total { + let _ = std::fs::remove_file(&worker_dest); + drop_segments = true; + StartMode::Fresh + } else { + StartMode::Resume(state) } - // Existing destination is refused for a fresh row; never overwrite a - // file that may belong to the user or another application. - None => match std::fs::metadata(&worker_dest) { - Err(_) => StartMode::Fresh, - Ok(_) if !started => { - return Err(DEST_EXISTS.to_string()); - } - Ok(_) => StartMode::Single, - }, - }) + } + // Existing destination is refused for a fresh row; never overwrite a + // file that may belong to the user or another application. + None => match std::fs::metadata(&worker_dest) { + Err(_) => StartMode::Fresh, + Ok(_) if !started => { + return Err(DEST_EXISTS.to_string()); + } + Ok(_) => StartMode::Single, + }, }; - Ok((mode, drop_segments, torrent_dest, torrent_output_dir)) + Ok(PreparedSpawn::Plain { mode, drop_segments }) }) .await; @@ -2642,6 +2639,20 @@ impl DownloadManager { } } +/// Validated worker preparation. The enum makes impossible combinations (such as a +/// torrent without an output directory or a plain row without a start mode) unrepresentable. +enum PreparedSpawn { + Torrent { + dest: std::path::PathBuf, + output_dir: Option, + }, + Video, + Plain { + mode: StartMode, + drop_segments: bool, + }, +} + /// Video-page spawn inputs, bundled so the field list doesn't trip clippy's too-many-arguments lint. struct SpawnVideoParams { item: DownloadItem, From d3269acfe54c48410e2a949de7b448f7ae347495 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:10:33 +0100 Subject: [PATCH 53/89] Use explicit prepared-spawn variants through engine dispatch --- src/download.rs | 87 ++++++++++++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 41 deletions(-) diff --git a/src/download.rs b/src/download.rs index 788eb372f..adb4c195c 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1741,7 +1741,7 @@ impl DownloadManager { return; } - let (mode, drop_segments, torrent_dest, torrent_output_dir) = match preparation { + let prepared = match preparation { Ok(Ok(prepared)) => prepared, Ok(Err(detail)) => { current.set_detail(detail.clone()); @@ -1762,21 +1762,21 @@ impl DownloadManager { return; } }; - if drop_segments { + if matches!( + prepared, + PreparedSpawn::Plain { + drop_segments: true, + .. + } + ) { manager.segment_state.borrow_mut().remove(&id); manager.persist_queue(); } - manager.spawn_prepared(current, mode, torrent_dest, torrent_output_dir); + manager.spawn_prepared(current, prepared); }); } - fn spawn_prepared( - self: &Rc, - item: DownloadItem, - mode: Option, - torrent_dest: Option, - torrent_output_dir: Option, - ) { + fn spawn_prepared(self: &Rc, item: DownloadItem, prepared: PreparedSpawn) { let opts = DownloadOptions::from_settings(&self.settings); // Re-stash per attempt: a stale Last-Modified must never apply when the new run's server sends none. self.server_mtime.borrow_mut().remove(&item.id()); @@ -1791,38 +1791,43 @@ impl DownloadManager { } publish_rate_limit(&self.settings); let url = item.url().to_string(); - if crate::torrent::is_torrent(&url) { - let Some(dest) = torrent_dest else { - item.set_detail(gettext("Could not prepare the torrent output folder")); - item.set_status(DownloadStatus::Failed); - self.changed(); - return; - }; - if let Some(output_dir) = torrent_output_dir { - item.set_output_dir(output_dir); + let mode = match prepared { + PreparedSpawn::Torrent { dest, output_dir } => { + if let Some(output_dir) = output_dir { + item.set_output_dir(output_dir); + } + return self.spawn_torrent(item, url, dest); } - return self.spawn_torrent(item, url, dest); - } - if let Some(crate::media_types::VideoSource::Page { - page_url, - quality, - audio_only, - is_live, - video_format_id, - playlist_item_id, - .. - }) = self.video_source(item.id()) - { - return self.spawn_video(SpawnVideoParams { - item, - page_url, - quality, - audio_only, - video_format_id, - is_live, - playlist_item_id, - }); - } + PreparedSpawn::Video => { + let Some(crate::media_types::VideoSource::Page { + page_url, + quality, + audio_only, + is_live, + video_format_id, + playlist_item_id, + .. + }) = self.video_source(item.id()) + else { + let detail = gettext("Could not prepare video source"); + item.set_detail(detail); + item.set_status(DownloadStatus::Failed); + self.changed(); + self.start_next(); + return; + }; + return self.spawn_video(SpawnVideoParams { + item, + page_url, + quality, + audio_only, + video_format_id, + is_live, + playlist_item_id, + }); + } + PreparedSpawn::Plain { mode, .. } => mode, + }; let connections = (opts.connections.max(1) as usize).min(16); let timeout = Duration::from_secs(30); // Restore the visible fraction from the saved bitmap before starting the From fe70a50ce4e9cf78fc3d78c448c2c2b2cbb2ebc3 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:10:40 +0100 Subject: [PATCH 54/89] Borrow prepared mode when checking segment cleanup --- src/download.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/download.rs b/src/download.rs index adb4c195c..6c29703ff 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1763,7 +1763,7 @@ impl DownloadManager { } }; if matches!( - prepared, + &prepared, PreparedSpawn::Plain { drop_segments: true, .. From 1cedf5118725ce817ce7364258dbf43ac9e2b546 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:10:47 +0100 Subject: [PATCH 55/89] Restore progress from explicit resume preparation mode --- src/download.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/download.rs b/src/download.rs index 6c29703ff..9d0cc491a 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1832,7 +1832,7 @@ impl DownloadManager { let timeout = Duration::from_secs(30); // Restore the visible fraction from the saved bitmap before starting the // resumed engine; preparation runs off-thread but UI state remains main-thread-owned. - if let Some(StartMode::Resume(state)) = mode.as_ref() { + if let StartMode::Resume(state) = &mode { let fraction = (state.completed_bytes() as f64 / state.total.max(1) as f64) .clamp(0.0, 1.0); item.set_progress(fraction); From da71cce8e324da2de5078309fc6569d09ad894a4 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:11:01 +0100 Subject: [PATCH 56/89] Resume scheduling after prepared-download validation failures --- src/download.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/download.rs b/src/download.rs index 9d0cc491a..d15a56eca 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1787,6 +1787,7 @@ impl DownloadManager { item.set_detail(gettext("Invalid speed limit: {limit}").replace("{limit}", limit)); item.set_status(DownloadStatus::Failed); self.changed(); + self.start_next(); return; } publish_rate_limit(&self.settings); @@ -1861,6 +1862,7 @@ impl DownloadManager { item.set_detail(e); item.set_status(DownloadStatus::Failed); self.changed(); + self.start_next(); return; } }; @@ -1870,6 +1872,7 @@ impl DownloadManager { item.set_detail(e); item.set_status(DownloadStatus::Failed); self.changed(); + self.start_next(); return; } }; From b7cf13ac07ecff991e1d31d7e2251ae8cb5ae454 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:11:11 +0100 Subject: [PATCH 57/89] Release manager before engine acknowledgement awaits --- src/download.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/download.rs b/src/download.rs index d15a56eca..470ffdc94 100644 --- a/src/download.rs +++ b/src/download.rs @@ -2358,6 +2358,7 @@ impl DownloadManager { // no bitmap, and a 416 would pass it off as complete on // filesystems that zero-fill instead of leaving holes. this.persist_queue(); + drop(this); ack.send(()).await.ok(); } EngineMsg::PieceDone(idx) => { @@ -2443,6 +2444,7 @@ impl DownloadManager { this.segment_state.borrow_mut().remove(&id); this.persist_queue(); } + drop(this); ack.send(()).await.ok(); } EngineMsg::SuggestName(name) => { From 20ac32d30376b3c858720d30b239a92f538581e4 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:11:20 +0100 Subject: [PATCH 58/89] Reject stale playlist expansion after asynchronous preparation --- src/download.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/download.rs b/src/download.rs index 470ffdc94..1e9fa2873 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1171,6 +1171,7 @@ impl DownloadManager { async fn expand_playlist_rows_async( weak: std::rc::Weak, id: u64, + generation: u64, item: &DownloadItem, pl: &crate::media_types::PlaylistInfo, ) -> (usize, usize, usize) { @@ -1220,6 +1221,9 @@ impl DownloadManager { // enqueue stale children after pause/cancel/remove. // Collection metadata is untrusted. Re-check the carrier after yielding so // removed/cancelled rows cannot create children from stale resolver output. + if !this.is_current(id, generation) { + return (0, pl.total, 0); + } let Some(current) = this.find(id) else { return (0, pl.total, 0); }; @@ -2224,7 +2228,14 @@ impl DownloadManager { let weak = Rc::downgrade(&this); drop(this); let (added, total, skipped_by_host_limit) = - Self::expand_playlist_rows_async(weak.clone(), id, &item, &pl).await; + Self::expand_playlist_rows_async( + weak.clone(), + id, + generation, + &item, + &pl, + ) + .await; let Some(this) = weak.upgrade() else { done = true; break; From b96191254c6ddee737a24d0a9b2a09757966d42a Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:11:54 +0100 Subject: [PATCH 59/89] Drop manager Rc across finished-name directory listing --- src/download.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/download.rs b/src/download.rs index 1e9fa2873..7fb5e082f 100644 --- a/src/download.rs +++ b/src/download.rs @@ -2092,14 +2092,15 @@ impl DownloadManager { { // A pause keeps its pending name: resumed single-stream attempts never re-suggest. let pending = this.pending_names.borrow_mut().remove(&id); - if let Some(name) = pending { - // Re-apply the final name policy: adoption is the last gate before dedupe/rename. Idempotent on finalized names. + // Drop the strong manager reference across the blocking directory + // listing. Re-upgrade into the arm's continuing binding afterward. + let (this, pending, existing) = if let Some(name) = pending { + // Re-apply the final name policy before yielding; idempotent on finalized names. let name = this.finalize_filename(&name); let current = item.filename().to_string(); let dir = item.dest_dir().to_string(); let listing_dir = dir.clone(); - // The listing yields the main context; validate the generation - // after the await before applying the result. + drop(this); let existing = match gio::spawn_blocking(move || { crate::video_staging::dir_file_names(std::path::Path::new( &listing_dir, @@ -2113,10 +2114,20 @@ impl DownloadManager { Vec::new() } }; + let Some(this) = weak.upgrade() else { + done = true; + break; + }; if !this.is_current(id, generation) { done = true; break; } + (this, Some((name, current, dir)), Some(existing)) + } else { + (this, None, None) + }; + if let Some((name, current, dir)) = pending { + let existing = existing.unwrap_or_default(); // The main context was yielded while the directory was read. Do not finalize a row that was // paused/cancelled, removed, or replaced while the worker ran. if item.status() == DownloadStatus::Cancelled From 31a02eee566fedb42b118178f739b598883d7c14 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:12:17 +0100 Subject: [PATCH 60/89] Check both worker and truncation results explicitly --- src/download.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/download.rs b/src/download.rs index 7fb5e082f..37c9ab847 100644 --- a/src/download.rs +++ b/src/download.rs @@ -2442,7 +2442,7 @@ impl DownloadManager { truncate_to_prefix(&worker_path, &state) }) .await - .is_ok_and(Result::is_ok); + .is_ok_and(|result| result.is_ok()); let Some(this) = weak.upgrade() else { done = true; break; From bc654bc38d8d7e4a6d6c9760e2119aa290a3892d Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:12:35 +0100 Subject: [PATCH 61/89] Reuse finished-name collision predicate across retries --- src/download.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/download.rs b/src/download.rs index 37c9ab847..d4fc32792 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1948,7 +1948,7 @@ impl DownloadManager { lost_claims.borrow().iter().any(|lost| lost == name) || is_taken(name) }; for _ in 0..8 { - let candidate = dedupe_filename(wanted, taken); + let candidate = dedupe_filename(wanted, &taken); let target = dir.join(&candidate); if old_path == target { return Some(candidate); From dd6f787098ee05a74e78d241122c262882eba171 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:13:32 +0100 Subject: [PATCH 62/89] Format prepared-spawn and shutdown changes --- src/download.rs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/download.rs b/src/download.rs index d4fc32792..84dc32e89 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1681,7 +1681,10 @@ impl DownloadManager { .replace("{error}", &error.to_string()) })?; } - return Ok(PreparedSpawn::Torrent { dest: output, output_dir }); + return Ok(PreparedSpawn::Torrent { + dest: output, + output_dir, + }); } if is_video { return Ok(PreparedSpawn::Video); @@ -1713,7 +1716,10 @@ impl DownloadManager { Ok(_) => StartMode::Single, }, }; - Ok(PreparedSpawn::Plain { mode, drop_segments }) + Ok(PreparedSpawn::Plain { + mode, + drop_segments, + }) }) .await; @@ -1838,8 +1844,8 @@ impl DownloadManager { // Restore the visible fraction from the saved bitmap before starting the // resumed engine; preparation runs off-thread but UI state remains main-thread-owned. if let StartMode::Resume(state) = &mode { - let fraction = (state.completed_bytes() as f64 / state.total.max(1) as f64) - .clamp(0.0, 1.0); + let fraction = + (state.completed_bytes() as f64 / state.total.max(1) as f64).clamp(0.0, 1.0); item.set_progress(fraction); } // Torrent and video rows returned above. A plain row must always carry @@ -1944,9 +1950,8 @@ impl DownloadManager { is_taken: impl Fn(&str) -> bool, ) -> Option { let lost_claims = RefCell::new(Vec::::new()); - let taken = |name: &str| { - lost_claims.borrow().iter().any(|lost| lost == name) || is_taken(name) - }; + let taken = + |name: &str| lost_claims.borrow().iter().any(|lost| lost == name) || is_taken(name); for _ in 0..8 { let candidate = dedupe_filename(wanted, &taken); let target = dir.join(&candidate); @@ -3930,9 +3935,8 @@ impl DownloadManager { // This fallback runs after the main loop has exited, when blocking // cannot freeze GTK. Bound the wait so a stalled disk cannot hang exit. if let Some(task) = self.persist_task.borrow_mut().take() { - let _ = tokio_rt().block_on(async { - tokio::time::timeout(Duration::from_secs(5), task).await - }); + let _ = tokio_rt() + .block_on(async { tokio::time::timeout(Duration::from_secs(5), task).await }); } } } From 1c6c0a1226b159e18e51612217b8588da3e602f6 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:13:41 +0100 Subject: [PATCH 63/89] Format new concurrency and filename race tests --- src/download_tests.rs | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 917193445..00016a248 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -8203,7 +8203,6 @@ fn restoring_public_only_row_reinstates_its_host_policy() { let _ = std::fs::remove_file(queue_file); } - #[test] fn start_next_counts_preparing_rows_against_the_cap() { let (_lock, _loop) = test_locks(); @@ -8214,11 +8213,7 @@ fn start_next_counts_preparing_rows_against_the_cap() { for i in 0..5 { manager - .enqueue( - &format!("http://127.0.0.1:9/f{i}"), - Some("/tmp"), - None, - ) + .enqueue(&format!("http://127.0.0.1:9/f{i}"), Some("/tmp"), None) .unwrap(); } @@ -8231,23 +8226,20 @@ fn start_next_counts_preparing_rows_against_the_cap() { let context = glib::MainContext::default(); let deadline = std::time::Instant::now() + Duration::from_secs(15); - while !manager.preparing.borrow().is_empty() - && std::time::Instant::now() < deadline - { + while !manager.preparing.borrow().is_empty() && std::time::Instant::now() < deadline { context.iteration(false); std::thread::sleep(Duration::from_millis(1)); } - assert!(manager.preparing.borrow().is_empty(), "preparation workers must drain"); + assert!( + manager.preparing.borrow().is_empty(), + "preparation workers must drain" + ); quiesce(&context); } - #[test] fn finished_name_adoption_retries_after_stale_listing_collision() { - let dir = std::env::temp_dir().join(format!( - "grab-finished-name-race-{}", - std::process::id() - )); + let dir = std::env::temp_dir().join(format!("grab-finished-name-race-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let source = dir.join("tmp.bin"); @@ -8257,13 +8249,8 @@ fn finished_name_adoption_retries_after_stale_listing_collision() { // Simulate a stale listing that says every name is free. The atomic rename // must reject a.mp4 and retry with a distinct name, never overwrite it. - let adopted = DownloadManager::adopt_final_name( - &source, - &dir, - "a.mp4", - |_| false, - ) - .expect("a later collision-free candidate should be adopted"); + let adopted = DownloadManager::adopt_final_name(&source, &dir, "a.mp4", |_| false) + .expect("a later collision-free candidate should be adopted"); assert_ne!(adopted, "a.mp4"); assert!(dir.join(&adopted).exists()); assert_eq!(std::fs::read(&occupied).unwrap(), b"foreign"); From b0d5cdc778238dac60eebcc52de3ee895e95cb40 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:13:47 +0100 Subject: [PATCH 64/89] Format async enqueue helper bounds --- src/inline_add.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/inline_add.rs b/src/inline_add.rs index 44a4146d8..15ae91819 100644 --- a/src/inline_add.rs +++ b/src/inline_add.rs @@ -1079,8 +1079,9 @@ pub(crate) fn enqueue_and_close_async( scheduled_at: Option, enqueue: impl FnOnce(Option) -> F + 'static, on_err: impl FnOnce(&str) + 'static, - ) where - F: std::future::Future> + 'static, +) where + F: std::future::Future> + + 'static, { let dest = Rc::clone(dest); let close_card = Rc::clone(close_card); From db4603f436257f44abdda377fe84fb8ff2256144 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:13:53 +0100 Subject: [PATCH 65/89] Format torrent cleanup signature --- src/torrent.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/torrent.rs b/src/torrent.rs index c5ec339c0..ade46526d 100644 --- a/src/torrent.rs +++ b/src/torrent.rs @@ -396,11 +396,7 @@ pub(crate) fn single_file_path(dest: &std::path::Path, url: &str) -> Option Date: Mon, 26 Oct 2026 00:14:33 +0100 Subject: [PATCH 66/89] Keep restored progress visible when segmented resume starts --- src/download.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/download.rs b/src/download.rs index 84dc32e89..0574ca02a 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1916,6 +1916,7 @@ impl DownloadManager { timeout, tx, }; + let resuming = matches!(&mode, StartMode::Resume(_)); let handle = tokio_rt().spawn(run_download(ctx, connections, mode)); self.running.borrow_mut().insert(item.id(), handle); // The engine owns the destination from here: later spawns resume it rather than refusing as foreign. @@ -1923,13 +1924,18 @@ impl DownloadManager { self.started.borrow_mut().insert(item.id()); self.persist_queue(); item.set_status(DownloadStatus::Downloading); - // Attempts start indeterminate: clear any stale fraction so the row pulses through the connecting phase. - item.set_progress(0.0); + // Fresh attempts pulse while connecting; a segmented resume keeps its + // restored completed-byte fraction visible instead of flashing back to zero. + if !resuming { + item.set_progress(0.0); + } let host = url::Url::parse(&item.url()) .ok() .and_then(|u| u.host_str().map(|h| h.to_string())) .unwrap_or_default(); - item.set_detail(if host.is_empty() { + item.set_detail(if resuming { + gettext("Resuming download…") + } else if host.is_empty() { gettext("Starting…") } else { gettext("Connecting to {host}…").replace("{host}", &host) From 7ac62e43fabf95db0b0cac3f449c3f594e113c4d Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:14:46 +0100 Subject: [PATCH 67/89] Remove obsolete optional-mode fallback after enum conversion --- src/download.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/download.rs b/src/download.rs index 0574ca02a..b49563c75 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1848,20 +1848,6 @@ impl DownloadManager { (state.completed_bytes() as f64 / state.total.max(1) as f64).clamp(0.0, 1.0); item.set_progress(fraction); } - // Torrent and video rows returned above. A plain row must always carry - // a prepared mode; never silently turn an invariant violation into Fresh, - // which could overwrite or corrupt an existing partial. - let mode = match mode { - Some(mode) => mode, - None => { - let detail = gettext("Could not prepare download mode"); - item.set_detail(detail); - item.set_status(DownloadStatus::Failed); - self.changed(); - self.start_next(); - return; - } - }; let generation = self.epoch.borrow().get(&item.id()).cloned().unwrap_or(0) + 1; self.epoch.borrow_mut().insert(item.id(), generation); let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); From 7704c9e445475e086d972e90342b47de26d02e49 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:15:33 +0100 Subject: [PATCH 68/89] Drop manager before fallback acknowledgement without using moved Rc --- src/download.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/download.rs b/src/download.rs index b49563c75..ca1f28170 100644 --- a/src/download.rs +++ b/src/download.rs @@ -2453,6 +2453,8 @@ impl DownloadManager { this.segment_state.borrow_mut().remove(&id); this.persist_queue(); this.start_next(); + drop(this); + ack.send(()).await.ok(); } else { tracing::warn!(path = %path.display(), "could not truncate segmented file before single-stream fallback"); // Dropping ack tells the engine that fallback cannot safely proceed. @@ -2462,9 +2464,9 @@ impl DownloadManager { } else { this.segment_state.borrow_mut().remove(&id); this.persist_queue(); + drop(this); + ack.send(()).await.ok(); } - drop(this); - ack.send(()).await.ok(); } EngineMsg::SuggestName(name) => { // Stash the server-advertised name for adoption at Finished: From a5bde14ea1289063cca9119848ae3dd9bb0a1811 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:21:43 +0100 Subject: [PATCH 69/89] Fix clippy compile error in truncate worker handling --- src/download.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/download.rs b/src/download.rs index ca1f28170..5f12381ac 100644 --- a/src/download.rs +++ b/src/download.rs @@ -2439,7 +2439,7 @@ impl DownloadManager { truncate_to_prefix(&worker_path, &state) }) .await - .is_ok_and(|result| result.is_ok()); + .is_ok(); let Some(this) = weak.upgrade() else { done = true; break; From a82640d42642ef87ba2799a7385d0f2723090b1a Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:21:47 +0100 Subject: [PATCH 70/89] Update playlist expansion test for weak manager API --- src/download_tests.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 00016a248..661a17c18 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -2695,7 +2695,13 @@ fn expand_playlist_rows_use_titled_subfolder() { .into_boxed_slice(), }; let (added, total, skipped_by_host_limit) = glib::MainContext::default() - .block_on(manager.expand_playlist_rows_async(parent.id(), &parent, &pl)); + .block_on(download::DownloadManager::expand_playlist_rows_async( + Rc::downgrade(&manager), + parent.id(), + 0, + &parent, + &pl, + )); assert_eq!((added, total, skipped_by_host_limit), (2, 2, 0)); let sub = dest.join("My Mix: Vol. 1"); assert!(sub.is_dir()); From a0fa9e8f232aefaf0faae98bb43f0e6441a1c5f9 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:21:51 +0100 Subject: [PATCH 71/89] Fix GTK signal connection borrow errors --- src/inline_add.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/inline_add.rs b/src/inline_add.rs index 15ae91819..9f7f18319 100644 --- a/src/inline_add.rs +++ b/src/inline_add.rs @@ -1237,7 +1237,7 @@ fn push_playlist_items_page( let picker_page_weak = picker_page_weak.clone(); let fallback_to_picker = fallback_to_picker.clone(); let add_btn_click = add_btn.clone(); - add_btn_click.connect_clicked(move |_| { + add_btn_click.clone().connect_clicked(move |_| { // Guard against re-entry: Enter emits clicked directly, bypassing // the sensitivity check, so a second press while the import is in // flight would enqueue duplicates. @@ -1594,7 +1594,7 @@ fn wire_torrent_picker( let nav = nav.clone(); let torrent_btn = torrent_btn.clone(); let scheduled_at_c = Rc::clone(&scheduled_at); - torrent_btn.connect_clicked(move |_| { + torrent_btn.clone().connect_clicked(move |_| { let torrent_btn = torrent_btn.clone(); let m = manager.clone(); let dd = dest_dir.clone(); From d9b2ce6d5d93d949647abeaa240247bd7214333d Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:22:58 +0100 Subject: [PATCH 72/89] Format playlist expansion test --- src/download_tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 661a17c18..192f5dda2 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -2694,14 +2694,15 @@ fn expand_playlist_rows_use_titled_subfolder() { ] .into_boxed_slice(), }; - let (added, total, skipped_by_host_limit) = glib::MainContext::default() - .block_on(download::DownloadManager::expand_playlist_rows_async( + let (added, total, skipped_by_host_limit) = glib::MainContext::default().block_on( + download::DownloadManager::expand_playlist_rows_async( Rc::downgrade(&manager), parent.id(), 0, &parent, &pl, - )); + ), + ); assert_eq!((added, total, skipped_by_host_limit), (2, 2, 0)); let sub = dest.join("My Mix: Vol. 1"); assert!(sub.is_dir()); From 09de5202a14a87ebfc605c58f87321fce634a071 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:33:35 +0100 Subject: [PATCH 73/89] Drop segment-state borrow before async worker --- src/download.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/download.rs b/src/download.rs index 5f12381ac..26c5715cc 100644 --- a/src/download.rs +++ b/src/download.rs @@ -2430,7 +2430,8 @@ impl DownloadManager { // Keep the bitmap persisted until the sparse/segmented file is // actually truncated. A crash during the worker must still restore // with the bitmap rather than append to a file with holes. - if let Some(state) = this.segment_state.borrow().get(&id).cloned() { + let segment_state = { this.segment_state.borrow().get(&id).cloned() }; + if let Some(state) = segment_state { let path = item.file_path(); this.reserve_dest(&path); let worker_path = path.clone(); From f9cbcb42328309bc5d64a1205f1acbab2d741fcf Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:33:40 +0100 Subject: [PATCH 74/89] Fix playlist test module path --- src/download_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 192f5dda2..52c536cc7 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -2695,7 +2695,7 @@ fn expand_playlist_rows_use_titled_subfolder() { .into_boxed_slice(), }; let (added, total, skipped_by_host_limit) = glib::MainContext::default().block_on( - download::DownloadManager::expand_playlist_rows_async( + crate::download::DownloadManager::expand_playlist_rows_async( Rc::downgrade(&manager), parent.id(), 0, From f65365773e69db3b293ac288261c48582702d0ea Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:33:44 +0100 Subject: [PATCH 75/89] Fix add-button signal receiver ownership --- src/inline_add.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/inline_add.rs b/src/inline_add.rs index 9f7f18319..036955f49 100644 --- a/src/inline_add.rs +++ b/src/inline_add.rs @@ -1534,7 +1534,8 @@ fn push_torrent_picker_page( let grid = grid.clone(); let fallback_to_picker = fallback_to_picker.clone(); let add_btn_click = add_btn.clone(); - add_btn_click.connect_clicked(move |_| { + let add_btn_signal = add_btn_click.clone(); + add_btn_signal.connect_clicked(move |_| { let selected: Vec = grid.selected_indices(); if selected.is_empty() { fallback_to_picker(); From 838b352ef600cd44b7eb938c3c159d98bbaf374e Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:40:05 +0100 Subject: [PATCH 76/89] Fix remaining Clippy warnings in download manager --- src/download.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/download.rs b/src/download.rs index 26c5715cc..47c7cfe00 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1014,6 +1014,7 @@ impl DownloadManager { /// # Errors /// Returns a display-ready message when the URL or filename is invalid. /// Enqueue a video page after preparing its directory listing off GTK. + #[cfg(test)] pub async fn enqueue_video_async( self: &Rc, page_url: &str, @@ -1051,6 +1052,7 @@ impl DownloadManager { /// Creates a DownloadItem with gallery flags set; the actual download /// is driven by the gallery runner (phase 12). /// Enqueue a gallery after listing its destination off GTK. + #[cfg(test)] pub async fn enqueue_gallery_async( self: &Rc, job: &crate::gallery_argv::GalleryJob, @@ -1945,7 +1947,7 @@ impl DownloadManager { let taken = |name: &str| lost_claims.borrow().iter().any(|lost| lost == name) || is_taken(name); for _ in 0..8 { - let candidate = dedupe_filename(wanted, &taken); + let candidate = dedupe_filename(wanted, taken); let target = dir.join(&candidate); if old_path == target { return Some(candidate); From 78d56b550e6557039b40ae70edded53fd4944a7a Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:48:12 +0100 Subject: [PATCH 77/89] Track and abort filesystem preparation tasks --- src/download.rs | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/download.rs b/src/download.rs index 47c7cfe00..2a0490c9a 100644 --- a/src/download.rs +++ b/src/download.rs @@ -280,6 +280,8 @@ pub struct DownloadManager { running: RefCell>>, /// Rows whose filesystem preparation is in flight; these consume scheduler slots too. preparing: RefCell>, + /// Filesystem-preparation task handles; abort them on manager drop so thread-bound GLib futures cannot leak into another test's context. + preparation_handles: RefCell>>, /// Pump task handles by download ID, so tests can wait for pump completion (a `spawn_future_local` task reaped on another thread trips glib's thread guard). pub(crate) pump_handles: RefCell>>, next_id: Cell, @@ -445,10 +447,12 @@ impl Drop for RestoreTaskGuard { impl Drop for DownloadManager { fn drop(&mut self) { - // Abort any pump tasks: they're `spawn_future_local`, so a stale one - // reaped on another thread trips glib's thread guard. Tests wait for - // pump completion via `drain_engine` (which checks `pump_handles`), - // so in practice none are pending here; this is a backstop. + // Abort thread-bound local tasks on their owning thread. Tests and app + // shutdown normally drain them; this backstop prevents an unfinished + // preparation/pump from being reaped by another test's context thread. + for (_, handle) in self.preparation_handles.borrow_mut().drain() { + handle.abort(); + } for (_, handle) in self.pump_handles.borrow_mut().drain() { handle.abort(); } @@ -588,6 +592,7 @@ impl DownloadManager { settings, running: RefCell::new(HashMap::new()), preparing: RefCell::new(HashSet::new()), + preparation_handles: RefCell::new(HashMap::new()), pump_handles: RefCell::new(HashMap::new()), next_id: Cell::new(1), on_change: RefCell::new(None), @@ -1635,7 +1640,9 @@ impl DownloadManager { self.reserve_dest(&dest); self.preparing.borrow_mut().insert(id); let weak = Rc::downgrade(self); - glib::spawn_future_local(async move { + let task_weak = weak.clone(); + let handle = glib::spawn_future_local(async move { + async move { let worker_dest = dest.clone(); let worker_dir = dest_dir.clone(); let worker_recorded = item.output_dir().to_string(); @@ -1785,7 +1792,16 @@ impl DownloadManager { manager.persist_queue(); } manager.spawn_prepared(current, prepared); + } + .await; + if let Some(manager) = task_weak.upgrade() { + manager.preparation_handles.borrow_mut().remove(&id); + } }); + // Insert after creating the task, before the main context can poll it. + if let Some(old) = self.preparation_handles.borrow_mut().insert(id, handle) { + old.abort(); + } } fn spawn_prepared(self: &Rc, item: DownloadItem, prepared: PreparedSpawn) { From 2318089d329014e042616b776f44f2910e86c55b Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:48:25 +0100 Subject: [PATCH 78/89] Wait for filesystem preparation in download tests --- src/download_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/download_tests.rs b/src/download_tests.rs index 52c536cc7..8108942a1 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -108,6 +108,8 @@ fn drain_engine(manager: &DownloadManager, id: u64) { // Also wait for finished-row sizings: `set_finished_detail` spawns one // per completion, and it outlives the engine that triggered it. while (manager.running.borrow().contains_key(&id) + || manager.preparing.borrow().contains(&id) + || manager.preparation_handles.borrow().contains_key(&id) || manager.pump_handles.borrow().contains_key(&id) || manager .find(id) From 652dd94c5890fb1abcaf615326eeb7fa1b8ab3ec Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:48:46 +0100 Subject: [PATCH 79/89] Format tracked preparation future --- src/download.rs | 152 ++++++++++++++++++++++++------------------------ 1 file changed, 76 insertions(+), 76 deletions(-) diff --git a/src/download.rs b/src/download.rs index 2a0490c9a..479b240ae 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1643,93 +1643,93 @@ impl DownloadManager { let task_weak = weak.clone(); let handle = glib::spawn_future_local(async move { async move { - let worker_dest = dest.clone(); - let worker_dir = dest_dir.clone(); - let worker_recorded = item.output_dir().to_string(); - let worker_segments = segments.clone(); - let preparation = gio::spawn_blocking(move || { - let parent = worker_dest.parent(); - if let Some(parent) = parent { - // Re-check containment on the worker immediately before touching the - // path: a symlink planted after intake must not redirect the write. - match ensure_contained_parent(parent, &worker_dir) { - Ok(true) => {} - Ok(false) => { - return Err(gettext("Download path escapes the download folder")); - } - Err(error) => { - return Err(gettext("Could not create the download folder: {error}") - .replace("{error}", &error.to_string())); + let worker_dest = dest.clone(); + let worker_dir = dest_dir.clone(); + let worker_recorded = item.output_dir().to_string(); + let worker_segments = segments.clone(); + let preparation = gio::spawn_blocking(move || { + let parent = worker_dest.parent(); + if let Some(parent) = parent { + // Re-check containment on the worker immediately before touching the + // path: a symlink planted after intake must not redirect the write. + match ensure_contained_parent(parent, &worker_dir) { + Ok(true) => {} + Ok(false) => { + return Err(gettext("Download path escapes the download folder")); + } + Err(error) => { + return Err(gettext("Could not create the download folder: {error}") + .replace("{error}", &error.to_string())); + } } } - } - if is_torrent { - let dir = std::path::PathBuf::from(&worker_dir); - let recorded_path = std::path::PathBuf::from(&worker_recorded); - let (output, output_dir) = if worker_recorded.is_empty() { - (dir.clone(), None) - } else { - // Re-guard the recorded folder at spawn time; a symlink may have - // been planted after intake. Never follow it outside the destination. - let guarded = - crate::torrent::guard_output_folder(&dir, recorded_path.clone()); - let output_dir = (guarded != recorded_path) - .then(|| guarded.to_string_lossy().into_owned()); - if output_dir.is_none() && guarded.parent() != Some(dir.as_path()) { - std::fs::create_dir_all(&guarded).map_err(|error| { + if is_torrent { + let dir = std::path::PathBuf::from(&worker_dir); + let recorded_path = std::path::PathBuf::from(&worker_recorded); + let (output, output_dir) = if worker_recorded.is_empty() { + (dir.clone(), None) + } else { + // Re-guard the recorded folder at spawn time; a symlink may have + // been planted after intake. Never follow it outside the destination. + let guarded = + crate::torrent::guard_output_folder(&dir, recorded_path.clone()); + let output_dir = (guarded != recorded_path) + .then(|| guarded.to_string_lossy().into_owned()); + if output_dir.is_none() && guarded.parent() != Some(dir.as_path()) { + std::fs::create_dir_all(&guarded).map_err(|error| { + gettext("Could not create the download folder: {error}") + .replace("{error}", &error.to_string()) + })?; + } + (guarded, output_dir) + }; + if worker_recorded.is_empty() { + std::fs::create_dir_all(&dir).map_err(|error| { gettext("Could not create the download folder: {error}") .replace("{error}", &error.to_string()) })?; } - (guarded, output_dir) - }; - if worker_recorded.is_empty() { - std::fs::create_dir_all(&dir).map_err(|error| { - gettext("Could not create the download folder: {error}") - .replace("{error}", &error.to_string()) - })?; + return Ok(PreparedSpawn::Torrent { + dest: output, + output_dir, + }); } - return Ok(PreparedSpawn::Torrent { - dest: output, - output_dir, - }); - } - if is_video { - return Ok(PreparedSpawn::Video); - } - - let mut drop_segments = false; - let mode = match worker_segments { - Some(state) => { - // Drop an invalid bitmap and stale partial together. Do both on - // the worker so resume validation does not block GTK. - let len = std::fs::metadata(&worker_dest) - .map(|metadata| metadata.len()) - .unwrap_or(0); - if len < state.prefix_len() || len > state.total { - let _ = std::fs::remove_file(&worker_dest); - drop_segments = true; - StartMode::Fresh - } else { - StartMode::Resume(state) - } + if is_video { + return Ok(PreparedSpawn::Video); } - // Existing destination is refused for a fresh row; never overwrite a - // file that may belong to the user or another application. - None => match std::fs::metadata(&worker_dest) { - Err(_) => StartMode::Fresh, - Ok(_) if !started => { - return Err(DEST_EXISTS.to_string()); + + let mut drop_segments = false; + let mode = match worker_segments { + Some(state) => { + // Drop an invalid bitmap and stale partial together. Do both on + // the worker so resume validation does not block GTK. + let len = std::fs::metadata(&worker_dest) + .map(|metadata| metadata.len()) + .unwrap_or(0); + if len < state.prefix_len() || len > state.total { + let _ = std::fs::remove_file(&worker_dest); + drop_segments = true; + StartMode::Fresh + } else { + StartMode::Resume(state) + } } - Ok(_) => StartMode::Single, - }, - }; - Ok(PreparedSpawn::Plain { - mode, - drop_segments, + // Existing destination is refused for a fresh row; never overwrite a + // file that may belong to the user or another application. + None => match std::fs::metadata(&worker_dest) { + Err(_) => StartMode::Fresh, + Ok(_) if !started => { + return Err(DEST_EXISTS.to_string()); + } + Ok(_) => StartMode::Single, + }, + }; + Ok(PreparedSpawn::Plain { + mode, + drop_segments, + }) }) - }) .await; let Some(manager) = weak.upgrade() else { From 580c13dab071a8ee802be89a63d96630e0697b98 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:49:37 +0100 Subject: [PATCH 80/89] Correct formatting of tracked preparation task --- src/download.rs | 1744 +++++++++++++++++++++++------------------------ 1 file changed, 872 insertions(+), 872 deletions(-) diff --git a/src/download.rs b/src/download.rs index 479b240ae..582449ab0 100644 --- a/src/download.rs +++ b/src/download.rs @@ -845,150 +845,74 @@ impl DownloadManager { "partial cleanup worker failed" ), } - if let Some(manager) = weak.upgrade() { - manager.release_dest(&path); - manager.start_next(); - } - }); - } - - /// Whether the row is currently capturing a live stream (stop-and-keep applies). - pub fn is_live_video(&self, id: u64) -> bool { - self.live_rows.borrow().contains(&id) - } - - /// Explicit absolute dest, else the effective download dir. - pub(crate) fn resolve_dir(&self, dest_dir: Option<&str>) -> String { - // Explicit destinations must be absolute (relative dirs would resolve against the launcher CWD and fail the sandbox). - dest_dir - .filter(|s| !s.is_empty() && std::path::Path::new(s).is_absolute()) - .map(|s| s.to_string()) - .unwrap_or_else(|| self.effective_download_dir()) - } - - /// Validate and queue a download, starting it when a slot is free. - /// - /// Names dedupe against live rows and reservations only: an on-disk file - /// never renames the intake. The engine refuses a fresh row whose - /// destination already exists (Parabolic-style), so duplicates surface as - /// an error instead of scattering ` (1)` copies. - /// - /// # Errors - /// Returns a display-ready message when the URL or filename is invalid. - pub fn enqueue( - self: &Rc, - url: &str, - dest_dir: Option<&str>, - filename: Option<&str>, - ) -> Result { - let url = normalize_url(url)?; - let dir = self.resolve_dir(dest_dir); - let name = filename - .filter(|s| sane_filename(s)) - .map(|s| s.to_string()) - .unwrap_or_else(|| { - if crate::torrent::is_magnet(&url) { - // Real name arrives with metadata; the info-hash stub labels the row until SuggestName renames it. - crate::torrent::stub_name(&url).unwrap_or_else(|| filename_from_url(&url)) - } else { - filename_from_url(&url) + if let Some(manager) = weak.upgrade() { + manager.release_dest(&path); + manager.start_next(); } }); - let name = shorten_filename(&name); - // Torrents record engine subfolders as output_dir, so the taken check covers those too. - let name = dedupe_filename(&name, |n| { - let p = std::path::Path::new(&dir).join(n); - // Plain rows never reach `clean_dest_parts`, so only the exact destination needs a reservation. - self.dest_reserved(&p) - || self.items().any(|it| { - (it.dest_dir() == dir && it.filename() == n) - || it.output_dir() == p.to_string_lossy() - }) - }); - let item = DownloadItem::new( - self.alloc_id_for(std::path::Path::new(&dir)), - &url, - &name, - &dir, - ); - if crate::torrent::is_magnet(&url) { - // Magnets carry no archive to recompute the output folder from later, so record their subfolder now. - item.set_output_dir( - std::path::Path::new(&dir) - .join(&name) - .to_string_lossy() - .into_owned(), - ); } - Ok(self.insert(item)) - } - /// Intake for .torrent files: archive the bytes, then enqueue the - /// pseudo-URL like any other download (stub from the file stem, real - /// name arrives with metadata via SuggestName). - /// - /// # Errors - /// Returns a display-ready message when the bytes are not a valid torrent. - /// Archive and enqueue a .torrent without performing disk I/O on GTK. - pub async fn enqueue_torrent_file_async( - self: &Rc, - bytes: Vec, - file_name: &str, - dest_dir: Option<&str>, - only_files: Option>, - ) -> Result { - let (item, guard) = self - .enqueue_torrent_file_async_batched(bytes, file_name, dest_dir, only_files) - .await?; - drop(guard); - Ok(item) - } + /// Whether the row is currently capturing a live stream (stop-and-keep applies). + pub fn is_live_video(&self, id: u64) -> bool { + self.live_rows.borrow().contains(&id) + } - pub(crate) async fn enqueue_torrent_file_async_batched( - self: &Rc, - bytes: Vec, - file_name: &str, - dest_dir: Option<&str>, - only_files: Option>, - ) -> Result<(DownloadItem, BatchGuard), String> { - let file_name = file_name.to_string(); - let dir = self.resolve_dir(dest_dir); - let worker_file_name = file_name.clone(); - let worker_dir = dir.clone(); - let (pseudo, base, existing) = gio::spawn_blocking(move || { - // Parse metadata before archiving so invalid input never leaves an archive behind. - let base = crate::torrent::intake_plan(&bytes).map(|(base, _multi)| base); - let pseudo = crate::torrent::archive_torrent_file(&worker_file_name, &bytes)?; - let existing = crate::video_staging::dir_file_names(std::path::Path::new(&worker_dir)); - Ok::<_, String>((pseudo, base, existing)) - }) - .await - .map_err(|error| format!("Could not prepare torrent file: {error:?}"))??; - - // Hold the queue only during the synchronous commit, not during disk I/O. - let guard = self.batch_guard(); - if let Some(selection) = only_files { - crate::torrent::stage_selection(&pseudo, selection); - } - let stub = crate::torrent::stub_name_for_file(&file_name); - let item = self.enqueue(&pseudo, dest_dir, Some(&stub))?; - - // The engine writes with overwrite:true, so reserve a collision-free - // output folder from the listing snapshot before it can be spawned. - if let Some(base) = base { - let candidate = std::path::Path::new(&dir).join(&base); - let candidate_s = candidate.to_string_lossy().into_owned(); - let disk_taken = existing.iter().any(|entry| entry == &base); - let row_taken = self.items().any(|row| row.output_dir() == candidate_s); - if disk_taken || row_taken || self.dest_reserved(&candidate) { - let name = dedupe_filename(&base, |name| { - let path = std::path::Path::new(&dir).join(name); - existing.iter().any(|entry| entry == name) - || self.dest_reserved(&path) - || self - .items() - .any(|row| row.output_dir() == path.to_string_lossy()) + /// Explicit absolute dest, else the effective download dir. + pub(crate) fn resolve_dir(&self, dest_dir: Option<&str>) -> String { + // Explicit destinations must be absolute (relative dirs would resolve against the launcher CWD and fail the sandbox). + dest_dir + .filter(|s| !s.is_empty() && std::path::Path::new(s).is_absolute()) + .map(|s| s.to_string()) + .unwrap_or_else(|| self.effective_download_dir()) + } + + /// Validate and queue a download, starting it when a slot is free. + /// + /// Names dedupe against live rows and reservations only: an on-disk file + /// never renames the intake. The engine refuses a fresh row whose + /// destination already exists (Parabolic-style), so duplicates surface as + /// an error instead of scattering ` (1)` copies. + /// + /// # Errors + /// Returns a display-ready message when the URL or filename is invalid. + pub fn enqueue( + self: &Rc, + url: &str, + dest_dir: Option<&str>, + filename: Option<&str>, + ) -> Result { + let url = normalize_url(url)?; + let dir = self.resolve_dir(dest_dir); + let name = filename + .filter(|s| sane_filename(s)) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + if crate::torrent::is_magnet(&url) { + // Real name arrives with metadata; the info-hash stub labels the row until SuggestName renames it. + crate::torrent::stub_name(&url).unwrap_or_else(|| filename_from_url(&url)) + } else { + filename_from_url(&url) + } }); + let name = shorten_filename(&name); + // Torrents record engine subfolders as output_dir, so the taken check covers those too. + let name = dedupe_filename(&name, |n| { + let p = std::path::Path::new(&dir).join(n); + // Plain rows never reach `clean_dest_parts`, so only the exact destination needs a reservation. + self.dest_reserved(&p) + || self.items().any(|it| { + (it.dest_dir() == dir && it.filename() == n) + || it.output_dir() == p.to_string_lossy() + }) + }); + let item = DownloadItem::new( + self.alloc_id_for(std::path::Path::new(&dir)), + &url, + &name, + &dir, + ); + if crate::torrent::is_magnet(&url) { + // Magnets carry no archive to recompute the output folder from later, so record their subfolder now. item.set_output_dir( std::path::Path::new(&dir) .join(&name) @@ -996,802 +920,878 @@ impl DownloadManager { .into_owned(), ); } - } - Ok((item, guard)) - } + Ok(self.insert(item)) + } + + /// Intake for .torrent files: archive the bytes, then enqueue the + /// pseudo-URL like any other download (stub from the file stem, real + /// name arrives with metadata via SuggestName). + /// + /// # Errors + /// Returns a display-ready message when the bytes are not a valid torrent. + /// Archive and enqueue a .torrent without performing disk I/O on GTK. + pub async fn enqueue_torrent_file_async( + self: &Rc, + bytes: Vec, + file_name: &str, + dest_dir: Option<&str>, + only_files: Option>, + ) -> Result { + let (item, guard) = self + .enqueue_torrent_file_async_batched(bytes, file_name, dest_dir, only_files) + .await?; + drop(guard); + Ok(item) + } + + pub(crate) async fn enqueue_torrent_file_async_batched( + self: &Rc, + bytes: Vec, + file_name: &str, + dest_dir: Option<&str>, + only_files: Option>, + ) -> Result<(DownloadItem, BatchGuard), String> { + let file_name = file_name.to_string(); + let dir = self.resolve_dir(dest_dir); + let worker_file_name = file_name.clone(); + let worker_dir = dir.clone(); + let (pseudo, base, existing) = gio::spawn_blocking(move || { + // Parse metadata before archiving so invalid input never leaves an archive behind. + let base = crate::torrent::intake_plan(&bytes).map(|(base, _multi)| base); + let pseudo = crate::torrent::archive_torrent_file(&worker_file_name, &bytes)?; + let existing = crate::video_staging::dir_file_names(std::path::Path::new(&worker_dir)); + Ok::<_, String>((pseudo, base, existing)) + }) + .await + .map_err(|error| format!("Could not prepare torrent file: {error:?}"))??; - /// Final filename policy, shared by intake naming and late server/container suggestions: shorten, then the opt-in ASCII fold. Dedupe stays at the call sites. - fn finalize_filename(&self, name: &str) -> String { - let name = shorten_filename(name); - if self.settings.restrict_filenames() { - // Opt-in ASCII fold where Grab actually names the file (yt-dlp's flag is a no-op on literal output paths). - restrict_filename_ascii(&name) - } else { - name + // Hold the queue only during the synchronous commit, not during disk I/O. + let guard = self.batch_guard(); + if let Some(selection) = only_files { + crate::torrent::stage_selection(&pseudo, selection); + } + let stub = crate::torrent::stub_name_for_file(&file_name); + let item = self.enqueue(&pseudo, dest_dir, Some(&stub))?; + + // The engine writes with overwrite:true, so reserve a collision-free + // output folder from the listing snapshot before it can be spawned. + if let Some(base) = base { + let candidate = std::path::Path::new(&dir).join(&base); + let candidate_s = candidate.to_string_lossy().into_owned(); + let disk_taken = existing.iter().any(|entry| entry == &base); + let row_taken = self.items().any(|row| row.output_dir() == candidate_s); + if disk_taken || row_taken || self.dest_reserved(&candidate) { + let name = dedupe_filename(&base, |name| { + let path = std::path::Path::new(&dir).join(name); + existing.iter().any(|entry| entry == name) + || self.dest_reserved(&path) + || self + .items() + .any(|row| row.output_dir() == path.to_string_lossy()) + }); + item.set_output_dir( + std::path::Path::new(&dir) + .join(&name) + .to_string_lossy() + .into_owned(), + ); + } + } + Ok((item, guard)) } - } - - /// Enqueue a video page: dialog-routed or probe-proven. The source is staged - /// before insert, so the persist inside [`DownloadManager::insert`] already - /// carries it and [`DownloadManager::start_next`] parks the row for the - /// resolver worker instead of feeding the page to the HTTP engine. - /// - /// # Errors - /// Returns a display-ready message when the URL or filename is invalid. - /// Enqueue a video page after preparing its directory listing off GTK. - #[cfg(test)] - pub async fn enqueue_video_async( - self: &Rc, - page_url: &str, - dest_dir: Option<&str>, - filename: Option<&str>, - choices: crate::media_types::VideoChoices, - ) -> Result { - let (item, guard) = self - .enqueue_video_async_batched(page_url, dest_dir, filename, choices) - .await?; - drop(guard); - Ok(item) - } - pub(crate) async fn enqueue_video_async_batched( - self: &Rc, - page_url: &str, - dest_dir: Option<&str>, - filename: Option<&str>, - choices: crate::media_types::VideoChoices, - ) -> Result<(DownloadItem, BatchGuard), String> { - let dir = self.resolve_dir(dest_dir); - let listing_dir = dir.clone(); - let existing = gio::spawn_blocking(move || { - crate::video_staging::dir_file_names(std::path::Path::new(&listing_dir)) - }) - .await - .map_err(|error| format!("Could not inspect download folder: {error:?}"))?; - let guard = self.batch_guard(); - let item = self.enqueue_video_staged(page_url, &dir, filename, choices, &existing)?; - Ok((item, guard)) - } - - /// Enqueue a gallery download (image gallery via gallery-dl). - /// Creates a DownloadItem with gallery flags set; the actual download - /// is driven by the gallery runner (phase 12). - /// Enqueue a gallery after listing its destination off GTK. - #[cfg(test)] - pub async fn enqueue_gallery_async( - self: &Rc, - job: &crate::gallery_argv::GalleryJob, - ) -> Result { - let (item, guard) = self.enqueue_gallery_async_batched(job).await?; - drop(guard); - Ok(item) - } + /// Final filename policy, shared by intake naming and late server/container suggestions: shorten, then the opt-in ASCII fold. Dedupe stays at the call sites. + fn finalize_filename(&self, name: &str) -> String { + let name = shorten_filename(name); + if self.settings.restrict_filenames() { + // Opt-in ASCII fold where Grab actually names the file (yt-dlp's flag is a no-op on literal output paths). + restrict_filename_ascii(&name) + } else { + name + } + } - pub(crate) async fn enqueue_gallery_async_batched( - self: &Rc, - job: &crate::gallery_argv::GalleryJob, - ) -> Result<(DownloadItem, BatchGuard), String> { - let url = normalize_url(&job.page_url)?; - // Resolve the destination before recording it: empty/relative paths must never - // become dependent on the launcher CWD. The sanitized title is used as a - // restore-safe folder component, not as an arbitrary path from metadata. - let dir = self.resolve_dir(Some(&job.dest_dir.to_string_lossy())); - let title = crate::file_names::sanitize_folder_name(&job.title); - let listing_dir = dir.clone(); - let existing = gio::spawn_blocking(move || { - crate::video_staging::dir_file_names(std::path::Path::new(&listing_dir)) - }) - .await - .map_err(|error| format!("Could not inspect gallery destination: {error:?}"))?; - - // Only hold the queue during the synchronous commit, never across listing I/O. - let guard = self.batch_guard(); - // Only claim a folder that this row can prove is new. A pre-existing user - // directory or another queued gallery must never become a trash target. - let mut unique_title = title.clone(); - let mut counter = 1; - while existing.iter().any(|entry| entry == &unique_title) - || self - .items() - .any(|it| it.dest_dir() == dir && it.filename() == unique_title) - || self.dest_reserved(&std::path::Path::new(&dir).join(&unique_title)) - { - counter += 1; - unique_title = format!("{title}-{counter}"); + /// Enqueue a video page: dialog-routed or probe-proven. The source is staged + /// before insert, so the persist inside [`DownloadManager::insert`] already + /// carries it and [`DownloadManager::start_next`] parks the row for the + /// resolver worker instead of feeding the page to the HTTP engine. + /// + /// # Errors + /// Returns a display-ready message when the URL or filename is invalid. + /// Enqueue a video page after preparing its directory listing off GTK. + #[cfg(test)] + pub async fn enqueue_video_async( + self: &Rc, + page_url: &str, + dest_dir: Option<&str>, + filename: Option<&str>, + choices: crate::media_types::VideoChoices, + ) -> Result { + let (item, guard) = self + .enqueue_video_async_batched(page_url, dest_dir, filename, choices) + .await?; + drop(guard); + Ok(item) + } + + pub(crate) async fn enqueue_video_async_batched( + self: &Rc, + page_url: &str, + dest_dir: Option<&str>, + filename: Option<&str>, + choices: crate::media_types::VideoChoices, + ) -> Result<(DownloadItem, BatchGuard), String> { + let dir = self.resolve_dir(dest_dir); + let listing_dir = dir.clone(); + let existing = gio::spawn_blocking(move || { + crate::video_staging::dir_file_names(std::path::Path::new(&listing_dir)) + }) + .await + .map_err(|error| format!("Could not inspect download folder: {error:?}"))?; + let guard = self.batch_guard(); + let item = self.enqueue_video_staged(page_url, &dir, filename, choices, &existing)?; + Ok((item, guard)) } - let item = DownloadItem::new( - self.alloc_id_for(std::path::Path::new(&dir)), - &url, - &unique_title, - &dir, - ); - item.set_is_gallery(true); - item.set_gallery_file_count(job.file_count.unwrap_or(0)); - item.set_gallery_files_done(0); - item.set_gallery_url(url.clone()); - item.set_detail(format!("Gallery • {} files", job.file_count.unwrap_or(0))); - let gallery_dir = std::path::Path::new(&dir).join(&unique_title); - let mut stored_job = job.clone(); - stored_job.dest_dir = gallery_dir; - stored_job.item_id = item.id(); - self.gallery_jobs.borrow_mut().insert(item.id(), stored_job); - Ok((self.insert(item), guard)) - } - /// [`DownloadManager::enqueue_video`] with a precomputed directory listing, - /// for bulk imports that would otherwise readdir once per row. The snapshot - /// stays correct as rows land: the per-row dedupe also consults the live - /// store and the stem reservation map. An on-disk file never renames the - /// intake (see [`DownloadManager::enqueue`]). - pub(crate) fn enqueue_video_staged( - self: &Rc, - page_url: &str, - dir: &str, - filename: Option<&str>, - choices: crate::media_types::VideoChoices, - existing: &[String], - ) -> Result { - let url = normalize_url(page_url)?; - let name = self.finalize_filename( - &filename - .filter(|s| sane_filename(s)) - .map(|s| s.to_string()) - .unwrap_or_else(|| filename_from_url(&url)), - ); - let name = dedupe_filename(&name, |n| { - let p = std::path::Path::new(dir).join(n); - crate::video_staging::stem_reserved_in(existing, name_stem(n)) - // Stem-wide reservation (see `is_name_taken`): a discard in flight owns this destination. - || self.dest_reserved(&p) - || self.items() - .any(|it| { - (it.dest_dir() == dir && it.filename() == n) - || it.output_dir() == p.to_string_lossy() - }) - }); - let item = DownloadItem::new( - self.alloc_id_for(std::path::Path::new(&dir)), - &url, - &name, - dir, - ); - item.set_detail(pending_resolve_detail(choices.audio_only)); - self.video_sources.borrow_mut().insert( - item.id(), - crate::media_types::VideoSource::Page { - page_url: url, - media_url: None, - expires_at: None, - quality: choices.quality, - audio_only: choices.audio_only, - is_live: choices.is_live, - video_format_id: choices.video_format_id, - playlist_item_id: choices.playlist_item_id, - backend: None, - }, - ); - Ok(self.insert(item)) - } + /// Enqueue a gallery download (image gallery via gallery-dl). + /// Creates a DownloadItem with gallery flags set; the actual download + /// is driven by the gallery runner (phase 12). + /// Enqueue a gallery after listing its destination off GTK. + #[cfg(test)] + pub async fn enqueue_gallery_async( + self: &Rc, + job: &crate::gallery_argv::GalleryJob, + ) -> Result { + let (item, guard) = self.enqueue_gallery_async_batched(job).await?; + drop(guard); + Ok(item) + } + + pub(crate) async fn enqueue_gallery_async_batched( + self: &Rc, + job: &crate::gallery_argv::GalleryJob, + ) -> Result<(DownloadItem, BatchGuard), String> { + let url = normalize_url(&job.page_url)?; + // Resolve the destination before recording it: empty/relative paths must never + // become dependent on the launcher CWD. The sanitized title is used as a + // restore-safe folder component, not as an arbitrary path from metadata. + let dir = self.resolve_dir(Some(&job.dest_dir.to_string_lossy())); + let title = crate::file_names::sanitize_folder_name(&job.title); + let listing_dir = dir.clone(); + let existing = gio::spawn_blocking(move || { + crate::video_staging::dir_file_names(std::path::Path::new(&listing_dir)) + }) + .await + .map_err(|error| format!("Could not inspect gallery destination: {error:?}"))?; + + // Only hold the queue during the synchronous commit, never across listing I/O. + let guard = self.batch_guard(); + // Only claim a folder that this row can prove is new. A pre-existing user + // directory or another queued gallery must never become a trash target. + let mut unique_title = title.clone(); + let mut counter = 1; + while existing.iter().any(|entry| entry == &unique_title) + || self + .items() + .any(|it| it.dest_dir() == dir && it.filename() == unique_title) + || self.dest_reserved(&std::path::Path::new(&dir).join(&unique_title)) + { + counter += 1; + unique_title = format!("{title}-{counter}"); + } + let item = DownloadItem::new( + self.alloc_id_for(std::path::Path::new(&dir)), + &url, + &unique_title, + &dir, + ); + item.set_is_gallery(true); + item.set_gallery_file_count(job.file_count.unwrap_or(0)); + item.set_gallery_files_done(0); + item.set_gallery_url(url.clone()); + item.set_detail(format!("Gallery • {} files", job.file_count.unwrap_or(0))); + let gallery_dir = std::path::Path::new(&dir).join(&unique_title); + let mut stored_job = job.clone(); + stored_job.dest_dir = gallery_dir; + stored_job.item_id = item.id(); + self.gallery_jobs.borrow_mut().insert(item.id(), stored_job); + Ok((self.insert(item), guard)) + } + + /// [`DownloadManager::enqueue_video`] with a precomputed directory listing, + /// for bulk imports that would otherwise readdir once per row. The snapshot + /// stays correct as rows land: the per-row dedupe also consults the live + /// store and the stem reservation map. An on-disk file never renames the + /// intake (see [`DownloadManager::enqueue`]). + pub(crate) fn enqueue_video_staged( + self: &Rc, + page_url: &str, + dir: &str, + filename: Option<&str>, + choices: crate::media_types::VideoChoices, + existing: &[String], + ) -> Result { + let url = normalize_url(page_url)?; + let name = self.finalize_filename( + &filename + .filter(|s| sane_filename(s)) + .map(|s| s.to_string()) + .unwrap_or_else(|| filename_from_url(&url)), + ); + let name = dedupe_filename(&name, |n| { + let p = std::path::Path::new(dir).join(n); + crate::video_staging::stem_reserved_in(existing, name_stem(n)) + // Stem-wide reservation (see `is_name_taken`): a discard in flight owns this destination. + || self.dest_reserved(&p) + || self.items() + .any(|it| { + (it.dest_dir() == dir && it.filename() == n) + || it.output_dir() == p.to_string_lossy() + }) + }); + let item = DownloadItem::new( + self.alloc_id_for(std::path::Path::new(&dir)), + &url, + &name, + dir, + ); + item.set_detail(pending_resolve_detail(choices.audio_only)); + self.video_sources.borrow_mut().insert( + item.id(), + crate::media_types::VideoSource::Page { + page_url: url, + media_url: None, + expires_at: None, + quality: choices.quality, + audio_only: choices.audio_only, + is_live: choices.is_live, + video_format_id: choices.video_format_id, + playlist_item_id: choices.playlist_item_id, + backend: None, + }, + ); + Ok(self.insert(item)) + } + + /// Queue one row per playlist entry for collection-shaped resolves with no picked entry. Returns (added, reported total). One persist for the whole import. + /// Queue collection children after creating/listing their destination off GTK. + async fn expand_playlist_rows_async( + weak: std::rc::Weak, + id: u64, + generation: u64, + item: &DownloadItem, + pl: &crate::media_types::PlaylistInfo, + ) -> (usize, usize, usize) { + let Some(this) = weak.upgrade() else { + return (0, pl.total, 0); + }; + let (quality, audio_only) = match this.video_source(id) { + Some(crate::media_types::VideoSource::Page { + quality, + audio_only, + .. + }) => (quality, audio_only), + _ => return (0, pl.total, 0), + }; + let base_dir = this.resolve_dir(Some(&item.dest_dir())); + let multi = pl.total > 1; + let title = pl.title.clone(); + // The local task must not keep a strong manager Rc pinned while the + // directory worker is pending; upgrade again before touching queue state. + drop(this); + let prepared = gio::spawn_blocking(move || { + let dest_dir = if multi { + crate::file_names::collection_subdir(&base_dir, &title) + } else { + base_dir + }; + let existing = crate::video_staging::dir_file_names(std::path::Path::new(&dest_dir)); + (dest_dir, existing) + }) + .await; + let (dest_dir, existing) = match prepared { + Ok(prepared) => prepared, + Err(error) => { + tracing::warn!( + error = ?error, + parent_id = id, + "playlist import directory preparation failed" + ); + return (0, pl.total, 0); + } + }; - /// Queue one row per playlist entry for collection-shaped resolves with no picked entry. Returns (added, reported total). One persist for the whole import. - /// Queue collection children after creating/listing their destination off GTK. - async fn expand_playlist_rows_async( - weak: std::rc::Weak, - id: u64, - generation: u64, - item: &DownloadItem, - pl: &crate::media_types::PlaylistInfo, - ) -> (usize, usize, usize) { - let Some(this) = weak.upgrade() else { - return (0, pl.total, 0); - }; - let (quality, audio_only) = match this.video_source(id) { - Some(crate::media_types::VideoSource::Page { - quality, - audio_only, - .. - }) => (quality, audio_only), - _ => return (0, pl.total, 0), - }; - let base_dir = this.resolve_dir(Some(&item.dest_dir())); - let multi = pl.total > 1; - let title = pl.title.clone(); - // The local task must not keep a strong manager Rc pinned while the - // directory worker is pending; upgrade again before touching queue state. - drop(this); - let prepared = gio::spawn_blocking(move || { - let dest_dir = if multi { - crate::file_names::collection_subdir(&base_dir, &title) - } else { - base_dir + let Some(this) = weak.upgrade() else { + return (0, pl.total, 0); }; - let existing = crate::video_staging::dir_file_names(std::path::Path::new(&dest_dir)); - (dest_dir, existing) - }) - .await; - let (dest_dir, existing) = match prepared { - Ok(prepared) => prepared, - Err(error) => { - tracing::warn!( - error = ?error, - parent_id = id, - "playlist import directory preparation failed" - ); + // The main context yielded while the folder was prepared. Do not + // enqueue stale children after pause/cancel/remove. + // Collection metadata is untrusted. Re-check the carrier after yielding so + // removed/cancelled rows cannot create children from stale resolver output. + if !this.is_current(id, generation) { return (0, pl.total, 0); } - }; - - let Some(this) = weak.upgrade() else { - return (0, pl.total, 0); - }; - // The main context yielded while the folder was prepared. Do not - // enqueue stale children after pause/cancel/remove. - // Collection metadata is untrusted. Re-check the carrier after yielding so - // removed/cancelled rows cannot create children from stale resolver output. - if !this.is_current(id, generation) { - return (0, pl.total, 0); - } - let Some(current) = this.find(id) else { - return (0, pl.total, 0); - }; - if matches!( - current.status(), - DownloadStatus::Cancelled | DownloadStatus::Paused - ) { - return (0, pl.total, 0); - } - - let _batch = this.batch_guard(); - // Collection results are untrusted input. Keep policy inheritance and the - // distinct-host cap in the shared helper so automatic expansion and the picker - // enforce the same destination/network rules. - let mut guarded_child_hosts = HashSet::new(); - let mut skipped_by_host_limit = 0usize; - let mut added = 0usize; - for entry in &pl.items { - let Some(url) = - crate::video_probe::expand_child_target(&item.url(), &pl.page_url, entry) - else { - continue; + let Some(current) = this.find(id) else { + return (0, pl.total, 0); }; - match crate::download_net::inherit_public_resolution_for_collection( - &item.url(), - &url, - &mut guarded_child_hosts, + if matches!( + current.status(), + DownloadStatus::Cancelled | DownloadStatus::Paused ) { - Ok(()) => {} - Err(crate::download_net::CollectionHostError::TooManyDistinctHosts) => { - skipped_by_host_limit += 1; + return (0, pl.total, 0); + } + + let _batch = this.batch_guard(); + // Collection results are untrusted input. Keep policy inheritance and the + // distinct-host cap in the shared helper so automatic expansion and the picker + // enforce the same destination/network rules. + let mut guarded_child_hosts = HashSet::new(); + let mut skipped_by_host_limit = 0usize; + let mut added = 0usize; + for entry in &pl.items { + let Some(url) = + crate::video_probe::expand_child_target(&item.url(), &pl.page_url, entry) + else { continue; + }; + match crate::download_net::inherit_public_resolution_for_collection( + &item.url(), + &url, + &mut guarded_child_hosts, + ) { + Ok(()) => {} + Err(crate::download_net::CollectionHostError::TooManyDistinctHosts) => { + skipped_by_host_limit += 1; + continue; + } + Err(crate::download_net::CollectionHostError::UnsafeDestination) => continue, + } + // Match the interactive picker by carrying each entry title into its filename. + let remux = crate::video_prefs::remux_video_active(&this.settings.remux_video()); + let name = + crate::video::default_video_filename(&entry.title, audio_only, remux.as_deref()); + // Live streams discovered inside a collection use the VOD path; each child + // resolves its own page rather than inheriting the carrier's live-capture state. + if this + .enqueue_video_staged( + &url, + &dest_dir, + Some(&name), + crate::media_types::VideoChoices { + quality: quality.clone(), + audio_only, + video_format_id: None, + is_live: false, + playlist_item_id: None, + }, + &existing, + ) + .is_ok() + { + added += 1; } - Err(crate::download_net::CollectionHostError::UnsafeDestination) => continue, } - // Match the interactive picker by carrying each entry title into its filename. - let remux = crate::video_prefs::remux_video_active(&this.settings.remux_video()); - let name = - crate::video::default_video_filename(&entry.title, audio_only, remux.as_deref()); - // Live streams discovered inside a collection use the VOD path; each child - // resolves its own page rather than inheriting the carrier's live-capture state. - if this - .enqueue_video_staged( - &url, - &dest_dir, - Some(&name), - crate::media_types::VideoChoices { - quality: quality.clone(), - audio_only, - video_format_id: None, - is_live: false, - playlist_item_id: None, - }, - &existing, - ) - .is_ok() - { - added += 1; + // Report the collection's stated total, not merely attempted entries, so the + // carrier can accurately show "added of total" alongside host-cap omissions. + (added, pl.total, skipped_by_host_limit) + } + + /// Re-queue one persisted entry, preserving its intent (paused/failed stay). Names restore verbatim to keep resume identity. + /// The video-page source is staged *before* insert (see `enqueue_video`). + /// + /// # Errors + /// Returns a display-ready message when the stored entry is invalid. + pub fn restore_existing(self: &Rc, stored: &StoredItem) -> Result { + let StoredItem { + id: stored_id, + url: raw_url, + dest_dir, + filename: raw_filename, + status, + segments, + video_source, + .. + } = stored; + let url = normalize_url(raw_url)?; + // Legacy queues predate origin metadata. Preserve their established + // behavior; new rows persist an explicit value for browser handoffs. + let public_only = stored.public_only.unwrap_or(false); + if public_only { + recheck_public_host(&url, "download")?; } - } - // Report the collection's stated total, not merely attempted entries, so the - // carrier can accurately show "added of total" alongside host-cap omissions. - (added, pl.total, skipped_by_host_limit) - } - - /// Re-queue one persisted entry, preserving its intent (paused/failed stay). Names restore verbatim to keep resume identity. - /// The video-page source is staged *before* insert (see `enqueue_video`). - /// - /// # Errors - /// Returns a display-ready message when the stored entry is invalid. - pub fn restore_existing(self: &Rc, stored: &StoredItem) -> Result { - let StoredItem { - id: stored_id, - url: raw_url, - dest_dir, - filename: raw_filename, - status, - segments, - video_source, - .. - } = stored; - let url = normalize_url(raw_url)?; - // Legacy queues predate origin metadata. Preserve their established - // behavior; new rows persist an explicit value for browser handoffs. - let public_only = stored.public_only.unwrap_or(false); - if public_only { - recheck_public_host(&url, "download")?; - } - if !sane_filename(raw_filename) { - return Err(format!("Invalid filename in queue: {raw_filename}")); - } - if !std::path::Path::new(dest_dir).is_absolute() { - return Err(format!("Invalid destination in queue: {dest_dir}")); - } - let filename = shorten_filename(raw_filename); - let item = DownloadItem::new( - self.claim_id(*stored_id, std::path::Path::new(dest_dir)), - &url, - &filename, - dest_dir, - ); - item.set_public_only(public_only); - // Resumed rows requeue; only settled rows keep their status. - // Scheduled rows keep their status and timestamp; overdue ones queue immediately. - let now = glib::DateTime::now_local() - .map(|dt| dt.to_unix()) - .unwrap_or(0); - let scheduled_at = stored.scheduled_at.unwrap_or(0); - item.set_scheduled_at(scheduled_at); - // Resume validators for If-Range (empty = none / predates the field). - if let Some(e) = stored.etag.as_deref() { - item.set_etag(e); - } - if let Some(lm) = stored.last_modified.as_deref() { - item.set_last_modified(lm); - } - item.set_status(match status { - DownloadStatus::Paused | DownloadStatus::Failed | DownloadStatus::Done => *status, - DownloadStatus::Scheduled if scheduled_at > now => DownloadStatus::Scheduled, - _ => DownloadStatus::Queued, - }); - if let Some(st) = segments { - self.segment_state - .borrow_mut() - .insert(item.id(), st.clone()); - } - if let Some(src) = video_source { - let matches = matches!(src, crate::media_types::VideoSource::Page { page_url, .. } if *page_url == url); - if matches { - let audio_only = matches!( - src, - crate::media_types::VideoSource::Page { - audio_only: true, - .. - } - ); - item.set_detail(pending_resolve_detail(audio_only)); - self.video_sources + if !sane_filename(raw_filename) { + return Err(format!("Invalid filename in queue: {raw_filename}")); + } + if !std::path::Path::new(dest_dir).is_absolute() { + return Err(format!("Invalid destination in queue: {dest_dir}")); + } + let filename = shorten_filename(raw_filename); + let item = DownloadItem::new( + self.claim_id(*stored_id, std::path::Path::new(dest_dir)), + &url, + &filename, + dest_dir, + ); + item.set_public_only(public_only); + // Resumed rows requeue; only settled rows keep their status. + // Scheduled rows keep their status and timestamp; overdue ones queue immediately. + let now = glib::DateTime::now_local() + .map(|dt| dt.to_unix()) + .unwrap_or(0); + let scheduled_at = stored.scheduled_at.unwrap_or(0); + item.set_scheduled_at(scheduled_at); + // Resume validators for If-Range (empty = none / predates the field). + if let Some(e) = stored.etag.as_deref() { + item.set_etag(e); + } + if let Some(lm) = stored.last_modified.as_deref() { + item.set_last_modified(lm); + } + item.set_status(match status { + DownloadStatus::Paused | DownloadStatus::Failed | DownloadStatus::Done => *status, + DownloadStatus::Scheduled if scheduled_at > now => DownloadStatus::Scheduled, + _ => DownloadStatus::Queued, + }); + if let Some(st) = segments { + self.segment_state .borrow_mut() - .insert(item.id(), src.clone()); + .insert(item.id(), st.clone()); + } + if let Some(src) = video_source { + let matches = matches!(src, crate::media_types::VideoSource::Page { page_url, .. } if *page_url == url); + if matches { + let audio_only = matches!( + src, + crate::media_types::VideoSource::Page { + audio_only: true, + .. + } + ); + item.set_detail(pending_resolve_detail(audio_only)); + self.video_sources + .borrow_mut() + .insert(item.id(), src.clone()); + } else { + tracing::warn!("dropping video source with mismatched page URL"); + } + } + // A row the engine started in a past session resumes its own + // partial; only a never-started row refuses an existing dest. + // Legacy queue files (started == None) keep the old resume behavior. + if stored.started.unwrap_or(true) { + self.started.borrow_mut().insert(item.id()); + } + Ok(self.insert(item)) + } + + /// Restore a gallery row from a validated stored entry + parsed job. + /// Mirrors `enqueue_gallery` but reuses the stored id and status. + pub fn restore_existing_gallery( + self: &Rc, + stored: &StoredItem, + job: Option, + ) -> Result { + let mut job = job.ok_or_else(|| "gallery entry missing job".to_string())?; + let url = normalize_url(&stored.url)?; + // Legacy queues predate origin metadata; only new rows can prove + // they came from a browser handoff. + let public_only = stored.public_only.unwrap_or(false); + if public_only { + recheck_public_host(&url, "gallery")?; + } + if !std::path::Path::new(&stored.dest_dir).is_absolute() { + return Err(format!("Invalid destination in queue: {}", stored.dest_dir)); + } + // The row's folder name (with its -2 suffix) was sane-validated on + // load; use it verbatim so the restored row matches the on-disk + // subfolder instead of the payload's base title. + let title = stored.filename.clone(); + let id = self.claim_id(stored.id, std::path::Path::new(&stored.dest_dir)); + job.item_id = id; + let item = DownloadItem::new(id, &url, &title, &stored.dest_dir); + item.set_public_only(public_only); + item.set_is_gallery(true); + let file_count = job.file_count.unwrap_or(0); + item.set_gallery_file_count(file_count); + // Done rows show the full count; others start at 0. + let files_done = if stored.status == DownloadStatus::Done { + file_count } else { - tracing::warn!("dropping video source with mismatched page URL"); + 0 + }; + item.set_gallery_files_done(files_done); + item.set_gallery_url(url.clone()); + // Done rows show the finished count; others mirror enqueue_gallery's + // total-count format (done_label(0) would read "Gallery • 0 files"). + if stored.status == DownloadStatus::Done { + item.set_detail(crate::gallery_runner::done_label(files_done as usize, 0)); + } else { + item.set_detail(format!("Gallery • {file_count} files")); } + // Status: Done/Failed/Paused kept; Downloading → Queued via the _ arm. + // (Scheduled doesn't apply to galleries.) + item.set_status(match stored.status { + DownloadStatus::Paused | DownloadStatus::Failed | DownloadStatus::Done => stored.status, + _ => DownloadStatus::Queued, + }); + // Done restores with a full bar; everything else starts at 0. + item.set_progress(if stored.status == DownloadStatus::Done { + 1.0 + } else { + 0.0 + }); + // Insert the job BEFORE self.insert: insert → start_next → + // spawn_gallery reads gallery_jobs synchronously. + self.gallery_jobs.borrow_mut().insert(id, job); + if stored.started.unwrap_or(true) { + self.started.borrow_mut().insert(id); + } + Ok(self.insert(item)) } - // A row the engine started in a past session resumes its own - // partial; only a never-started row refuses an existing dest. - // Legacy queue files (started == None) keep the old resume behavior. - if stored.started.unwrap_or(true) { - self.started.borrow_mut().insert(item.id()); - } - Ok(self.insert(item)) - } - /// Restore a gallery row from a validated stored entry + parsed job. - /// Mirrors `enqueue_gallery` but reuses the stored id and status. - pub fn restore_existing_gallery( - self: &Rc, - stored: &StoredItem, - job: Option, - ) -> Result { - let mut job = job.ok_or_else(|| "gallery entry missing job".to_string())?; - let url = normalize_url(&stored.url)?; - // Legacy queues predate origin metadata; only new rows can prove - // they came from a browser handoff. - let public_only = stored.public_only.unwrap_or(false); - if public_only { - recheck_public_host(&url, "gallery")?; - } - if !std::path::Path::new(&stored.dest_dir).is_absolute() { - return Err(format!("Invalid destination in queue: {}", stored.dest_dir)); - } - // The row's folder name (with its -2 suffix) was sane-validated on - // load; use it verbatim so the restored row matches the on-disk - // subfolder instead of the payload's base title. - let title = stored.filename.clone(); - let id = self.claim_id(stored.id, std::path::Path::new(&stored.dest_dir)); - job.item_id = id; - let item = DownloadItem::new(id, &url, &title, &stored.dest_dir); - item.set_public_only(public_only); - item.set_is_gallery(true); - let file_count = job.file_count.unwrap_or(0); - item.set_gallery_file_count(file_count); - // Done rows show the full count; others start at 0. - let files_done = if stored.status == DownloadStatus::Done { - file_count - } else { - 0 - }; - item.set_gallery_files_done(files_done); - item.set_gallery_url(url.clone()); - // Done rows show the finished count; others mirror enqueue_gallery's - // total-count format (done_label(0) would read "Gallery • 0 files"). - if stored.status == DownloadStatus::Done { - item.set_detail(crate::gallery_runner::done_label(files_done as usize, 0)); - } else { - item.set_detail(format!("Gallery • {file_count} files")); - } - // Status: Done/Failed/Paused kept; Downloading → Queued via the _ arm. - // (Scheduled doesn't apply to galleries.) - item.set_status(match stored.status { - DownloadStatus::Paused | DownloadStatus::Failed | DownloadStatus::Done => stored.status, - _ => DownloadStatus::Queued, - }); - // Done restores with a full bar; everything else starts at 0. - item.set_progress(if stored.status == DownloadStatus::Done { - 1.0 - } else { - 0.0 - }); - // Insert the job BEFORE self.insert: insert → start_next → - // spawn_gallery reads gallery_jobs synchronously. - self.gallery_jobs.borrow_mut().insert(id, job); - if stored.started.unwrap_or(true) { - self.started.borrow_mut().insert(id); + /// Claim one reference on a host's public-only policy for this row. + fn claim_public_policy(item: &DownloadItem) { + if let Some(host) = url::Url::parse(&item.url()) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + && (item.public_only() || crate::download_net::is_untrusted_handoff_host(&host)) + { + item.set_public_only(true); + crate::download_net::retain_public_resolution(&host); + } } - Ok(self.insert(item)) - } - /// Claim one reference on a host's public-only policy for this row. - fn claim_public_policy(item: &DownloadItem) { - if let Some(host) = url::Url::parse(&item.url()) - .ok() - .and_then(|url| url.host_str().map(str::to_string)) - && (item.public_only() || crate::download_net::is_untrusted_handoff_host(&host)) - { - item.set_public_only(true); - crate::download_net::retain_public_resolution(&host); + /// Drop the row's policy reference. The host stays registered so an open + /// card or another row on the same host keeps its protection. + fn release_public_policy(item: &DownloadItem) { + if item.public_only() + && let Some(host) = url::Url::parse(&item.url()) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + { + crate::download_net::release_public_resolution(&host); + } } - } - /// Drop the row's policy reference. The host stays registered so an open - /// card or another row on the same host keeps its protection. - fn release_public_policy(item: &DownloadItem) { - if item.public_only() - && let Some(host) = url::Url::parse(&item.url()) - .ok() - .and_then(|url| url.host_str().map(str::to_string)) - { - crate::download_net::release_public_resolution(&host); + fn insert(self: &Rc, item: DownloadItem) -> DownloadItem { + Self::claim_public_policy(&item); + self.store.append(&item); + self.persist_queue(); + self.changed(); + // Inside a batch the close kicks the scheduler once; a queue scan per insert is pure overhead. + if self.batch.get() == 0 { + self.start_next(); + } + item } - } - fn insert(self: &Rc, item: DownloadItem) -> DownloadItem { - Self::claim_public_policy(&item); - self.store.append(&item); - self.persist_queue(); - self.changed(); - // Inside a batch the close kicks the scheduler once; a queue scan per insert is pure overhead. - if self.batch.get() == 0 { - self.start_next(); + /// "Finished • {size}" detail with the size measured off the GTK thread. + /// `measure_disk_usage` is a recursive scan that can stall the main loop on + /// large folders, so the label paints immediately with `size` (the engine's + /// figure, 0 for plain "Finished") and is refined when the measurement lands. + /// A row that is gone by then simply never gets the update. + fn set_finished_detail(&self, item: &DownloadItem, size: u64) { + item.set_detail(if size > 0 { + gettext("Finished • {size}").replace("{size}", &fmt_bytes(size)) + } else { + gettext("Finished") + }); + let weak = item.downgrade(); + let path = item.file_path(); + let inflight = Rc::clone(&self.sizing_inflight); + inflight.set(inflight.get() + 1); + glib::spawn_future_local(async move { + let measured = crate::file_names::path_size_async(path) + .await + .filter(|m| *m > 0); + // Balance the counter before any early return: the row may be + // gone or the path missing, but the task still finished. + inflight.set(inflight.get() - 1); + let (Some(item), Some(measured)) = (weak.upgrade(), measured) else { + return; + }; + item.set_detail(gettext("Finished • {size}").replace("{size}", &fmt_bytes(measured))); + }); } - item - } - /// "Finished • {size}" detail with the size measured off the GTK thread. - /// `measure_disk_usage` is a recursive scan that can stall the main loop on - /// large folders, so the label paints immediately with `size` (the engine's - /// figure, 0 for plain "Finished") and is refined when the measurement lands. - /// A row that is gone by then simply never gets the update. - fn set_finished_detail(&self, item: &DownloadItem, size: u64) { - item.set_detail(if size > 0 { - gettext("Finished • {size}").replace("{size}", &fmt_bytes(size)) - } else { - gettext("Finished") - }); - let weak = item.downgrade(); - let path = item.file_path(); - let inflight = Rc::clone(&self.sizing_inflight); - inflight.set(inflight.get() + 1); - glib::spawn_future_local(async move { - let measured = crate::file_names::path_size_async(path) - .await - .filter(|m| *m > 0); - // Balance the counter before any early return: the row may be - // gone or the path missing, but the task still finished. - inflight.set(inflight.get() - 1); - let (Some(item), Some(measured)) = (weak.upgrade(), measured) else { + fn insert_history( + self: &Rc, + url: String, + dir: String, + name: String, + progress: f64, + output_dir: Option, + video_source: Option, + ) { + let Ok(url) = normalize_url(&url) else { + tracing::warn!("skipping history entry with bad URL"); return; }; - item.set_detail(gettext("Finished • {size}").replace("{size}", &fmt_bytes(measured))); - }); - } - - fn insert_history( - self: &Rc, - url: String, - dir: String, - name: String, - progress: f64, - output_dir: Option, - video_source: Option, - ) { - let Ok(url) = normalize_url(&url) else { - tracing::warn!("skipping history entry with bad URL"); - return; - }; - if !sane_filename(&name) { - tracing::warn!("skipping invalid history entry for {name}"); - return; - } - if !std::path::Path::new(&dir).is_absolute() { - tracing::warn!("skipping history entry with relative destination"); - return; - } - let item = DownloadItem::new( - self.alloc_id_for(std::path::Path::new(&dir)), - &url, - &name, - &dir, - ); - item.set_progress(progress.clamp(0.0, 1.0)); - item.set_status(DownloadStatus::Done); - // Already trust-checked by the caller (must sit inside `dir`). - if let Some(folder) = output_dir { - item.set_output_dir(folder); - } - // Size off the final path, measured off-thread: the engine measured the - // pre-rename one, and folders sum contents. Paints "Finished" now, - // refines with the measured size when it lands. - self.set_finished_detail(&item, 0); - // Restored duplicates collapse too: the last Done row per URL wins. - self.drop_finished_duplicates(&url, item.id()); - // Keep the row's media identity: a restored video page must not fall - // back to the generic file icon (same page-URL guard as - // `restore_existing`). - if let Some(src) = video_source - && matches!(src, crate::media_types::VideoSource::Page { ref page_url, .. } if *page_url == url) - { - self.video_sources.borrow_mut().insert(item.id(), src); + if !sane_filename(&name) { + tracing::warn!("skipping invalid history entry for {name}"); + return; + } + if !std::path::Path::new(&dir).is_absolute() { + tracing::warn!("skipping history entry with relative destination"); + return; + } + let item = DownloadItem::new( + self.alloc_id_for(std::path::Path::new(&dir)), + &url, + &name, + &dir, + ); + item.set_progress(progress.clamp(0.0, 1.0)); + item.set_status(DownloadStatus::Done); + // Already trust-checked by the caller (must sit inside `dir`). + if let Some(folder) = output_dir { + item.set_output_dir(folder); + } + // Size off the final path, measured off-thread: the engine measured the + // pre-rename one, and folders sum contents. Paints "Finished" now, + // refines with the measured size when it lands. + self.set_finished_detail(&item, 0); + // Restored duplicates collapse too: the last Done row per URL wins. + self.drop_finished_duplicates(&url, item.id()); + // Keep the row's media identity: a restored video page must not fall + // back to the generic file icon (same page-URL guard as + // `restore_existing`). + if let Some(src) = video_source + && matches!(src, crate::media_types::VideoSource::Page { ref page_url, .. } if *page_url == url) + { + self.video_sources.borrow_mut().insert(item.id(), src); + } + self.insert(item); } - self.insert(item); - } - /// Whether finish/fail desktop notifications are enabled. - pub fn notifications_enabled(&self) -> bool { - self.settings.show_notifications() - } - - /// Whether closing over active downloads notifies. - pub fn background_notifications_enabled(&self) -> bool { - self.settings.notify_background() - } + /// Whether finish/fail desktop notifications are enabled. + pub fn notifications_enabled(&self) -> bool { + self.settings.show_notifications() + } - /// Configured folder, or the system Downloads folder when empty or - /// relative (a relative dir would resolve against the launcher CWD). - pub fn effective_download_dir(&self) -> String { - let configured = self.settings.download_dir(); - if !configured.is_empty() && std::path::Path::new(&configured).is_absolute() { - return configured; + /// Whether closing over active downloads notifies. + pub fn background_notifications_enabled(&self) -> bool { + self.settings.notify_background() } - glib::user_special_dir(glib::UserDirectory::Downloads) - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_else(|| "/tmp".to_string()) - } - /// Simultaneous-download limit (at least 1). - pub fn max_concurrent(&self) -> usize { - self.settings.max_concurrent().max(1) as usize - } + /// Configured folder, or the system Downloads folder when empty or + /// relative (a relative dir would resolve against the launcher CWD). + pub fn effective_download_dir(&self) -> String { + let configured = self.settings.download_dir(); + if !configured.is_empty() && std::path::Path::new(&configured).is_absolute() { + return configured; + } + glib::user_special_dir(glib::UserDirectory::Downloads) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| "/tmp".to_string()) + } - fn start_next(self: &Rc) { - // Startup restore runs asynchronously in production. Don't start a newly - // queued URL before persisted active rows have been inserted, or we can - // briefly exceed the configured concurrency limit. - if self.restore_state.get() != RestoreState::Ready || self.shutdown_started.get() { - return; + /// Simultaneous-download limit (at least 1). + pub fn max_concurrent(&self) -> usize { + self.settings.max_concurrent().max(1) as usize } - // Prune finished discard finalizers so the registry doesn't grow per removed row. - self.discards - .borrow_mut() - .retain(|_, pending| !pending.finalizer.is_finished()); - while self.running.borrow().len() + self.preparing.borrow().len() < self.max_concurrent() { - let next = self - .items() - // A discard still tearing down owns its destination: the row stays Queued until the reservation clears. - .find(|it| { - it.status() == DownloadStatus::Queued && !self.dest_reserved(&it.file_path()) - }); - match next { - Some(item) => { - if item.is_gallery() { - self.spawn_gallery(item); - } else { - self.spawn(item); + + fn start_next(self: &Rc) { + // Startup restore runs asynchronously in production. Don't start a newly + // queued URL before persisted active rows have been inserted, or we can + // briefly exceed the configured concurrency limit. + if self.restore_state.get() != RestoreState::Ready || self.shutdown_started.get() { + return; + } + // Prune finished discard finalizers so the registry doesn't grow per removed row. + self.discards + .borrow_mut() + .retain(|_, pending| !pending.finalizer.is_finished()); + while self.running.borrow().len() + self.preparing.borrow().len() < self.max_concurrent() { + let next = self + .items() + // A discard still tearing down owns its destination: the row stays Queued until the reservation clears. + .find(|it| { + it.status() == DownloadStatus::Queued && !self.dest_reserved(&it.file_path()) + }); + match next { + Some(item) => { + if item.is_gallery() { + self.spawn_gallery(item); + } else { + self.spawn(item); + } } + None => break, } - None => break, } } - } - fn spawn(self: &Rc, item: DownloadItem) { - let id = item.id(); - let dest = item.file_path(); - let dest_dir = item.dest_dir().to_string(); - let url = item.url().to_string(); - let is_torrent = crate::torrent::is_torrent(&url); - let is_video = matches!( - self.video_source(id), - Some(crate::media_types::VideoSource::Page { .. }) - ); - let segments = self.segment_state.borrow().get(&id).cloned(); - let started = self.started.borrow().contains(&id); - // Reserve the target and a scheduler slot while filesystem validation is in flight. - // This keeps another queued row or a rapid retry from claiming the same path, - // and prevents async preparation from bypassing max_concurrent. - self.reserve_dest(&dest); - self.preparing.borrow_mut().insert(id); - let weak = Rc::downgrade(self); - let task_weak = weak.clone(); - let handle = glib::spawn_future_local(async move { - async move { - let worker_dest = dest.clone(); - let worker_dir = dest_dir.clone(); - let worker_recorded = item.output_dir().to_string(); - let worker_segments = segments.clone(); - let preparation = gio::spawn_blocking(move || { - let parent = worker_dest.parent(); - if let Some(parent) = parent { - // Re-check containment on the worker immediately before touching the - // path: a symlink planted after intake must not redirect the write. - match ensure_contained_parent(parent, &worker_dir) { - Ok(true) => {} - Ok(false) => { - return Err(gettext("Download path escapes the download folder")); - } - Err(error) => { - return Err(gettext("Could not create the download folder: {error}") - .replace("{error}", &error.to_string())); + fn spawn(self: &Rc, item: DownloadItem) { + let id = item.id(); + let dest = item.file_path(); + let dest_dir = item.dest_dir().to_string(); + let url = item.url().to_string(); + let is_torrent = crate::torrent::is_torrent(&url); + let is_video = matches!( + self.video_source(id), + Some(crate::media_types::VideoSource::Page { .. }) + ); + let segments = self.segment_state.borrow().get(&id).cloned(); + let started = self.started.borrow().contains(&id); + // Reserve the target and a scheduler slot while filesystem validation is in flight. + // This keeps another queued row or a rapid retry from claiming the same path, + // and prevents async preparation from bypassing max_concurrent. + self.reserve_dest(&dest); + self.preparing.borrow_mut().insert(id); + let weak = Rc::downgrade(self); + let task_weak = weak.clone(); + let handle = glib::spawn_future_local(async move { + async move { + let worker_dest = dest.clone(); + let worker_dir = dest_dir.clone(); + let worker_recorded = item.output_dir().to_string(); + let worker_segments = segments.clone(); + let preparation = gio::spawn_blocking(move || { + let parent = worker_dest.parent(); + if let Some(parent) = parent { + // Re-check containment on the worker immediately before touching the + // path: a symlink planted after intake must not redirect the write. + match ensure_contained_parent(parent, &worker_dir) { + Ok(true) => {} + Ok(false) => { + return Err(gettext("Download path escapes the download folder")); + } + Err(error) => { + return Err(gettext("Could not create the download folder: {error}") + .replace("{error}", &error.to_string())); + } } } - } - if is_torrent { - let dir = std::path::PathBuf::from(&worker_dir); - let recorded_path = std::path::PathBuf::from(&worker_recorded); - let (output, output_dir) = if worker_recorded.is_empty() { - (dir.clone(), None) - } else { - // Re-guard the recorded folder at spawn time; a symlink may have - // been planted after intake. Never follow it outside the destination. - let guarded = - crate::torrent::guard_output_folder(&dir, recorded_path.clone()); - let output_dir = (guarded != recorded_path) - .then(|| guarded.to_string_lossy().into_owned()); - if output_dir.is_none() && guarded.parent() != Some(dir.as_path()) { - std::fs::create_dir_all(&guarded).map_err(|error| { + if is_torrent { + let dir = std::path::PathBuf::from(&worker_dir); + let recorded_path = std::path::PathBuf::from(&worker_recorded); + let (output, output_dir) = if worker_recorded.is_empty() { + (dir.clone(), None) + } else { + // Re-guard the recorded folder at spawn time; a symlink may have + // been planted after intake. Never follow it outside the destination. + let guarded = + crate::torrent::guard_output_folder(&dir, recorded_path.clone()); + let output_dir = (guarded != recorded_path) + .then(|| guarded.to_string_lossy().into_owned()); + if output_dir.is_none() && guarded.parent() != Some(dir.as_path()) { + std::fs::create_dir_all(&guarded).map_err(|error| { + gettext("Could not create the download folder: {error}") + .replace("{error}", &error.to_string()) + })?; + } + (guarded, output_dir) + }; + if worker_recorded.is_empty() { + std::fs::create_dir_all(&dir).map_err(|error| { gettext("Could not create the download folder: {error}") .replace("{error}", &error.to_string()) })?; } - (guarded, output_dir) - }; - if worker_recorded.is_empty() { - std::fs::create_dir_all(&dir).map_err(|error| { - gettext("Could not create the download folder: {error}") - .replace("{error}", &error.to_string()) - })?; + return Ok(PreparedSpawn::Torrent { + dest: output, + output_dir, + }); } - return Ok(PreparedSpawn::Torrent { - dest: output, - output_dir, - }); - } - if is_video { - return Ok(PreparedSpawn::Video); - } - - let mut drop_segments = false; - let mode = match worker_segments { - Some(state) => { - // Drop an invalid bitmap and stale partial together. Do both on - // the worker so resume validation does not block GTK. - let len = std::fs::metadata(&worker_dest) - .map(|metadata| metadata.len()) - .unwrap_or(0); - if len < state.prefix_len() || len > state.total { - let _ = std::fs::remove_file(&worker_dest); - drop_segments = true; - StartMode::Fresh - } else { - StartMode::Resume(state) - } + if is_video { + return Ok(PreparedSpawn::Video); } - // Existing destination is refused for a fresh row; never overwrite a - // file that may belong to the user or another application. - None => match std::fs::metadata(&worker_dest) { - Err(_) => StartMode::Fresh, - Ok(_) if !started => { - return Err(DEST_EXISTS.to_string()); + + let mut drop_segments = false; + let mode = match worker_segments { + Some(state) => { + // Drop an invalid bitmap and stale partial together. Do both on + // the worker so resume validation does not block GTK. + let len = std::fs::metadata(&worker_dest) + .map(|metadata| metadata.len()) + .unwrap_or(0); + if len < state.prefix_len() || len > state.total { + let _ = std::fs::remove_file(&worker_dest); + drop_segments = true; + StartMode::Fresh + } else { + StartMode::Resume(state) + } } - Ok(_) => StartMode::Single, - }, - }; - Ok(PreparedSpawn::Plain { - mode, - drop_segments, + // Existing destination is refused for a fresh row; never overwrite a + // file that may belong to the user or another application. + None => match std::fs::metadata(&worker_dest) { + Err(_) => StartMode::Fresh, + Ok(_) if !started => { + return Err(DEST_EXISTS.to_string()); + } + Ok(_) => StartMode::Single, + }, + }; + Ok(PreparedSpawn::Plain { + mode, + drop_segments, + }) }) - }) - .await; - - let Some(manager) = weak.upgrade() else { - return; - }; - manager.preparing.borrow_mut().remove(&id); - manager.release_dest(&dest); - let Some(current) = manager.find(id) else { - manager.start_next(); - return; - }; - if current.status() != DownloadStatus::Queued || current.file_path() != dest { - manager.start_next(); - return; - } - // A cancel/retry can happen while the blocking task runs. If any input - // changed, discard its answer and let the scheduler take a fresh snapshot. - let current_segments = manager.segment_state.borrow().get(&id).cloned(); - let same_segments = match (&segments, ¤t_segments) { - (None, None) => true, - (Some(before), Some(after)) => { - before.total == after.total && before.done == after.done - } - _ => false, - }; - if !same_segments || manager.started.borrow().contains(&id) != started { - manager.start_next(); - return; - } + .await; - let prepared = match preparation { - Ok(Ok(prepared)) => prepared, - Ok(Err(detail)) => { - current.set_detail(detail.clone()); - current.set_status(DownloadStatus::Failed); - manager.changed(); - manager.notify_finished(¤t, Err(detail)); + let Some(manager) = weak.upgrade() else { + return; + }; + manager.preparing.borrow_mut().remove(&id); + manager.release_dest(&dest); + let Some(current) = manager.find(id) else { + manager.start_next(); + return; + }; + if current.status() != DownloadStatus::Queued || current.file_path() != dest { manager.start_next(); return; } - Err(error) => { - let detail = gettext("Could not prepare download destination: {error}") - .replace("{error}", &format!("{error:?}")); - current.set_detail(detail.clone()); - current.set_status(DownloadStatus::Failed); - manager.changed(); - manager.notify_finished(¤t, Err(detail)); + // A cancel/retry can happen while the blocking task runs. If any input + // changed, discard its answer and let the scheduler take a fresh snapshot. + let current_segments = manager.segment_state.borrow().get(&id).cloned(); + let same_segments = match (&segments, ¤t_segments) { + (None, None) => true, + (Some(before), Some(after)) => { + before.total == after.total && before.done == after.done + } + _ => false, + }; + if !same_segments || manager.started.borrow().contains(&id) != started { manager.start_next(); return; } - }; - if matches!( - &prepared, - PreparedSpawn::Plain { - drop_segments: true, - .. + + let prepared = match preparation { + Ok(Ok(prepared)) => prepared, + Ok(Err(detail)) => { + current.set_detail(detail.clone()); + current.set_status(DownloadStatus::Failed); + manager.changed(); + manager.notify_finished(¤t, Err(detail)); + manager.start_next(); + return; + } + Err(error) => { + let detail = gettext("Could not prepare download destination: {error}") + .replace("{error}", &format!("{error:?}")); + current.set_detail(detail.clone()); + current.set_status(DownloadStatus::Failed); + manager.changed(); + manager.notify_finished(¤t, Err(detail)); + manager.start_next(); + return; + } + }; + if matches!( + &prepared, + PreparedSpawn::Plain { + drop_segments: true, + .. + } + ) { + manager.segment_state.borrow_mut().remove(&id); + manager.persist_queue(); } - ) { - manager.segment_state.borrow_mut().remove(&id); - manager.persist_queue(); - } - manager.spawn_prepared(current, prepared); + manager.spawn_prepared(current, prepared); } .await; if let Some(manager) = task_weak.upgrade() { From 7c77dad84fd8837037932027744af83521b576f2 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:49:50 +0100 Subject: [PATCH 81/89] Fix formatting of tracked preparation task --- src/download.rs | 1640 +++++++++++++++++++++++------------------------ 1 file changed, 820 insertions(+), 820 deletions(-) diff --git a/src/download.rs b/src/download.rs index 582449ab0..9106be307 100644 --- a/src/download.rs +++ b/src/download.rs @@ -845,74 +845,150 @@ impl DownloadManager { "partial cleanup worker failed" ), } - if let Some(manager) = weak.upgrade() { - manager.release_dest(&path); - manager.start_next(); + if let Some(manager) = weak.upgrade() { + manager.release_dest(&path); + manager.start_next(); + } + }); + } + + /// Whether the row is currently capturing a live stream (stop-and-keep applies). + pub fn is_live_video(&self, id: u64) -> bool { + self.live_rows.borrow().contains(&id) + } + + /// Explicit absolute dest, else the effective download dir. + pub(crate) fn resolve_dir(&self, dest_dir: Option<&str>) -> String { + // Explicit destinations must be absolute (relative dirs would resolve against the launcher CWD and fail the sandbox). + dest_dir + .filter(|s| !s.is_empty() && std::path::Path::new(s).is_absolute()) + .map(|s| s.to_string()) + .unwrap_or_else(|| self.effective_download_dir()) + } + + /// Validate and queue a download, starting it when a slot is free. + /// + /// Names dedupe against live rows and reservations only: an on-disk file + /// never renames the intake. The engine refuses a fresh row whose + /// destination already exists (Parabolic-style), so duplicates surface as + /// an error instead of scattering ` (1)` copies. + /// + /// # Errors + /// Returns a display-ready message when the URL or filename is invalid. + pub fn enqueue( + self: &Rc, + url: &str, + dest_dir: Option<&str>, + filename: Option<&str>, + ) -> Result { + let url = normalize_url(url)?; + let dir = self.resolve_dir(dest_dir); + let name = filename + .filter(|s| sane_filename(s)) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + if crate::torrent::is_magnet(&url) { + // Real name arrives with metadata; the info-hash stub labels the row until SuggestName renames it. + crate::torrent::stub_name(&url).unwrap_or_else(|| filename_from_url(&url)) + } else { + filename_from_url(&url) } }); + let name = shorten_filename(&name); + // Torrents record engine subfolders as output_dir, so the taken check covers those too. + let name = dedupe_filename(&name, |n| { + let p = std::path::Path::new(&dir).join(n); + // Plain rows never reach `clean_dest_parts`, so only the exact destination needs a reservation. + self.dest_reserved(&p) + || self.items().any(|it| { + (it.dest_dir() == dir && it.filename() == n) + || it.output_dir() == p.to_string_lossy() + }) + }); + let item = DownloadItem::new( + self.alloc_id_for(std::path::Path::new(&dir)), + &url, + &name, + &dir, + ); + if crate::torrent::is_magnet(&url) { + // Magnets carry no archive to recompute the output folder from later, so record their subfolder now. + item.set_output_dir( + std::path::Path::new(&dir) + .join(&name) + .to_string_lossy() + .into_owned(), + ); } + Ok(self.insert(item)) + } - /// Whether the row is currently capturing a live stream (stop-and-keep applies). - pub fn is_live_video(&self, id: u64) -> bool { - self.live_rows.borrow().contains(&id) - } + /// Intake for .torrent files: archive the bytes, then enqueue the + /// pseudo-URL like any other download (stub from the file stem, real + /// name arrives with metadata via SuggestName). + /// + /// # Errors + /// Returns a display-ready message when the bytes are not a valid torrent. + /// Archive and enqueue a .torrent without performing disk I/O on GTK. + pub async fn enqueue_torrent_file_async( + self: &Rc, + bytes: Vec, + file_name: &str, + dest_dir: Option<&str>, + only_files: Option>, + ) -> Result { + let (item, guard) = self + .enqueue_torrent_file_async_batched(bytes, file_name, dest_dir, only_files) + .await?; + drop(guard); + Ok(item) + } - /// Explicit absolute dest, else the effective download dir. - pub(crate) fn resolve_dir(&self, dest_dir: Option<&str>) -> String { - // Explicit destinations must be absolute (relative dirs would resolve against the launcher CWD and fail the sandbox). - dest_dir - .filter(|s| !s.is_empty() && std::path::Path::new(s).is_absolute()) - .map(|s| s.to_string()) - .unwrap_or_else(|| self.effective_download_dir()) - } - - /// Validate and queue a download, starting it when a slot is free. - /// - /// Names dedupe against live rows and reservations only: an on-disk file - /// never renames the intake. The engine refuses a fresh row whose - /// destination already exists (Parabolic-style), so duplicates surface as - /// an error instead of scattering ` (1)` copies. - /// - /// # Errors - /// Returns a display-ready message when the URL or filename is invalid. - pub fn enqueue( - self: &Rc, - url: &str, - dest_dir: Option<&str>, - filename: Option<&str>, - ) -> Result { - let url = normalize_url(url)?; - let dir = self.resolve_dir(dest_dir); - let name = filename - .filter(|s| sane_filename(s)) - .map(|s| s.to_string()) - .unwrap_or_else(|| { - if crate::torrent::is_magnet(&url) { - // Real name arrives with metadata; the info-hash stub labels the row until SuggestName renames it. - crate::torrent::stub_name(&url).unwrap_or_else(|| filename_from_url(&url)) - } else { - filename_from_url(&url) - } + pub(crate) async fn enqueue_torrent_file_async_batched( + self: &Rc, + bytes: Vec, + file_name: &str, + dest_dir: Option<&str>, + only_files: Option>, + ) -> Result<(DownloadItem, BatchGuard), String> { + let file_name = file_name.to_string(); + let dir = self.resolve_dir(dest_dir); + let worker_file_name = file_name.clone(); + let worker_dir = dir.clone(); + let (pseudo, base, existing) = gio::spawn_blocking(move || { + // Parse metadata before archiving so invalid input never leaves an archive behind. + let base = crate::torrent::intake_plan(&bytes).map(|(base, _multi)| base); + let pseudo = crate::torrent::archive_torrent_file(&worker_file_name, &bytes)?; + let existing = crate::video_staging::dir_file_names(std::path::Path::new(&worker_dir)); + Ok::<_, String>((pseudo, base, existing)) + }) + .await + .map_err(|error| format!("Could not prepare torrent file: {error:?}"))??; + + // Hold the queue only during the synchronous commit, not during disk I/O. + let guard = self.batch_guard(); + if let Some(selection) = only_files { + crate::torrent::stage_selection(&pseudo, selection); + } + let stub = crate::torrent::stub_name_for_file(&file_name); + let item = self.enqueue(&pseudo, dest_dir, Some(&stub))?; + + // The engine writes with overwrite:true, so reserve a collision-free + // output folder from the listing snapshot before it can be spawned. + if let Some(base) = base { + let candidate = std::path::Path::new(&dir).join(&base); + let candidate_s = candidate.to_string_lossy().into_owned(); + let disk_taken = existing.iter().any(|entry| entry == &base); + let row_taken = self.items().any(|row| row.output_dir() == candidate_s); + if disk_taken || row_taken || self.dest_reserved(&candidate) { + let name = dedupe_filename(&base, |name| { + let path = std::path::Path::new(&dir).join(name); + existing.iter().any(|entry| entry == name) + || self.dest_reserved(&path) + || self + .items() + .any(|row| row.output_dir() == path.to_string_lossy()) }); - let name = shorten_filename(&name); - // Torrents record engine subfolders as output_dir, so the taken check covers those too. - let name = dedupe_filename(&name, |n| { - let p = std::path::Path::new(&dir).join(n); - // Plain rows never reach `clean_dest_parts`, so only the exact destination needs a reservation. - self.dest_reserved(&p) - || self.items().any(|it| { - (it.dest_dir() == dir && it.filename() == n) - || it.output_dir() == p.to_string_lossy() - }) - }); - let item = DownloadItem::new( - self.alloc_id_for(std::path::Path::new(&dir)), - &url, - &name, - &dir, - ); - if crate::torrent::is_magnet(&url) { - // Magnets carry no archive to recompute the output folder from later, so record their subfolder now. item.set_output_dir( std::path::Path::new(&dir) .join(&name) @@ -920,817 +996,741 @@ impl DownloadManager { .into_owned(), ); } - Ok(self.insert(item)) - } - - /// Intake for .torrent files: archive the bytes, then enqueue the - /// pseudo-URL like any other download (stub from the file stem, real - /// name arrives with metadata via SuggestName). - /// - /// # Errors - /// Returns a display-ready message when the bytes are not a valid torrent. - /// Archive and enqueue a .torrent without performing disk I/O on GTK. - pub async fn enqueue_torrent_file_async( - self: &Rc, - bytes: Vec, - file_name: &str, - dest_dir: Option<&str>, - only_files: Option>, - ) -> Result { - let (item, guard) = self - .enqueue_torrent_file_async_batched(bytes, file_name, dest_dir, only_files) - .await?; - drop(guard); - Ok(item) - } - - pub(crate) async fn enqueue_torrent_file_async_batched( - self: &Rc, - bytes: Vec, - file_name: &str, - dest_dir: Option<&str>, - only_files: Option>, - ) -> Result<(DownloadItem, BatchGuard), String> { - let file_name = file_name.to_string(); - let dir = self.resolve_dir(dest_dir); - let worker_file_name = file_name.clone(); - let worker_dir = dir.clone(); - let (pseudo, base, existing) = gio::spawn_blocking(move || { - // Parse metadata before archiving so invalid input never leaves an archive behind. - let base = crate::torrent::intake_plan(&bytes).map(|(base, _multi)| base); - let pseudo = crate::torrent::archive_torrent_file(&worker_file_name, &bytes)?; - let existing = crate::video_staging::dir_file_names(std::path::Path::new(&worker_dir)); - Ok::<_, String>((pseudo, base, existing)) - }) - .await - .map_err(|error| format!("Could not prepare torrent file: {error:?}"))??; - - // Hold the queue only during the synchronous commit, not during disk I/O. - let guard = self.batch_guard(); - if let Some(selection) = only_files { - crate::torrent::stage_selection(&pseudo, selection); - } - let stub = crate::torrent::stub_name_for_file(&file_name); - let item = self.enqueue(&pseudo, dest_dir, Some(&stub))?; - - // The engine writes with overwrite:true, so reserve a collision-free - // output folder from the listing snapshot before it can be spawned. - if let Some(base) = base { - let candidate = std::path::Path::new(&dir).join(&base); - let candidate_s = candidate.to_string_lossy().into_owned(); - let disk_taken = existing.iter().any(|entry| entry == &base); - let row_taken = self.items().any(|row| row.output_dir() == candidate_s); - if disk_taken || row_taken || self.dest_reserved(&candidate) { - let name = dedupe_filename(&base, |name| { - let path = std::path::Path::new(&dir).join(name); - existing.iter().any(|entry| entry == name) - || self.dest_reserved(&path) - || self - .items() - .any(|row| row.output_dir() == path.to_string_lossy()) - }); - item.set_output_dir( - std::path::Path::new(&dir) - .join(&name) - .to_string_lossy() - .into_owned(), - ); - } - } - Ok((item, guard)) } + Ok((item, guard)) + } - /// Final filename policy, shared by intake naming and late server/container suggestions: shorten, then the opt-in ASCII fold. Dedupe stays at the call sites. - fn finalize_filename(&self, name: &str) -> String { - let name = shorten_filename(name); - if self.settings.restrict_filenames() { - // Opt-in ASCII fold where Grab actually names the file (yt-dlp's flag is a no-op on literal output paths). - restrict_filename_ascii(&name) - } else { - name - } + /// Final filename policy, shared by intake naming and late server/container suggestions: shorten, then the opt-in ASCII fold. Dedupe stays at the call sites. + fn finalize_filename(&self, name: &str) -> String { + let name = shorten_filename(name); + if self.settings.restrict_filenames() { + // Opt-in ASCII fold where Grab actually names the file (yt-dlp's flag is a no-op on literal output paths). + restrict_filename_ascii(&name) + } else { + name } + } - /// Enqueue a video page: dialog-routed or probe-proven. The source is staged - /// before insert, so the persist inside [`DownloadManager::insert`] already - /// carries it and [`DownloadManager::start_next`] parks the row for the - /// resolver worker instead of feeding the page to the HTTP engine. - /// - /// # Errors - /// Returns a display-ready message when the URL or filename is invalid. - /// Enqueue a video page after preparing its directory listing off GTK. - #[cfg(test)] - pub async fn enqueue_video_async( - self: &Rc, - page_url: &str, - dest_dir: Option<&str>, - filename: Option<&str>, - choices: crate::media_types::VideoChoices, - ) -> Result { - let (item, guard) = self - .enqueue_video_async_batched(page_url, dest_dir, filename, choices) - .await?; - drop(guard); - Ok(item) - } - - pub(crate) async fn enqueue_video_async_batched( - self: &Rc, - page_url: &str, - dest_dir: Option<&str>, - filename: Option<&str>, - choices: crate::media_types::VideoChoices, - ) -> Result<(DownloadItem, BatchGuard), String> { - let dir = self.resolve_dir(dest_dir); - let listing_dir = dir.clone(); - let existing = gio::spawn_blocking(move || { - crate::video_staging::dir_file_names(std::path::Path::new(&listing_dir)) - }) - .await - .map_err(|error| format!("Could not inspect download folder: {error:?}"))?; - let guard = self.batch_guard(); - let item = self.enqueue_video_staged(page_url, &dir, filename, choices, &existing)?; - Ok((item, guard)) + /// Enqueue a video page: dialog-routed or probe-proven. The source is staged + /// before insert, so the persist inside [`DownloadManager::insert`] already + /// carries it and [`DownloadManager::start_next`] parks the row for the + /// resolver worker instead of feeding the page to the HTTP engine. + /// + /// # Errors + /// Returns a display-ready message when the URL or filename is invalid. + /// Enqueue a video page after preparing its directory listing off GTK. + #[cfg(test)] + pub async fn enqueue_video_async( + self: &Rc, + page_url: &str, + dest_dir: Option<&str>, + filename: Option<&str>, + choices: crate::media_types::VideoChoices, + ) -> Result { + let (item, guard) = self + .enqueue_video_async_batched(page_url, dest_dir, filename, choices) + .await?; + drop(guard); + Ok(item) + } + + pub(crate) async fn enqueue_video_async_batched( + self: &Rc, + page_url: &str, + dest_dir: Option<&str>, + filename: Option<&str>, + choices: crate::media_types::VideoChoices, + ) -> Result<(DownloadItem, BatchGuard), String> { + let dir = self.resolve_dir(dest_dir); + let listing_dir = dir.clone(); + let existing = gio::spawn_blocking(move || { + crate::video_staging::dir_file_names(std::path::Path::new(&listing_dir)) + }) + .await + .map_err(|error| format!("Could not inspect download folder: {error:?}"))?; + let guard = self.batch_guard(); + let item = self.enqueue_video_staged(page_url, &dir, filename, choices, &existing)?; + Ok((item, guard)) + } + + /// Enqueue a gallery download (image gallery via gallery-dl). + /// Creates a DownloadItem with gallery flags set; the actual download + /// is driven by the gallery runner (phase 12). + /// Enqueue a gallery after listing its destination off GTK. + #[cfg(test)] + pub async fn enqueue_gallery_async( + self: &Rc, + job: &crate::gallery_argv::GalleryJob, + ) -> Result { + let (item, guard) = self.enqueue_gallery_async_batched(job).await?; + drop(guard); + Ok(item) + } + + pub(crate) async fn enqueue_gallery_async_batched( + self: &Rc, + job: &crate::gallery_argv::GalleryJob, + ) -> Result<(DownloadItem, BatchGuard), String> { + let url = normalize_url(&job.page_url)?; + // Resolve the destination before recording it: empty/relative paths must never + // become dependent on the launcher CWD. The sanitized title is used as a + // restore-safe folder component, not as an arbitrary path from metadata. + let dir = self.resolve_dir(Some(&job.dest_dir.to_string_lossy())); + let title = crate::file_names::sanitize_folder_name(&job.title); + let listing_dir = dir.clone(); + let existing = gio::spawn_blocking(move || { + crate::video_staging::dir_file_names(std::path::Path::new(&listing_dir)) + }) + .await + .map_err(|error| format!("Could not inspect gallery destination: {error:?}"))?; + + // Only hold the queue during the synchronous commit, never across listing I/O. + let guard = self.batch_guard(); + // Only claim a folder that this row can prove is new. A pre-existing user + // directory or another queued gallery must never become a trash target. + let mut unique_title = title.clone(); + let mut counter = 1; + while existing.iter().any(|entry| entry == &unique_title) + || self + .items() + .any(|it| it.dest_dir() == dir && it.filename() == unique_title) + || self.dest_reserved(&std::path::Path::new(&dir).join(&unique_title)) + { + counter += 1; + unique_title = format!("{title}-{counter}"); } + let item = DownloadItem::new( + self.alloc_id_for(std::path::Path::new(&dir)), + &url, + &unique_title, + &dir, + ); + item.set_is_gallery(true); + item.set_gallery_file_count(job.file_count.unwrap_or(0)); + item.set_gallery_files_done(0); + item.set_gallery_url(url.clone()); + item.set_detail(format!("Gallery • {} files", job.file_count.unwrap_or(0))); + let gallery_dir = std::path::Path::new(&dir).join(&unique_title); + let mut stored_job = job.clone(); + stored_job.dest_dir = gallery_dir; + stored_job.item_id = item.id(); + self.gallery_jobs.borrow_mut().insert(item.id(), stored_job); + Ok((self.insert(item), guard)) + } - /// Enqueue a gallery download (image gallery via gallery-dl). - /// Creates a DownloadItem with gallery flags set; the actual download - /// is driven by the gallery runner (phase 12). - /// Enqueue a gallery after listing its destination off GTK. - #[cfg(test)] - pub async fn enqueue_gallery_async( - self: &Rc, - job: &crate::gallery_argv::GalleryJob, - ) -> Result { - let (item, guard) = self.enqueue_gallery_async_batched(job).await?; - drop(guard); - Ok(item) - } - - pub(crate) async fn enqueue_gallery_async_batched( - self: &Rc, - job: &crate::gallery_argv::GalleryJob, - ) -> Result<(DownloadItem, BatchGuard), String> { - let url = normalize_url(&job.page_url)?; - // Resolve the destination before recording it: empty/relative paths must never - // become dependent on the launcher CWD. The sanitized title is used as a - // restore-safe folder component, not as an arbitrary path from metadata. - let dir = self.resolve_dir(Some(&job.dest_dir.to_string_lossy())); - let title = crate::file_names::sanitize_folder_name(&job.title); - let listing_dir = dir.clone(); - let existing = gio::spawn_blocking(move || { - crate::video_staging::dir_file_names(std::path::Path::new(&listing_dir)) - }) - .await - .map_err(|error| format!("Could not inspect gallery destination: {error:?}"))?; - - // Only hold the queue during the synchronous commit, never across listing I/O. - let guard = self.batch_guard(); - // Only claim a folder that this row can prove is new. A pre-existing user - // directory or another queued gallery must never become a trash target. - let mut unique_title = title.clone(); - let mut counter = 1; - while existing.iter().any(|entry| entry == &unique_title) - || self - .items() - .any(|it| it.dest_dir() == dir && it.filename() == unique_title) - || self.dest_reserved(&std::path::Path::new(&dir).join(&unique_title)) - { - counter += 1; - unique_title = format!("{title}-{counter}"); - } - let item = DownloadItem::new( - self.alloc_id_for(std::path::Path::new(&dir)), - &url, - &unique_title, - &dir, - ); - item.set_is_gallery(true); - item.set_gallery_file_count(job.file_count.unwrap_or(0)); - item.set_gallery_files_done(0); - item.set_gallery_url(url.clone()); - item.set_detail(format!("Gallery • {} files", job.file_count.unwrap_or(0))); - let gallery_dir = std::path::Path::new(&dir).join(&unique_title); - let mut stored_job = job.clone(); - stored_job.dest_dir = gallery_dir; - stored_job.item_id = item.id(); - self.gallery_jobs.borrow_mut().insert(item.id(), stored_job); - Ok((self.insert(item), guard)) - } - - /// [`DownloadManager::enqueue_video`] with a precomputed directory listing, - /// for bulk imports that would otherwise readdir once per row. The snapshot - /// stays correct as rows land: the per-row dedupe also consults the live - /// store and the stem reservation map. An on-disk file never renames the - /// intake (see [`DownloadManager::enqueue`]). - pub(crate) fn enqueue_video_staged( - self: &Rc, - page_url: &str, - dir: &str, - filename: Option<&str>, - choices: crate::media_types::VideoChoices, - existing: &[String], - ) -> Result { - let url = normalize_url(page_url)?; - let name = self.finalize_filename( - &filename - .filter(|s| sane_filename(s)) - .map(|s| s.to_string()) - .unwrap_or_else(|| filename_from_url(&url)), - ); - let name = dedupe_filename(&name, |n| { - let p = std::path::Path::new(dir).join(n); - crate::video_staging::stem_reserved_in(existing, name_stem(n)) - // Stem-wide reservation (see `is_name_taken`): a discard in flight owns this destination. - || self.dest_reserved(&p) - || self.items() - .any(|it| { - (it.dest_dir() == dir && it.filename() == n) - || it.output_dir() == p.to_string_lossy() - }) - }); - let item = DownloadItem::new( - self.alloc_id_for(std::path::Path::new(&dir)), - &url, - &name, - dir, - ); - item.set_detail(pending_resolve_detail(choices.audio_only)); - self.video_sources.borrow_mut().insert( - item.id(), - crate::media_types::VideoSource::Page { - page_url: url, - media_url: None, - expires_at: None, - quality: choices.quality, - audio_only: choices.audio_only, - is_live: choices.is_live, - video_format_id: choices.video_format_id, - playlist_item_id: choices.playlist_item_id, - backend: None, - }, - ); - Ok(self.insert(item)) - } - - /// Queue one row per playlist entry for collection-shaped resolves with no picked entry. Returns (added, reported total). One persist for the whole import. - /// Queue collection children after creating/listing their destination off GTK. - async fn expand_playlist_rows_async( - weak: std::rc::Weak, - id: u64, - generation: u64, - item: &DownloadItem, - pl: &crate::media_types::PlaylistInfo, - ) -> (usize, usize, usize) { - let Some(this) = weak.upgrade() else { - return (0, pl.total, 0); - }; - let (quality, audio_only) = match this.video_source(id) { - Some(crate::media_types::VideoSource::Page { - quality, - audio_only, - .. - }) => (quality, audio_only), - _ => return (0, pl.total, 0), - }; - let base_dir = this.resolve_dir(Some(&item.dest_dir())); - let multi = pl.total > 1; - let title = pl.title.clone(); - // The local task must not keep a strong manager Rc pinned while the - // directory worker is pending; upgrade again before touching queue state. - drop(this); - let prepared = gio::spawn_blocking(move || { - let dest_dir = if multi { - crate::file_names::collection_subdir(&base_dir, &title) - } else { - base_dir - }; - let existing = crate::video_staging::dir_file_names(std::path::Path::new(&dest_dir)); - (dest_dir, existing) - }) - .await; - let (dest_dir, existing) = match prepared { - Ok(prepared) => prepared, - Err(error) => { - tracing::warn!( - error = ?error, - parent_id = id, - "playlist import directory preparation failed" - ); - return (0, pl.total, 0); - } - }; + /// [`DownloadManager::enqueue_video`] with a precomputed directory listing, + /// for bulk imports that would otherwise readdir once per row. The snapshot + /// stays correct as rows land: the per-row dedupe also consults the live + /// store and the stem reservation map. An on-disk file never renames the + /// intake (see [`DownloadManager::enqueue`]). + pub(crate) fn enqueue_video_staged( + self: &Rc, + page_url: &str, + dir: &str, + filename: Option<&str>, + choices: crate::media_types::VideoChoices, + existing: &[String], + ) -> Result { + let url = normalize_url(page_url)?; + let name = self.finalize_filename( + &filename + .filter(|s| sane_filename(s)) + .map(|s| s.to_string()) + .unwrap_or_else(|| filename_from_url(&url)), + ); + let name = dedupe_filename(&name, |n| { + let p = std::path::Path::new(dir).join(n); + crate::video_staging::stem_reserved_in(existing, name_stem(n)) + // Stem-wide reservation (see `is_name_taken`): a discard in flight owns this destination. + || self.dest_reserved(&p) + || self.items() + .any(|it| { + (it.dest_dir() == dir && it.filename() == n) + || it.output_dir() == p.to_string_lossy() + }) + }); + let item = DownloadItem::new( + self.alloc_id_for(std::path::Path::new(&dir)), + &url, + &name, + dir, + ); + item.set_detail(pending_resolve_detail(choices.audio_only)); + self.video_sources.borrow_mut().insert( + item.id(), + crate::media_types::VideoSource::Page { + page_url: url, + media_url: None, + expires_at: None, + quality: choices.quality, + audio_only: choices.audio_only, + is_live: choices.is_live, + video_format_id: choices.video_format_id, + playlist_item_id: choices.playlist_item_id, + backend: None, + }, + ); + Ok(self.insert(item)) + } - let Some(this) = weak.upgrade() else { - return (0, pl.total, 0); + /// Queue one row per playlist entry for collection-shaped resolves with no picked entry. Returns (added, reported total). One persist for the whole import. + /// Queue collection children after creating/listing their destination off GTK. + async fn expand_playlist_rows_async( + weak: std::rc::Weak, + id: u64, + generation: u64, + item: &DownloadItem, + pl: &crate::media_types::PlaylistInfo, + ) -> (usize, usize, usize) { + let Some(this) = weak.upgrade() else { + return (0, pl.total, 0); + }; + let (quality, audio_only) = match this.video_source(id) { + Some(crate::media_types::VideoSource::Page { + quality, + audio_only, + .. + }) => (quality, audio_only), + _ => return (0, pl.total, 0), + }; + let base_dir = this.resolve_dir(Some(&item.dest_dir())); + let multi = pl.total > 1; + let title = pl.title.clone(); + // The local task must not keep a strong manager Rc pinned while the + // directory worker is pending; upgrade again before touching queue state. + drop(this); + let prepared = gio::spawn_blocking(move || { + let dest_dir = if multi { + crate::file_names::collection_subdir(&base_dir, &title) + } else { + base_dir }; - // The main context yielded while the folder was prepared. Do not - // enqueue stale children after pause/cancel/remove. - // Collection metadata is untrusted. Re-check the carrier after yielding so - // removed/cancelled rows cannot create children from stale resolver output. - if !this.is_current(id, generation) { + let existing = crate::video_staging::dir_file_names(std::path::Path::new(&dest_dir)); + (dest_dir, existing) + }) + .await; + let (dest_dir, existing) = match prepared { + Ok(prepared) => prepared, + Err(error) => { + tracing::warn!( + error = ?error, + parent_id = id, + "playlist import directory preparation failed" + ); return (0, pl.total, 0); } - let Some(current) = this.find(id) else { - return (0, pl.total, 0); + }; + + let Some(this) = weak.upgrade() else { + return (0, pl.total, 0); + }; + // The main context yielded while the folder was prepared. Do not + // enqueue stale children after pause/cancel/remove. + // Collection metadata is untrusted. Re-check the carrier after yielding so + // removed/cancelled rows cannot create children from stale resolver output. + if !this.is_current(id, generation) { + return (0, pl.total, 0); + } + let Some(current) = this.find(id) else { + return (0, pl.total, 0); + }; + if matches!( + current.status(), + DownloadStatus::Cancelled | DownloadStatus::Paused + ) { + return (0, pl.total, 0); + } + + let _batch = this.batch_guard(); + // Collection results are untrusted input. Keep policy inheritance and the + // distinct-host cap in the shared helper so automatic expansion and the picker + // enforce the same destination/network rules. + let mut guarded_child_hosts = HashSet::new(); + let mut skipped_by_host_limit = 0usize; + let mut added = 0usize; + for entry in &pl.items { + let Some(url) = + crate::video_probe::expand_child_target(&item.url(), &pl.page_url, entry) + else { + continue; }; - if matches!( - current.status(), - DownloadStatus::Cancelled | DownloadStatus::Paused + match crate::download_net::inherit_public_resolution_for_collection( + &item.url(), + &url, + &mut guarded_child_hosts, ) { - return (0, pl.total, 0); - } - - let _batch = this.batch_guard(); - // Collection results are untrusted input. Keep policy inheritance and the - // distinct-host cap in the shared helper so automatic expansion and the picker - // enforce the same destination/network rules. - let mut guarded_child_hosts = HashSet::new(); - let mut skipped_by_host_limit = 0usize; - let mut added = 0usize; - for entry in &pl.items { - let Some(url) = - crate::video_probe::expand_child_target(&item.url(), &pl.page_url, entry) - else { + Ok(()) => {} + Err(crate::download_net::CollectionHostError::TooManyDistinctHosts) => { + skipped_by_host_limit += 1; continue; - }; - match crate::download_net::inherit_public_resolution_for_collection( - &item.url(), - &url, - &mut guarded_child_hosts, - ) { - Ok(()) => {} - Err(crate::download_net::CollectionHostError::TooManyDistinctHosts) => { - skipped_by_host_limit += 1; - continue; - } - Err(crate::download_net::CollectionHostError::UnsafeDestination) => continue, - } - // Match the interactive picker by carrying each entry title into its filename. - let remux = crate::video_prefs::remux_video_active(&this.settings.remux_video()); - let name = - crate::video::default_video_filename(&entry.title, audio_only, remux.as_deref()); - // Live streams discovered inside a collection use the VOD path; each child - // resolves its own page rather than inheriting the carrier's live-capture state. - if this - .enqueue_video_staged( - &url, - &dest_dir, - Some(&name), - crate::media_types::VideoChoices { - quality: quality.clone(), - audio_only, - video_format_id: None, - is_live: false, - playlist_item_id: None, - }, - &existing, - ) - .is_ok() - { - added += 1; } + Err(crate::download_net::CollectionHostError::UnsafeDestination) => continue, } - // Report the collection's stated total, not merely attempted entries, so the - // carrier can accurately show "added of total" alongside host-cap omissions. - (added, pl.total, skipped_by_host_limit) - } - - /// Re-queue one persisted entry, preserving its intent (paused/failed stay). Names restore verbatim to keep resume identity. - /// The video-page source is staged *before* insert (see `enqueue_video`). - /// - /// # Errors - /// Returns a display-ready message when the stored entry is invalid. - pub fn restore_existing(self: &Rc, stored: &StoredItem) -> Result { - let StoredItem { - id: stored_id, - url: raw_url, - dest_dir, - filename: raw_filename, - status, - segments, - video_source, - .. - } = stored; - let url = normalize_url(raw_url)?; - // Legacy queues predate origin metadata. Preserve their established - // behavior; new rows persist an explicit value for browser handoffs. - let public_only = stored.public_only.unwrap_or(false); - if public_only { - recheck_public_host(&url, "download")?; - } - if !sane_filename(raw_filename) { - return Err(format!("Invalid filename in queue: {raw_filename}")); - } - if !std::path::Path::new(dest_dir).is_absolute() { - return Err(format!("Invalid destination in queue: {dest_dir}")); - } - let filename = shorten_filename(raw_filename); - let item = DownloadItem::new( - self.claim_id(*stored_id, std::path::Path::new(dest_dir)), - &url, - &filename, - dest_dir, - ); - item.set_public_only(public_only); - // Resumed rows requeue; only settled rows keep their status. - // Scheduled rows keep their status and timestamp; overdue ones queue immediately. - let now = glib::DateTime::now_local() - .map(|dt| dt.to_unix()) - .unwrap_or(0); - let scheduled_at = stored.scheduled_at.unwrap_or(0); - item.set_scheduled_at(scheduled_at); - // Resume validators for If-Range (empty = none / predates the field). - if let Some(e) = stored.etag.as_deref() { - item.set_etag(e); - } - if let Some(lm) = stored.last_modified.as_deref() { - item.set_last_modified(lm); + // Match the interactive picker by carrying each entry title into its filename. + let remux = crate::video_prefs::remux_video_active(&this.settings.remux_video()); + let name = + crate::video::default_video_filename(&entry.title, audio_only, remux.as_deref()); + // Live streams discovered inside a collection use the VOD path; each child + // resolves its own page rather than inheriting the carrier's live-capture state. + if this + .enqueue_video_staged( + &url, + &dest_dir, + Some(&name), + crate::media_types::VideoChoices { + quality: quality.clone(), + audio_only, + video_format_id: None, + is_live: false, + playlist_item_id: None, + }, + &existing, + ) + .is_ok() + { + added += 1; } - item.set_status(match status { - DownloadStatus::Paused | DownloadStatus::Failed | DownloadStatus::Done => *status, - DownloadStatus::Scheduled if scheduled_at > now => DownloadStatus::Scheduled, - _ => DownloadStatus::Queued, - }); - if let Some(st) = segments { - self.segment_state + } + // Report the collection's stated total, not merely attempted entries, so the + // carrier can accurately show "added of total" alongside host-cap omissions. + (added, pl.total, skipped_by_host_limit) + } + + /// Re-queue one persisted entry, preserving its intent (paused/failed stay). Names restore verbatim to keep resume identity. + /// The video-page source is staged *before* insert (see `enqueue_video`). + /// + /// # Errors + /// Returns a display-ready message when the stored entry is invalid. + pub fn restore_existing(self: &Rc, stored: &StoredItem) -> Result { + let StoredItem { + id: stored_id, + url: raw_url, + dest_dir, + filename: raw_filename, + status, + segments, + video_source, + .. + } = stored; + let url = normalize_url(raw_url)?; + // Legacy queues predate origin metadata. Preserve their established + // behavior; new rows persist an explicit value for browser handoffs. + let public_only = stored.public_only.unwrap_or(false); + if public_only { + recheck_public_host(&url, "download")?; + } + if !sane_filename(raw_filename) { + return Err(format!("Invalid filename in queue: {raw_filename}")); + } + if !std::path::Path::new(dest_dir).is_absolute() { + return Err(format!("Invalid destination in queue: {dest_dir}")); + } + let filename = shorten_filename(raw_filename); + let item = DownloadItem::new( + self.claim_id(*stored_id, std::path::Path::new(dest_dir)), + &url, + &filename, + dest_dir, + ); + item.set_public_only(public_only); + // Resumed rows requeue; only settled rows keep their status. + // Scheduled rows keep their status and timestamp; overdue ones queue immediately. + let now = glib::DateTime::now_local() + .map(|dt| dt.to_unix()) + .unwrap_or(0); + let scheduled_at = stored.scheduled_at.unwrap_or(0); + item.set_scheduled_at(scheduled_at); + // Resume validators for If-Range (empty = none / predates the field). + if let Some(e) = stored.etag.as_deref() { + item.set_etag(e); + } + if let Some(lm) = stored.last_modified.as_deref() { + item.set_last_modified(lm); + } + item.set_status(match status { + DownloadStatus::Paused | DownloadStatus::Failed | DownloadStatus::Done => *status, + DownloadStatus::Scheduled if scheduled_at > now => DownloadStatus::Scheduled, + _ => DownloadStatus::Queued, + }); + if let Some(st) = segments { + self.segment_state + .borrow_mut() + .insert(item.id(), st.clone()); + } + if let Some(src) = video_source { + let matches = matches!(src, crate::media_types::VideoSource::Page { page_url, .. } if *page_url == url); + if matches { + let audio_only = matches!( + src, + crate::media_types::VideoSource::Page { + audio_only: true, + .. + } + ); + item.set_detail(pending_resolve_detail(audio_only)); + self.video_sources .borrow_mut() - .insert(item.id(), st.clone()); - } - if let Some(src) = video_source { - let matches = matches!(src, crate::media_types::VideoSource::Page { page_url, .. } if *page_url == url); - if matches { - let audio_only = matches!( - src, - crate::media_types::VideoSource::Page { - audio_only: true, - .. - } - ); - item.set_detail(pending_resolve_detail(audio_only)); - self.video_sources - .borrow_mut() - .insert(item.id(), src.clone()); - } else { - tracing::warn!("dropping video source with mismatched page URL"); - } - } - // A row the engine started in a past session resumes its own - // partial; only a never-started row refuses an existing dest. - // Legacy queue files (started == None) keep the old resume behavior. - if stored.started.unwrap_or(true) { - self.started.borrow_mut().insert(item.id()); - } - Ok(self.insert(item)) - } - - /// Restore a gallery row from a validated stored entry + parsed job. - /// Mirrors `enqueue_gallery` but reuses the stored id and status. - pub fn restore_existing_gallery( - self: &Rc, - stored: &StoredItem, - job: Option, - ) -> Result { - let mut job = job.ok_or_else(|| "gallery entry missing job".to_string())?; - let url = normalize_url(&stored.url)?; - // Legacy queues predate origin metadata; only new rows can prove - // they came from a browser handoff. - let public_only = stored.public_only.unwrap_or(false); - if public_only { - recheck_public_host(&url, "gallery")?; - } - if !std::path::Path::new(&stored.dest_dir).is_absolute() { - return Err(format!("Invalid destination in queue: {}", stored.dest_dir)); - } - // The row's folder name (with its -2 suffix) was sane-validated on - // load; use it verbatim so the restored row matches the on-disk - // subfolder instead of the payload's base title. - let title = stored.filename.clone(); - let id = self.claim_id(stored.id, std::path::Path::new(&stored.dest_dir)); - job.item_id = id; - let item = DownloadItem::new(id, &url, &title, &stored.dest_dir); - item.set_public_only(public_only); - item.set_is_gallery(true); - let file_count = job.file_count.unwrap_or(0); - item.set_gallery_file_count(file_count); - // Done rows show the full count; others start at 0. - let files_done = if stored.status == DownloadStatus::Done { - file_count - } else { - 0 - }; - item.set_gallery_files_done(files_done); - item.set_gallery_url(url.clone()); - // Done rows show the finished count; others mirror enqueue_gallery's - // total-count format (done_label(0) would read "Gallery • 0 files"). - if stored.status == DownloadStatus::Done { - item.set_detail(crate::gallery_runner::done_label(files_done as usize, 0)); - } else { - item.set_detail(format!("Gallery • {file_count} files")); - } - // Status: Done/Failed/Paused kept; Downloading → Queued via the _ arm. - // (Scheduled doesn't apply to galleries.) - item.set_status(match stored.status { - DownloadStatus::Paused | DownloadStatus::Failed | DownloadStatus::Done => stored.status, - _ => DownloadStatus::Queued, - }); - // Done restores with a full bar; everything else starts at 0. - item.set_progress(if stored.status == DownloadStatus::Done { - 1.0 + .insert(item.id(), src.clone()); } else { - 0.0 - }); - // Insert the job BEFORE self.insert: insert → start_next → - // spawn_gallery reads gallery_jobs synchronously. - self.gallery_jobs.borrow_mut().insert(id, job); - if stored.started.unwrap_or(true) { - self.started.borrow_mut().insert(id); + tracing::warn!("dropping video source with mismatched page URL"); } - Ok(self.insert(item)) } + // A row the engine started in a past session resumes its own + // partial; only a never-started row refuses an existing dest. + // Legacy queue files (started == None) keep the old resume behavior. + if stored.started.unwrap_or(true) { + self.started.borrow_mut().insert(item.id()); + } + Ok(self.insert(item)) + } - /// Claim one reference on a host's public-only policy for this row. - fn claim_public_policy(item: &DownloadItem) { - if let Some(host) = url::Url::parse(&item.url()) - .ok() - .and_then(|url| url.host_str().map(str::to_string)) - && (item.public_only() || crate::download_net::is_untrusted_handoff_host(&host)) - { - item.set_public_only(true); - crate::download_net::retain_public_resolution(&host); - } + /// Restore a gallery row from a validated stored entry + parsed job. + /// Mirrors `enqueue_gallery` but reuses the stored id and status. + pub fn restore_existing_gallery( + self: &Rc, + stored: &StoredItem, + job: Option, + ) -> Result { + let mut job = job.ok_or_else(|| "gallery entry missing job".to_string())?; + let url = normalize_url(&stored.url)?; + // Legacy queues predate origin metadata; only new rows can prove + // they came from a browser handoff. + let public_only = stored.public_only.unwrap_or(false); + if public_only { + recheck_public_host(&url, "gallery")?; + } + if !std::path::Path::new(&stored.dest_dir).is_absolute() { + return Err(format!("Invalid destination in queue: {}", stored.dest_dir)); + } + // The row's folder name (with its -2 suffix) was sane-validated on + // load; use it verbatim so the restored row matches the on-disk + // subfolder instead of the payload's base title. + let title = stored.filename.clone(); + let id = self.claim_id(stored.id, std::path::Path::new(&stored.dest_dir)); + job.item_id = id; + let item = DownloadItem::new(id, &url, &title, &stored.dest_dir); + item.set_public_only(public_only); + item.set_is_gallery(true); + let file_count = job.file_count.unwrap_or(0); + item.set_gallery_file_count(file_count); + // Done rows show the full count; others start at 0. + let files_done = if stored.status == DownloadStatus::Done { + file_count + } else { + 0 + }; + item.set_gallery_files_done(files_done); + item.set_gallery_url(url.clone()); + // Done rows show the finished count; others mirror enqueue_gallery's + // total-count format (done_label(0) would read "Gallery • 0 files"). + if stored.status == DownloadStatus::Done { + item.set_detail(crate::gallery_runner::done_label(files_done as usize, 0)); + } else { + item.set_detail(format!("Gallery • {file_count} files")); } + // Status: Done/Failed/Paused kept; Downloading → Queued via the _ arm. + // (Scheduled doesn't apply to galleries.) + item.set_status(match stored.status { + DownloadStatus::Paused | DownloadStatus::Failed | DownloadStatus::Done => stored.status, + _ => DownloadStatus::Queued, + }); + // Done restores with a full bar; everything else starts at 0. + item.set_progress(if stored.status == DownloadStatus::Done { + 1.0 + } else { + 0.0 + }); + // Insert the job BEFORE self.insert: insert → start_next → + // spawn_gallery reads gallery_jobs synchronously. + self.gallery_jobs.borrow_mut().insert(id, job); + if stored.started.unwrap_or(true) { + self.started.borrow_mut().insert(id); + } + Ok(self.insert(item)) + } - /// Drop the row's policy reference. The host stays registered so an open - /// card or another row on the same host keeps its protection. - fn release_public_policy(item: &DownloadItem) { - if item.public_only() - && let Some(host) = url::Url::parse(&item.url()) - .ok() - .and_then(|url| url.host_str().map(str::to_string)) - { - crate::download_net::release_public_resolution(&host); - } + /// Claim one reference on a host's public-only policy for this row. + fn claim_public_policy(item: &DownloadItem) { + if let Some(host) = url::Url::parse(&item.url()) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + && (item.public_only() || crate::download_net::is_untrusted_handoff_host(&host)) + { + item.set_public_only(true); + crate::download_net::retain_public_resolution(&host); } + } - fn insert(self: &Rc, item: DownloadItem) -> DownloadItem { - Self::claim_public_policy(&item); - self.store.append(&item); - self.persist_queue(); - self.changed(); - // Inside a batch the close kicks the scheduler once; a queue scan per insert is pure overhead. - if self.batch.get() == 0 { - self.start_next(); - } - item + /// Drop the row's policy reference. The host stays registered so an open + /// card or another row on the same host keeps its protection. + fn release_public_policy(item: &DownloadItem) { + if item.public_only() + && let Some(host) = url::Url::parse(&item.url()) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + { + crate::download_net::release_public_resolution(&host); } + } - /// "Finished • {size}" detail with the size measured off the GTK thread. - /// `measure_disk_usage` is a recursive scan that can stall the main loop on - /// large folders, so the label paints immediately with `size` (the engine's - /// figure, 0 for plain "Finished") and is refined when the measurement lands. - /// A row that is gone by then simply never gets the update. - fn set_finished_detail(&self, item: &DownloadItem, size: u64) { - item.set_detail(if size > 0 { - gettext("Finished • {size}").replace("{size}", &fmt_bytes(size)) - } else { - gettext("Finished") - }); - let weak = item.downgrade(); - let path = item.file_path(); - let inflight = Rc::clone(&self.sizing_inflight); - inflight.set(inflight.get() + 1); - glib::spawn_future_local(async move { - let measured = crate::file_names::path_size_async(path) - .await - .filter(|m| *m > 0); - // Balance the counter before any early return: the row may be - // gone or the path missing, but the task still finished. - inflight.set(inflight.get() - 1); - let (Some(item), Some(measured)) = (weak.upgrade(), measured) else { - return; - }; - item.set_detail(gettext("Finished • {size}").replace("{size}", &fmt_bytes(measured))); - }); + fn insert(self: &Rc, item: DownloadItem) -> DownloadItem { + Self::claim_public_policy(&item); + self.store.append(&item); + self.persist_queue(); + self.changed(); + // Inside a batch the close kicks the scheduler once; a queue scan per insert is pure overhead. + if self.batch.get() == 0 { + self.start_next(); } + item + } - fn insert_history( - self: &Rc, - url: String, - dir: String, - name: String, - progress: f64, - output_dir: Option, - video_source: Option, - ) { - let Ok(url) = normalize_url(&url) else { - tracing::warn!("skipping history entry with bad URL"); + /// "Finished • {size}" detail with the size measured off the GTK thread. + /// `measure_disk_usage` is a recursive scan that can stall the main loop on + /// large folders, so the label paints immediately with `size` (the engine's + /// figure, 0 for plain "Finished") and is refined when the measurement lands. + /// A row that is gone by then simply never gets the update. + fn set_finished_detail(&self, item: &DownloadItem, size: u64) { + item.set_detail(if size > 0 { + gettext("Finished • {size}").replace("{size}", &fmt_bytes(size)) + } else { + gettext("Finished") + }); + let weak = item.downgrade(); + let path = item.file_path(); + let inflight = Rc::clone(&self.sizing_inflight); + inflight.set(inflight.get() + 1); + glib::spawn_future_local(async move { + let measured = crate::file_names::path_size_async(path) + .await + .filter(|m| *m > 0); + // Balance the counter before any early return: the row may be + // gone or the path missing, but the task still finished. + inflight.set(inflight.get() - 1); + let (Some(item), Some(measured)) = (weak.upgrade(), measured) else { return; }; - if !sane_filename(&name) { - tracing::warn!("skipping invalid history entry for {name}"); - return; - } - if !std::path::Path::new(&dir).is_absolute() { - tracing::warn!("skipping history entry with relative destination"); - return; - } - let item = DownloadItem::new( - self.alloc_id_for(std::path::Path::new(&dir)), - &url, - &name, - &dir, - ); - item.set_progress(progress.clamp(0.0, 1.0)); - item.set_status(DownloadStatus::Done); - // Already trust-checked by the caller (must sit inside `dir`). - if let Some(folder) = output_dir { - item.set_output_dir(folder); - } - // Size off the final path, measured off-thread: the engine measured the - // pre-rename one, and folders sum contents. Paints "Finished" now, - // refines with the measured size when it lands. - self.set_finished_detail(&item, 0); - // Restored duplicates collapse too: the last Done row per URL wins. - self.drop_finished_duplicates(&url, item.id()); - // Keep the row's media identity: a restored video page must not fall - // back to the generic file icon (same page-URL guard as - // `restore_existing`). - if let Some(src) = video_source - && matches!(src, crate::media_types::VideoSource::Page { ref page_url, .. } if *page_url == url) - { - self.video_sources.borrow_mut().insert(item.id(), src); - } - self.insert(item); - } + item.set_detail(gettext("Finished • {size}").replace("{size}", &fmt_bytes(measured))); + }); + } - /// Whether finish/fail desktop notifications are enabled. - pub fn notifications_enabled(&self) -> bool { - self.settings.show_notifications() + fn insert_history( + self: &Rc, + url: String, + dir: String, + name: String, + progress: f64, + output_dir: Option, + video_source: Option, + ) { + let Ok(url) = normalize_url(&url) else { + tracing::warn!("skipping history entry with bad URL"); + return; + }; + if !sane_filename(&name) { + tracing::warn!("skipping invalid history entry for {name}"); + return; } - - /// Whether closing over active downloads notifies. - pub fn background_notifications_enabled(&self) -> bool { - self.settings.notify_background() + if !std::path::Path::new(&dir).is_absolute() { + tracing::warn!("skipping history entry with relative destination"); + return; } - - /// Configured folder, or the system Downloads folder when empty or - /// relative (a relative dir would resolve against the launcher CWD). - pub fn effective_download_dir(&self) -> String { - let configured = self.settings.download_dir(); - if !configured.is_empty() && std::path::Path::new(&configured).is_absolute() { - return configured; - } - glib::user_special_dir(glib::UserDirectory::Downloads) - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_else(|| "/tmp".to_string()) + let item = DownloadItem::new( + self.alloc_id_for(std::path::Path::new(&dir)), + &url, + &name, + &dir, + ); + item.set_progress(progress.clamp(0.0, 1.0)); + item.set_status(DownloadStatus::Done); + // Already trust-checked by the caller (must sit inside `dir`). + if let Some(folder) = output_dir { + item.set_output_dir(folder); + } + // Size off the final path, measured off-thread: the engine measured the + // pre-rename one, and folders sum contents. Paints "Finished" now, + // refines with the measured size when it lands. + self.set_finished_detail(&item, 0); + // Restored duplicates collapse too: the last Done row per URL wins. + self.drop_finished_duplicates(&url, item.id()); + // Keep the row's media identity: a restored video page must not fall + // back to the generic file icon (same page-URL guard as + // `restore_existing`). + if let Some(src) = video_source + && matches!(src, crate::media_types::VideoSource::Page { ref page_url, .. } if *page_url == url) + { + self.video_sources.borrow_mut().insert(item.id(), src); } + self.insert(item); + } + + /// Whether finish/fail desktop notifications are enabled. + pub fn notifications_enabled(&self) -> bool { + self.settings.show_notifications() + } + + /// Whether closing over active downloads notifies. + pub fn background_notifications_enabled(&self) -> bool { + self.settings.notify_background() + } - /// Simultaneous-download limit (at least 1). - pub fn max_concurrent(&self) -> usize { - self.settings.max_concurrent().max(1) as usize + /// Configured folder, or the system Downloads folder when empty or + /// relative (a relative dir would resolve against the launcher CWD). + pub fn effective_download_dir(&self) -> String { + let configured = self.settings.download_dir(); + if !configured.is_empty() && std::path::Path::new(&configured).is_absolute() { + return configured; } + glib::user_special_dir(glib::UserDirectory::Downloads) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| "/tmp".to_string()) + } - fn start_next(self: &Rc) { - // Startup restore runs asynchronously in production. Don't start a newly - // queued URL before persisted active rows have been inserted, or we can - // briefly exceed the configured concurrency limit. - if self.restore_state.get() != RestoreState::Ready || self.shutdown_started.get() { - return; - } - // Prune finished discard finalizers so the registry doesn't grow per removed row. - self.discards - .borrow_mut() - .retain(|_, pending| !pending.finalizer.is_finished()); - while self.running.borrow().len() + self.preparing.borrow().len() < self.max_concurrent() { - let next = self - .items() - // A discard still tearing down owns its destination: the row stays Queued until the reservation clears. - .find(|it| { - it.status() == DownloadStatus::Queued && !self.dest_reserved(&it.file_path()) - }); - match next { - Some(item) => { - if item.is_gallery() { - self.spawn_gallery(item); - } else { - self.spawn(item); - } + /// Simultaneous-download limit (at least 1). + pub fn max_concurrent(&self) -> usize { + self.settings.max_concurrent().max(1) as usize + } + + fn start_next(self: &Rc) { + // Startup restore runs asynchronously in production. Don't start a newly + // queued URL before persisted active rows have been inserted, or we can + // briefly exceed the configured concurrency limit. + if self.restore_state.get() != RestoreState::Ready || self.shutdown_started.get() { + return; + } + // Prune finished discard finalizers so the registry doesn't grow per removed row. + self.discards + .borrow_mut() + .retain(|_, pending| !pending.finalizer.is_finished()); + while self.running.borrow().len() + self.preparing.borrow().len() < self.max_concurrent() { + let next = self + .items() + // A discard still tearing down owns its destination: the row stays Queued until the reservation clears. + .find(|it| { + it.status() == DownloadStatus::Queued && !self.dest_reserved(&it.file_path()) + }); + match next { + Some(item) => { + if item.is_gallery() { + self.spawn_gallery(item); + } else { + self.spawn(item); } - None => break, } + None => break, } } + } - fn spawn(self: &Rc, item: DownloadItem) { - let id = item.id(); - let dest = item.file_path(); - let dest_dir = item.dest_dir().to_string(); - let url = item.url().to_string(); - let is_torrent = crate::torrent::is_torrent(&url); - let is_video = matches!( - self.video_source(id), - Some(crate::media_types::VideoSource::Page { .. }) - ); - let segments = self.segment_state.borrow().get(&id).cloned(); - let started = self.started.borrow().contains(&id); - // Reserve the target and a scheduler slot while filesystem validation is in flight. - // This keeps another queued row or a rapid retry from claiming the same path, - // and prevents async preparation from bypassing max_concurrent. - self.reserve_dest(&dest); - self.preparing.borrow_mut().insert(id); - let weak = Rc::downgrade(self); - let task_weak = weak.clone(); - let handle = glib::spawn_future_local(async move { - async move { - let worker_dest = dest.clone(); - let worker_dir = dest_dir.clone(); - let worker_recorded = item.output_dir().to_string(); - let worker_segments = segments.clone(); - let preparation = gio::spawn_blocking(move || { - let parent = worker_dest.parent(); - if let Some(parent) = parent { - // Re-check containment on the worker immediately before touching the - // path: a symlink planted after intake must not redirect the write. - match ensure_contained_parent(parent, &worker_dir) { - Ok(true) => {} - Ok(false) => { - return Err(gettext("Download path escapes the download folder")); - } - Err(error) => { - return Err(gettext("Could not create the download folder: {error}") - .replace("{error}", &error.to_string())); - } + fn spawn(self: &Rc, item: DownloadItem) { + let id = item.id(); + let dest = item.file_path(); + let dest_dir = item.dest_dir().to_string(); + let url = item.url().to_string(); + let is_torrent = crate::torrent::is_torrent(&url); + let is_video = matches!( + self.video_source(id), + Some(crate::media_types::VideoSource::Page { .. }) + ); + let segments = self.segment_state.borrow().get(&id).cloned(); + let started = self.started.borrow().contains(&id); + // Reserve the target and a scheduler slot while filesystem validation is in flight. + // This keeps another queued row or a rapid retry from claiming the same path, + // and prevents async preparation from bypassing max_concurrent. + self.reserve_dest(&dest); + self.preparing.borrow_mut().insert(id); + let weak = Rc::downgrade(self); + let task_weak = weak.clone(); + let handle = glib::spawn_future_local(async move { + async move { + let worker_dest = dest.clone(); + let worker_dir = dest_dir.clone(); + let worker_recorded = item.output_dir().to_string(); + let worker_segments = segments.clone(); + let preparation = gio::spawn_blocking(move || { + let parent = worker_dest.parent(); + if let Some(parent) = parent { + // Re-check containment on the worker immediately before touching the + // path: a symlink planted after intake must not redirect the write. + match ensure_contained_parent(parent, &worker_dir) { + Ok(true) => {} + Ok(false) => { + return Err(gettext("Download path escapes the download folder")); + } + Err(error) => { + return Err(gettext("Could not create the download folder: {error}") + .replace("{error}", &error.to_string())); } } + } - if is_torrent { - let dir = std::path::PathBuf::from(&worker_dir); - let recorded_path = std::path::PathBuf::from(&worker_recorded); - let (output, output_dir) = if worker_recorded.is_empty() { - (dir.clone(), None) - } else { - // Re-guard the recorded folder at spawn time; a symlink may have - // been planted after intake. Never follow it outside the destination. - let guarded = - crate::torrent::guard_output_folder(&dir, recorded_path.clone()); - let output_dir = (guarded != recorded_path) - .then(|| guarded.to_string_lossy().into_owned()); - if output_dir.is_none() && guarded.parent() != Some(dir.as_path()) { - std::fs::create_dir_all(&guarded).map_err(|error| { - gettext("Could not create the download folder: {error}") - .replace("{error}", &error.to_string()) - })?; - } - (guarded, output_dir) - }; - if worker_recorded.is_empty() { - std::fs::create_dir_all(&dir).map_err(|error| { + if is_torrent { + let dir = std::path::PathBuf::from(&worker_dir); + let recorded_path = std::path::PathBuf::from(&worker_recorded); + let (output, output_dir) = if worker_recorded.is_empty() { + (dir.clone(), None) + } else { + // Re-guard the recorded folder at spawn time; a symlink may have + // been planted after intake. Never follow it outside the destination. + let guarded = + crate::torrent::guard_output_folder(&dir, recorded_path.clone()); + let output_dir = (guarded != recorded_path) + .then(|| guarded.to_string_lossy().into_owned()); + if output_dir.is_none() && guarded.parent() != Some(dir.as_path()) { + std::fs::create_dir_all(&guarded).map_err(|error| { gettext("Could not create the download folder: {error}") .replace("{error}", &error.to_string()) })?; } - return Ok(PreparedSpawn::Torrent { - dest: output, - output_dir, - }); - } - if is_video { - return Ok(PreparedSpawn::Video); + (guarded, output_dir) + }; + if worker_recorded.is_empty() { + std::fs::create_dir_all(&dir).map_err(|error| { + gettext("Could not create the download folder: {error}") + .replace("{error}", &error.to_string()) + })?; } + return Ok(PreparedSpawn::Torrent { + dest: output, + output_dir, + }); + } + if is_video { + return Ok(PreparedSpawn::Video); + } - let mut drop_segments = false; - let mode = match worker_segments { - Some(state) => { - // Drop an invalid bitmap and stale partial together. Do both on - // the worker so resume validation does not block GTK. - let len = std::fs::metadata(&worker_dest) - .map(|metadata| metadata.len()) - .unwrap_or(0); - if len < state.prefix_len() || len > state.total { - let _ = std::fs::remove_file(&worker_dest); - drop_segments = true; - StartMode::Fresh - } else { - StartMode::Resume(state) - } + let mut drop_segments = false; + let mode = match worker_segments { + Some(state) => { + // Drop an invalid bitmap and stale partial together. Do both on + // the worker so resume validation does not block GTK. + let len = std::fs::metadata(&worker_dest) + .map(|metadata| metadata.len()) + .unwrap_or(0); + if len < state.prefix_len() || len > state.total { + let _ = std::fs::remove_file(&worker_dest); + drop_segments = true; + StartMode::Fresh + } else { + StartMode::Resume(state) } - // Existing destination is refused for a fresh row; never overwrite a - // file that may belong to the user or another application. - None => match std::fs::metadata(&worker_dest) { - Err(_) => StartMode::Fresh, - Ok(_) if !started => { - return Err(DEST_EXISTS.to_string()); - } - Ok(_) => StartMode::Single, - }, - }; - Ok(PreparedSpawn::Plain { - mode, - drop_segments, - }) + } + // Existing destination is refused for a fresh row; never overwrite a + // file that may belong to the user or another application. + None => match std::fs::metadata(&worker_dest) { + Err(_) => StartMode::Fresh, + Ok(_) if !started => { + return Err(DEST_EXISTS.to_string()); + } + Ok(_) => StartMode::Single, + }, + }; + Ok(PreparedSpawn::Plain { + mode, + drop_segments, }) - .await; + }) + .await; let Some(manager) = weak.upgrade() else { return; @@ -1793,7 +1793,7 @@ impl DownloadManager { } manager.spawn_prepared(current, prepared); } - .await; + .await; if let Some(manager) = task_weak.upgrade() { manager.preparation_handles.borrow_mut().remove(&id); } From 385daea1224d58a5f12fe357567e126012cb88eb Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:50:03 +0100 Subject: [PATCH 82/89] Align async preparation await chains --- src/download.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/download.rs b/src/download.rs index 9106be307..b78c70fee 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1730,7 +1730,7 @@ impl DownloadManager { drop_segments, }) }) - .await; + .await; let Some(manager) = weak.upgrade() else { return; @@ -1793,7 +1793,7 @@ impl DownloadManager { } manager.spawn_prepared(current, prepared); } - .await; + .await; if let Some(manager) = task_weak.upgrade() { manager.preparation_handles.borrow_mut().remove(&id); } From 1034caa11efec39ca087819d1249b9cd13512e3a Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:51:11 +0100 Subject: [PATCH 83/89] Fix rustfmt output for preparation task --- src/download.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/download.rs b/src/download.rs index b78c70fee..923c1e624 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1658,8 +1658,10 @@ impl DownloadManager { return Err(gettext("Download path escapes the download folder")); } Err(error) => { - return Err(gettext("Could not create the download folder: {error}") - .replace("{error}", &error.to_string())); + return Err(gettext( + "Could not create the download folder: {error}", + ) + .replace("{error}", &error.to_string())); } } } From 42e5892b80016fc6f842185c15701fd6cf244db4 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:05:08 +0100 Subject: [PATCH 84/89] Wait for asynchronous download preparation in regression tests --- src/download_tests.rs | 53 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 8108942a1..ec07ac754 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -66,6 +66,22 @@ fn wait_for_started(manager: &DownloadManager, id: u64, timeout: Duration) -> bo manager.started.borrow().contains(&id) } +/// Wait for async destination preparation to settle, including its local-task cleanup. +fn wait_for_preparation(manager: &DownloadManager, id: u64, timeout: Duration) -> bool { + let ctx = glib::MainContext::default(); + let deadline = std::time::Instant::now() + timeout; + while (manager.preparing.borrow().contains(&id) + || manager.preparation_handles.borrow().contains_key(&id)) + && std::time::Instant::now() < deadline + { + ctx.iteration(false); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + quiesce(&ctx); + !manager.preparing.borrow().contains(&id) + && !manager.preparation_handles.borrow().contains_key(&id) +} + /// Minimal persisted row for `restore_existing` fixtures (no id => pre-v3 allocate path). fn stored_row(url: &str, dest_dir: &str, filename: &str, status: DownloadStatus) -> StoredItem { StoredItem { @@ -2458,7 +2474,8 @@ fn enqueue_keeps_name_when_file_exists() { ) .expect("enqueue"); assert_eq!(item.filename(), "dup.bin"); - // insert -> start_next -> spawn run synchronously: the fresh row refuses the foreign file. + // Destination validation now runs asynchronously off the main context. + assert!(wait_for_preparation(&manager, item.id(), Duration::from_secs(15))); assert_eq!(item.status(), DownloadStatus::Failed); assert_eq!(item.detail(), crate::engine_msg::DEST_EXISTS); assert_eq!( @@ -2528,6 +2545,7 @@ fn restored_unstarted_row_refuses_existing_dest() { // Explicit never-started (new queue file): the bytes are foreign. stored.started = Some(false); let item = manager.restore_existing(&stored).expect("restore"); + assert!(wait_for_preparation(&manager, item.id(), Duration::from_secs(15))); assert_eq!(item.status(), DownloadStatus::Failed); assert_eq!(item.detail(), crate::engine_msg::DEST_EXISTS); assert_eq!( @@ -2696,11 +2714,12 @@ fn expand_playlist_rows_use_titled_subfolder() { ] .into_boxed_slice(), }; + let generation = manager.epoch.borrow().get(&parent.id()).copied().unwrap_or(0); let (added, total, skipped_by_host_limit) = glib::MainContext::default().block_on( crate::download::DownloadManager::expand_playlist_rows_async( Rc::downgrade(&manager), parent.id(), - 0, + generation, &parent, &pl, ), @@ -3651,6 +3670,10 @@ fn stale_pump_future_ignores_respawned_row() { .enqueue(&url, Some(&dest), Some("h.bin")) .unwrap_or_else(|e| abort(&server, &e)); let id = item.id(); + assert!( + wait_for_started(&manager, id, Duration::from_secs(15)), + "download preparation did not start the engine" + ); assert_eq!(item.status(), DownloadStatus::Downloading); // Let the first engine get going, then defer: park aborts it and a // second engine takes over the same row. @@ -3679,7 +3702,11 @@ fn stale_pump_future_ignores_respawned_row() { } manager.cancel(id); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - while manager.running.borrow().contains_key(&id) && std::time::Instant::now() < deadline { + while (manager.running.borrow().contains_key(&id) + || manager.preparing.borrow().contains(&id) + || manager.preparation_handles.borrow().contains_key(&id)) + && std::time::Instant::now() < deadline + { ctx.iteration(false); std::thread::sleep(std::time::Duration::from_millis(1)); } @@ -3821,13 +3848,15 @@ fn segmented_pause_resume_keeps_bytes() { .enqueue(&url, Some(&dest), Some("big.bin")) .unwrap_or_else(|e| abort(&server, &e)); let id = item.id(); - // Wait for one 1 MB piece (5%), then pause. Status check is in the loop condition - // so it cannot slip to Done before pause() with no await in between. + // Wait for asynchronous preparation to start the engine, then reach one + // 1 MB piece (5%) before pausing. A queued row is not evidence of completion. let mut waited = 0; - while item.status() == DownloadStatus::Downloading - && item.progress() < 0.05 - && waited < 2000 + while item.status() == DownloadStatus::Queued + || (item.status() == DownloadStatus::Downloading && item.progress() < 0.05) { + if waited >= 2000 { + break; + } glib::timeout_future(std::time::Duration::from_millis(5)).await; waited += 1; } @@ -4031,10 +4060,12 @@ fn shutdown_restart_resumes_segmented() { .unwrap_or_else(|e| abort(&server, &e)); let id = item.id(); let mut waited = 0; - while item.status() == DownloadStatus::Downloading - && item.progress() < 0.05 - && waited < 2000 + while item.status() == DownloadStatus::Queued + || (item.status() == DownloadStatus::Downloading && item.progress() < 0.05) { + if waited >= 2000 { + break; + } glib::timeout_future(std::time::Duration::from_millis(5)).await; waited += 1; } From d4895b33df2546f5549737e402db2c15f7437f6b Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:05:30 +0100 Subject: [PATCH 85/89] Wait for async preparation in resume failure test --- src/download_tests.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index ec07ac754..49bb800bf 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -4324,10 +4324,14 @@ fn restart_with_smaller_file_keeps_partial() { .restore_existing(&stored) .unwrap_or_else(|e| abort(&server, &e)); let id = item.id(); - // Drain the engine's pump future on this thread (see MAIN_LOOP_LOCK). + // Drain both asynchronous destination preparation and the engine/pump on this thread. let ctx = glib::MainContext::default(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); - while manager.running.borrow().contains_key(&id) && std::time::Instant::now() < deadline { + while (manager.running.borrow().contains_key(&id) + || manager.preparing.borrow().contains(&id) + || manager.preparation_handles.borrow().contains_key(&id)) + && std::time::Instant::now() < deadline + { ctx.iteration(false); std::thread::sleep(std::time::Duration::from_millis(1)); } From 1cdf5c36b8e569f1f65e3bfb8fcacd4a5173bc79 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:06:33 +0100 Subject: [PATCH 86/89] Format async preparation regression tests --- src/download_tests.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 49bb800bf..a53023136 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -2475,7 +2475,11 @@ fn enqueue_keeps_name_when_file_exists() { .expect("enqueue"); assert_eq!(item.filename(), "dup.bin"); // Destination validation now runs asynchronously off the main context. - assert!(wait_for_preparation(&manager, item.id(), Duration::from_secs(15))); + assert!(wait_for_preparation( + &manager, + item.id(), + Duration::from_secs(15) + )); assert_eq!(item.status(), DownloadStatus::Failed); assert_eq!(item.detail(), crate::engine_msg::DEST_EXISTS); assert_eq!( @@ -2714,7 +2718,12 @@ fn expand_playlist_rows_use_titled_subfolder() { ] .into_boxed_slice(), }; - let generation = manager.epoch.borrow().get(&parent.id()).copied().unwrap_or(0); + let generation = manager + .epoch + .borrow() + .get(&parent.id()) + .copied() + .unwrap_or(0); let (added, total, skipped_by_host_limit) = glib::MainContext::default().block_on( crate::download::DownloadManager::expand_playlist_rows_async( Rc::downgrade(&manager), From b7c9eaf6a170ce8b5fb22dba0be680e7d72726a8 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:12:16 +0100 Subject: [PATCH 87/89] Fix remaining rustfmt call formatting --- src/download_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index a53023136..1792f0a1f 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -2549,7 +2549,11 @@ fn restored_unstarted_row_refuses_existing_dest() { // Explicit never-started (new queue file): the bytes are foreign. stored.started = Some(false); let item = manager.restore_existing(&stored).expect("restore"); - assert!(wait_for_preparation(&manager, item.id(), Duration::from_secs(15))); + assert!(wait_for_preparation( + &manager, + item.id(), + Duration::from_secs(15) + )); assert_eq!(item.status(), DownloadStatus::Failed); assert_eq!(item.detail(), crate::engine_msg::DEST_EXISTS); assert_eq!( From 82d81567a6626d3e0be220db2cb7eefcbd56bed5 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:20:46 +0100 Subject: [PATCH 88/89] Wait for async preparation and replacement engine in tests --- src/download_tests.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index 1792f0a1f..fdff94d30 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -2722,6 +2722,10 @@ fn expand_playlist_rows_use_titled_subfolder() { ] .into_boxed_slice(), }; + assert!( + wait_for_preparation(&manager, parent.id(), Duration::from_secs(15)), + "playlist parent preparation did not settle" + ); let generation = manager .epoch .borrow() @@ -3692,7 +3696,22 @@ fn stale_pump_future_ignores_respawned_row() { // second engine takes over the same row. std::thread::sleep(std::time::Duration::from_millis(500)); manager.defer(id); - assert_eq!(item.status(), DownloadStatus::Downloading); + assert_eq!(item.status(), DownloadStatus::Queued); + // Preparation is now asynchronous; wait until the replacement engine owns + // the row before checking that the stale pump cannot overwrite its state. + let restart_deadline = std::time::Instant::now() + Duration::from_secs(15); + while (!manager.running.borrow().contains_key(&id) + || item.status() != DownloadStatus::Downloading) + && std::time::Instant::now() < restart_deadline + { + glib::MainContext::default().iteration(false); + std::thread::sleep(Duration::from_millis(1)); + } + assert!( + manager.running.borrow().contains_key(&id) + && item.status() == DownloadStatus::Downloading, + "replacement engine did not start after defer" + ); // Let the aborted engine die and the hanging one settle, then pump: // the stale future's tail runs here and must stay silent. std::thread::sleep(std::time::Duration::from_millis(500)); From 1208e8ddd964ba9b7b725c2fcdf4b5e5c6a891d4 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:25:03 +0100 Subject: [PATCH 89/89] Fix rustfmt formatting in deferred pump test --- src/download_tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/download_tests.rs b/src/download_tests.rs index fdff94d30..ca709b1bb 100644 --- a/src/download_tests.rs +++ b/src/download_tests.rs @@ -3708,8 +3708,7 @@ fn stale_pump_future_ignores_respawned_row() { std::thread::sleep(Duration::from_millis(1)); } assert!( - manager.running.borrow().contains_key(&id) - && item.status() == DownloadStatus::Downloading, + manager.running.borrow().contains_key(&id) && item.status() == DownloadStatus::Downloading, "replacement engine did not start after defer" ); // Let the aborted engine die and the hanging one settle, then pump: