diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-08 22:53:53 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-08 22:53:53 +0200 |
| commit | 051100ca32f72dd8f28489e1b6cb084f321b3b54 (patch) | |
| tree | 728d982351d70b0b01652bb28f950ce449edd542 /packages/meshbay-hub/src/meshbay_hub/static/downloads.js | |
| parent | a15c3912008b7dc444c7fc6a2b4ccf782c647215 (diff) | |
| download | meshbay-051100ca32f72dd8f28489e1b6cb084f321b3b54.tar.gz | |
fix(hub): keep the download worker alive, and never hang on a dead sink
A download froze part-way through, on Firefox, with an empty console and a node
that stayed perfectly healthy. Three separate measurements cleared the node
(615 MB pulled whole over MNP), the transport (three files interleaved on one
connection, 1.5 GB, all whole) and the service worker (three concurrent 150 MB
streams in real Firefox 154) — because none of them was wrong.
The empty console was the evidence. `_sendAndWait` logs every timeout, so no
chunk request had expired: the client was not waiting on the node. Of the three
awaits left on that path only one was unbounded.
**A service worker with no event for about thirty seconds is terminated**, and
`respondWith(new Response(stream))` does not extend its life while the response
is still being written. The reader vanished mid-file and `writable.write()` then
never resolved and never rejected — no error, no log, no failed transfer, just a
progress bar that stops. The first stress probe wrote 450 MB in two seconds and
passed: fast enough to hide it entirely.
Measured in Firefox 154, writing 1 MB every 2 s: without the ping it stalled at
17 MB after 59 s; with it, 40 MB in 80 s, complete.
- the page pings the worker every 10 s while it writes, and the worker
answers. Receiving a message is an event, and an event resets the timer;
- that interval stops itself after two minutes with no write. A target can be
opened and never written to — a transfer cancelled while it waits for a slot
never runs, so nothing calls close() or abort() — and a timer nobody clears
pings for the life of the page. It also kept the Node test process alive for
ever, which is the same defect wearing a louder symptom;
- `writable.write()` is bounded at 60 s and fails with a message naming the
chunk. That does not fix whatever stopped a sink; it turns an unexplainable
freeze into a failed transfer that says so, which is the difference between
a mystery and a bug report.
Also: `Content-Disposition` lost a filename to a single apostrophe.
`encodeURIComponent` leaves `'` alone and `'` is the delimiter in RFC 5987's
`filename*=<charset>'<lang>'<value>`, so the header became unparseable and
Firefox named the file after the URL — 449 MB of film arrived complete as
"mtsshk9w-ohqty535". `(`, `)` and `*` get the same treatment, and a plain ASCII
`filename=` rides alongside.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/downloads.js | 33 |
1 files changed, 32 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index f620e15..cfb0051 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -216,6 +216,17 @@ const SW_CONTROL_BUDGET_MS = 15000; const SW_SERVED_BUDGET_MS = 15000; // A transient miss gets a second go with a fresh id and a fresh iframe. const SW_ATTEMPTS = 2; +// How often the page pokes the worker while a download is being written. +// Firefox terminates a service worker that has had no event for roughly thirty +// seconds, and a streaming response does not count as activity — so a download +// that takes longer than that lost its reader half way through. Ten seconds +// leaves a wide margin and costs one empty message. +const SW_KEEPALIVE_MS = 10000; +// And the ping stops on its own once nothing has been written for this long. +// Well past any real gap between chunks, and short enough that an abandoned +// target does not ping for ever. Bounded because the alternative is a timer +// whose lifetime depends on every caller remembering to close its sink. +const SW_KEEPALIVE_IDLE_MS = 120000; // Holds a *successful* controller, or an in-flight attempt. Never a failure — // see serviceWorker(). The previous version cached the rejected/null result @@ -399,6 +410,24 @@ async function _attemptStreamedDownload(filename, size, attempt, } _lastFailure = ''; + // Every few seconds for as long as this download is being written. Well + // inside the ~30 s Firefox allows an idle worker, and cheap: one postMessage + // with no payload. Cleared by close() and abort() below, so a finished + // download leaves no timer behind. + // Self-limiting, and that is not belt-and-braces: a target can be opened and + // then never written to — a transfer cancelled while it waits for a slot + // never runs, so nothing calls close() or abort() — and an interval nobody + // clears pings for the life of the page. It also kept the Node test process + // alive for ever, which is the same defect wearing a louder symptom (the + // MessagePort above did exactly this a few hours earlier). + let lastWrite = Date.now(); + const keepAlive = setInterval(() => { + if (Date.now() - lastWrite > SW_KEEPALIVE_IDLE_MS) { + clearInterval(keepAlive); + return; + } + try { worker.postMessage({ type: 'mbdl-ping' }); } catch { /* gone */ } + }, SW_KEEPALIVE_MS); // The port has delivered the one message it exists for. Closing it matters: // an open MessagePort is a live handle, and one was leaked per download for // the life of the page. (It is also what hung the Node harness in @@ -410,12 +439,14 @@ async function _attemptStreamedDownload(filename, size, attempt, return { name: filename, writable: { - write: (bytes) => writer.write(bytes), + write: (bytes) => { lastWrite = Date.now(); return writer.write(bytes); }, close: async () => { + clearInterval(keepAlive); await writer.close(); setTimeout(() => frame.remove(), 2000); }, abort: async (reason) => { + clearInterval(keepAlive); try { await writer.abort(reason); } catch { /* already gone */ } frame.remove(); }, |