fix: SpeedTracker uses monotonic bytes + 15s smoothing for accurate speed display

Speed display was wildly inaccurate during parallel multipart uploads because:
1. Parallel chunk progress events caused non-monotonic byte counts
2. The 5-second smoothing window was too short for bursty uploads
3. Speed dropped to near-zero between chunk boundaries

Fixes:
- Enforce monotonic byte tracking via _peak guard
- Extend sliding window from 5s to 15s
- Cache last good speed value for display continuity
- Require >= 1s of data before calculating (was 0.5s)
This commit is contained in:
Zac Gaetano 2026-04-12 13:34:03 -04:00
parent 5113adb635
commit 2e3ac69f97

View file

@ -1135,28 +1135,38 @@ async function startUpload() {
// Falls back to server-proxied chunked upload if presigned fails. // Falls back to server-proxied chunked upload if presigned fails.
// ============================================================ // ============================================================
// Upload speed tracker — smoothed speed + ETA // Upload speed tracker — smoothed speed + ETA
// Uses monotonically-increasing cumulative bytes with a 15-second sliding
// window so that parallel chunk uploads don't cause wild speed fluctuations.
class SpeedTracker { class SpeedTracker {
constructor(totalBytes) { constructor(totalBytes) {
this.total = totalBytes; this.total = totalBytes;
this.startTime = Date.now(); this.startTime = Date.now();
this.samples = []; // {time, bytes} this.samples = []; // {time, bytes} — cumulative bytes only
this.uploaded = 0; this.uploaded = 0;
this._peak = 0; // highest cumulative value seen (monotonic guard)
this._lastSpeed = 0; // cache last good speed for display continuity
} }
update(bytes) { update(bytes) {
// Enforce monotonic: with parallel chunks, in-flight progress can
// temporarily report a lower total when a new chunk starts from 0.
if (bytes < this._peak) return;
this._peak = bytes;
this.uploaded = bytes; this.uploaded = bytes;
const now = Date.now(); const now = Date.now();
this.samples.push({ time: now, bytes }); this.samples.push({ time: now, bytes });
// Keep last 5 seconds of samples for smoothing // 15-second sliding window — long enough to smooth out inter-chunk gaps
const cutoff = now - 5000; const cutoff = now - 15000;
this.samples = this.samples.filter(s => s.time >= cutoff); this.samples = this.samples.filter(s => s.time >= cutoff);
} }
speed() { speed() {
if (this.samples.length < 2) return 0; if (this.samples.length < 2) return this._lastSpeed;
const first = this.samples[0]; const first = this.samples[0];
const last = this.samples[this.samples.length - 1]; const last = this.samples[this.samples.length - 1];
const dt = (last.time - first.time) / 1000; const dt = (last.time - first.time) / 1000;
if (dt < 0.5) return 0; if (dt < 1) return this._lastSpeed; // need >= 1 s of data
return (last.bytes - first.bytes) / dt; const s = (last.bytes - first.bytes) / dt;
if (s > 0) this._lastSpeed = s;
return this._lastSpeed;
} }
eta() { eta() {
const s = this.speed(); const s = this.speed();