From a24aa98cf0f9f03a120ed8a31d59a6985ace97dc Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 1 Aug 2026 23:44:44 -0400 Subject: [PATCH 1/7] =?UTF-8?q?feat(transfers):=20real=20queue=20=E2=80=94?= =?UTF-8?q?=20bounded=20concurrency,=20FIFO,=20move,=20pause-all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/commands.rs | 61 ++++++++++- src-tauri/src/lib.rs | 17 +++ src-tauri/src/transfer.rs | 212 +++++++++++++++++++++++++++++++++++++- 3 files changed, 286 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2e74829..44d4c1c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -737,9 +737,68 @@ pub async fn start_upload( #[tauri::command] pub async fn cancel_transfer( transfer_id: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + state + .transfers + .cancel(&transfer_id, &app) + .await + .map_err(err) +} + +/// Reorder a waiting transfer in the queue FIFO (Plan 17). `direction` is +/// "up" (sooner) or "down" (later); active transfers are untouched. +#[tauri::command] +pub async fn transfer_move( + transfer_id: String, + direction: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + state + .transfers + .move_in_queue(&transfer_id, direction == "up", &app) + .await + .map_err(err) +} + +/// Pause admission of new transfers; running ones keep going. +#[tauri::command] +pub async fn transfer_pause_all( + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + state.transfers.pause_all(&app).await; + Ok(()) +} + +#[tauri::command] +pub async fn transfer_resume_all( + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + state.transfers.resume_all(&app).await; + Ok(()) +} + +/// 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.cancel(&transfer_id).await.map_err(err) + state.transfers.set_concurrency(count as usize); + Ok(()) +} + +/// Queue snapshot for the panel's initial load (waiting FIFO + pause-all + +/// concurrency + throttle). +#[tauri::command] +pub async fn transfer_queue_state( + state: State<'_, AppState>, +) -> Result { + Ok(state.transfers.queue_state().await) } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8dcc94f..8009e9d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -250,6 +250,18 @@ pub fn run() { }; app.manage(state); + // Apply persisted transfer-queue settings (Plan 17). The frontend + // writes `transferConcurrency`/`transferThrottleKbps` via the + // settings table; the manager starts from those values. + { + let st = app.state::(); + if let Ok(Some(raw)) = st.db.settings_get("transferConcurrency") { + if let Ok(n) = serde_json::from_str::(&raw) { + st.transfers.set_concurrency(n); + } + } + } + // Bring the Agent Bridge back up if the user left its master switch // on, so the `faro-cli agent …` path keeps working across restarts. // Spawned off the async runtime so the sync setup() returns at once. @@ -389,6 +401,11 @@ pub fn run() { commands::start_directory_upload, commands::cancel_transfer, commands::list_transfers, + commands::transfer_move, + commands::transfer_pause_all, + commands::transfer_resume_all, + commands::transfer_set_concurrency, + commands::transfer_queue_state, commands::rename_path, commands::delete_path, commands::create_directory, diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs index 7830b64..6adf311 100644 --- a/src-tauri/src/transfer.rs +++ b/src-tauri/src/transfer.rs @@ -5,13 +5,14 @@ use crate::session::{ }; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::sync::Mutex; +use tokio::sync::{watch, Mutex, OwnedSemaphorePermit, Semaphore}; use tokio::task::JoinHandle; use uuid::Uuid; @@ -42,6 +43,47 @@ pub enum TransferStatus { Canceled, } +/// Payload of the `transfer://queue` event: the FIFO of waiting transfer ids +/// plus the manager-level state the panel header renders (Plan 17). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QueueState { + pub waiting: Vec, + pub paused_all: bool, + pub concurrency: usize, + pub throttle_kbps: u64, +} + +/// A pause gate shared by the scheduler (pause-all) and individual transfers +/// (Phase 2). watch-channel based so waiters never miss a wakeup. +#[derive(Debug, Clone)] +pub struct PauseGate { + tx: watch::Sender, +} + +impl PauseGate { + fn new() -> Self { + let (tx, _) = watch::channel(false); + Self { tx } + } + fn is_paused(&self) -> bool { + *self.tx.borrow() + } + fn set(&self, paused: bool) { + let _ = self.tx.send(paused); + } + /// Park until the gate opens. Returns immediately if already open. + #[allow(dead_code)] // used by the Phase 2 chunk checkpoints + async fn wait_open(&self) { + let mut rx = self.tx.subscribe(); + while *rx.borrow_and_update() { + if rx.changed().await.is_err() { + break; + } + } + } +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Transfer { @@ -57,9 +99,23 @@ pub struct Transfer { pub started_at: i64, } +/// Default bound on concurrently running transfers (Plan 17); the rest wait +/// in the FIFO as `Queued`. Overridden by the `transferConcurrency` setting. +const DEFAULT_CONCURRENCY: usize = 3; + pub struct TransferManager { transfers: Mutex>, tasks: Mutex>>, + /// FIFO of transfer ids waiting for a slot (Plan 17). An id is popped only + /// once it is at the front, the pause-all gate is open, and a concurrency + /// permit is available — until then it waits as `Queued`. + waiting: Mutex>, + semaphore: Arc, + concurrency: AtomicUsize, + /// Manager-level pause gate. Admission checks it; Phase 2 checkpoints do too. + pause_all: PauseGate, + /// Bumped on every queue change so admission waiters re-check their turn. + queue_gen: watch::Sender, } fn now_ts() -> i64 { @@ -127,6 +183,11 @@ impl TransferManager { Self { transfers: Mutex::new(HashMap::new()), tasks: Mutex::new(HashMap::new()), + waiting: Mutex::new(VecDeque::new()), + semaphore: Arc::new(Semaphore::new(DEFAULT_CONCURRENCY)), + concurrency: AtomicUsize::new(DEFAULT_CONCURRENCY), + pause_all: PauseGate::new(), + queue_gen: watch::channel(0).0, } } @@ -156,7 +217,133 @@ impl TransferManager { self.transfers.lock().await.insert(t.id.clone(), t); } - pub async fn cancel(&self, id: &str) -> Result<()> { + // ---------- Queue scheduling (Plan 17) ---------- + + fn build_queue_state(&self, waiting: &VecDeque) -> QueueState { + QueueState { + waiting: waiting.iter().cloned().collect(), + paused_all: self.pause_all.is_paused(), + concurrency: self.concurrency.load(Ordering::Relaxed), + throttle_kbps: 0, // wired up in Phase 4 + } + } + + /// Current queue snapshot for the panel's initial load. + pub async fn queue_state(&self) -> QueueState { + let w = self.waiting.lock().await; + self.build_queue_state(&w) + } + + /// Emit `transfer://queue` and bump the generation so admission waiters + /// re-check whether it is their turn. + async fn bump_queue(&self, app: &AppHandle) { + let state = { + let w = self.waiting.lock().await; + self.build_queue_state(&w) + }; + self.queue_gen.send_modify(|g| *g += 1); + let _ = app.emit("transfer://queue", &state); + } + + /// Wait until this transfer is at the front of the FIFO with the pause-all + /// gate open, take a concurrency permit, and pop it from the queue. + /// Returns `None` if the id left the queue (canceled while waiting). + /// The permit is returned so the caller holds it for the transfer's life. + async fn admit(&self, id: &str) -> Option { + let mut rx = self.queue_gen.subscribe(); + loop { + { + let w = self.waiting.lock().await; + if !w.iter().any(|x| x == id) { + return None; + } + if w.front().map(String::as_str) != Some(id) || self.pause_all.is_paused() { + drop(w); + if rx.changed().await.is_err() { + return None; + } + continue; + } + } + // Front of the queue with the gate open: take a permit, then pop. + let permit = self.semaphore.clone().acquire_owned().await.ok()?; + let mut w = self.waiting.lock().await; + if w.front().map(String::as_str) == Some(id) && !self.pause_all.is_paused() { + w.pop_front(); + return Some(permit); + } + // Lost a race (pause-all engaged mid-acquire): release, re-evaluate. + drop(permit); + if !w.iter().any(|x| x == id) { + return None; + } + drop(w); + } + } + + /// Reorder a waiting transfer (active transfers are untouched). + pub async fn move_in_queue(&self, id: &str, up: bool, app: &AppHandle) -> Result<()> { + { + let mut w = self.waiting.lock().await; + let Some(pos) = w.iter().position(|x| x == id) else { + anyhow::bail!("transfer {id} is not waiting in the queue"); + }; + let swap_with = if up { + pos.checked_sub(1) + } else if pos + 1 < w.len() { + Some(pos + 1) + } else { + None + }; + if let Some(other) = swap_with { + w.swap(pos, other); + } + } + self.bump_queue(app).await; + Ok(()) + } + + /// Pause admission of new transfers (running ones keep going until Phase + /// 2's chunk checkpoints let them park too). + pub async fn pause_all(&self, app: &AppHandle) { + self.pause_all.set(true); + self.bump_queue(app).await; + } + + pub async fn resume_all(&self, app: &AppHandle) { + self.pause_all.set(false); + self.bump_queue(app).await; + } + + pub fn is_paused_all(&self) -> bool { + self.pause_all.is_paused() + } + + /// Live-adjust the concurrency bound. Growing adds permits at once; + /// shrinking forgets permits as running transfers release them, so + /// in-flight transfers are never killed to satisfy the new bound. + pub fn set_concurrency(&self, n: usize) { + let n = n.clamp(1, 32); + let old = self.concurrency.swap(n, Ordering::Relaxed); + if n > old { + self.semaphore.add_permits(n - old); + } else if n < old { + let sem = Arc::clone(&self.semaphore); + tokio::spawn(async move { + if let Ok(p) = sem.acquire_many((old - n) as u32).await { + p.forget(); + } + }); + } + } + + pub async fn cancel(&self, id: &str, app: &AppHandle) -> Result<()> { + { + let mut w = self.waiting.lock().await; + if let Some(pos) = w.iter().position(|x| x == id) { + w.remove(pos); + } + } if let Some(h) = self.tasks.lock().await.remove(id) { h.abort(); } @@ -166,6 +353,11 @@ impl TransferManager { } }) .await; + // There is no `transfer://canceled` event — `updated` carries the row. + if let Some(t) = self.get(id).await { + let _ = app.emit("transfer://updated", &t); + } + self.bump_queue(app).await; Ok(()) } @@ -213,9 +405,15 @@ impl TransferManager { return Ok(id); } + self.waiting.lock().await.push_back(id.clone()); + self.bump_queue(&app).await; + let mgr = Arc::clone(self); let id_for_task = id.clone(); let task = tokio::spawn(async move { + let Some(_permit) = mgr.admit(&id_for_task).await else { + return; + }; let res = match &*session { Session::Ssh(ssh) => { mgr.run_ssh_download( @@ -349,6 +547,7 @@ impl TransferManager { } }; finalize(&mgr, &id_for_task, &app, res).await; + mgr.bump_queue(&app).await; }); self.tasks.lock().await.insert(id.clone(), task); Ok(id) @@ -498,9 +697,15 @@ impl TransferManager { return Ok(id); } + self.waiting.lock().await.push_back(id.clone()); + self.bump_queue(&app).await; + let mgr = Arc::clone(self); let id_for_task = id.clone(); let task = tokio::spawn(async move { + let Some(_permit) = mgr.admit(&id_for_task).await else { + return; + }; let res = match &*session { Session::Ssh(ssh) => { mgr.run_ssh_upload( @@ -627,6 +832,7 @@ impl TransferManager { } }; finalize(&mgr, &id_for_task, &app, res).await; + mgr.bump_queue(&app).await; }); self.tasks.lock().await.insert(id.clone(), task); Ok(id) From 255ecec1f9b26270a26c5c8ae4da6cefd4821694 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 2 Aug 2026 00:07:19 -0400 Subject: [PATCH 2/7] feat(transfers): per-transfer pause/resume with chunk checkpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/commands.rs | 24 +++++ src-tauri/src/foldersync.rs | 2 +- src-tauri/src/lib.rs | 2 + src-tauri/src/transfer.rs | 183 +++++++++++++++++++++++++++++++-- src-tauri/src/virtualfs/mod.rs | 2 +- 5 files changed, 202 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 44d4c1c..a67ae7b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -747,6 +747,30 @@ pub async fn cancel_transfer( .map_err(err) } +/// Pause a queued or running transfer (Plan 17 Phase 2). A running one parks +/// at the next chunk boundary; resume re-runs its file from byte 0. +#[tauri::command] +pub async fn transfer_pause( + transfer_id: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + state.transfers.pause(&transfer_id, &app).await.map_err(err) +} + +#[tauri::command] +pub async fn transfer_resume( + transfer_id: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + state + .transfers + .resume(&transfer_id, &app) + .await + .map_err(err) +} + /// Reorder a waiting transfer in the queue FIFO (Plan 17). `direction` is /// "up" (sooner) or "down" (later); active transfers are untouched. #[tauri::command] diff --git a/src-tauri/src/foldersync.rs b/src-tauri/src/foldersync.rs index 204fbc6..4c7bb78 100644 --- a/src-tauri/src/foldersync.rs +++ b/src-tauri/src/foldersync.rs @@ -605,7 +605,7 @@ async fn wait_for_transfers(state: &AppState, ids: Vec) { for id in &ids { if let Some(t) = state.transfers.snapshot(id).await { match t.status { - TransferStatus::Queued | TransferStatus::Transferring => remaining += 1, + TransferStatus::Queued | TransferStatus::Transferring | TransferStatus::Paused => remaining += 1, _ => {} } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8009e9d..d62e7de 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -402,6 +402,8 @@ pub fn run() { commands::cancel_transfer, commands::list_transfers, commands::transfer_move, + commands::transfer_pause, + commands::transfer_resume, commands::transfer_pause_all, commands::transfer_resume_all, commands::transfer_set_concurrency, diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs index 6adf311..31d16e0 100644 --- a/src-tauri/src/transfer.rs +++ b/src-tauri/src/transfer.rs @@ -37,12 +37,27 @@ pub enum TransferKind { pub enum TransferStatus { Queued, Transferring, + Paused, Done, Skipped, Error, Canceled, } +/// Marker error: a paused transfer was resumed — the copy loop unwinds with +/// this and the runner re-runs the file from byte 0 (Plan 17 Phase 2). +/// Honest on every backend: no per-backend seek support needed. +#[derive(Debug)] +struct RestartFromPause; + +impl std::fmt::Display for RestartFromPause { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("transfer resumed after pause; restarting file from the beginning") + } +} + +impl std::error::Error for RestartFromPause {} + /// Payload of the `transfer://queue` event: the FIFO of waiting transfer ids /// plus the manager-level state the panel header renders (Plan 17). #[derive(Debug, Clone, Serialize)] @@ -73,7 +88,6 @@ impl PauseGate { let _ = self.tx.send(paused); } /// Park until the gate opens. Returns immediately if already open. - #[allow(dead_code)] // used by the Phase 2 chunk checkpoints async fn wait_open(&self) { let mut rx = self.tx.subscribe(); while *rx.borrow_and_update() { @@ -114,6 +128,8 @@ pub struct TransferManager { concurrency: AtomicUsize, /// Manager-level pause gate. Admission checks it; Phase 2 checkpoints do too. pause_all: PauseGate, + /// Per-transfer pause gates, created at enqueue time (Plan 17 Phase 2). + pauses: Mutex>, /// Bumped on every queue change so admission waiters re-check their turn. queue_gen: watch::Sender, } @@ -187,6 +203,7 @@ impl TransferManager { semaphore: Arc::new(Semaphore::new(DEFAULT_CONCURRENCY)), concurrency: AtomicUsize::new(DEFAULT_CONCURRENCY), pause_all: PauseGate::new(), + pauses: Mutex::new(HashMap::new()), queue_gen: watch::channel(0).0, } } @@ -257,7 +274,7 @@ impl TransferManager { if !w.iter().any(|x| x == id) { return None; } - if w.front().map(String::as_str) != Some(id) || self.pause_all.is_paused() { + if !self.is_my_turn(&w, id).await { drop(w); if rx.changed().await.is_err() { return None; @@ -265,14 +282,16 @@ impl TransferManager { continue; } } - // Front of the queue with the gate open: take a permit, then pop. + // My turn: take a permit, then pop. let permit = self.semaphore.clone().acquire_owned().await.ok()?; let mut w = self.waiting.lock().await; - if w.front().map(String::as_str) == Some(id) && !self.pause_all.is_paused() { - w.pop_front(); + if self.is_my_turn(&w, id).await { + if let Some(pos) = w.iter().position(|x| x == id) { + w.remove(pos); + } return Some(permit); } - // Lost a race (pause-all engaged mid-acquire): release, re-evaluate. + // Lost a race (pause engaged mid-acquire): release, re-evaluate. drop(permit); if !w.iter().any(|x| x == id) { return None; @@ -281,6 +300,21 @@ impl TransferManager { } } + /// Is `id` the first waiting transfer allowed to run? Strict FIFO except + /// that per-transfer-paused rows are skipped (a paused row must not + /// head-of-line block the queue); pause-all blocks everyone. Caller must + /// hold the `waiting` lock; lock order is waiting → pauses. + async fn is_my_turn(&self, w: &VecDeque, id: &str) -> bool { + if self.pause_all.is_paused() { + return false; + } + let pauses = self.pauses.lock().await; + let first_open = w + .iter() + .position(|x| pauses.get(x).map_or(true, |g| !g.is_paused())); + first_open == w.iter().position(|x| x == id) + } + /// Reorder a waiting transfer (active transfers are untouched). pub async fn move_in_queue(&self, id: &str, up: bool, app: &AppHandle) -> Result<()> { { @@ -319,6 +353,83 @@ impl TransferManager { self.pause_all.is_paused() } + /// Chunk-boundary checkpoint shared by every copy loop (Plan 17). Phase 4 + /// draws `_bytes` from the bandwidth bucket here. When the transfer (or + /// the whole manager) is paused, this parks until resumed, then returns + /// `RestartFromPause` so the runner re-runs the file from byte 0. + async fn checkpoint(&self, id: &str, _bytes: u64) -> Result<()> { + let gate = self.pauses.lock().await.get(id).cloned(); + let parked = self.pause_all.is_paused() || gate.as_ref().is_some_and(|g| g.is_paused()); + if !parked { + return Ok(()); + } + // Park until BOTH gates are open (resume requires both). + loop { + self.pause_all.wait_open().await; + if let Some(g) = &gate { + g.wait_open().await; + } + if !self.pause_all.is_paused() && gate.as_ref().is_none_or(|g| !g.is_paused()) { + break; + } + } + Err(RestartFromPause.into()) + } + + /// Pause a queued or transferring transfer. A running one parks at the + /// next chunk boundary; a queued one is skipped by admission until resumed. + pub async fn pause(&self, id: &str, app: &AppHandle) -> Result<()> { + match self.get(id).await.map(|t| t.status) { + Some(TransferStatus::Transferring) | Some(TransferStatus::Queued) => {} + _ => anyhow::bail!("transfer {id} is not running or queued"), + } + let gate = self + .pauses + .lock() + .await + .get(id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("transfer {id} not found"))?; + 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(()) + } + + /// Resume a paused transfer. A parked one re-runs its file from byte 0; + /// a queued one re-enters FIFO admission. + pub async fn resume(&self, id: &str, app: &AppHandle) -> Result<()> { + if self.get(id).await.map(|t| t.status) != Some(TransferStatus::Paused) { + anyhow::bail!("transfer {id} is not paused"); + } + let still_queued = self.waiting.lock().await.iter().any(|x| x == id); + let gate = self + .pauses + .lock() + .await + .get(id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("transfer {id} not found"))?; + self.update(id, |t| { + t.status = if still_queued { + TransferStatus::Queued + } else { + TransferStatus::Transferring + }; + t.transferred = 0; + }) + .await; + gate.set(false); + if let Some(t) = self.get(id).await { + let _ = app.emit("transfer://updated", &t); + } + // Wake admission waiters: a queued row may have become runnable. + self.bump_queue(app).await; + Ok(()) + } + /// Live-adjust the concurrency bound. Growing adds permits at once; /// shrinking forgets permits as running transfers release them, so /// in-flight transfers are never killed to satisfy the new bound. @@ -348,7 +459,10 @@ impl TransferManager { h.abort(); } self.update(id, |t| { - if t.status == TransferStatus::Transferring || t.status == TransferStatus::Queued { + if matches!( + t.status, + TransferStatus::Transferring | TransferStatus::Queued | TransferStatus::Paused + ) { t.status = TransferStatus::Canceled; } }) @@ -406,6 +520,7 @@ impl TransferManager { } self.waiting.lock().await.push_back(id.clone()); + self.pauses.lock().await.insert(id.clone(), PauseGate::new()); self.bump_queue(&app).await; let mgr = Arc::clone(self); @@ -414,7 +529,10 @@ impl TransferManager { let Some(_permit) = mgr.admit(&id_for_task).await else { return; }; - let res = match &*session { + // A resume-after-pause unwinds the copy loop with RestartFromPause; + // re-run the file from byte 0 (Plan 17 Phase 2). + let res = loop { + let attempt = match &*session { Session::Ssh(ssh) => { mgr.run_ssh_download( &id_for_task, @@ -546,6 +664,14 @@ impl TransferManager { .await } }; + match attempt { + Err(e) if e.downcast_ref::().is_some() => { + mgr.update(&id_for_task, |t| t.transferred = 0).await; + continue; + } + other => break other, + } + }; finalize(&mgr, &id_for_task, &app, res).await; mgr.bump_queue(&app).await; }); @@ -586,6 +712,7 @@ impl TransferManager { let bytes = base64::engine::general_purpose::STANDARD .decode(&data_b64) .context("decode chunk")?; + self.checkpoint(id, bytes.len() as u64).await?; if !bytes.is_empty() { local_file.write_all(&bytes).await?; offset += bytes.len() as u64; @@ -636,6 +763,7 @@ impl TransferManager { if n == 0 { break; } + self.checkpoint(id, n as u64).await?; local_file.write_all(&buf[..n]).await?; transferred += n as u64; if last_emit.elapsed() > Duration::from_millis(100) { @@ -698,6 +826,7 @@ impl TransferManager { } self.waiting.lock().await.push_back(id.clone()); + self.pauses.lock().await.insert(id.clone(), PauseGate::new()); self.bump_queue(&app).await; let mgr = Arc::clone(self); @@ -706,7 +835,10 @@ impl TransferManager { let Some(_permit) = mgr.admit(&id_for_task).await else { return; }; - let res = match &*session { + // A resume-after-pause unwinds the copy loop with RestartFromPause; + // re-run the file from byte 0 (Plan 17 Phase 2). + let res = loop { + let attempt = match &*session { Session::Ssh(ssh) => { mgr.run_ssh_upload( &id_for_task, @@ -831,6 +963,14 @@ impl TransferManager { .await } }; + match attempt { + Err(e) if e.downcast_ref::().is_some() => { + mgr.update(&id_for_task, |t| t.transferred = 0).await; + continue; + } + other => break other, + } + }; finalize(&mgr, &id_for_task, &app, res).await; mgr.bump_queue(&app).await; }); @@ -866,6 +1006,7 @@ impl TransferManager { if n == 0 { break; } + self.checkpoint(id, n as u64).await?; let data = base64::engine::general_purpose::STANDARD.encode(&buf[..n]); let resp = session .request(Request::WriteChunk { @@ -927,6 +1068,7 @@ impl TransferManager { if n == 0 { break; } + self.checkpoint(id, n as u64).await?; remote_file.write_all(&buf[..n]).await?; transferred += n as u64; if last_emit.elapsed() > Duration::from_millis(100) { @@ -1111,6 +1253,7 @@ impl TransferManager { let _ = (mgr_for_emit, id_for_emit, app_for_emit); + self.checkpoint(id, 0).await?; let res: Result = session .with_stream(move |stream| { let file = std::fs::File::create(&final_path) @@ -1138,6 +1281,7 @@ impl TransferManager { .await; let _ = app; // progress events come at completion for FTP + self.checkpoint(id, 0).await?; let local = local_path.to_path_buf(); let remote = remote_path.to_string(); let res: Result = session @@ -1184,6 +1328,7 @@ impl TransferManager { let mut last_emit = Instant::now(); while let Some(chunk) = stream.next().await { let chunk = chunk.with_context(|| format!("s3 chunk for {key}"))?; + self.checkpoint(id, chunk.len() as u64).await?; file.write_all(&chunk).await?; transferred += chunk.len() as u64; if last_emit.elapsed() > Duration::from_millis(100) { @@ -1227,6 +1372,7 @@ impl TransferManager { .with_context(|| format!("open {}", local_path.display()))?; if size <= 16 * 1024 * 1024 { + self.checkpoint(id, size).await?; let mut buf = Vec::with_capacity(size as usize); file.read_to_end(&mut buf).await?; session @@ -1265,6 +1411,7 @@ impl TransferManager { if filled == 0 { break; } + self.checkpoint(id, filled as u64).await?; let chunk = bytes::Bytes::copy_from_slice(&buf[..filled]); upload .put_part(chunk.into()) @@ -1326,6 +1473,7 @@ impl TransferManager { let mut last_emit = Instant::now(); while let Some(chunk) = stream.next().await { let chunk = chunk.with_context(|| format!("webdav chunk for {remote_path}"))?; + self.checkpoint(id, chunk.len() as u64).await?; file.write_all(&chunk).await?; transferred += chunk.len() as u64; if last_emit.elapsed() > Duration::from_millis(100) { @@ -1365,6 +1513,7 @@ impl TransferManager { .await .with_context(|| format!("open {}", local_path.display()))?; let body = reqwest::Body::wrap_stream(ReaderStream::new(file)); + self.checkpoint(id, size).await?; let url = session.url_for(remote_path, false); let resp = session @@ -1419,6 +1568,7 @@ impl TransferManager { let mut last_emit = Instant::now(); while let Some(chunk) = stream.next().await { let chunk = chunk.with_context(|| format!("http chunk for {remote_path}"))?; + self.checkpoint(id, chunk.len() as u64).await?; file.write_all(&chunk).await?; transferred += chunk.len() as u64; if last_emit.elapsed() > Duration::from_millis(100) { @@ -1461,6 +1611,7 @@ impl TransferManager { let mut last_emit = Instant::now(); while let Some(chunk) = stream.next().await { let chunk = chunk.with_context(|| format!("dropbox chunk for {remote_path}"))?; + self.checkpoint(id, chunk.len() as u64).await?; file.write_all(&chunk).await?; transferred += chunk.len() as u64; if last_emit.elapsed() > Duration::from_millis(100) { @@ -1515,6 +1666,7 @@ impl TransferManager { // Proactive refresh covers the common case; on a hard 401 we refresh and // retry once, re-opening the file for a fresh streamed body. + self.checkpoint(id, size).await?; let mut attempt = 0; loop { let token = session.access_token().await?; @@ -1563,6 +1715,7 @@ impl TransferManager { .await; let _ = app; // single-shot API: progress is reported at completion. + self.checkpoint(id, 0).await?; let data = crate::remotefs::shopify::read_asset(&session, remote_path).await?; let mut file = tokio::fs::File::create(local_path) .await @@ -1591,6 +1744,7 @@ impl TransferManager { .await .with_context(|| format!("read {}", local_path.display()))?; let size = data.len() as u64; + self.checkpoint(id, size).await?; crate::remotefs::shopify::write_asset(&session, remote_path, &data).await?; self.update(id, |t| t.transferred = size).await; Ok(()) @@ -1611,6 +1765,7 @@ impl TransferManager { .await; let _ = app; // single-shot API: progress is reported at completion. + self.checkpoint(id, 0).await?; let data = crate::remotefs::hubspot::read_file(&session, remote_path).await?; let mut file = tokio::fs::File::create(local_path) .await @@ -1640,6 +1795,7 @@ impl TransferManager { .await .with_context(|| format!("read {}", local_path.display()))?; let size = data.len() as u64; + self.checkpoint(id, size).await?; crate::remotefs::hubspot::write_file(&session, remote_path, &data).await?; self.update(id, |t| t.transferred = size).await; Ok(()) @@ -1660,6 +1816,7 @@ impl TransferManager { .await; let _ = app; // single-shot API: progress is reported at completion. + self.checkpoint(id, 0).await?; let data = crate::remotefs::dynamics::read_file(&session, remote_path).await?; let mut file = tokio::fs::File::create(local_path) .await @@ -1688,6 +1845,7 @@ impl TransferManager { .await .with_context(|| format!("read {}", local_path.display()))?; let size = data.len() as u64; + self.checkpoint(id, size).await?; crate::remotefs::dynamics::write_file(&session, remote_path, &data).await?; self.update(id, |t| t.transferred = size).await; Ok(()) @@ -1719,6 +1877,7 @@ impl TransferManager { let mut last_emit = Instant::now(); while let Some(chunk) = stream.next().await { let chunk = chunk.with_context(|| format!("onedrive chunk for {remote_path}"))?; + self.checkpoint(id, chunk.len() as u64).await?; file.write_all(&chunk).await?; transferred += chunk.len() as u64; if last_emit.elapsed() > Duration::from_millis(100) { @@ -1758,6 +1917,7 @@ impl TransferManager { .unwrap_or(4 * 1024 * 1024); if size <= simple_max { + self.checkpoint(id, size).await?; self.onedrive_simple_upload(id, &session, local_path, remote_path) .await?; } else { @@ -1852,6 +2012,7 @@ impl TransferManager { if filled == 0 { break; } + self.checkpoint(id, filled as u64).await?; let start = offset; let end = offset + filled as u64 - 1; let range = format!("bytes {start}-{end}/{size}"); @@ -1912,6 +2073,7 @@ impl TransferManager { let mut last_emit = Instant::now(); while let Some(chunk) = stream.next().await { let chunk = chunk.with_context(|| format!("drive chunk for {remote_path}"))?; + self.checkpoint(id, chunk.len() as u64).await?; file.write_all(&chunk).await?; transferred += chunk.len() as u64; if last_emit.elapsed() > Duration::from_millis(100) { @@ -1954,6 +2116,7 @@ impl TransferManager { .await .with_context(|| format!("read {}", local_path.display()))?; let token = session.access_token().await?; + self.checkpoint(id, size).await?; let resp = if let Some((file_id, _)) = existing { // Update the existing file's content in place. @@ -2038,6 +2201,7 @@ impl TransferManager { let mut last_emit = Instant::now(); while let Some(chunk) = stream.next().await { let chunk = chunk.with_context(|| format!("box chunk for {remote_path}"))?; + self.checkpoint(id, chunk.len() as u64).await?; file.write_all(&chunk).await?; transferred += chunk.len() as u64; if last_emit.elapsed() > Duration::from_millis(100) { @@ -2080,6 +2244,7 @@ impl TransferManager { .await .with_context(|| format!("read {}", local_path.display()))?; let token = session.access_token().await?; + self.checkpoint(id, size).await?; let file_part = reqwest::multipart::Part::bytes(bytes).file_name(name.clone()); let (url, form) = match existing { diff --git a/src-tauri/src/virtualfs/mod.rs b/src-tauri/src/virtualfs/mod.rs index b423940..0cc8b8e 100644 --- a/src-tauri/src/virtualfs/mod.rs +++ b/src-tauri/src/virtualfs/mod.rs @@ -357,7 +357,7 @@ impl Hydrator for SessionHydrator { anyhow::bail!("hydration download failed: {}", t.error.unwrap_or_default()) } TransferStatus::Canceled => anyhow::bail!("hydration download canceled"), - TransferStatus::Queued | TransferStatus::Transferring => { + TransferStatus::Queued | TransferStatus::Transferring | TransferStatus::Paused => { tokio::time::sleep(std::time::Duration::from_millis(120)).await; } }, From 44cbdbb05110bae3c6341201d421bdd2e0586dd8 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 2 Aug 2026 00:14:35 -0400 Subject: [PATCH 3/7] feat(transfers): manual + auto retry with backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/commands.rs | 11 + src-tauri/src/error.rs | 2 +- src-tauri/src/lib.rs | 1 + src-tauri/src/transfer.rs | 651 +++++++++++++++++++++----------------- 4 files changed, 371 insertions(+), 294 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a67ae7b..da64a7a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -771,6 +771,17 @@ pub async fn transfer_resume( .map_err(err) } +/// Re-enqueue a failed or canceled transfer with its original source/ +/// destination (Plan 17 Phase 3). Same id — the panel row resets in place. +#[tauri::command] +pub async fn transfer_retry( + transfer_id: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + state.transfers.retry(&transfer_id, &app).await.map_err(err) +} + /// Reorder a waiting transfer in the queue FIFO (Plan 17). `direction` is /// "up" (sooner) or "down" (later); active transfers are untouched. #[tauri::command] diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 46f5f88..8d8a7c7 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -117,7 +117,7 @@ fn classify_io(kind: std::io::ErrorKind) -> Option { /// Keyword heuristics over an error message. Backend-side only — the frontend /// gets the resulting `kind` and never repeats this matching. -fn classify_message(message: &str) -> ErrorKind { +pub(crate) fn classify_message(message: &str) -> ErrorKind { let s = message.to_ascii_lowercase(); let has = |needles: &[&str]| needles.iter().any(|n| s.contains(n)); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d62e7de..6836e05 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -404,6 +404,7 @@ pub fn run() { commands::transfer_move, commands::transfer_pause, commands::transfer_resume, + commands::transfer_retry, commands::transfer_pause_all, commands::transfer_resume_all, commands::transfer_set_concurrency, diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs index 31d16e0..950385b 100644 --- a/src-tauri/src/transfer.rs +++ b/src-tauri/src/transfer.rs @@ -110,13 +110,38 @@ pub struct Transfer { pub status: TransferStatus, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + /// Auto-retry round in progress (1- or 2-of-2), for the panel's + /// "retrying in Ns (attempt N/3)" state (Plan 17 Phase 3). + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_attempt: Option, pub started_at: i64, } +/// Everything needed to re-run a failed/canceled transfer with its already +/// policy-resolved destination (overwrite/skip/rename was applied at enqueue +/// time) — Plan 17 Phase 3 manual retry. +#[derive(Clone)] +enum RetryInfo { + Download { + session: Arc, + remote_path: String, + final_path: PathBuf, + }, + Upload { + session: Arc, + local: PathBuf, + final_remote: String, + }, +} + /// Default bound on concurrently running transfers (Plan 17); the rest wait /// in the FIFO as `Queued`. Overridden by the `transferConcurrency` setting. const DEFAULT_CONCURRENCY: usize = 3; +/// How many times a transfer auto-retries on transient (Network/Timeout) +/// errors before surfacing the failure (Plan 17 Phase 3). +const MAX_AUTO_RETRIES: u32 = 2; + pub struct TransferManager { transfers: Mutex>, tasks: Mutex>>, @@ -130,6 +155,8 @@ pub struct TransferManager { pause_all: PauseGate, /// Per-transfer pause gates, created at enqueue time (Plan 17 Phase 2). pauses: Mutex>, + /// Original resolved inputs per transfer, for manual retry (Phase 3). + retry: Mutex>, /// Bumped on every queue change so admission waiters re-check their turn. queue_gen: watch::Sender, } @@ -204,6 +231,7 @@ impl TransferManager { concurrency: AtomicUsize::new(DEFAULT_CONCURRENCY), pause_all: PauseGate::new(), pauses: Mutex::new(HashMap::new()), + retry: Mutex::new(HashMap::new()), queue_gen: watch::channel(0).0, } } @@ -430,6 +458,73 @@ impl TransferManager { Ok(()) } + /// Re-enqueue a failed or canceled transfer with its original (already + /// policy-resolved) source/destination. Same id — the panel row resets + /// in place (Plan 17 Phase 3 manual retry). + pub async fn retry(self: &Arc, id: &str, app: &AppHandle) -> Result<()> { + match self.get(id).await.map(|t| t.status) { + Some(TransferStatus::Error) | Some(TransferStatus::Canceled) => {} + _ => anyhow::bail!("only failed or canceled transfers can be retried"), + } + let info = self + .retry + .lock() + .await + .get(id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("transfer {id} cannot be retried"))?; + if let Some(h) = self.tasks.lock().await.remove(id) { + h.abort(); + } + // A cancel-while-paused leaves the gate closed — reopen it. + if let Some(g) = self.pauses.lock().await.get(id) { + g.set(false); + } + self.update(id, |t| { + t.status = TransferStatus::Queued; + t.transferred = 0; + t.error = None; + t.retry_attempt = None; + }) + .await; + if let Some(t) = self.get(id).await { + let _ = app.emit("transfer://updated", &t); + } + self.waiting.lock().await.push_back(id.to_string()); + self.bump_queue(app).await; + let mgr = Arc::clone(self); + let id_for_task = id.to_string(); + let app_for_task = app.clone(); + let task = match info { + RetryInfo::Download { + session, + remote_path, + final_path, + } => tokio::spawn(run_download_task( + mgr, + id_for_task, + session, + remote_path, + final_path, + app_for_task, + )), + RetryInfo::Upload { + session, + local, + final_remote, + } => tokio::spawn(run_upload_task( + mgr, + id_for_task, + session, + local, + final_remote, + app_for_task, + )), + }; + self.tasks.lock().await.insert(id.to_string(), task); + Ok(()) + } + /// Live-adjust the concurrency bound. Growing adds permits at once; /// shrinking forgets permits as running transfers release them, so /// in-flight transfers are never killed to satisfy the new bound. @@ -509,6 +604,7 @@ impl TransferManager { TransferStatus::Queued }, error: None, + retry_attempt: None, started_at: now_ts(), }; self.insert(transfer.clone()).await; @@ -519,162 +615,28 @@ impl TransferManager { return Ok(id); } + self.retry.lock().await.insert( + id.clone(), + RetryInfo::Download { + session: Arc::clone(&session), + remote_path: remote_path.clone(), + final_path: final_path.clone(), + }, + ); self.waiting.lock().await.push_back(id.clone()); self.pauses.lock().await.insert(id.clone(), PauseGate::new()); self.bump_queue(&app).await; let mgr = Arc::clone(self); let id_for_task = id.clone(); - let task = tokio::spawn(async move { - let Some(_permit) = mgr.admit(&id_for_task).await else { - return; - }; - // A resume-after-pause unwinds the copy loop with RestartFromPause; - // re-run the file from byte 0 (Plan 17 Phase 2). - let res = loop { - let attempt = match &*session { - Session::Ssh(ssh) => { - mgr.run_ssh_download( - &id_for_task, - ssh.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - Session::Ftp(ftp) => { - mgr.run_ftp_download( - &id_for_task, - ftp.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - Session::Object(obj) => { - mgr.run_object_download( - &id_for_task, - obj.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - Session::Webdav(dav) => { - mgr.run_webdav_download( - &id_for_task, - dav.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - Session::Http(http) => { - mgr.run_http_download( - &id_for_task, - http.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - Session::Dropbox(dbx) => { - mgr.run_dropbox_download( - &id_for_task, - dbx.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - Session::OneDrive(od) => { - mgr.run_onedrive_download( - &id_for_task, - od.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - Session::GDrive(gd) => { - mgr.run_gdrive_download( - &id_for_task, - gd.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - Session::Box(bx) => { - mgr.run_box_download( - &id_for_task, - bx.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - Session::Shopify(sh) => { - mgr.run_shopify_download( - &id_for_task, - sh.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - Session::HubSpot(hs) => { - mgr.run_hubspot_download( - &id_for_task, - hs.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - Session::Dynamics(dynm) => { - mgr.run_dynamics_download( - &id_for_task, - dynm.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - Session::Agent(agent) => { - mgr.run_agent_download( - &id_for_task, - agent.clone(), - &remote_path, - &final_path, - &app, - ) - .await - } - }; - match attempt { - Err(e) if e.downcast_ref::().is_some() => { - mgr.update(&id_for_task, |t| t.transferred = 0).await; - continue; - } - other => break other, - } - }; - finalize(&mgr, &id_for_task, &app, res).await; - mgr.bump_queue(&app).await; - }); + let task = tokio::spawn(run_download_task( + mgr, + id_for_task, + session, + remote_path, + final_path, + app, + )); self.tasks.lock().await.insert(id.clone(), task); Ok(id) } @@ -815,6 +777,7 @@ impl TransferManager { TransferStatus::Queued }, error: None, + retry_attempt: None, started_at: now_ts(), }; self.insert(transfer.clone()).await; @@ -825,155 +788,28 @@ impl TransferManager { return Ok(id); } + self.retry.lock().await.insert( + id.clone(), + RetryInfo::Upload { + session: Arc::clone(&session), + local: local.clone(), + final_remote: final_remote.clone(), + }, + ); self.waiting.lock().await.push_back(id.clone()); self.pauses.lock().await.insert(id.clone(), PauseGate::new()); self.bump_queue(&app).await; let mgr = Arc::clone(self); let id_for_task = id.clone(); - let task = tokio::spawn(async move { - let Some(_permit) = mgr.admit(&id_for_task).await else { - return; - }; - // A resume-after-pause unwinds the copy loop with RestartFromPause; - // re-run the file from byte 0 (Plan 17 Phase 2). - let res = loop { - let attempt = match &*session { - Session::Ssh(ssh) => { - mgr.run_ssh_upload( - &id_for_task, - ssh.clone(), - &local, - &final_remote, - &app, - ) - .await - } - Session::Ftp(ftp) => { - mgr.run_ftp_upload( - &id_for_task, - ftp.clone(), - &local, - &final_remote, - &app, - ) - .await - } - Session::Object(obj) => { - mgr.run_object_upload( - &id_for_task, - obj.clone(), - &local, - &final_remote, - &app, - ) - .await - } - Session::Webdav(dav) => { - mgr.run_webdav_upload( - &id_for_task, - dav.clone(), - &local, - &final_remote, - &app, - ) - .await - } - Session::Http(_) => { - Err(anyhow::anyhow!("HTTP source is read-only — upload not supported")) - } - Session::Dropbox(dbx) => { - mgr.run_dropbox_upload( - &id_for_task, - dbx.clone(), - &local, - &final_remote, - &app, - ) - .await - } - Session::OneDrive(od) => { - mgr.run_onedrive_upload( - &id_for_task, - od.clone(), - &local, - &final_remote, - &app, - ) - .await - } - Session::GDrive(gd) => { - mgr.run_gdrive_upload( - &id_for_task, - gd.clone(), - &local, - &final_remote, - &app, - ) - .await - } - Session::Box(bx) => { - mgr.run_box_upload( - &id_for_task, - bx.clone(), - &local, - &final_remote, - &app, - ) - .await - } - Session::Shopify(sh) => { - mgr.run_shopify_upload( - &id_for_task, - sh.clone(), - &local, - &final_remote, - &app, - ) - .await - } - Session::HubSpot(hs) => { - mgr.run_hubspot_upload( - &id_for_task, - hs.clone(), - &local, - &final_remote, - &app, - ) - .await - } - Session::Dynamics(dynm) => { - mgr.run_dynamics_upload( - &id_for_task, - dynm.clone(), - &local, - &final_remote, - &app, - ) - .await - } - Session::Agent(agent) => { - mgr.run_agent_upload( - &id_for_task, - agent.clone(), - &local, - &final_remote, - &app, - ) - .await - } - }; - match attempt { - Err(e) if e.downcast_ref::().is_some() => { - mgr.update(&id_for_task, |t| t.transferred = 0).await; - continue; - } - other => break other, - } - }; - finalize(&mgr, &id_for_task, &app, res).await; - mgr.bump_queue(&app).await; - }); + let task = tokio::spawn(run_upload_task( + mgr, + id_for_task, + session, + local, + final_remote, + app, + )); self.tasks.lock().await.insert(id.clone(), task); Ok(id) } @@ -2649,6 +2485,235 @@ fn split_ext(path: &str) -> (&str, &str) { } } +/// Transient = worth an auto-retry (Plan 12's structured error kinds): +/// network and timeout failures; auth/permission/not-found never retry. +fn is_transient(e: &anyhow::Error) -> bool { + matches!( + crate::error::classify_message(&format!("{e:#}")), + crate::error::ErrorKind::Network | crate::error::ErrorKind::Timeout + ) +} + +/// Shared download runner (Plan 17): admission → run loop → finalize → wake +/// the queue. The loop re-runs the file from byte 0 after a resume-from-pause +/// (Phase 2) and auto-retries transient errors with 5s/20s backoff (Phase 3). +/// The concurrency permit is held through backoff — a deliberate trade-off so +/// a retrying transfer keeps its slot. +async fn run_download_task( + mgr: Arc, + id: String, + session: Arc, + remote_path: String, + final_path: PathBuf, + app: AppHandle, +) { + let Some(_permit) = mgr.admit(&id).await else { + return; + }; + let mut auto_retries = 0u32; + let res = loop { + let attempt = dispatch_download(&mgr, &id, &session, &remote_path, &final_path, &app).await; + match attempt { + Err(e) if e.downcast_ref::().is_some() => { + mgr.update(&id, |t| t.transferred = 0).await; + continue; + } + Err(e) if auto_retries < MAX_AUTO_RETRIES && is_transient(&e) => { + auto_retries += 1; + let delay = if auto_retries == 1 { 5 } else { 20 }; + mgr.update(&id, |t| { + t.transferred = 0; + t.retry_attempt = Some(auto_retries); + t.error = Some(format!("retrying in {delay}s (attempt {}/3)", auto_retries + 1)); + }) + .await; + if let Some(t) = mgr.get(&id).await { + let _ = app.emit("transfer://updated", &t); + } + tokio::time::sleep(Duration::from_secs(delay)).await; + mgr.update(&id, |t| t.error = None).await; + continue; + } + other => break other, + } + }; + finalize(&mgr, &id, &app, res).await; + mgr.bump_queue(&app).await; +} + +/// Upload twin of `run_download_task`. +async fn run_upload_task( + mgr: Arc, + id: String, + session: Arc, + local: PathBuf, + final_remote: String, + app: AppHandle, +) { + let Some(_permit) = mgr.admit(&id).await else { + return; + }; + let mut auto_retries = 0u32; + let res = loop { + let attempt = dispatch_upload(&mgr, &id, &session, &local, &final_remote, &app).await; + match attempt { + Err(e) if e.downcast_ref::().is_some() => { + mgr.update(&id, |t| t.transferred = 0).await; + continue; + } + Err(e) if auto_retries < MAX_AUTO_RETRIES && is_transient(&e) => { + auto_retries += 1; + let delay = if auto_retries == 1 { 5 } else { 20 }; + mgr.update(&id, |t| { + t.transferred = 0; + t.retry_attempt = Some(auto_retries); + t.error = Some(format!("retrying in {delay}s (attempt {}/3)", auto_retries + 1)); + }) + .await; + if let Some(t) = mgr.get(&id).await { + let _ = app.emit("transfer://updated", &t); + } + tokio::time::sleep(Duration::from_secs(delay)).await; + mgr.update(&id, |t| t.error = None).await; + continue; + } + other => break other, + } + }; + finalize(&mgr, &id, &app, res).await; + mgr.bump_queue(&app).await; +} + +/// Backend dispatch for a single-file download. Extracted so the runner (and +/// Phase 3 retry) can re-invoke it. +async fn dispatch_download( + mgr: &Arc, + id: &str, + session: &Arc, + remote_path: &str, + final_path: &Path, + app: &AppHandle, +) -> Result<()> { + match &**session { + Session::Ssh(ssh) => { + mgr.run_ssh_download(id, ssh.clone(), remote_path, final_path, app) + .await + } + Session::Ftp(ftp) => { + mgr.run_ftp_download(id, ftp.clone(), remote_path, final_path, app) + .await + } + Session::Object(obj) => { + mgr.run_object_download(id, obj.clone(), remote_path, final_path, app) + .await + } + Session::Webdav(dav) => { + mgr.run_webdav_download(id, dav.clone(), remote_path, final_path, app) + .await + } + Session::Http(http) => { + mgr.run_http_download(id, http.clone(), remote_path, final_path, app) + .await + } + Session::Dropbox(dbx) => { + mgr.run_dropbox_download(id, dbx.clone(), remote_path, final_path, app) + .await + } + Session::OneDrive(od) => { + mgr.run_onedrive_download(id, od.clone(), remote_path, final_path, app) + .await + } + Session::GDrive(gd) => { + mgr.run_gdrive_download(id, gd.clone(), remote_path, final_path, app) + .await + } + Session::Box(bx) => { + mgr.run_box_download(id, bx.clone(), remote_path, final_path, app) + .await + } + Session::Shopify(sh) => { + mgr.run_shopify_download(id, sh.clone(), remote_path, final_path, app) + .await + } + Session::HubSpot(hs) => { + mgr.run_hubspot_download(id, hs.clone(), remote_path, final_path, app) + .await + } + Session::Dynamics(dynm) => { + mgr.run_dynamics_download(id, dynm.clone(), remote_path, final_path, app) + .await + } + Session::Agent(agent) => { + mgr.run_agent_download(id, agent.clone(), remote_path, final_path, app) + .await + } + } +} + +/// Backend dispatch for a single-file upload. +async fn dispatch_upload( + mgr: &Arc, + id: &str, + session: &Arc, + local: &Path, + final_remote: &str, + app: &AppHandle, +) -> Result<()> { + match &**session { + Session::Ssh(ssh) => { + mgr.run_ssh_upload(id, ssh.clone(), local, final_remote, app) + .await + } + Session::Ftp(ftp) => { + mgr.run_ftp_upload(id, ftp.clone(), local, final_remote, app) + .await + } + Session::Object(obj) => { + mgr.run_object_upload(id, obj.clone(), local, final_remote, app) + .await + } + Session::Webdav(dav) => { + mgr.run_webdav_upload(id, dav.clone(), local, final_remote, app) + .await + } + Session::Http(_) => Err(anyhow::anyhow!( + "HTTP source is read-only — upload not supported" + )), + Session::Dropbox(dbx) => { + mgr.run_dropbox_upload(id, dbx.clone(), local, final_remote, app) + .await + } + Session::OneDrive(od) => { + mgr.run_onedrive_upload(id, od.clone(), local, final_remote, app) + .await + } + Session::GDrive(gd) => { + mgr.run_gdrive_upload(id, gd.clone(), local, final_remote, app) + .await + } + Session::Box(bx) => { + mgr.run_box_upload(id, bx.clone(), local, final_remote, app) + .await + } + Session::Shopify(sh) => { + mgr.run_shopify_upload(id, sh.clone(), local, final_remote, app) + .await + } + Session::HubSpot(hs) => { + mgr.run_hubspot_upload(id, hs.clone(), local, final_remote, app) + .await + } + Session::Dynamics(dynm) => { + mgr.run_dynamics_upload(id, dynm.clone(), local, final_remote, app) + .await + } + Session::Agent(agent) => { + mgr.run_agent_upload(id, agent.clone(), local, final_remote, app) + .await + } + } +} + async fn finalize( mgr: &Arc, id: &str, From a923185a6ab656651756ec55d04667be84e65766 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 2 Aug 2026 00:17:22 -0400 Subject: [PATCH 4/7] feat(transfers): global bandwidth throttle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/commands.rs | 11 +++++ src-tauri/src/lib.rs | 6 +++ src-tauri/src/transfer.rs | 88 +++++++++++++++++++++++++++++++++++---- 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index da64a7a..eb10cb1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -827,6 +827,17 @@ pub async fn transfer_set_concurrency( Ok(()) } +/// 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(()) +} + /// Queue snapshot for the panel's initial load (waiting FIFO + pause-all + /// concurrency + throttle). #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6836e05..858349c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -260,6 +260,11 @@ pub fn run() { st.transfers.set_concurrency(n); } } + if let Ok(Some(raw)) = st.db.settings_get("transferThrottleKbps") { + if let Ok(kbps) = serde_json::from_str::(&raw) { + st.transfers.set_throttle_kbps(kbps); + } + } } // Bring the Agent Bridge back up if the user left its master switch @@ -408,6 +413,7 @@ pub fn run() { commands::transfer_pause_all, commands::transfer_resume_all, commands::transfer_set_concurrency, + commands::transfer_set_throttle, commands::transfer_queue_state, commands::rename_path, commands::delete_path, diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs index 950385b..cd9f714 100644 --- a/src-tauri/src/transfer.rs +++ b/src-tauri/src/transfer.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter}; @@ -69,6 +69,70 @@ pub struct QueueState { pub throttle_kbps: u64, } +/// Global bandwidth cap shared by every active copy loop (Plan 17 Phase 4): +/// a token bucket refilling at `rate` bytes/sec (0 = unlimited). Because all +/// transfers draw from this one bucket, the cap is split across active +/// transfers rather than applied per transfer. +struct TokenBucket { + inner: Mutex, + rate_bps: AtomicU64, +} + +struct BucketInner { + tokens: f64, + last: Instant, +} + +impl TokenBucket { + fn new() -> Self { + Self { + inner: Mutex::new(BucketInner { + tokens: 0.0, + last: Instant::now(), + }), + rate_bps: AtomicU64::new(0), + } + } + + fn rate_kbps(&self) -> u64 { + self.rate_bps.load(Ordering::Relaxed) / 1024 + } + + fn set_rate_kbps(&self, kbps: u64) { + self.rate_bps + .store(kbps.saturating_mul(1024), Ordering::Relaxed); + } + + /// Wait until `bytes` may flow under the cap. A grant is capped at one + /// second's worth of rate (min 64 KiB) so even a tiny rate always lets a + /// chunk through — a 1 KiB file never waits a full token window. + async fn acquire(&self, bytes: u64) { + loop { + let rate = self.rate_bps.load(Ordering::Relaxed); + if rate == 0 { + return; + } + let wait = { + let mut g = self.inner.lock().await; + let now = Instant::now(); + let elapsed = now.duration_since(g.last).as_secs_f64(); + let rate_f = rate as f64; + let cap = rate_f.max(64.0 * 1024.0); + g.tokens = (g.tokens + elapsed * rate_f).min(cap); + g.last = now; + let grant = (bytes as f64).min(cap); + if g.tokens >= grant { + g.tokens -= grant; + return; + } + Duration::from_secs_f64((grant - g.tokens) / rate_f) + }; + // Cap the sleep so a live rate change takes effect promptly. + tokio::time::sleep(wait.min(Duration::from_millis(250))).await; + } + } +} + /// A pause gate shared by the scheduler (pause-all) and individual transfers /// (Phase 2). watch-channel based so waiters never miss a wakeup. #[derive(Debug, Clone)] @@ -159,6 +223,8 @@ pub struct TransferManager { retry: Mutex>, /// Bumped on every queue change so admission waiters re-check their turn. queue_gen: watch::Sender, + /// Global bandwidth cap every copy loop draws from per chunk (Phase 4). + bucket: TokenBucket, } fn now_ts() -> i64 { @@ -233,6 +299,7 @@ impl TransferManager { pauses: Mutex::new(HashMap::new()), retry: Mutex::new(HashMap::new()), queue_gen: watch::channel(0).0, + bucket: TokenBucket::new(), } } @@ -269,7 +336,7 @@ impl TransferManager { waiting: waiting.iter().cloned().collect(), paused_all: self.pause_all.is_paused(), concurrency: self.concurrency.load(Ordering::Relaxed), - throttle_kbps: 0, // wired up in Phase 4 + throttle_kbps: self.bucket.rate_kbps(), } } @@ -381,11 +448,12 @@ impl TransferManager { self.pause_all.is_paused() } - /// Chunk-boundary checkpoint shared by every copy loop (Plan 17). Phase 4 - /// draws `_bytes` from the bandwidth bucket here. When the transfer (or - /// the whole manager) is paused, this parks until resumed, then returns - /// `RestartFromPause` so the runner re-runs the file from byte 0. - async fn checkpoint(&self, id: &str, _bytes: u64) -> Result<()> { + /// Chunk-boundary checkpoint shared by every copy loop (Plan 17). Draws + /// `bytes` from the global bandwidth bucket (Phase 4), then — when the + /// transfer (or the whole manager) is paused — parks until resumed and + /// returns `RestartFromPause` so the runner re-runs the file from byte 0. + async fn checkpoint(&self, id: &str, bytes: u64) -> Result<()> { + self.bucket.acquire(bytes).await; let gate = self.pauses.lock().await.get(id).cloned(); let parked = self.pause_all.is_paused() || gate.as_ref().is_some_and(|g| g.is_paused()); if !parked { @@ -525,6 +593,12 @@ impl TransferManager { Ok(()) } + /// Live-adjust the global bandwidth cap (KiB/s, 0 = unlimited). Takes + /// effect on the next chunk of every active transfer. + pub fn set_throttle_kbps(&self, kbps: u64) { + self.bucket.set_rate_kbps(kbps); + } + /// Live-adjust the concurrency bound. Growing adds permits at once; /// shrinking forgets permits as running transfers release them, so /// in-flight transfers are never killed to satisfy the new bound. From 01af86b70466b3d13a4524380623f8474a83f479 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 2 Aug 2026 00:25:59 -0400 Subject: [PATCH 5/7] =?UTF-8?q?feat(transfers):=20queue=20UI=20=E2=80=94?= =?UTF-8?q?=20pause/retry/reorder=20controls,=20throttle,=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/Settings.tsx | 23 ++++ src/components/TransferQueue.tsx | 207 +++++++++++++++++++++++++++---- src/lib/ipc.ts | 37 +++++- src/lib/notifications.ts | 8 ++ src/lib/types.ts | 14 +++ src/stores/settingsStore.ts | 19 +++ src/stores/transfersStore.ts | 89 ++++++++++++- 7 files changed, 369 insertions(+), 28 deletions(-) diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 0a8db69..b61b2a9 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -149,6 +149,29 @@ export function Settings({ onClose }: Props) { checked={s.autoOpenTransferPanel} onChange={s.setAutoOpenTransferPanel} /> + + + + + + { let unsub: (() => void) | undefined; @@ -23,8 +51,9 @@ export function TransferQueue() { if (!panelOpen) return null; const active = transfers.filter( - (t) => t.status === "transferring" || t.status === "queued" + (t) => t.status === "transferring" || t.status === "paused" ).length; + const queued = queue.length; return (
@@ -32,12 +61,20 @@ export function TransferQueue() { Transfers - {active > 0 && ( + {(active > 0 || queued > 0) && ( - {active} active + {active} active · {queued} queued )}
+ +
); } -function Row({ t, onCancel }: { t: Transfer; onCancel: () => void }) { +/** Global bandwidth cap (KiB/s, 0 = unlimited). Commits on blur or Enter. */ +function ThrottleInput({ + value, + onCommit, +}: { + value: number; + onCommit: (kbps: number) => void; +}) { + const [draft, setDraft] = useState(String(value)); + + // Follow external changes (settings sync / other windows) while not editing. + useEffect(() => { + setDraft(String(value)); + }, [value]); + + const commit = () => { + const n = Math.max(0, parseInt(draft) || 0); + setDraft(String(n)); + if (n !== value) onCommit(n); + }; + + return ( + + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + }} + className="w-14 rounded border border-border bg-bg-panel px-1.5 py-0.5 text-[11px] text-text outline-none focus:border-accent" + /> + KiB/s + + ); +} + +function Row({ + t, + queue, + onCancel, + onPause, + onResume, + onRetry, + onMove, +}: { + t: Transfer; + queue: string[]; + onCancel: () => void; + onPause: () => void; + onResume: () => void; + onRetry: () => void; + onMove: (dir: "up" | "down") => void; +}) { const Icon = t.kind === "download" ? ArrowDownToLine : ArrowUpFromLine; const pct = t.size > 0 ? Math.min(100, (t.transferred / t.size) * 100) : 0; + // Mid-auto-retry: the error text reads "retrying in Ns (attempt N/3)". + const retrying = t.retryAttempt !== undefined && !!t.error; + const queuePos = queue.indexOf(t.id); + + const statusLabel = + t.status === "transferring" + ? `${pct.toFixed(0)}%` + : t.status === "done" + ? "done" + : t.status === "error" + ? "error" + : t.status === "canceled" + ? "canceled" + : t.status === "paused" + ? "Paused" + : t.status === "queued" && queuePos >= 0 + ? `#${queuePos + 1} in queue` + : t.status; return (
@@ -99,24 +218,70 @@ function Row({ t, onCancel }: { t: Transfer; onCancel: () => void }) {
)} {t.error && ( -
{t.error}
+
+ {t.error} +
)}
{fmtSize(t.transferred)}
-
- {t.status === "transferring" - ? `${pct.toFixed(0)}%` - : t.status === "done" - ? "done" - : t.status === "error" - ? "error" - : t.status === "canceled" - ? "canceled" - : t.status} -
+
{statusLabel}
+ {t.status === "queued" && queuePos >= 0 && ( + <> + + + + )} {(t.status === "transferring" || t.status === "queued") && ( + + )} + {t.status === "paused" && ( + + )} + {(t.status === "error" || t.status === "canceled") && ( + + )} + {(t.status === "transferring" || + t.status === "queued" || + t.status === "paused") && (