mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 12:29:29 +00:00
bb618aef7d
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.
330 lines
11 KiB
Rust
330 lines
11 KiB
Rust
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();
|
|
}
|