Conversation
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.
There was a problem hiding this comment.
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’sthrottleKbpscan 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 on lines
+2589
to
+2592
| let Some(_permit) = mgr.admit(&id).await else { | ||
| return; | ||
| }; | ||
| let mut auto_retries = 0u32; |
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 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 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}/`; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
#2 in queue).Technical changes
TransferManager(transfer.rs) gained a real scheduler: awaitingVecDequeFIFO plus a tokioSemaphorefor 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 asQueuedand re-checks on aqueue_genwatch channel.is_my_turnimplements 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.PauseGateis awatch-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 aRestartFromPausemarker error so the runner re-runs the file from byte 0.matchblocks instart_download/start_uploadwere extracted into sharedrun_download_task/run_upload_taskrunners plusdispatch_download/dispatch_upload. The runner loop handles both resume-from-pause restarts and auto-retry, and the same runners back Phase 3 manual retry.classify_message, nowpub(crate)) retry up to twice with 5s then 20s backoff; aretry_attemptfield 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 (AtomicU64rate, tokioInstanttimestamps) 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.RetryInfo(so the panel row resets in place).set_concurrencygrows 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.transfer_pause,transfer_resume,transfer_retry,transfer_move,transfer_pause_all,transfer_resume_all,transfer_set_concurrency,transfer_set_throttle,transfer_queue_state— registered inlib.rs.cancelnow takes theAppHandle, removes the id from the waiting queue, and emitstransfer://updated(there is no dedicated cancel event).lib.rssetup applies the persistedtransferConcurrency/transferThrottleKbpssettings to the manager at startup so the queue starts from the user's saved values.ipc.ts): the newtransfer://queueevent andtransfer_queue_statesnapshot are wired viaonTransferQueueand a typedTransferQueueState;onTransferEventlearned theupdatedkind.transfersStorenow tracksqueue/pausedAll/concurrency/throttleKbps, treatsupdatedlikeprogress(replaces the row, never toasts), andclearFinishedkeepspausedrows 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 aThrottleInputthat 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 onupdated(canceled emits no terminal event, so without this the batch would never flush) while leaving paused rows in flight.Settings.tsxgained "Concurrent transfers" (1–32) and "Bandwidth limit" (KiB/s) fields under Transfers;settingsStorepersists both and live-applies each change through the matching ipc setter.