mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-10 03:27:05 +00:00
feat(queue): implement backend-driven download queue coordinator
This commit replaces the frontend-imperative download dispatcher with a centralized backend `QueueManager`. It acts as the sole concurrency gatekeeper using a single `tokio::sync::Semaphore` across all download paths (aria2 RPC, native HTTP, yt-dlp media). Backend Changes: - **queue**: Added `QueueManager` to manage an ordered `VecDeque` of tasks, semaphore permits, and retirement debt (CAS resize). - **commands**: Replaced direct start commands with `enqueue_download`, `enqueue_many`, `move_in_queue`, and `remove_from_queue`. - **ipc**: Exported `DownloadStateEvent` and `QueueDirection` to the frontend. - **tests**: Added 11 integration tests covering idle-parking, idempotent releases, CAS underflow prevention, and gid-completion races. Frontend Changes: - **store**: Made `useDownloadStore` reactive to the backend via the `download-state` event. - **store**: Removed `processQueue` and introduced `pendingOrder` to track the accurate sequence of queued items. - **ui**: Updated `DownloadItem` with queue visuals (clock icon, position badge). - **ui**: Added Move Up/Down controls to interact with the backend queue reordering API.
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
use firelink_lib::queue::{QueueManager, SpawnPayload, TaskKind, QueuedTask};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use tauri::test::{mock_builder, mock_context, noop_assets};
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// A fake spawner that records calls and lets tests gate sidecar lifetime.
|
||||
struct CountingSpawner {
|
||||
add_uri_calls: AtomicUsize,
|
||||
media_calls: AtomicUsize,
|
||||
native_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl CountingSpawner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
add_uri_calls: AtomicUsize::new(0),
|
||||
media_calls: AtomicUsize::new(0),
|
||||
native_calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
|
||||
self.add_uri_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(format!("gid-{}", self.add_uri_calls.load(Ordering::SeqCst)))
|
||||
}
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
self.media_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
async fn run_native(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
self.native_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a QueueManager with a fake spawner. Tauri's mock AppHandle is needed
|
||||
/// for emit; we construct the minimal mock.
|
||||
fn make_manager(capacity: usize) -> (QueueManager<tauri::test::MockRuntime>, Arc<CountingSpawner>) {
|
||||
let app = mock_builder()
|
||||
.build(mock_context(noop_assets()))
|
||||
.expect("mock app");
|
||||
let spawner = Arc::new(CountingSpawner::new());
|
||||
let mgr = QueueManager::test_new(app.handle().clone(), capacity, spawner.clone());
|
||||
(mgr, spawner)
|
||||
}
|
||||
|
||||
fn sample_task(id: &str) -> QueuedTask {
|
||||
QueuedTask {
|
||||
id: id.to_string(),
|
||||
kind: TaskKind::Native,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_appends_to_pending_and_emits_queued() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
mgr.push(sample_task("a")).await;
|
||||
mgr.push(sample_task("b")).await;
|
||||
let order = mgr.pending_order().await;
|
||||
assert_eq!(order, vec!["a".to_string(), "b".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_permit_is_idempotent() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
let permit = mgr.acquire_permit().await;
|
||||
mgr.park_permit("a", permit).await;
|
||||
let avail_before = mgr.available_permits();
|
||||
mgr.release_permit("a").await; // first release: frees the slot
|
||||
let avail_after_first = mgr.available_permits();
|
||||
mgr.release_permit("a").await; // second release: no-op
|
||||
let avail_after_second = mgr.available_permits();
|
||||
assert_eq!(avail_after_first - avail_before, 1);
|
||||
assert_eq!(avail_after_second, avail_after_first, "second release must not free another slot");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_then_pop_front_drains_fifo() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
mgr.push(sample_task("a")).await;
|
||||
mgr.push(sample_task("b")).await;
|
||||
let first = mgr.pop_front().await.expect("some task");
|
||||
let second = mgr.pop_front().await.expect("some task");
|
||||
assert_eq!(first.id, "a");
|
||||
assert_eq!(second.id, "b");
|
||||
assert!(mgr.pop_front().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatcher_parks_when_idle_no_busy_spin() {
|
||||
let (mgr, _spawner) = make_manager(3);
|
||||
let mgr_arc = Arc::new(mgr);
|
||||
let handle = {
|
||||
let mgr_clone = Arc::clone(&mgr_arc);
|
||||
tokio::spawn(async move { mgr_clone.run_dispatcher().await })
|
||||
};
|
||||
|
||||
// Queue is empty. Sleep long enough for any busy-spin to drain permits.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// No permit should have been acquired while idle.
|
||||
assert_eq!(
|
||||
mgr_arc.available_permits(), 3,
|
||||
"dispatcher must not acquire permits when pending is empty"
|
||||
);
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cas_retirement_never_underflows() {
|
||||
let (mgr, _spawner) = make_manager(3);
|
||||
let mgr_arc = Arc::new(mgr);
|
||||
let handle = {
|
||||
let mgr_clone = Arc::clone(&mgr_arc);
|
||||
tokio::spawn(async move { mgr_clone.run_dispatcher().await })
|
||||
};
|
||||
|
||||
// Repeatedly toggle capacity down while idle. A fetch_sub-based impl
|
||||
// would underflow to usize::MAX on the second set_capacity(1).
|
||||
for _ in 0..5 {
|
||||
mgr_arc.set_capacity(3);
|
||||
mgr_arc.set_capacity(1);
|
||||
mgr_arc.set_capacity(3);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
let debt = mgr_arc.slots_to_retire_load();
|
||||
assert!(
|
||||
debt <= 2,
|
||||
"retirement debt must stay within [0, capacity-target], got {debt}"
|
||||
);
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grow_releases_immediately_and_dispatches_waiting_tasks() {
|
||||
let (mgr, spawner) = make_manager(2);
|
||||
let mgr_arc = Arc::new(mgr);
|
||||
|
||||
for i in 0..4 {
|
||||
mgr_arc.push(sample_task(&format!("t{i}"))).await;
|
||||
}
|
||||
let handle = {
|
||||
let mgr_clone = Arc::clone(&mgr_arc);
|
||||
tokio::spawn(async move { mgr_clone.run_dispatcher().await })
|
||||
};
|
||||
|
||||
// Give dispatcher time to dispatch 2 (capacity) of the 4.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let native_after_initial = spawner.native_calls.load(Ordering::SeqCst);
|
||||
assert_eq!(native_after_initial, 2, "only capacity-many tasks dispatch initially");
|
||||
|
||||
// Grow to 4; the remaining 2 should dispatch.
|
||||
mgr_arc.set_capacity(4);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let native_after_grow = spawner.native_calls.load(Ordering::SeqCst);
|
||||
assert_eq!(native_after_grow, 4, "grow must allow the waiting tasks to dispatch");
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shrink_converges_to_target_without_killing_active() {
|
||||
let (mgr, spawner) = make_manager(4);
|
||||
let mgr_arc = Arc::new(mgr);
|
||||
|
||||
for i in 0..6 {
|
||||
mgr_arc.push(sample_task(&format!("t{i}"))).await;
|
||||
}
|
||||
let handle = {
|
||||
let mgr_clone = Arc::clone(&mgr_arc);
|
||||
tokio::spawn(async move { mgr_clone.run_dispatcher().await })
|
||||
};
|
||||
|
||||
// Let 4 dispatch (capacity).
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(spawner.native_calls.load(Ordering::SeqCst), 4);
|
||||
|
||||
// Shrink to 2 while 4 are "active" (permits parked).
|
||||
mgr_arc.set_capacity(2);
|
||||
|
||||
// Release active permits. Debt is 2; two releases retire both.
|
||||
mgr_arc.release_permit("t0").await;
|
||||
mgr_arc.release_permit("t1").await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Debt was 2; two releases retired both. The 2 remaining pending tasks
|
||||
// (t4, t5) can now dispatch since debt is 0 and slots freed.
|
||||
assert_eq!(
|
||||
spawner.native_calls.load(Ordering::SeqCst),
|
||||
6,
|
||||
"shrink converges: after debt exhausted, remaining pending dispatch"
|
||||
);
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
fn aria2_task(id: &str) -> QueuedTask {
|
||||
QueuedTask {
|
||||
id: id.to_string(),
|
||||
kind: TaskKind::Aria2,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aria2_permit_survives_rpc_return() {
|
||||
let (mgr, spawner) = make_manager(1);
|
||||
let mgr_arc = Arc::new(mgr);
|
||||
mgr_arc.push(aria2_task("a")).await;
|
||||
let handle = {
|
||||
let mgr_clone = Arc::clone(&mgr_arc);
|
||||
tokio::spawn(async move { mgr_clone.run_dispatcher().await })
|
||||
};
|
||||
|
||||
// Dispatcher acquires the single permit, parks it, calls add_uri (returns
|
||||
// instantly). The permit must STAY parked.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
mgr_arc.available_permits(), 0,
|
||||
"permit must remain parked while aria2 download is notionally running"
|
||||
);
|
||||
|
||||
// Now simulate aria2 completion: release_permit frees the slot.
|
||||
mgr_arc.release_permit("a").await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(mgr_arc.available_permits(), 1, "release frees the parked permit");
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gid_completion_before_store_buffers_and_reconciles() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let (mgr, _spawner) = make_manager(1);
|
||||
let mgr_arc = Arc::new(mgr);
|
||||
mgr_arc.push(aria2_task("a")).await;
|
||||
let handle = {
|
||||
let mgr_clone = Arc::clone(&mgr_arc);
|
||||
tokio::spawn(async move { mgr_clone.run_dispatcher().await })
|
||||
};
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// The dispatcher called add_uri and got "gid-1", then remember_gid stored it.
|
||||
// Simulate a completion arriving for an UNKNOWN gid first:
|
||||
mgr_arc
|
||||
.handle_aria2_event("gid-unknown", PendingOutcome::Complete)
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// Permit still parked (gid-unknown is not ours).
|
||||
assert_eq!(mgr_arc.available_permits(), 0);
|
||||
|
||||
// Now store gid-1 -> "a" via remember_gid. The buffered gid-unknown stays
|
||||
// buffered (different gid). Release via the real gid:
|
||||
mgr_arc.release_permit("a").await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(mgr_arc.available_permits(), 1);
|
||||
|
||||
// Push another aria2 task; its gid will be "gid-2".
|
||||
mgr_arc.push(aria2_task("b")).await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
mgr_arc.release_permit("b").await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn move_up_down_reorders_pending() {
|
||||
use firelink_lib::ipc::QueueDirection;
|
||||
|
||||
let (mgr, _spawner) = make_manager(3);
|
||||
let mgr_arc = Arc::new(mgr);
|
||||
mgr_arc.push(sample_task("a")).await;
|
||||
mgr_arc.push(sample_task("b")).await;
|
||||
mgr_arc.push(sample_task("c")).await;
|
||||
|
||||
mgr_arc.move_in_queue("c", QueueDirection::Down).await;
|
||||
assert_eq!(mgr_arc.pending_order().await, vec!["a", "b", "c"]);
|
||||
|
||||
mgr_arc.move_in_queue("c", QueueDirection::Up).await;
|
||||
assert_eq!(mgr_arc.pending_order().await, vec!["a", "c", "b"]);
|
||||
|
||||
mgr_arc.move_in_queue("a", QueueDirection::Down).await;
|
||||
assert_eq!(mgr_arc.pending_order().await, vec!["c", "a", "b"]);
|
||||
|
||||
mgr_arc.move_in_queue("c", QueueDirection::Up).await;
|
||||
assert_eq!(mgr_arc.pending_order().await, vec!["c", "a", "b"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_fires_on_push_and_release() {
|
||||
let (mgr, _spawner) = make_manager(1);
|
||||
let mgr_arc = Arc::new(mgr);
|
||||
|
||||
let permit = mgr_arc.acquire_permit().await;
|
||||
mgr_arc.park_permit("a", permit).await;
|
||||
|
||||
let handle = {
|
||||
let mgr_clone = Arc::clone(&mgr_arc);
|
||||
tokio::spawn(async move { mgr_clone.run_dispatcher().await })
|
||||
};
|
||||
|
||||
mgr_arc.push(sample_task("x")).await;
|
||||
let dispatched = timeout(
|
||||
Duration::from_millis(150),
|
||||
async {
|
||||
loop {
|
||||
if mgr_arc.available_permits() == 0 {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
},
|
||||
).await;
|
||||
assert!(dispatched.is_ok(), "push must wake the idle dispatcher");
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
Reference in New Issue
Block a user