Skip to content

Commit 4737acb

Browse files
committed
Copy ETA: stop decaying files_rate during long single-file streams
The EWMA on `files_rate` updated on every progress sample, including those where `delta_files == 0` (no file completed). During a long single-file stream (e.g. a 500 MB Pixel video over MTP at ~28 MB/s for 20 s = ~90 samples), the rate decayed by `0.936^90 ≈ 0.001` per step from its last positive value, ending up near `7e-4 files/s`. That made `eta_files = remaining / 7e-4` explode to ~1.4M s, and `max(eta_bytes, eta_files)` then picked the bogus value (a 393 h ETA on a 22 min copy in the user's screenshot). Fix: only update `state.files_rate` when `delta_files > 0`. Zero-delta samples are "no information about the files axis" (the file is still streaming), not a true zero rate. `state.bytes_rate` is unaffected because bytes flow continuously during streaming. Preserves the May 2026 174k-tiny-files fix (`big_first_then_small_tail_keeps_eta_alive`): when files complete continuously, every sample has `delta_files > 0` and the EWMA updates normally. Added `long_single_file_stream_does_not_decay_files_rate_to_zero` regression test: 6 small files → 90 zero-delta samples of one big file. Asserts `eta_seconds < 10_000` and `files_per_second >= 0.1`.
1 parent 10a952a commit 4737acb

1 file changed

Lines changed: 90 additions & 1 deletion

File tree

  • apps/desktop/src-tauri/src/file_system/write_operations

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

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,17 @@ impl EtaEstimator {
164164
state.files_rate = inst_files_rate;
165165
} else {
166166
state.bytes_rate = alpha * inst_bytes_rate + (1.0 - alpha) * state.bytes_rate;
167-
state.files_rate = alpha * inst_files_rate + (1.0 - alpha) * state.files_rate;
167+
// Only update files_rate when a file actually completed. File
168+
// completions are bursty (one whole file at a time), so EWMA-ing
169+
// `delta_files == 0` samples decays the rate toward zero during
170+
// long single-file streams (e.g. a 500 MB video over MTP). That
171+
// makes `eta_files` explode, and `max(eta_bytes, eta_files)` picks
172+
// the bogus value (a 393 h ETA on a 22 min copy). Treat zero-delta
173+
// samples as "no information"; keep the last positive rate until
174+
// another completion arrives.
175+
if delta_files > 0.0 {
176+
state.files_rate = alpha * inst_files_rate + (1.0 - alpha) * state.files_rate;
177+
}
168178
}
169179

170180
state.last_t = now;
@@ -398,6 +408,85 @@ mod tests {
398408
assert!(last.files_per_second > 1000.0);
399409
}
400410

411+
/// The pathological inverse of `big_first_then_small_tail_keeps_eta_alive`:
412+
/// small files first, then a long single-file stream (e.g. a 500 MB video
413+
/// from a phone). `delta_files == 0` for many samples in a row while bytes
414+
/// keep flowing — historically the EWMA decayed `files_rate` to ~0.001,
415+
/// which made `eta_files` explode to >100 hours and `max(eta_bytes, eta_files)`
416+
/// picked the bogus value. Fix: skip the `files_rate` EWMA update when
417+
/// `delta_files == 0`. ETA must stay bytes-rate-bounded in this scenario.
418+
#[test]
419+
fn long_single_file_stream_does_not_decay_files_rate_to_zero() {
420+
let start = Instant::now();
421+
let mut est = EtaEstimator::new();
422+
let bytes_total = 35_000_000_000_u64; // 35 GB total
423+
let files_total = 1_046_usize;
424+
425+
// Phase 1 (0–6 s): 6 small-to-medium files complete at ~1/s.
426+
// Each ~80 MB at ~80 MB/s. After this: 480 MB done, 6 files done.
427+
est.update(
428+
at(start, 0),
429+
WriteOperationPhase::Copying,
430+
0,
431+
bytes_total,
432+
0,
433+
files_total,
434+
);
435+
for i in 1..=6 {
436+
let t = i * 1000;
437+
est.update(
438+
at(start, t),
439+
WriteOperationPhase::Copying,
440+
i * 80_000_000,
441+
bytes_total,
442+
i as usize,
443+
files_total,
444+
);
445+
}
446+
447+
// Phase 2 (6–24 s): one big 500 MB video streams in. Bytes flow at
448+
// ~28 MB/s (560 MB over 20 s); no file completes for 90 sample points
449+
// at 200 ms each. This is the regime that used to wreck `files_rate`.
450+
let mut last = EtaStats::ZERO;
451+
let mut bytes_done = 480_000_000_u64;
452+
for i in 1..=90 {
453+
let t = 6_000 + i * 200;
454+
bytes_done += 5_600_000; // 5.6 MB per 200 ms = 28 MB/s
455+
last = est.update(
456+
at(start, t),
457+
WriteOperationPhase::Copying,
458+
bytes_done,
459+
bytes_total,
460+
6, // ← no completion across all 90 samples
461+
files_total,
462+
);
463+
}
464+
465+
// Sanity: bytes_rate stays healthy across the long stream.
466+
assert!(
467+
last.bytes_per_second >= 25_000_000 && last.bytes_per_second <= 32_000_000,
468+
"bytes_per_second = {} should remain ~28 MB/s during the long stream",
469+
last.bytes_per_second,
470+
);
471+
472+
// The bug: `files_rate` decayed to ~7e-4 → `eta_files` ≈ 1040/7e-4 = 1.4M s.
473+
// After the fix `files_rate` stays at the last positive EWMA value (~0.6)
474+
// so `eta_files` stays bounded (~1700 s).
475+
let eta = last.eta_seconds.expect("warmed up by now");
476+
assert!(
477+
eta < 10_000,
478+
"ETA exploded to {eta} s: files_rate decay during a long single-file stream broke the readout",
479+
);
480+
// And the files-axis rate must not have collapsed below a believable floor.
481+
// 6 completions in the first 6 s seeded the EWMA around 1 files/s; the
482+
// 90 zero-delta samples after the stream should not drag it below 0.1.
483+
assert!(
484+
last.files_per_second >= 0.1,
485+
"files_per_second = {} collapsed below 0.1 during the zero-delta stream",
486+
last.files_per_second,
487+
);
488+
}
489+
401490
/// Mid-operation slowdown: starts at 60 MB/s, drops to 6 MB/s. The EWMA
402491
/// must converge to the new rate within ~3τ (≈ 9 s), not stay anchored to
403492
/// the historical average.

0 commit comments

Comments
 (0)