From b58b2d8f95835e4e8b0853ab8dccd225101738c3 Mon Sep 17 00:00:00 2001 From: DioCrafts Date: Sat, 20 Jun 2026 18:33:53 +0200 Subject: [PATCH] fix(upload): self-aborting watchdog + lower concurrency to end stalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/lib/api/endpoints/files.ts | 28 ++++++++++++++++++- .../src/routes/files/[...path]/+page.svelte | 16 +++++------ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/api/endpoints/files.ts b/frontend/src/lib/api/endpoints/files.ts index dfe3441d..880e0d8f 100644 --- a/frontend/src/lib/api/endpoints/files.ts +++ b/frontend/src/lib/api/endpoints/files.ts @@ -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; + 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); }); } diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 4c655d3d..d1566cd9 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -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(p: Promise, ms: number): Promise { @@ -388,7 +388,7 @@ }, owned.get(file) ?? null ), - uploadDeadlineMs(file) + FILE_BACKSTOP_MS ); } catch (e) { failures++;