aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-08 22:53:53 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-08 22:53:53 +0200
commit051100ca32f72dd8f28489e1b6cb084f321b3b54 (patch)
tree728d982351d70b0b01652bb28f950ce449edd542 /packages/meshbay-hub/src
parenta15c3912008b7dc444c7fc6a2b4ccf782c647215 (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/sw.js49
2 files changed, 79 insertions, 3 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();
},
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/sw.js b/packages/meshbay-hub/src/meshbay_hub/static/sw.js
index 0dd87d8..309ecc1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/sw.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/sw.js
@@ -17,6 +17,35 @@
*/
const PREFIX = '/_mbdl/';
+
+/**
+ * A filename, safe to put in Content-Disposition.
+ *
+ * `encodeURIComponent` alone is not enough, and the way it fails is invisible
+ * until somebody downloads the wrong film: it leaves `'` untouched, and `'` is
+ * the *delimiter* in RFC 5987's `filename*=<charset>'<lang>'<value>`. A single
+ * apostrophe in a name therefore makes the header unparseable, and a browser
+ * that cannot parse it falls back to the last segment of the URL — which here
+ * is the made-up id this worker answers on. The file arrives complete, 449 MB
+ * of it, called "mtsshk9w-ohqty535".
+ *
+ * Found by downloading three files where exactly one had an apostrophe in its
+ * name. `(`, `)` and `*` are excluded from RFC 5987's attr-char for the same
+ * reason and get the same treatment.
+ *
+ * The plain `filename=` beside it is the ASCII fallback every parser
+ * understands: it loses the accents, and it is what stops a name being lost
+ * entirely the next time one of these encodings surprises us.
+ */
+function contentDisposition(name) {
+ const encoded = encodeURIComponent(name)
+ .replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
+ // Quotes and backslashes would end the quoted-string early; anything not
+ // plain ASCII is dropped rather than mangled, since the starred form above
+ // carries the real name.
+ const ascii = name.replace(/["\\]/g, '_').replace(/[^\x20-\x7e]/g, '_');
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
+}
const pending = new Map();
self.addEventListener('install', () => self.skipWaiting());
@@ -24,6 +53,23 @@ self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim(
self.addEventListener('message', (event) => {
const data = event.data || {};
+ // A worker with nothing to do is terminated — Firefox after about thirty
+ // seconds, and `respondWith(new Response(stream))` does not extend its life
+ // for the duration of the response. So a download longer than that lost its
+ // reader mid-file: the page's next `write()` never resolved and never
+ // rejected, the progress bar stopped, the console stayed empty and the node
+ // went on looking perfectly healthy. Handling a message is an event, and an
+ // event resets that timer, so the page pings while it is writing.
+ //
+ // It also has to be answered: a ping that only arrives keeps *this* worker
+ // alive, and the reply is how the page learns the worker it is talking to is
+ // still the one holding its stream.
+ if (data.type === 'mbdl-ping') {
+ if (event.ports && event.ports[0]) {
+ try { event.ports[0].postMessage({ type: 'mbdl-pong' }); } catch { /* gone */ }
+ }
+ return;
+ }
// A page that loaded before any worker existed can miss the claim on
// activate. Rather than declare the streamed path unavailable — which on
// Firefox and Safari means the download cannot happen at all — the page asks
@@ -64,8 +110,7 @@ self.addEventListener('fetch', (event) => {
const headers = {
'Content-Type': 'application/octet-stream',
// filename* so a name with accents or spaces survives the trip.
- 'Content-Disposition':
- `attachment; filename*=UTF-8''${encodeURIComponent(entry.filename)}`,
+ 'Content-Disposition': contentDisposition(entry.filename),
'Cache-Control': 'no-store',
};
// Only when it is known. A zip is assembled as it goes and announcing a