Skip to content

Commit 16b49a0

Browse files
committed
Transfer: Honest ETA when files outnumber bytes
- New `EtaEstimator` in `write_operations/eta.rs` tracks bytes/s and files/s independently with time-weighted EWMA (τ ≈ 3 s) and combines via `max(ETA_bytes, ETA_files)`. The operation can't finish before either axis is done, so the larger remaining time is reality. - Fixes the small-file-tail bug: deleting 5 GB / 174k files showed `~0 s remaining` for 20 s once the size bar saturated. ETA now tracks whichever axis still has work. - `WriteProgressEvent` gains `bytesPerSecond`, `filesPerSecond`, `etaSeconds` — populated by `WriteOperationState::enrich_progress` at every emit site (local copy/delete/trash/scan/move + volume copy/move). One estimator per operation, lives in state. - Pure EWMA, no overall-average anchor — adapts to network changes within ~3τ instead of being pulled toward historical numbers. Warm-up gate (≥ 2 samples AND ≥ 800 ms) kills early-readout flicker. - `TransferProgressDialog` ripped out its blended-speed math; renders both speeds side by side ("27.7 MB/s · 1,234 files/s"). 25%/tick display low-pass on ETA prevents flicker without dampening real changes. - 10 unit tests covering byte-heavy, file-heavy, big-then-small-tail pathology, mid-op slowdown convergence, brief stall + resume, phase transitions, and rollback. All write_operations tests (161) still green.
1 parent 3c12ff2 commit 16b49a0

17 files changed

Lines changed: 953 additions & 239 deletions

File tree

apps/desktop/src-tauri/src/file_system/volume/smb.rs

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3502,7 +3502,6 @@ mod tests {
35023502
use crate::file_system::write_operations::{
35033503
CollectorEventSink, VolumeCopyConfig, WriteOperationState, copy_volumes_with_progress,
35043504
};
3505-
use std::sync::atomic::AtomicU8;
35063505
use std::time::{Duration, Instant};
35073506

35083507
// Content scheme: `blake3(b"cmdr-fix8-" || index_le) .as_bytes() repeated 320 times`
@@ -3564,11 +3563,7 @@ mod tests {
35643563
local_dir.path().to_path_buf(),
35653564
));
35663565

3567-
let state = Arc::new(WriteOperationState {
3568-
intent: Arc::new(AtomicU8::new(0)),
3569-
progress_interval: Duration::from_millis(200),
3570-
conflict_resolution_tx: std::sync::Mutex::new(None),
3571-
});
3566+
let state = Arc::new(WriteOperationState::new(Duration::from_millis(200)));
35723567
let events = CollectorEventSink::new();
35733568
let config = VolumeCopyConfig::default();
35743569

@@ -3777,7 +3772,6 @@ mod tests {
37773772
use crate::file_system::write_operations::{
37783773
CollectorEventSink, VolumeCopyConfig, WriteOperationState, copy_volumes_with_progress,
37793774
};
3780-
use std::sync::atomic::AtomicU8;
37813775
use std::time::{Duration, Instant};
37823776

37833777
let _ = env_logger::try_init();
@@ -3889,11 +3883,7 @@ mod tests {
38893883
"dest",
38903884
local_dir.path().to_path_buf(),
38913885
));
3892-
let state = Arc::new(WriteOperationState {
3893-
intent: Arc::new(AtomicU8::new(0)),
3894-
progress_interval: Duration::from_millis(200),
3895-
conflict_resolution_tx: std::sync::Mutex::new(None),
3896-
});
3886+
let state = Arc::new(WriteOperationState::new(Duration::from_millis(200)));
38973887
let events = CollectorEventSink::new();
38983888
let config = VolumeCopyConfig::default();
38993889

apps/desktop/src-tauri/src/file_system/write_operations/CLAUDE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ network mounts, cross-filesystem moves, and name/path length limits.
2121
| `copy.rs` | `copy_files_with_progress`: scan → disk space check → per-file copy via `copy_single_item`. `CopyTransaction` for rollback. |
2222
| `move_op.rs` | Same-fs: `fs::rename`. Cross-fs: copy to `.cmdr-staging-<uuid>`, atomic rename, delete sources. |
2323
| `delete.rs` | Scan, delete files first, then directories in reverse/deepest-first order. Not rollbackable. Also contains `delete_volume_files_with_progress` for non-local volumes (MTP): scans via `volume.list_directory()`, deletes via `volume.delete()` per item. |
24+
| `eta.rs` | `EtaEstimator` — time-weighted EWMA per axis (bytes, files), τ ≈ 3 s. Combines via `max(ETA_bytes, ETA_files)`. One per `WriteOperationState`, fed by `state.enrich_progress` at every `write-progress` emit site. See [ETA + throughput](#eta--throughput) below. |
2425
| `trash.rs` | `move_to_trash_sync()` (macOS: ObjC `trashItemAtURL`; Linux: `trash` crate; reused by `commands/rename.rs`) and `trash_files_with_progress()` (batch trash with per-item progress, cancellation, partial failure). Uses `symlink_metadata()` for existence checks (handles dangling symlinks). |
2526
| `copy_strategy.rs` | Strategy selection per file: network FS → chunked copy; overwrite → temp+rename; macOS → `copyfile(3)`; Linux → `copy_file_range(2)`. |
2627
| `macos_copy.rs` | FFI to macOS `copyfile(3)`. Preserves xattrs, ACLs, resource forks, Finder metadata. Supports APFS `clonefile`. |
@@ -58,6 +59,24 @@ Frontend
5859
→ state removed from both caches
5960
```
6061

62+
## ETA + throughput
63+
64+
Rates and ETA are computed in the backend (`eta.rs`) and shipped on every `WriteProgressEvent` as `bytes_per_second`, `files_per_second`, and `eta_seconds`. The frontend renders these directly — no client-side math, no sample buffer.
65+
66+
**Why backend, not frontend:** one place to test, one set of fields exposed on the wire, identical behavior across copy/move/delete/MTP/SMB/local. Putting the math in Svelte couples the estimator to dialog lifecycle and makes any future client (CLI, menu bar app) reinvent it.
67+
68+
**Why two axes, not one:** the bug we hit in May 2026 was a delete of 5.4 GB / 174k files where the size bar saturated in the first second (a few large files) and the byte-based ETA collapsed to ~0 s while 165k small files were still streaming through. The estimator now tracks bytes/sec and files/sec independently and reports `eta = max(ETA_bytes, ETA_files)`. The operation can't finish before either axis is done, so the larger one is reality. When one axis has zero remaining work, its ETA is `0` and the other axis dominates naturally — no branching needed.
69+
70+
**EWMA, not blended overall:** `α = 1 - exp(-Δt / τ)` with `τ = 3 s` (see `EWMA_TAU_SECS`). Pure exponential decay, no "overall average" anchor — if the network drops mid-operation, the EWMA converges to the new rate within a few τ instead of being pulled back toward historical numbers. Time-weighted means the response is the same whether progress events arrive every 50 ms or every 500 ms.
71+
72+
**Warm-up:** the estimator returns `None` for ETA until it has ≥ 2 samples in the current phase AND ≥ 800 ms elapsed (`MIN_SAMPLES_FOR_ETA`, `MIN_ELAPSED_FOR_ETA`). This kills the early "200 ms in, rate = 50 MB/s → ETA = 0 s" footgun. Rates are populated as soon as we have the first delta; only the ETA is gated.
73+
74+
**Phase transitions reset:** `update()` reseeds on every `phase` change. Without this, the counters' reset (scanning → copying both restart from 0) would feed a negative delta into the EWMA. Rollback is treated as a forward phase toward target `(0, 0)` — the estimator subtracts the new counters from the previous ones and ETA = current value / decay rate.
75+
76+
**Wiring:** every `write-progress` emit site calls `state.emit_progress_via_app(app, event)` (for the AppHandle-direct path: copy/delete/trash/scan/move) or `state.emit_progress_via_sink(events, event)` (for the `OperationEventSink` path: volume copy/move). Both methods call `enrich_progress` internally, so no caller has to remember. The `bytes_per_second: None, files_per_second: None, eta_seconds: None` placeholders in the struct literals get overwritten before the event reaches the FE.
77+
78+
**Frontend display:** `TransferProgressDialog.svelte` stores the three fields in local `$state` and renders both speeds side by side ("27.7 MB/s · 1,234 files/s"). A tiny low-pass on the displayed ETA (25% gap-closure per tick) prevents flicker without dampening real changes. The display ETA also resets to `null` on phase transitions to re-warm with the backend.
79+
6180
## Key patterns and gotchas
6281

6382
**All blocking work in `spawn_blocking`.** Never call blocking I/O on the async executor.

apps/desktop/src-tauri/src/file_system/write_operations/copy.rs

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,8 @@ pub(super) fn copy_files_with_progress(
156156
let mut created_dirs: HashSet<PathBuf> = HashSet::new();
157157

158158
// Emit initial copying phase event (important when reusing cached scan - no scanning events were emitted)
159-
let _ = app.emit(
160-
"write-progress",
159+
state.emit_progress_via_app(
160+
app,
161161
WriteProgressEvent {
162162
operation_id: operation_id.to_string(),
163163
operation_type: WriteOperationType::Copy,
@@ -167,6 +167,10 @@ pub(super) fn copy_files_with_progress(
167167
files_total: scan_result.file_count,
168168
bytes_done: 0,
169169
bytes_total: scan_result.total_bytes,
170+
171+
bytes_per_second: None,
172+
files_per_second: None,
173+
eta_seconds: None,
170174
},
171175
);
172176
update_operation_status(
@@ -365,8 +369,6 @@ pub(super) fn copy_single_item(
365369
apply_to_all_resolution: &mut Option<ConflictResolution>,
366370
created_dirs: &mut HashSet<PathBuf>,
367371
) -> Result<(), WriteOperationError> {
368-
use tauri::Emitter;
369-
370372
// Check cancellation
371373
if is_cancelled(&state.intent) {
372374
log::debug!(
@@ -620,8 +622,8 @@ pub(super) fn copy_single_item(
620622
effective_bytes_done,
621623
bytes_total
622624
);
623-
let _ = app.emit(
624-
"write-progress",
625+
state.emit_progress_via_app(
626+
app,
625627
WriteProgressEvent {
626628
operation_id: operation_id.to_string(),
627629
operation_type,
@@ -631,6 +633,10 @@ pub(super) fn copy_single_item(
631633
files_total,
632634
bytes_done: effective_bytes_done,
633635
bytes_total,
636+
637+
bytes_per_second: None,
638+
files_per_second: None,
639+
eta_seconds: None,
634640
},
635641
);
636642
update_operation_status(
@@ -669,8 +675,8 @@ pub(super) fn copy_single_item(
669675
*bytes_done,
670676
bytes_total
671677
);
672-
let _ = app.emit(
673-
"write-progress",
678+
state.emit_progress_via_app(
679+
app,
674680
WriteProgressEvent {
675681
operation_id: operation_id.to_string(),
676682
operation_type,
@@ -680,6 +686,10 @@ pub(super) fn copy_single_item(
680686
files_total,
681687
bytes_done: *bytes_done,
682688
bytes_total,
689+
690+
bytes_per_second: None,
691+
files_per_second: None,
692+
eta_seconds: None,
683693
},
684694
);
685695
update_operation_status(
@@ -727,15 +737,13 @@ fn rollback_with_progress(
727737
files_total: usize,
728738
bytes_total: u64,
729739
) -> bool {
730-
use tauri::Emitter;
731-
732740
let files_to_delete = transaction.created_files.len();
733741
let mut files_deleted = 0usize;
734742
let mut last_progress_time = Instant::now();
735743

736744
// Emit initial rollback phase event (same values as cancellation point)
737-
let _ = app.emit(
738-
"write-progress",
745+
state.emit_progress_via_app(
746+
app,
739747
WriteProgressEvent {
740748
operation_id: operation_id.to_string(),
741749
operation_type,
@@ -745,6 +753,10 @@ fn rollback_with_progress(
745753
files_total,
746754
bytes_done: bytes_at_cancel,
747755
bytes_total,
756+
757+
bytes_per_second: None,
758+
files_per_second: None,
759+
eta_seconds: None,
748760
},
749761
);
750762
update_operation_status(
@@ -788,8 +800,8 @@ fn rollback_with_progress(
788800
.file_name()
789801
.map(|n| n.to_string_lossy().to_string())
790802
.unwrap_or_default();
791-
let _ = app.emit(
792-
"write-progress",
803+
state.emit_progress_via_app(
804+
app,
793805
WriteProgressEvent {
794806
operation_id: operation_id.to_string(),
795807
operation_type,
@@ -799,6 +811,10 @@ fn rollback_with_progress(
799811
files_total,
800812
bytes_done: remaining_bytes,
801813
bytes_total,
814+
815+
bytes_per_second: None,
816+
files_per_second: None,
817+
eta_seconds: None,
802818
},
803819
);
804820
update_operation_status(

apps/desktop/src-tauri/src/file_system/write_operations/delete.rs

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ pub(super) fn delete_files_with_progress(
101101
// Emit progress
102102
if last_progress_time.elapsed() >= state.progress_interval {
103103
let current_file = file_info.path.file_name().map(|n| n.to_string_lossy().to_string());
104-
let _ = app.emit(
105-
"write-progress",
104+
state.emit_progress_via_app(
105+
app,
106106
WriteProgressEvent {
107107
operation_id: operation_id.to_string(),
108108
operation_type: WriteOperationType::Delete,
@@ -112,6 +112,10 @@ pub(super) fn delete_files_with_progress(
112112
files_total: scan_result.file_count,
113113
bytes_done,
114114
bytes_total: scan_result.total_bytes,
115+
116+
bytes_per_second: None,
117+
files_per_second: None,
118+
eta_seconds: None,
115119
},
116120
);
117121
update_operation_status(
@@ -194,8 +198,6 @@ async fn scan_volume_recursive(
194198
operation_id: &str,
195199
last_progress_time: &mut Instant,
196200
) -> Result<(), WriteOperationError> {
197-
use tauri::Emitter;
198-
199201
if super::state::is_cancelled(&state.intent) {
200202
return Err(WriteOperationError::Cancelled {
201203
message: "Operation cancelled by user".to_string(),
@@ -261,8 +263,8 @@ async fn scan_volume_recursive(
261263
if last_progress_time.elapsed() >= state.progress_interval {
262264
let file_count = entries.iter().filter(|e| !e.is_dir).count();
263265
let current_file = path.file_name().map(|n| n.to_string_lossy().to_string());
264-
let _ = app.emit(
265-
"write-progress",
266+
state.emit_progress_via_app(
267+
app,
266268
WriteProgressEvent {
267269
operation_id: operation_id.to_string(),
268270
operation_type: WriteOperationType::Delete,
@@ -272,6 +274,10 @@ async fn scan_volume_recursive(
272274
files_total: 0,
273275
bytes_done: *total_bytes,
274276
bytes_total: 0,
277+
278+
bytes_per_second: None,
279+
files_per_second: None,
280+
eta_seconds: None,
275281
},
276282
);
277283
update_operation_status(
@@ -344,8 +350,8 @@ pub(super) async fn delete_volume_files_with_progress(
344350
let file_count = entries.iter().filter(|e| !e.is_dir).count();
345351

346352
// Emit final scan progress
347-
let _ = app.emit(
348-
"write-progress",
353+
state.emit_progress_via_app(
354+
app,
349355
WriteProgressEvent {
350356
operation_id: operation_id.to_string(),
351357
operation_type: WriteOperationType::Delete,
@@ -355,6 +361,10 @@ pub(super) async fn delete_volume_files_with_progress(
355361
files_total: file_count,
356362
bytes_done: total_bytes,
357363
bytes_total: total_bytes,
364+
365+
bytes_per_second: None,
366+
files_per_second: None,
367+
eta_seconds: None,
358368
},
359369
);
360370

@@ -408,8 +418,8 @@ pub(super) async fn delete_volume_files_with_progress(
408418

409419
if last_progress_time.elapsed() >= state.progress_interval {
410420
let current_file = entry.path.file_name().map(|n| n.to_string_lossy().to_string());
411-
let _ = app.emit(
412-
"write-progress",
421+
state.emit_progress_via_app(
422+
app,
413423
WriteProgressEvent {
414424
operation_id: operation_id.to_string(),
415425
operation_type: WriteOperationType::Delete,
@@ -419,6 +429,10 @@ pub(super) async fn delete_volume_files_with_progress(
419429
files_total: file_count,
420430
bytes_done,
421431
bytes_total: total_bytes,
432+
433+
bytes_per_second: None,
434+
files_per_second: None,
435+
eta_seconds: None,
422436
},
423437
);
424438
update_operation_status(

0 commit comments

Comments
 (0)