fix(upload): self-aborting watchdog + lower concurrency to end stalls

A folder upload with several large files could appear frozen for ~2 min: a few
concurrent uploads stalled and the old 120s per-file timeout neither aborted the
request (leaving zombie XHRs that exhaust the browser's per-host connection pool)
nor recovered quickly.

- uploadFileWithProgress now self-aborts on a stalled connection: the deadline
  resets on every upload-progress tick (a slow but *moving* transfer is fine),
  and once the body is sent the server gets a fixed window to respond; on a stall
  xhr.abort() frees the connection immediately — no zombie, no cascade.
- Lower upload concurrency 4 -> 3 to reduce server contention from large
  concurrent uploads.
- The outer per-file timeout is now just a generous backstop for a wedged delta
  worker / by-hash request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-20 18:33:53 +02:00
parent edfbd68e6c
commit b58b2d8f95
2 changed files with 35 additions and 9 deletions
+27 -1
View File
@@ -76,10 +76,28 @@ export function uploadFileWithProgress(
xhr.open('POST', '/api/files/upload');
xhr.withCredentials = true;
for (const [k, v] of Object.entries(getCsrfHeaders())) xhr.setRequestHeader(k, v);
// Self-aborting watchdog so a stalled connection can never pin an upload
// slot forever (and leave a zombie XHR holding one of the browser's few
// per-host connections). While the body is uploading we reset the deadline
// on every progress tick — a slow but *moving* transfer is fine; once the
// body is fully sent we give the server a fixed window to respond. On a
// stall we `xhr.abort()`, which frees the connection immediately.
const SEND_STALL_MS = 30_000;
const RESPONSE_MS = 60_000;
let watchdog: ReturnType<typeof setTimeout>;
const arm = (ms: number) => {
clearTimeout(watchdog);
watchdog = setTimeout(() => xhr.abort(), ms);
};
xhr.upload.onprogress = (e) => {
onProgress(e.lengthComputable ? e.loaded / e.total : NaN);
arm(SEND_STALL_MS);
};
xhr.upload.onload = () => arm(RESPONSE_MS); // body sent — wait for the server
xhr.onload = () => {
clearTimeout(watchdog);
if (xhr.status >= 200 && xhr.status < 300) resolve();
else {
// Flag quota so a batch can stop early instead of retrying every file.
@@ -88,7 +106,15 @@ export function uploadFileWithProgress(
reject(err);
}
};
xhr.onerror = () => reject(new Error('upload failed: network error'));
xhr.onerror = () => {
clearTimeout(watchdog);
reject(new Error('upload failed: network error'));
};
xhr.onabort = () => {
clearTimeout(watchdog);
reject(new Error('upload stalled — aborted'));
};
arm(SEND_STALL_MS);
xhr.send(form);
});
}
@@ -291,14 +291,14 @@
// Upload at most this many files concurrently. Bounded so one stuck file
// blocks only its own lane (the others keep going) without overwhelming the
// browser's per-host connection cap or spawning too many delta workers.
const UPLOAD_CONCURRENCY = 4;
// browser's per-host connection cap, spawning too many delta workers, or
// over-contending the server with many large concurrent uploads.
const UPLOAD_CONCURRENCY = 3;
/** Per-file deadline (ms): a tiny file that stops responding fails after 2
* min; larger files get proportionally longer (20 KB/s floor) so a slow but
* progressing transfer is never killed. Stops a stuck file pinning its lane
* forever. */
const uploadDeadlineMs = (file: File) => Math.max(120_000, (file.size / (20 * 1024)) * 1000);
/** Outer backstop deadline (ms). The plain-upload path already self-aborts on
* a stalled connection (see `uploadFileWithProgress`); this only catches a
* wedged delta worker or by-hash request so a lane can never hang forever. */
const FILE_BACKSTOP_MS = 4 * 60_000;
/** Reject after `ms` if `p` hasn't settled. */
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
@@ -388,7 +388,7 @@
},
owned.get(file) ?? null
),
uploadDeadlineMs(file)
FILE_BACKSTOP_MS
);
} catch (e) {
failures++;