From dd7c2196fef97ef77fbcd551e892543414d848be Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 25 Jul 2026 16:50:00 +0330 Subject: [PATCH] fix(queue): dispatch admitted downloads concurrently --- src-tauri/src/queue.rs | 10 +++- src-tauri/tests/queue_manager.rs | 91 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index f4e6a9a..ee17e6d 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -1199,7 +1199,15 @@ impl QueueManager { loop { let notified = self.notify.notified(); if let Some((permit, task)) = self.try_admit_next_task().await { - Arc::clone(&self).dispatch_one(permit, task).await; + // Admission owns the global and per-queue reservation before + // this task is spawned. Keep the dispatcher free to admit + // other work while an Aria2 addUri call is retrying or a + // control RPC is slow; dispatch_one retains the reservation + // until that lifecycle reaches its own terminal path. + let manager = Arc::clone(&self); + tauri::async_runtime::spawn(async move { + manager.dispatch_one(permit, task).await; + }); } else { // This covers both an empty pending list and the case where // all queue/global capacity is occupied. The notification diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index 105aea9..c505ec8 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -27,6 +27,13 @@ struct DelayedAria2Spawner { remove_uri_calls: AtomicUsize, } +struct BlockingAria2Spawner { + first_started: tokio::sync::Notify, + second_started: tokio::sync::Notify, + release_first: tokio::sync::Notify, + add_uri_calls: AtomicUsize, +} + struct FailFirstAria2Spawner { add_uri_calls: AtomicUsize, fail_first: std::sync::atomic::AtomicBool, @@ -56,6 +63,49 @@ impl DelayedAria2Spawner { } } +impl BlockingAria2Spawner { + fn new() -> Self { + Self { + first_started: tokio::sync::Notify::new(), + second_started: tokio::sync::Notify::new(), + release_first: tokio::sync::Notify::new(), + add_uri_calls: AtomicUsize::new(0), + } + } +} + +#[async_trait::async_trait] +impl SidecarSpawner for BlockingAria2Spawner { + async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result { + let call = self.add_uri_calls.fetch_add(1, Ordering::SeqCst) + 1; + match call { + 1 => { + self.first_started.notify_one(); + self.release_first.notified().await; + Ok("gid-first".to_string()) + } + 2 => { + self.second_started.notify_one(); + Ok("gid-second".to_string()) + } + _ => Ok(format!("gid-{call}")), + } + } + + async fn remove_uri(&self, _gid: &str) -> Result<(), String> { + Ok(()) + } + + async fn run_media( + &self, + _id: &str, + _payload: &SpawnPayload, + _generation: u64, + ) -> Result<(), String> { + unreachable!("media is not used by blocking aria2 tests") + } +} + #[async_trait::async_trait] impl SidecarSpawner for DelayedAria2Spawner { async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result { @@ -820,6 +870,47 @@ async fn dispatcher_skips_a_full_front_queue_for_later_eligible_work() { dispatcher.abort(); } +#[tokio::test] +async fn dispatcher_admits_next_task_while_first_add_uri_is_blocked() { + let app = mock_builder() + .build(mock_context(noop_assets())) + .expect("mock app"); + let spawner = Arc::new(BlockingAria2Spawner::new()); + let manager = Arc::new(QueueManager::test_new( + app.handle().clone(), + 2, + spawner.clone(), + )); + manager.push(aria2_task("blocked-first")).await.unwrap(); + manager.push(aria2_task("admitted-second")).await.unwrap(); + + let first_started = spawner.first_started.notified(); + let dispatcher = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { manager.run_dispatcher().await }) + }; + timeout(Duration::from_secs(1), first_started) + .await + .expect("the first addUri should start"); + + timeout(Duration::from_secs(1), spawner.second_started.notified()) + .await + .expect("a blocked addUri must not serialize admission of the next task"); + + spawner.release_first.notify_one(); + timeout(Duration::from_secs(1), async { + while manager.aria2_gid_for_download("blocked-first").is_none() + || manager.aria2_gid_for_download("admitted-second").is_none() + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("both admitted tasks should finish dispatching"); + + dispatcher.abort(); +} + #[tokio::test] async fn dispatcher_rotates_eligible_queues_without_starving_later_work() { let (manager, spawner) = make_manager(1);