Skip to content

Transfer queue: bounded concurrency, pause/resume, retry, and throttling - #13

Merged
jhd3197 merged 7 commits into
mainfrom
dev
Aug 2, 2026
Merged

Transfer queue: bounded concurrency, pause/resume, retry, and throttling#13
jhd3197 merged 7 commits into
mainfrom
dev

Conversation

@jhd3197

@jhd3197 jhd3197 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

The "transfer list" was, until now, exactly that — a list. Kick off ten downloads and all ten stampede the connection at once, with no way to pause one, no way to nudge another to the front, and a failed transfer just sat there red until you re-dragged the file. This PR turns that list into an actual queue with a scheduler behind it: a bounded number of transfers run at a time (default 3, adjustable 1–32) while the rest wait in a visible, reorderable FIFO. On top of the scheduler it adds per-transfer pause/resume, manual and automatic retry, and a global bandwidth throttle. The one design decision worth calling out: resuming a paused transfer re-runs its file from byte 0 rather than seeking — an honest choice that works identically across all eleven backends instead of depending on per-backend range support, and it lives in the shared copy loops so every backend gets the whole feature set for free.

Highlights

  • Transfers now run a few at a time instead of all at once — the default is 3, configurable from 1 to 32, with everything else waiting in a queue that shows each row's position (#2 in queue).
  • Pause and resume any transfer, individually or all at once from the panel header. A running transfer parks at the next chunk boundary; resuming it restarts that file cleanly.
  • Transfers that fail on a transient network or timeout error retry themselves automatically with backoff, and any failed or canceled transfer gets a one-click Retry button.
  • A global bandwidth limit (in KiB/s, 0 = unlimited) caps total throughput across all active transfers, adjustable live from the panel or from Settings.
  • The queue panel gained per-row pause/resume/retry controls, up/down reorder arrows for waiting transfers, a pause-all toggle, and an inline throttle input.
  • Concurrency and bandwidth defaults live under Settings → Transfers, persist across restarts, and apply live to in-flight transfers without a relaunch.
Technical changes
  • TransferManager (transfer.rs) gained a real scheduler: a waiting VecDeque FIFO plus a tokio Semaphore for the concurrency bound. admit() pops a transfer only once it is at the FIFO front, the pause-all gate is open, and a permit is free — otherwise it waits as Queued and re-checks on a queue_gen watch channel.
  • is_my_turn implements strict FIFO with one exception: per-transfer-paused rows are skipped so a paused transfer never head-of-line-blocks the rest of the queue; pause-all blocks everyone.
  • PauseGate is a watch-channel gate used both for the manager-wide pause-all and for each transfer's own pause. checkpoint() is invoked in every backend copy loop — it first draws the chunk's bytes from the bandwidth bucket, then, if either gate is closed, parks until both reopen and returns a RestartFromPause marker error so the runner re-runs the file from byte 0.
  • The two large inline per-backend match blocks in start_download/start_upload were extracted into shared run_download_task/run_upload_task runners plus dispatch_download/dispatch_upload. The runner loop handles both resume-from-pause restarts and auto-retry, and the same runners back Phase 3 manual retry.
  • Auto-retry: transient failures (Network/Timeout, classified via classify_message, now pub(crate)) retry up to twice with 5s then 20s backoff; a retry_attempt field plus a "retrying in Ns (attempt N/3)" error string drive the panel's mid-retry rendering. The concurrency permit is deliberately held through the backoff sleep so a retrying transfer keeps its slot.
  • TokenBucket (Phase 4) is one global token bucket (AtomicU64 rate, tokio Instant timestamps) shared by every copy loop, so the cap is split across active transfers rather than applied per transfer. It draws in tranches capped at one second of rate (min 64 KiB) so a large chunk is charged in full while a 1 KiB file never waits a whole window, and each internal sleep is capped at 250ms so a live rate change takes effect promptly.
  • Manual retry re-enqueues under the same transfer id using the original, already-policy-resolved source/destination captured in RetryInfo (so the panel row resets in place). set_concurrency grows by adding permits immediately and shrinks by forgetting permits as running transfers release them, so in-flight transfers are never killed to satisfy a lowered bound.
  • New Tauri commands — transfer_pause, transfer_resume, transfer_retry, transfer_move, transfer_pause_all, transfer_resume_all, transfer_set_concurrency, transfer_set_throttle, transfer_queue_state — registered in lib.rs. cancel now takes the AppHandle, removes the id from the waiting queue, and emits transfer://updated (there is no dedicated cancel event).
  • lib.rs setup applies the persisted transferConcurrency/transferThrottleKbps settings to the manager at startup so the queue starts from the user's saved values.
  • Frontend IPC (ipc.ts): the new transfer://queue event and transfer_queue_state snapshot are wired via onTransferQueue and a typed TransferQueueState; onTransferEvent learned the updated kind. transfersStore now tracks queue/pausedAll/concurrency/throttleKbps, treats updated like progress (replaces the row, never toasts), and clearFinished keeps paused rows since they are still in flight.
  • TransferQueue.tsx: per-row pause/resume/retry buttons, ↑/↓ reorder arrows for waiting rows, queue-position labels, a pause-all header toggle, and a ThrottleInput that commits on blur or Enter; rows mid-auto-retry render their status text in the warning color instead of danger.
  • notifications.ts: the batch tracker clears canceled rows from the active set on updated (canceled emits no terminal event, so without this the batch would never flush) while leaving paused rows in flight.
  • Settings.tsx gained "Concurrent transfers" (1–32) and "Bandwidth limit" (KiB/s) fields under Transfers; settingsStore persists both and live-applies each change through the matching ipc setter.

jhd3197 added 7 commits August 1, 2026 23:44
Plan 17 Phase 1: Queued now genuinely waits. TransferManager gains a FIFO
(waiting deque + watch-channel generation), a semaphore bounding concurrent
transfers (default 3, live-adjustable via transfer_set_concurrency and the
transferConcurrency setting), FIFO admission with a manager-level pause-all
gate, transfer_move for queue priority, and a transfer://queue event plus
transfer_queue_state for the panel. cancel removes waiting ids from the FIFO
and reports via the new transfer://updated event.
Plan 17 Phase 2: a PauseGate per transfer (watch-channel, no missed wakeups)
plus a shared checkpoint() called at every chunk boundary in all 26 backend
copy loops. Pause parks the task at the boundary; resume unwinds the loop
with RestartFromPause and the runner re-runs the file from byte 0 — honest
on every backend, no seek support needed. Admission skips per-transfer-
paused rows so a paused item never head-of-line blocks the queue; pause-all
and per-transfer pauses compose (resume requires both gates open). New
transfer_pause/transfer_resume commands; Paused status; virtualfs hydration
and foldersync's wait_for_transfers treat Paused as in-flight.
Plan 17 Phase 3: errors are classified with Plan 12's structured kinds
(classify_message, now pub(crate)); Network/Timeout failures auto-retry up
to 2 times with 5s/20s backoff — the row shows "retrying in Ns (attempt
N/3)" and the concurrency slot is held through backoff so a retrying
transfer keeps its place. Auth/Permission/NotFound never auto-retry. A
manual transfer_retry re-enqueues Error/Canceled rows with their original
policy-resolved source/destination (stored as RetryInfo at enqueue), same
id, reopening a pause gate left closed by cancel-while-paused. The spawn
closures are extracted into shared run_download_task/run_upload_task +
dispatch_download/dispatch_upload so enqueue and retry share one runner.
Plan 17 Phase 4: a shared token bucket every copy loop draws from per chunk
inside checkpoint() — one bucket, so the cap is split across active
transfers rather than per-transfer. Grants are capped at one second of rate
(min 64 KiB) so tiny files never wait a full window; sleeps are capped at
250 ms so a live transfer_set_throttle takes effect on the next chunk.
transferThrottleKbps persists via the settings table and is applied at
startup alongside transferConcurrency; the queue-state event now reports
the live rate.
…ettings

Plan 17 frontend: TransferQueue header gains pause-all/resume-all, active/
queued counts, and a live global throttle input (KiB/s, 0 = unlimited).
Rows gain pause/resume (transferring/paused), retry (error/canceled), and
move up/down with FIFO position for queued rows; mid-auto-retry rows render
"retrying in Ns (attempt N/3)" as a warning. Settings gains Concurrent
transfers + Bandwidth limit, persisted via the settings table and applied
live through transfer_set_concurrency/transfer_set_throttle. The batch
notification tracker consumes transfer://updated so canceled rows no longer
wedge a batch; paused rows still count as in flight.
Plan 17 verification: 8 Rust unit tests (token-bucket pacing under a paused
clock incl. full-amount charging for large chunks, tiny-file floor; pause
gate park/unpark; checkpoint → RestartFromPause; FIFO skip-paused + pause-all
precedence; concurrency grow/shrink; transient vs permanent retry
classification). Testing surfaced two real bugs, both fixed here: PauseGate
dropped watch::channel's initial receiver so send() silently failed (pause
would never have engaged in production), and TokenBucket charged only one
rate-second tranche per acquire (large chunks burst past the cap).
scripts/verify-transfers.mjs + a mock transfer-queue engine drive the panel
headlessly (25 checks: counts, positions, pause/resume/pause-all toggles,
retry/move/throttle command dispatch, retrying-in-Ns rendering); the
notifications harness still passes. tokio test-util added as a dev-dep.
Copilot AI review requested due to automatic review settings August 2, 2026 14:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR turns Faro’s transfer “list” into a real, user-controllable transfer queue spanning both the Rust backend scheduler and the React/Zustand UI, adding bounded concurrency, pause/resume, retry, and a global bandwidth throttle.

Changes:

  • Backend: add FIFO + semaphore scheduler, pause gates, auto/manual retry, and token-bucket throttling, with new Tauri commands/events.
  • Frontend: wire new transfer queue IPC/events into Zustand stores and update the transfer panel UI (pause/resume/retry/reorder/throttle + settings).
  • Add mock + headless verification script to exercise the transfer queue UI without the Rust backend.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/stores/transfersStore.ts Track queue state in the store and wire new transfer queue listeners + commands.
src/stores/settingsStore.ts Persist and live-apply transfer concurrency and throttle settings.
src/mock/transfers.ts Mock transfer-queue engine for headless UI verification.
src/mock/demo.ts Expose transfer mock seeding/call inspection via window.__demo.
src/mock/core.ts Route new transfer-queue IPC commands to the mock engine.
src/lib/types.ts Add paused status, retryAttempt, and TransferQueueState type.
src/lib/notifications.ts Treat updated events to avoid wedged batch notifications on cancel.
src/lib/ipc.ts Add new transfer queue IPC commands and transfer://queue listener.
src/components/TransferQueue.tsx UI controls for pause/resume/retry/reorder + throttle input + queue position labels.
src/components/Settings.tsx Settings UI for concurrent transfers + bandwidth limit.
src-tauri/src/virtualfs/mod.rs Treat Paused as “still in progress” for hydration waits.
src-tauri/src/transfer.rs Implement scheduler, pause gates, retry/backoff, and global throttling + tests.
src-tauri/src/lib.rs Apply persisted transfer queue settings at startup and register new commands.
src-tauri/src/foldersync.rs Treat Paused transfers as still pending in sync waits.
src-tauri/src/error.rs Make classify_message usable for transfer retry classification.
src-tauri/src/commands.rs Add new Tauri commands for transfer queue controls and queue snapshot.
src-tauri/Cargo.toml Add tokio test-util for paused-clock tests.
scripts/verify-transfers.mjs Headless mock UI verification script for transfer queue behavior.
docs/plans/ROADMAP.md Mark Plan 17 as built and document remaining smoke run step.
Suppressed comments (2)

src-tauri/src/transfer.rs:2635

  • Same issue as run_download_task: after admission, the queue isn’t bumped until completion, so subsequent queued uploads may never re-check their turn and concurrency > 1 won’t actually take effect.
    let Some(_permit) = mgr.admit(&id).await else {
        return;
    };
    let mut auto_retries = 0u32;

src-tauri/src/commands.rs:838

  • Same as concurrency: the throttle is live-applied but this command doesn’t emit transfer://queue, so the frontend store’s throttleKbps can remain stale (especially noticeable when adjusting from Settings or in another window).
/// Live-adjust the global bandwidth cap in KiB/s (0 = unlimited). Takes
/// effect on the next chunk of every active transfer (Plan 17 Phase 4).
#[tauri::command]
pub async fn transfer_set_throttle(
    kbps: u64,
    state: State<'_, AppState>,
) -> Result<(), String> {
    state.transfers.set_throttle_kbps(kbps);
    Ok(())

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src-tauri/src/transfer.rs
Comment on lines +2589 to +2592
let Some(_permit) = mgr.admit(&id).await else {
return;
};
let mut auto_retries = 0u32;
Comment thread src-tauri/src/transfer.rs
Comment on lines +494 to +499
gate.set(true);
self.update(id, |t| t.status = TransferStatus::Paused).await;
if let Some(t) = self.get(id).await {
let _ = app.emit("transfer://updated", &t);
}
Ok(())
Comment thread src-tauri/src/commands.rs
Comment on lines +820 to +828
/// Live-adjust how many transfers may run at once (1..=32).
#[tauri::command]
pub async fn transfer_set_concurrency(
count: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
state.transfers.set_concurrency(count as usize);
Ok(())
}
(t) => t.status === "transferring" || t.status === "queued"
(t) => t.status === "transferring" || t.status === "paused"
).length;
const queued = queue.length;
Comment thread src/mock/transfers.ts
Comment on lines +55 to +70
case "transfer_pause":
// Valid from queued or transferring. A queued row leaves the FIFO.
if (t && (t.status === "transferring" || t.status === "queued")) {
if (queue.waiting.includes(t.id)) {
queue.waiting = queue.waiting.filter((id) => id !== t.id);
emitQueue();
}
update({ ...t, status: "paused" });
}
break;
case "transfer_resume":
// Valid from paused; the backend re-runs from byte 0.
if (t && t.status === "paused") {
update({ ...t, status: "transferring", transferred: 0 });
}
break;
Comment on lines +7 to +20
import { spawn } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import puppeteer from "puppeteer-core";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const OUT =
process.env.SHOT_DIR ||
"C:/Users/Juan/AppData/Local/Temp/claude/C--Users-Juan-Documents-GitHub-Faro/cbf0dfb0-7a23-418f-834f-25dcd5128966/scratchpad";
const PORT = 1425;
const URL = `http://localhost:${PORT}/`;
@jhd3197
jhd3197 merged commit e16525a into main Aug 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants