From 3f2bb22586d3e1aef765149b555ccc8e174ce7eb Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 8 Sep 2026 13:20:29 +0200 Subject: fix(hub): make the streamed download path reliable, and clean up after an abort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Firefox and Safari the service worker is the only unbounded way to write a download to disk: neither has the File System Access API, and OPFS is not a substitute — measured on Firefox 154, its quota is exactly 10% of the volume's size (389,233,459 bytes of a 3,892,334,592-byte volume, refused to the byte), which a film exceeds. So when this path declines, a large download has nowhere left to go, which makes its reliability a correctness property. Four ways it declined, all of them avoidable: - it was registered inside the first click on Download, so that click paid install, activate and claim while somebody watched a button do nothing; - `_swReady` cached a null for the life of the page. One slow first click left the tab unable to stream anything again, curable only by a reload nobody knew to do. Only a successful controller is remembered now; - control was waited for with a 3 s cap. It is 15 s, and a page that is active but not controlled asks the worker to claim again (`mbdl-claim`) instead of declaring the path unavailable; - a missed navigation gave up at once. It gets a second attempt with a fresh id and iframe, the failed one torn down completely first. Also closes a MessagePort leaked per download, and gives the reason a name (`lastStreamFailure`) so a refusal can say what happened. The timeouts became parameters: the defaults are the production values, no caller passes any, and the tests do not spend a minute waiting. `openTarget` gets an unrelated but adjacent fix, in the same file: it creates the destination with `getFileHandle({create: true})`, so an empty file exists before the first byte, and `abort()` leaves the target untouched — every cancelled download left a 0-byte file behind, and since `freeName` avoids collisions, three cancels left film.mkv, film (2).mkv and film (3).mkv, all empty. Its `abort()` now removes the entry. Safe here and only here, because `freeName` guarantees the name was not taken: the `showSaveFilePicker` path must not do the same, where the person may have picked an existing file whose contents `abort()` correctly preserves. Verified by hand in Chrome. test_streamed_download_reliability.py runs the real module under Node against a stubbed browser — it fails if the null is cached again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- .../src/meshbay_hub/static/downloads.js | 203 +++++++++++++++++---- 1 file changed, 166 insertions(+), 37 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index 90f88b0..f620e15 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -155,8 +155,28 @@ export async function openTarget(filename) { }; const name = await freeName(filename, exists); const handle = await dir.getFileHandle(name, { create: true }); + const writable = await handle.createWritable(); return { - writable: await handle.createWritable(), + // `abort()` on a FileSystemWritableFileStream discards the swap file and + // leaves the target as it was — which here is the empty file + // `getFileHandle({create: true})` just made, before a single byte arrived. + // So every cancelled or failed download left a 0-byte file behind, and + // because `freeName` avoids collisions, three cancels left `film.mkv`, + // `film (2).mkv` and `film (3).mkv`, all empty, in the person's folder. + // + // Removing it is safe *here and only here*: `freeName` guarantees this name + // was not taken, so the file being deleted is one we created moments ago + // and nothing else. The `showSaveFilePicker` path in file-utils.js must not + // do the same — there the person may have picked an existing file, whose + // contents `abort()` correctly preserves. + writable: { + write: (bytes) => writable.write(bytes), + close: () => writable.close(), + abort: async (reason) => { + try { await writable.abort(reason); } catch { /* already gone */ } + try { await dir.removeEntry(name); } catch { /* already gone */ } + }, + }, name, // Reading it back is the only way a page can "open" a file it wrote: hand // the bytes to a tab and let the browser decide what to do with them. No @@ -180,40 +200,114 @@ export const BLOB_LIMIT = 512 * 1024 * 1024; // ── Streaming to disk without the File System Access API ──────────────────── const SW_PATH = '/sw.js'; -let _swReady = null; + +// On Firefox and Safari this worker is not a nicety, it is the only unbounded +// way to write a download to disk: the File System Access API does not exist +// there, and OPFS is capped at 10% of the volume's size (measured on Firefox +// 154: 389,233,459 bytes on a 3,892,334,592-byte volume, refused to the byte), +// which a film can exceed. Everything below exists to make sure this path is +// available when it is needed, because there is nothing underneath it. +// +// How long to wait for this page to become *controlled*. Generous on purpose: +// the cost of waiting is a spinner, and the cost of giving up is a download +// this browser then cannot do at all. +const SW_CONTROL_BUDGET_MS = 15000; +// How long to wait for the worker to confirm it answered the iframe. +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; + +// Holds a *successful* controller, or an in-flight attempt. Never a failure — +// see serviceWorker(). The previous version cached the rejected/null result +// for the life of the page, so one slow first click (a cold worker, a busy +// phone) left the tab unable to stream anything ever again, with no way back +// but a reload nobody knew to do. +let _swPromise = null; +let _lastFailure = ''; + +/** Why the streamed path last declined, for a message worth reading. */ +export function lastStreamFailure() { return _lastFailure; } export const STREAMS_VIA_SW = typeof window !== 'undefined' && 'serviceWorker' in navigator && typeof TransformStream === 'function' && window.isSecureContext; -async function serviceWorker() { - if (!STREAMS_VIA_SW) return null; - if (!_swReady) { - _swReady = navigator.serviceWorker.register(SW_PATH, { scope: '/' }) - .then(() => navigator.serviceWorker.ready) - .then(async (reg) => { - // `reg.active` is not enough. A worker can be active while this page is - // still uncontrolled, and an uncontrolled page's requests are never - // handed to its fetch handler — so the worker would take our stream and - // then never be asked for it. The iframe would 404, nothing would read - // the stream, and the first write() would block for good: a download - // stuck at one chunk. - if (navigator.serviceWorker.controller) return navigator.serviceWorker.controller; - // sw.js claims clients on activate, so control usually arrives within a - // tick of registration. Wait briefly rather than give up at once. - return await new Promise((resolve) => { - const done = () => resolve(navigator.serviceWorker.controller || null); - navigator.serviceWorker.addEventListener('controllerchange', done, { once: true }); - setTimeout(done, 3000); - }); - }) - .catch(err => { - console.warn('[MeshBay] service worker unavailable:', err.message); - return null; - }); +/** Resolves with the controller, or null once `budgetMs` is spent. */ +function _awaitControl(budgetMs) { + if (navigator.serviceWorker.controller) { + return Promise.resolve(navigator.serviceWorker.controller); + } + return new Promise((resolve) => { + let timer = 0; + const done = () => { + clearTimeout(timer); + navigator.serviceWorker.removeEventListener('controllerchange', done); + resolve(navigator.serviceWorker.controller || null); + }; + navigator.serviceWorker.addEventListener('controllerchange', done); + timer = setTimeout(done, budgetMs); + }); +} + +async function _claimController(budgetMs) { + const reg = await navigator.serviceWorker.register(SW_PATH, { scope: '/' }); + // `ready` resolves on an *active* registration; being active is not being in + // control. An uncontrolled page's requests never reach the fetch handler, so + // the worker would take our stream and never be asked for it — the download + // then freezes after exactly one chunk, which is how this was found. + await navigator.serviceWorker.ready; + const controller = await _awaitControl(budgetMs); + if (controller) return controller; + // Active but not controlling after the whole budget. `sw.js` calls + // `clients.claim()` on activate, so this is rare; when it happens the page + // was loaded before any worker existed and the claim was missed. Ask the + // active worker to claim again rather than declare the path unavailable. + if (reg.active) { + try { reg.active.postMessage({ type: 'mbdl-claim' }); } catch { /* gone */ } + return await _awaitControl(2000); + } + return null; +} + +/** + * Register the worker and get this page controlled, now. + * + * Called at application start, not at the first download. Registration used to + * happen inside the first click, so that click paid install, activate and claim + * while somebody watched a button do nothing — and if the claim did not land + * inside the budget, the download fell through to a path that cannot hold a + * film. By the time anyone clicks anything, this has long since finished. + * + * Fire-and-forget by design: nothing waits on it, and a failure here is not + * fatal because `serviceWorker()` will simply try again. + */ +export function primeServiceWorker() { + if (!STREAMS_VIA_SW) return; + serviceWorker().catch(() => {}); +} + +async function serviceWorker(controlMs = SW_CONTROL_BUDGET_MS) { + if (!STREAMS_VIA_SW) { + _lastFailure = 'no service worker support in this browser'; + return null; } - return _swReady; + if (navigator.serviceWorker.controller) return navigator.serviceWorker.controller; + if (!_swPromise) { + _swPromise = _claimController(controlMs).catch((err) => { + _lastFailure = 'service worker registration failed: ' + err.message; + console.warn('[MeshBay]', _lastFailure); + return null; + }); + } + const controller = await _swPromise; + if (!controller) { + // Not remembered. The next attempt starts from scratch, which is the whole + // point: these failures are transient far more often than they are final. + _swPromise = null; + if (!_lastFailure) _lastFailure = 'the page did not come under the worker’s control'; + } + return controller; } /** @@ -229,8 +323,29 @@ async function serviceWorker() { * Returns {writable, name} shaped like the File System Access one, or null if * this browser cannot do it either. */ -export async function openStreamedDownload(filename, size = 0) { - const worker = await serviceWorker(); +export async function openStreamedDownload(filename, size = 0, { + controlMs = SW_CONTROL_BUDGET_MS, + servedMs = SW_SERVED_BUDGET_MS, + attempts = SW_ATTEMPTS, +} = {}) { + for (let attempt = 1; attempt <= attempts; attempt++) { + const target = await _attemptStreamedDownload( + filename, size, attempt, controlMs, servedMs); + if (target) return target; + // A miss is usually the worker having been asleep or the navigation losing + // a race, not this browser being unable. Falling through on the first miss + // is what sent large downloads to the in-memory floor. + if (attempt < attempts) { + console.warn(`[MeshBay] streamed download attempt ${attempt} missed ` + + `(${_lastFailure}); retrying`); + } + } + return null; +} + +async function _attemptStreamedDownload(filename, size, attempt, + controlMs, servedMs) { + const worker = await serviceWorker(controlMs); if (!worker) return null; const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; @@ -252,8 +367,11 @@ export async function openStreamedDownload(filename, size = 0) { [readable, chan.port2]); } catch (err) { // Transferable streams are what makes the backpressure work; without them - // this would be a memory buffer wearing a stream's clothes. - console.warn('[MeshBay] streams cannot be transferred here:', err.message); + // this would be a memory buffer wearing a stream's clothes. This one is + // final rather than transient — a browser does not grow the capability + // between two attempts — so it is reported as such. + _lastFailure = 'this browser cannot transfer a stream to the worker: ' + err.message; + console.warn('[MeshBay]', _lastFailure); return null; } @@ -264,19 +382,30 @@ export async function openStreamedDownload(filename, size = 0) { const answered = await Promise.race([ serving, - new Promise((r) => setTimeout(() => r(false), 8000)), + new Promise((r) => setTimeout(() => r(false), servedMs)), ]); if (!answered) { // Some browsers refuse a download started from a hidden iframe, and an - // uncontrolled page never reaches the worker at all. Say so and let the - // caller fall back rather than hand back a sink nothing drains. - console.warn('[MeshBay] the service worker never served the download; ' - + 'falling back'); + // uncontrolled page never reaches the worker at all. Tear this attempt + // down completely — the stream, the port and the frame — so a retry starts + // clean rather than leaving a half-open sink behind. + _lastFailure = `the worker did not answer the download within ` + + `${servedMs / 1000}s (attempt ${attempt})`; + console.warn('[MeshBay]', _lastFailure); frame.remove(); + try { chan.port1.close(); } catch { /* already gone */ } try { await writable.abort('not served'); } catch { /* already gone */ } return null; } + _lastFailure = ''; + // 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 + // test_streamed_download_reliability.py — there the leak is a process that + // never exits, which is the same defect wearing a louder symptom.) + try { chan.port1.close(); } catch { /* already gone */ } + const writer = writable.getWriter(); return { name: filename, -- cgit v1.2.3 From 051100ca32f72dd8f28489e1b6cb084f321b3b54 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 8 Sep 2026 22:53:53 +0200 Subject: fix(hub): keep the download worker alive, and never hang on a dead sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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*=''`, 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 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- .../src/meshbay_hub/static/downloads.js | 33 ++++- packages/meshbay-hub/src/meshbay_hub/static/sw.js | 49 ++++++- packages/meshbay-hub/tests/test_downloads.py | 146 +++++++++++++++++++++ .../tests/test_streamed_download_reliability.py | 17 ++- 4 files changed, 238 insertions(+), 7 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js') 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*=''`. 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 diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index fc69fac..e68396f 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -217,3 +217,149 @@ def test_an_uncontrolled_page_is_not_treated_as_ready(): "control can arrive a tick after registration; waiting beats refusing") assert "mbdl-claim" in section, ( "an active-but-uncontrolled page must ask for a claim, not give up") + + +def test_an_apostrophe_in_a_name_does_not_lose_the_name(tmp_path): + """ + `encodeURIComponent` leaves `'` alone, and `'` is the delimiter in RFC + 5987's `filename*=''`. One apostrophe made the header + unparseable, and a browser that cannot parse it names the file after the + last segment of the URL — which for this worker is a made-up id. The file + arrived complete and 449 MB of it was called "mtsshk9w-ohqty535". + + Found by downloading three files where exactly one had an apostrophe. + Nothing in the suite could have: the header was built correctly for every + name anybody had tested with. + + The real function is lifted out of sw.js and run — a second copy here would + have the same blind spot as the first. + """ + src = SW.read_text() + fn = src[src.index("function contentDisposition"):] + fn = fn[:fn.index("\n}") + 2] + + script = tmp_path / "case.mjs" + script.write_text(fn + """ +const out = {}; +for (const name of ["S03E02. Queen's Landing.mp4", 'Caf\\u00e9 (2019).mkv', + 'plain.mp4', 'quote".mp4', 'star*.mp4']) { + out[name] = contentDisposition(name); +} +console.log(JSON.stringify(out)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + + for name, header in out.items(): + starred = header.split("filename*=UTF-8''", 1)[1] + assert "'" not in starred, ( + f"{name!r}: an apostrophe survived into the starred value, which " + f"is where RFC 5987 puts its delimiter — the name is lost") + for forbidden in "()*": + assert forbidden not in starred, ( + f"{name!r}: {forbidden!r} is not an attr-char and must be " + f"percent-encoded") + # The starred value has to decode back to the real name, or the escaping + # fixed the parse and broke the result. + from urllib.parse import unquote + assert unquote(starred) == name + + # The ASCII fallback must not end its own quoted string. + for name, header in out.items(): + ascii_part = header.split('filename="', 1)[1].split('";', 1)[0] + assert '"' not in ascii_part and "\\" not in ascii_part + + +def test_a_sink_that_stops_consuming_fails_instead_of_hanging(tmp_path): + """ + `writable.write()` was the one await on the download path with no bound. + + Every other one reports itself: `_sendAndWait` logs a Response timeout, + `_fetchChunkResilient` retries and throws. A sink that stops consuming — a + service-worker stream the browser has stopped reading — leaves `write()` + pending for ever. It never rejects, so there is no error, no log and no + failed transfer: the progress bar stops, the console stays empty, and the + node is healthy throughout. + + That combination is what made it unfindable: three separate measurements + cleared the node, the transport and the worker, because none of them was + wrong. Bounding it does not fix whatever stopped the sink — it turns an + unexplainable freeze into a failed transfer that names itself. + """ + src = (STATIC / "file-utils.js").read_text() + fn = src[src.index("async function _writeOrStall"):] + fn = fn[:fn.index("\n}\n") + 2] + + script = tmp_path / "case.mjs" + script.write_text(""" +const t = (key, vars) => key + ' ' + JSON.stringify(vars); +const WRITE_STALL_MS = 300; // the real value is 60s; the shape is the test +""" + fn + """ +const out = {}; +// A sink that never resolves — the frozen download, exactly. +const dead = { write: () => new Promise(() => {}) }; +const t0 = Date.now(); +try { + await _writeOrStall(dead, new Uint8Array(4), 41); + out.threw = null; +} catch (e) { out.threw = e.message; } +out.ms = Date.now() - t0; + +// And a working sink is not slowed down or wrapped in anything. +const live = { write: async () => {} }; +await _writeOrStall(live, new Uint8Array(4), 0); +out.liveOk = true; +console.log(JSON.stringify(out)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["threw"], "a dead sink hung for ever instead of failing" + assert "group.download_write_stalled" in out["threw"], ( + "the failure must name itself in the transfers panel") + assert "41" in out["threw"], "and say which chunk it stopped at" + assert out["ms"] < 3000 + assert out["liveOk"] is True + + +def test_the_worker_is_kept_alive_while_it_streams(): + """ + A service worker with no event for ~30 s is terminated — Firefox does it, + and `respondWith(new Response(stream))` does not extend its life while the + response is still being written. The reader vanishes mid-file, the page's + next `write()` never resolves and never rejects: the progress bar stops, + the console stays empty, and the node looks healthy throughout. + + Measured in real Firefox 154 on 2026-09-08, 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 first version of that probe wrote 450 MB in two seconds and + passed — fast enough to hide the bug entirely, which is why the pacing + matters and is written down here. + + Source-reading, because the behaviour needs a browser and a minute of wall + clock. What it protects is that the ping exists at all, is cleared on both + exits, and is answered by the worker. + """ + dl = DOWNLOADS.read_text() + sw = SW.read_text() + + assert "SW_KEEPALIVE_MS" in dl and "mbdl-ping" in dl, ( + "nothing keeps the worker alive; downloads longer than ~30 s will " + "stall on Firefox with no error anywhere") + fn = dl[dl.index("async function _attemptStreamedDownload"):] + interval = fn[fn.index("setInterval"):] + assert "mbdl-ping" in interval[:200] + + # Cleared on both ways out, or a finished download leaves a timer pinging a + # worker for the life of the page. + for exit_path in ("close:", "abort:"): + block = fn[fn.index(exit_path):] + assert "clearInterval(keepAlive)" in block[:220], ( + f"the keep-alive is not cleared in {exit_path} — it outlives the " + f"download") + + # And the worker has to answer it: a message it ignores still counts as an + # event, but the reply is what tells the page it is talking to the worker + # that holds its stream. + assert "mbdl-ping" in sw and "mbdl-pong" in sw diff --git a/packages/meshbay-hub/tests/test_streamed_download_reliability.py b/packages/meshbay-hub/tests/test_streamed_download_reliability.py index fa346cb..45138b4 100644 --- a/packages/meshbay-hub/tests/test_streamed_download_reliability.py +++ b/packages/meshbay-hub/tests/test_streamed_download_reliability.py @@ -168,8 +168,13 @@ def test_a_missed_claim_does_not_poison_the_page(tmp_path): def test_a_success_is_reused_rather_than_re_registered(tmp_path): """The other half: once controlled, it must not re-register per download.""" r = _run(tmp_path, """ - out.a = await M.openStreamedDownload('a.bin', 10, FAST) !== null; - out.b = await M.openStreamedDownload('b.bin', 10, FAST) !== null; + // Closed, like a real caller: an open target holds a keep-alive interval + // for the worker, and a test that leaks one never lets Node exit. + for (const name of ['a.bin', 'b.bin']) { + const t = await M.openStreamedDownload(name, 10, FAST); + out[name[0]] = t !== null; + if (t) await t.writable.close(); + } """) assert r["a"] and r["b"] assert r["log"]["registers"] <= 1, "re-registered on a page already controlled" @@ -187,8 +192,10 @@ def test_control_arriving_late_is_still_used(tmp_path): """ r = _run(tmp_path, """ const t0 = Date.now(); - out.ok = await M.openStreamedDownload('film.mkv', 20e9, FAST) !== null; + const target = await M.openStreamedDownload('film.mkv', 20e9, FAST); + out.ok = target !== null; out.waitedMs = Date.now() - t0; + if (target) await target.writable.close(); """, control_after_ms=1200, control_budget_ms=6000) assert r["ok"] is True, "gave up on a claim that arrived late" assert r["waitedMs"] >= 1100, "did not actually wait for the claim" @@ -214,7 +221,9 @@ def test_a_missed_navigation_is_retried(tmp_path): floor. It gets a second go, with a fresh id and a fresh iframe. """ r = _run(tmp_path, """ - out.ok = await M.openStreamedDownload('film.mkv', 20e9, FAST) !== null; + const t = await M.openStreamedDownload('film.mkv', 20e9, FAST); + out.ok = t !== null; + if (t) await t.writable.close(); """, serve="second") assert r["ok"] is True, "one missed navigation ended the download" assert r["log"]["navigations"] == 2 -- cgit v1.2.3 From 88de521725d8de10eb8bf956b31cf9ce70a81f33 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 9 Sep 2026 02:45:23 +0200 Subject: fix(spa): nothing on the worker path may wait for ever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four downloads on Firefox sat at "preparing" indefinitely, with the target queue already bypassed there, so each opening was hanging on its own. The node journal showed `d=0/8(q0) u=0/8(q0)` — no transfer had been asked for yet. `_claimController` had two waits with no deadline at all, `navigator.serviceWorker.register()` and `navigator.serviceWorker.ready`, while SW_CONTROL_BUDGET_MS bounded only the wait that comes after them. `_swPromise` is shared, so one unsettled wait left every download on the page suspended on the same promise for the life of the tab. Measured on Firefox 154, against a local 127.0.0.1 site so no hub was involved: a worker that installs gives register() in 8ms and ready in 0ms; a worker whose install handler rejects gives register() in 7ms and a `ready` that never settles — still pending past ten seconds. register() resolves as soon as the registration object exists, carrying nothing but an *installing* worker; ready is what waits for an active one. Every wait is now inside one budget, with two carve-outs so that a deadline never costs a capability. A `ready` that times out while registration.active is set is not fatal: ready may be waiting on a newer worker that cannot install while an older one serves perfectly well. And the mbdl-claim recovery keeps its own budget outside the deadline, because giving up there would cost Firefox the only unbounded way it has to write a download to disk. A deadline alone would have been a better-explained failure rather than a fix: a registration stuck with nothing but an installing worker does not heal, and every later visit finds the same one. So when ready times out with no active worker, the registration is discarded and asked for once more with a fresh budget, and the page repairs itself instead of needing developer tools. Four cases pinned, each checked against the unfixed source. Hub suite 830 passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- .../src/meshbay_hub/static/downloads.js | 84 +++++++++++++-- .../tests/test_streamed_download_reliability.py | 115 +++++++++++++++++++-- 2 files changed, 186 insertions(+), 13 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index cfb0051..7e4802a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -261,19 +261,89 @@ function _awaitControl(budgetMs) { }); } +/** + * The promise's value, or `TIMED_OUT` once `ms` is spent. + * + * A rejection is still a rejection — the caller reports those — and the timer + * is cleared either way, so nothing is left running behind a fast answer. + */ +const TIMED_OUT = Symbol('timed out'); + +function _within(promise, ms) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(TIMED_OUT), ms); + promise.then((v) => { clearTimeout(timer); resolve(v); }, + (err) => { clearTimeout(timer); reject(err); }); + }); +} + async function _claimController(budgetMs) { - const reg = await navigator.serviceWorker.register(SW_PATH, { scope: '/' }); - // `ready` resolves on an *active* registration; being active is not being in - // control. An uncontrolled page's requests never reach the fetch handler, so - // the worker would take our stream and never be asked for it — the download - // then freezes after exactly one chunk, which is how this was found. - await navigator.serviceWorker.ready; - const controller = await _awaitControl(budgetMs); + // Every wait in here is inside one budget. Neither of the first two used to + // have any deadline at all, and `_swPromise` is shared, so a single one of + // them left every download on the page waiting on the same promise for ever + // — four rows stuck at "preparing", with nothing in the node's journal + // because no transfer had been asked for yet. + const deadline = Date.now() + budgetMs; + const left = () => Math.max(0, deadline - Date.now()); + + let reg = await _within( + navigator.serviceWorker.register(SW_PATH, { scope: '/' }), left()); + if (reg === TIMED_OUT) { + _lastFailure = `the worker did not register within ${budgetMs / 1000}s`; + return null; + } + // `register()` resolves as soon as the registration object exists, with + // nothing but an *installing* worker; `ready` is what waits for an active + // one. A worker that never finishes installing leaves `ready` pending + // indefinitely — measured on Firefox 154: an install handler that rejects + // leaves `ready` unsettled past ten seconds while `register()` returns in + // seven milliseconds. + let ready = await _within(navigator.serviceWorker.ready, left()); + if (ready === TIMED_OUT && !reg.active) { + // A registration stuck with nothing but an installing worker does not heal + // on its own: every later visit finds the same registration and waits on + // the same `ready`. Left alone it is permanent, and it costs Firefox the + // only unbounded way it has to write a download to disk — so the stuck + // registration is thrown away and asked for once more, with its own budget, + // rather than reported and lived with. + console.warn('[MeshBay] the download worker never became active; ' + + 'discarding the registration and asking again'); + try { + await _within(reg.unregister(), budgetMs); + } catch (err) { + console.warn('[MeshBay] could not discard it:', err.message); + } + const again = await _within( + navigator.serviceWorker.register(SW_PATH, { scope: '/' }), budgetMs); + if (again === TIMED_OUT) { + _lastFailure = `the worker did not register within ${budgetMs / 1000}s`; + return null; + } + reg = again; + ready = await _within(navigator.serviceWorker.ready, budgetMs); + if (ready === TIMED_OUT && !reg.active) { + _lastFailure = `the worker did not become active within ${budgetMs / 1000}s` + + ', even after its registration was discarded'; + return null; + } + } + // Past here `ready` may still be waiting on a *newer* worker that cannot + // install while an older one is perfectly able to serve. An active worker is + // all this path needs, so a stuck `ready` is not on its own a reason to give + // up a capability Firefox has nothing else to offer for. + // + // Being active is not being in control, either. An uncontrolled page's + // requests never reach the fetch handler, so the worker would take our stream + // and never be asked for it — the download then freezes after exactly one + // chunk, which is how that was found. + const controller = await _awaitControl(left()); if (controller) return controller; // Active but not controlling after the whole budget. `sw.js` calls // `clients.claim()` on activate, so this is rare; when it happens the page // was loaded before any worker existed and the claim was missed. Ask the // active worker to claim again rather than declare the path unavailable. + // This wait is deliberately outside the budget above: giving up here would + // cost Firefox the only unbounded way it has to write a download to disk. if (reg.active) { try { reg.active.postMessage({ type: 'mbdl-claim' }); } catch { /* gone */ } return await _awaitControl(2000); diff --git a/packages/meshbay-hub/tests/test_streamed_download_reliability.py b/packages/meshbay-hub/tests/test_streamed_download_reliability.py index 45138b4..9e6f77d 100644 --- a/packages/meshbay-hub/tests/test_streamed_download_reliability.py +++ b/packages/meshbay-hub/tests/test_streamed_download_reliability.py @@ -49,7 +49,8 @@ globalThis.localStorage = { removeItem: k => store.delete(k), }; const PLAN = %(plan)s; -const log = { registers: 0, claims: 0, navigations: 0, served: 0 }; +const log = { registers: 0, claims: 0, navigations: 0, served: 0, + unregisters: 0 }; // The worker as the page sees it: something with postMessage. It answers a // navigation by posting mbdl-serving back on the port it was handed, which is @@ -75,15 +76,32 @@ Object.defineProperty(globalThis, 'navigator', { register: async () => { log.registers += 1; if (PLAN.registerThrows) throw new Error('registration blocked'); - if (PLAN.controlAfterMs !== null) { + // A registration that never answers at all. Distinct from one that + // rejects: nothing is reported, nothing fails, the caller just waits. + if (PLAN.registerHangs) await new Promise(() => {}); + // A worker that only becomes installable once the stuck registration + // has been thrown away -- the browser this was reported from. + const healed = PLAN.activeAfterUnregister && log.unregisters > 0; + if (PLAN.controlAfterMs !== null || healed) { setTimeout(() => { controller = makeController(); for (const fn of listeners) fn(); - }, PLAN.controlAfterMs); + }, healed ? 0 : PLAN.controlAfterMs); } - return {active: PLAN.active ? makeController() : null}; + return { + active: (PLAN.active || healed) ? makeController() : null, + unregister: async () => { log.unregisters += 1; return true; }, + }; + }, + // `register()` resolves as soon as the registration object exists, with + // nothing but an installing worker; `ready` is what waits for an active + // one. Measured on Firefox 154: an install handler that rejects leaves + // `ready` unsettled past ten seconds while `register()` returns in 7 ms. + get ready() { + const healed = PLAN.activeAfterUnregister && log.unregisters > 0; + return (PLAN.readySettles || healed) + ? Promise.resolve({}) : new Promise(() => {}); }, - ready: Promise.resolve({}), addEventListener: (type, fn) => { if (type === 'controllerchange') listeners.add(fn); }, removeEventListener: (type, fn) => { listeners.delete(fn); }, }, @@ -122,7 +140,9 @@ const out = {}; def _run(tmp_path, body, *, control_after_ms=0, active=True, - serve="always", register_throws=False, control_budget_ms=800): + serve="always", register_throws=False, control_budget_ms=800, + ready_settles=True, register_hangs=False, + active_after_unregister=False): module = tmp_path / "downloads.mjs" module.write_text(DOWNLOADS.read_text()) (tmp_path / "package.json").write_text('{"type":"module"}') @@ -131,6 +151,9 @@ def _run(tmp_path, body, *, control_after_ms=0, active=True, "active": active, "serveOnNavigation": serve, "registerThrows": register_throws, + "readySettles": ready_settles, + "registerHangs": register_hangs, + "activeAfterUnregister": active_after_unregister, } script = tmp_path / "case.mjs" script.write_text( @@ -274,3 +297,83 @@ def test_the_worker_answers_a_re_claim(tmp_path): if sw.js implements the other half.""" sw = (STATIC / "sw.js").read_text() assert "mbdl-claim" in sw and "clients.claim()" in sw + + +# ── Nothing on this path may wait for ever ────────────────────────────────── + +def test_a_worker_that_never_installs_does_not_hang_every_download(tmp_path): + """The one that reached a person: four downloads stuck at "preparing", for + ever, with nothing in the node's journal because no transfer had been asked + for yet. + + `register()` resolves as soon as the registration object exists — with + nothing but an *installing* worker — and `ready` waits for an active one. + Measured on Firefox 154: an install handler that rejects leaves `ready` + unsettled past ten seconds while `register()` returns in seven + milliseconds. Neither had a deadline, and `_swPromise` is shared, so every + download on the page waited on the same promise that would never settle. + """ + out = _run(tmp_path, """ +const t0 = Date.now(); +out.worker = await M.openStreamedDownload('film.mkv', 1, FAST); +out.ms = Date.now() - t0; +out.why = M.lastStreamFailure(); +""", ready_settles=False, active=False, control_after_ms=None, + control_budget_ms=300) + assert out["worker"] is None + assert out["ms"] < 8000, ( + f"gave up after {out['ms']}ms — a budget that is not enforced is not a " + "budget, and the row above it says 'preparing' the whole time") + assert "active" in out["why"], out["why"] + + +def test_a_stuck_ready_does_not_throw_away_a_working_worker(tmp_path): + """`ready` can be waiting on a *newer* worker that cannot install while an + older one is perfectly able to serve. Giving up then would cost Firefox the + only unbounded way it has to write a download to disk — a deadline must + bound the waiting, never remove the capability.""" + out = _run(tmp_path, """ +const target = await M.openStreamedDownload('film.mkv', 1, FAST); +out.target = target !== null; +// Closing stops the keep-alive; left open, its interval keeps this process +// alive well past the test's own timeout. +if (target) await target.writable.close(); +""", ready_settles=False, active=True, control_budget_ms=300) + assert out["target"] is True + + +def test_a_registration_that_never_answers_gives_up_too(tmp_path): + """The other unbounded await. It rejects loudly in the case above; this is + the case where it says nothing at all.""" + out = _run(tmp_path, """ +const t0 = Date.now(); +out.worker = await M.openStreamedDownload('film.mkv', 1, FAST); +out.ms = Date.now() - t0; +out.why = M.lastStreamFailure(); +""", register_hangs=True, active=False, control_after_ms=None, + control_budget_ms=300) + assert out["worker"] is None + assert out["ms"] < 8000, f"gave up after {out['ms']}ms" + assert "register" in out["why"], out["why"] + + +def test_a_registration_stuck_installing_is_discarded_and_asked_for_again(tmp_path): + """A deadline turns an invisible hang into a named failure, which is better + but is not a fix: a registration stuck with nothing but an installing worker + does not heal on its own. Every later visit finds the same registration and + waits on the same `ready`, so the browser stays unable to stream a download + until somebody opens developer tools — and on Firefox there is nothing else + that can write a film to disk. + + So the stuck registration is thrown away and asked for once more. + """ + out = _run(tmp_path, """ +const target = await M.openStreamedDownload('film.mkv', 1, FAST); +out.target = target !== null; +if (target) await target.writable.close(); +""", ready_settles=False, active=False, control_after_ms=None, + active_after_unregister=True, control_budget_ms=300) + assert out["log"]["unregisters"] == 1, ( + "the stuck registration was left in place") + assert out["target"] is True, ( + "discarding it did not get the page a worker it could stream to") -- cgit v1.2.3 From fc148e185c01b2e25361c7625a67d310d2e1d288 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 9 Sep 2026 10:05:30 +0200 Subject: fix(spa): repair a page the download worker cannot serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads on Firefox failed with "the worker did not answer the download within 15s", every time, for one operator, while the same profile driven from here succeeded every time. Their own test sequence found it: a freshly started browser downloaded four files out of four, twice; one Ctrl+F5 and every attempt afterwards failed; restart, fine again; Ctrl+F5 before any attempt and the very first one failed. A document fetched by a hard reload is loaded with the service worker bypassed. It can still be claimed afterwards, so `navigator.serviceWorker.controller` comes back and every check in `_claimController` passes — but the navigations that document starts keep missing the worker, and the hidden iframe a streamed download needs is a navigation. On Firefox and Safari that is the only way to write a file too large to hold in memory, so the download cannot happen at all, for the life of the page. Being controlled is not being servable, so priming now asks the question directly instead of inferring it: a four-byte stream and a hidden iframe, exactly as a real download would, torn down completely so nothing lands in the download folder. When it goes unanswered the page reloads once, ordinarily, which puts it back under the worker. The flag lives in sessionStorage rather than a variable because it has to survive the reload it triggers, and because a page that is still unservable afterwards must stop rather than loop. Also stops telling people to change browser. The message said "use the desktop app, or Chrome or Edge" for a state an ordinary reload undoes, on the one path Firefox has no alternative to; all ten catalogues now say to reload first. The hard reloads were on my instruction: the SPA's HTML is served `no-store`, so a plain reload has always picked up a new build and Ctrl+F5 was never needed. Hub suite 834 passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- .../src/meshbay_hub/static/downloads.js | 90 +++++++++++++++++++++- .../src/meshbay_hub/static/locales/de.js | 2 +- .../src/meshbay_hub/static/locales/en.js | 2 +- .../src/meshbay_hub/static/locales/es.js | 2 +- .../src/meshbay_hub/static/locales/fr.js | 2 +- .../src/meshbay_hub/static/locales/it.js | 2 +- .../src/meshbay_hub/static/locales/ja.js | 2 +- .../src/meshbay_hub/static/locales/nl.js | 2 +- .../src/meshbay_hub/static/locales/pl.js | 2 +- .../src/meshbay_hub/static/locales/pt-BR.js | 2 +- .../src/meshbay_hub/static/locales/zh-CN.js | 2 +- .../tests/test_streamed_download_reliability.py | 74 ++++++++++++++++++ 12 files changed, 172 insertions(+), 12 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index 7e4802a..b1eade6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -200,6 +200,8 @@ export const BLOB_LIMIT = 512 * 1024 * 1024; // ── Streaming to disk without the File System Access API ──────────────────── const SW_PATH = '/sw.js'; +// Kept in step with sw.js's own PREFIX. +const PREFIX_PATH = '/_mbdl/'; // On Firefox and Safari this worker is not a nicety, it is the only unbounded // way to write a download to disk: the File System Access API does not exist @@ -365,7 +367,91 @@ async function _claimController(budgetMs) { */ export function primeServiceWorker() { if (!STREAMS_VIA_SW) return; - serviceWorker().catch(() => {}); + serviceWorker().then((worker) => worker && _repairIfBypassed(worker)) + .catch(() => {}); +} + +// How long to wait for the worker to answer the one-byte self-test below. +// Milliseconds when it works; a page that cannot stream at all is worth four +// seconds to find out about, once, at boot. +const SELF_TEST_BUDGET_MS = 4000; +// Set for the life of this tab, so the repair below can happen at most once and +// can never become a reload loop. +const REPAIRED_KEY = 'meshbay.sw-repaired'; + +/** + * Can this document actually have a download served, or only talk to the worker? + * + * Being controlled is not the same thing, and the gap between them is a real + * failure people hit. A document fetched by a **hard** reload — Ctrl+F5, + * Ctrl+Shift+R — is loaded with the service worker bypassed. It can still be + * claimed afterwards, so `navigator.serviceWorker.controller` comes back and + * every check in `_claimController` passes; but the navigations that document + * starts keep missing the worker, and the hidden iframe a streamed download + * needs *is* a navigation. Every download then fails with "the worker did not + * answer", for the life of that page — on Firefox and Safari, the only path + * there is for a file too large to hold in memory. + * + * Reported after an operator was told, by this author, to hard-reload after + * each deployment: four downloads out of four worked on a freshly started + * browser, and the first attempt after a Ctrl+F5 failed, every time. + * + * This asks the question directly rather than inferring it: a four-byte stream + * and a hidden iframe, exactly as a real download would. + */ +async function _canServeDownloads(worker) { + const id = `selftest-${Math.random().toString(36).slice(2, 10)}`; + let readable, writable; + try { + ({ readable, writable } = new TransformStream()); + } catch { + return true; // No transferable streams: a different failure, not this one. + } + const chan = new MessageChannel(); + const serving = new Promise((resolve) => { + chan.port1.onmessage = (e) => { + if (e.data && e.data.type === 'mbdl-serving') resolve(true); + }; + }); + try { + worker.postMessage({ type: 'mbdl', id, filename: 'meshbay-selftest.bin', + size: 4, readable, port: chan.port2 }, + [readable, chan.port2]); + } catch { + return true; // Same: not the bypass this is looking for. + } + const frame = document.createElement('iframe'); + frame.hidden = true; + frame.src = `${PREFIX_PATH}${id}`; + document.body.appendChild(frame); + const served = await Promise.race([ + serving, + new Promise((r) => setTimeout(() => r(false), SELF_TEST_BUDGET_MS)), + ]); + frame.remove(); + try { chan.port1.close(); } catch { /* already gone */ } + // Never completed, so the browser has nothing to save and no file appears. + try { await writable.abort('self-test'); } catch { /* already gone */ } + return served; +} + +/** + * An ordinary reload puts the document back under the worker, so do that once. + * + * Only at boot, where nothing is in flight and the reload costs a flicker. + * Guarded by a session flag rather than a variable: the point is to survive the + * reload it triggers, and to stop rather than loop if reloading does not help. + */ +async function _repairIfBypassed(worker) { + let repaired = false; + try { repaired = sessionStorage.getItem(REPAIRED_KEY) === '1'; } catch { /* blocked */ } + if (repaired) return; + if (await _canServeDownloads(worker)) return; + console.warn('[MeshBay] this page cannot be served by the download worker — ' + + 'reloading once to put it back under the worker\u2019s control ' + + '(a hard reload leaves a page in this state)'); + try { sessionStorage.setItem(REPAIRED_KEY, '1'); } catch { /* blocked */ } + location.reload(); } async function serviceWorker(controlMs = SW_CONTROL_BUDGET_MS) { @@ -458,7 +544,7 @@ async function _attemptStreamedDownload(filename, size, attempt, const frame = document.createElement('iframe'); frame.hidden = true; - frame.src = `/_mbdl/${id}`; + frame.src = `${PREFIX_PATH}${id}`; document.body.appendChild(frame); const answered = await Promise.race([ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 1458cf2..f0ec776 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -219,7 +219,7 @@ export default { 'preview.pdf_fallback': 'Dieser Browser zeigt das PDF nicht direkt an. Laden Sie es ' + 'stattdessen herunter — entschlüsselt wurde es ohnehin hier.', 'preview.too_large': 'Diese Datei ist {size} groß, mehr als diese Seite im Arbeitsspeicher halten kann ({limit}). Laden Sie sie stattdessen herunter — ein Download wird direkt auf die Festplatte geschrieben.', - 'download.too_large_for_memory': '„{name}“ ist {size} groß. Dieser Browser kann eine Datei dieser Größe nur speichern, indem er sie direkt auf die Festplatte schreibt, und das ist hier nicht möglich — er müsste die ganze Datei im Arbeitsspeicher halten. Verwenden Sie die Desktop-App oder Chrome bzw. Edge.', + 'download.too_large_for_memory': '„{name}“ ist {size} groß. Dieser Browser kann eine Datei dieser Größe nur speichern, indem er sie direkt auf die Festplatte schreibt, und das ist hier nicht möglich — er müsste die ganze Datei im Arbeitsspeicher halten. Laden Sie die Seite neu und versuchen Sie es erneut; falls das nicht hilft, verwenden Sie die Desktop-App.', 'group.upload_indexing': 'wird indiziert …', 'video.err_transport': 'Transport nicht verbunden', 'video.err_mse': 'Codec wird für das Streaming nicht unterstützt: {codec}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index b0430c0..c065b8a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -217,7 +217,7 @@ export default { 'video.close': 'Close (Esc)', 'preview.pdf_fallback': 'This browser will not display the PDF inline. Download it instead — it was decrypted here either way.', 'preview.too_large': 'This file is {size}, more than this page can hold in memory ({limit}). Download it instead — a download is written straight to disk.', - 'download.too_large_for_memory': '"{name}" is {size}. This browser can only save a file that large by streaming it to disk, and it has no way to do that here — it would have to hold the whole file in memory. Use the desktop app, or Chrome or Edge.', + 'download.too_large_for_memory': '"{name}" is {size}. This browser can only save a file that large by streaming it to disk, and it has no way to do that here — it would have to hold the whole file in memory. Reload the page and try again; if that does not help, use the desktop app.', 'group.upload_indexing': 'indexing…', 'video.err_transport': 'Transport not connected', 'video.err_mse': 'Codec not supported for streaming: {codec}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index adfb1cc..f655bc0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -217,7 +217,7 @@ export default { 'preview.pdf_fallback': 'Este navegador no mostrará el PDF integrado. Descárguelo ' + 'en su lugar — en cualquier caso se descifró aquí.', 'preview.too_large': 'Este archivo ocupa {size}, más de lo que esta página puede mantener en memoria ({limit}). Descárguelo en su lugar — una descarga se escribe directamente en disco.', - 'download.too_large_for_memory': '«{name}» ocupa {size}. Este navegador solo puede guardar un archivo así transmitiéndolo al disco, y aquí no puede hacerlo — tendría que mantener el archivo entero en memoria. Use la aplicación de escritorio, o Chrome o Edge.', + 'download.too_large_for_memory': '«{name}» ocupa {size}. Este navegador solo puede guardar un archivo así transmitiéndolo al disco, y aquí no puede hacerlo — tendría que mantener el archivo entero en memoria. Recargue la página e inténtelo de nuevo; si eso no ayuda, use la aplicación de escritorio.', 'group.upload_indexing': 'indexando…', 'video.err_transport': 'Transporte no conectado', 'video.err_mse': 'Códec no compatible con la reproducción en continuo: {codec}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index b409927..e011873 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -218,7 +218,7 @@ export default { 'preview.pdf_fallback': 'Ce navigateur n’affichera pas le PDF directement. ' + 'Téléchargez-le plutôt — il a été déchiffré ici dans les deux cas.', 'preview.too_large': 'Ce fichier fait {size}, plus que cette page ne peut garder en mémoire ({limit}). Téléchargez-le plutôt — un téléchargement est écrit directement sur le disque.', - 'download.too_large_for_memory': '« {name} » fait {size}. Ce navigateur ne peut enregistrer un fichier de cette taille qu\'en l\'écrivant au fil de l\'eau sur le disque, ce qu\'il ne peut pas faire ici — il devrait garder le fichier entier en mémoire. Utilisez l\'application de bureau, ou Chrome ou Edge.', + 'download.too_large_for_memory': '« {name} » fait {size}. Ce navigateur ne peut enregistrer un fichier de cette taille qu\'en l\'écrivant au fil de l\'eau sur le disque, ce qu\'il ne peut pas faire ici — il devrait garder le fichier entier en mémoire. Rechargez la page et réessayez ; si cela ne suffit pas, utilisez l\'application de bureau.', 'group.upload_indexing': 'indexation…', 'video.err_transport': 'Transport non connecté', 'video.err_mse': 'Codec non pris en charge pour la diffusion : {codec}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 7e6246e..80e3c17 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -218,7 +218,7 @@ export default { 'preview.pdf_fallback': 'Questo browser non mostrerà il PDF nella pagina. Lo scarichi ' + 'invece — in ogni caso è stato decifrato qui.', 'preview.too_large': 'Questo file è di {size}, più di quanto questa pagina possa tenere in memoria ({limit}). Lo scarichi invece — un download viene scritto direttamente su disco.', - 'download.too_large_for_memory': '«{name}» è di {size}. Questo browser può salvare un file di queste dimensioni solo scrivendolo su disco man mano, e qui non può farlo — dovrebbe tenere l’intero file in memoria. Usi l’applicazione desktop, oppure Chrome o Edge.', + 'download.too_large_for_memory': '«{name}» è di {size}. Questo browser può salvare un file di queste dimensioni solo scrivendolo su disco man mano, e qui non può farlo — dovrebbe tenere l’intero file in memoria. Ricarichi la pagina e riprovi; se non basta, usi l’applicazione desktop.', 'group.upload_indexing': 'indicizzazione…', 'video.err_transport': 'Trasporto non connesso', 'video.err_mse': 'Codec non supportato per lo streaming: {codec}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index a74eec9..5dbb4fa 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -215,7 +215,7 @@ export default { 'preview.pdf_fallback': 'このブラウザーはページ内に PDF を表示しません。' + 'ダウンロードしてご覧ください。いずれにせよ復号はここで行われています。', 'preview.too_large': 'このファイルは {size} で、このページがメモリに保持できる上限({limit})を超えています。代わりにダウンロードしてください。ダウンロードはディスクに直接書き込まれます。', - 'download.too_large_for_memory': '「{name}」は {size} です。このブラウザーでこの大きさのファイルを保存するにはディスクへ逐次書き出すしかありませんが、ここではそれができません — ファイル全体をメモリに保持することになります。デスクトップアプリ、または Chrome か Edge をお使いください。', + 'download.too_large_for_memory': '「{name}」は {size} です。このブラウザーでこの大きさのファイルを保存するにはディスクへ逐次書き出すしかありませんが、ここではそれができません — ファイル全体をメモリに保持することになります。ページを再読み込みしてもう一度お試しください。解決しない場合はデスクトップアプリをお使いください。', 'group.upload_indexing': 'インデックスを作成中…', 'video.err_transport': 'トランスポートが接続されていません', 'video.err_mse': 'ストリーミング再生に対応していないコーデックです:{codec}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index a7d64f7..f9c7cf3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -219,7 +219,7 @@ export default { 'preview.pdf_fallback': 'Deze browser toont de PDF niet in de pagina zelf. Download ' + 'hem in plaats daarvan — ontsleuteld werd hij hoe dan ook hier.', 'preview.too_large': 'Dit bestand is {size}, meer dan deze pagina in het geheugen kan houden ({limit}). Download het in plaats daarvan — een download wordt rechtstreeks naar schijf geschreven.', - 'download.too_large_for_memory': '“{name}” is {size}. Deze browser kan een bestand van die omvang alleen opslaan door het meteen naar schijf te schrijven, en dat kan hier niet — het hele bestand zou in het geheugen moeten. Gebruik de desktop-app, of Chrome of Edge.', + 'download.too_large_for_memory': '“{name}” is {size}. Deze browser kan een bestand van die omvang alleen opslaan door het meteen naar schijf te schrijven, en dat kan hier niet — het hele bestand zou in het geheugen moeten. Herlaad de pagina en probeer het opnieuw; als dat niet helpt, gebruik dan de desktop-app.', 'group.upload_indexing': 'indexeren…', 'video.err_transport': 'Transport niet verbonden', 'video.err_mse': 'Codec wordt niet ondersteund voor streamen: {codec}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 5bd34fb..b26f3cb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -224,7 +224,7 @@ export default { 'preview.pdf_fallback': 'Ta przeglądarka nie wyświetli pliku PDF na stronie. Proszę ' + 'go pobrać — i tak został odszyfrowany tutaj.', 'preview.too_large': 'Ten plik ma {size}, więcej niż ta strona może utrzymać w pamięci ({limit}). Proszę go zamiast tego pobrać — pobieranie jest zapisywane wprost na dysk.', - 'download.too_large_for_memory': '„{name}” ma {size}. Ta przeglądarka może zapisać plik tej wielkości tylko strumieniowo na dysk, a tutaj nie ma takiej możliwości — musiałaby utrzymać cały plik w pamięci. Proszę użyć aplikacji desktopowej albo przeglądarki Chrome lub Edge.', + 'download.too_large_for_memory': '„{name}” ma {size}. Ta przeglądarka może zapisać plik tej wielkości tylko strumieniowo na dysk, a tutaj nie ma takiej możliwości — musiałaby utrzymać cały plik w pamięci. Proszę odświeżyć stronę i spróbować ponownie; jeśli to nie pomoże, proszę użyć aplikacji desktopowej.', 'group.upload_indexing': 'indeksowanie…', 'video.err_transport': 'Transport nie jest połączony', 'video.err_mse': 'Kodek nieobsługiwany przy odtwarzaniu strumieniowym: {codec}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 102ec92..db5a408 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -219,7 +219,7 @@ export default { 'preview.pdf_fallback': 'Este navegador não exibirá o PDF na própria página. Baixe ' + 'o arquivo — de todo modo ele foi descriptografado aqui.', 'preview.too_large': 'Este arquivo tem {size}, mais do que esta página consegue manter na memória ({limit}). Baixe-o em vez disso — um download é gravado direto no disco.', - 'download.too_large_for_memory': '"{name}" tem {size}. Este navegador só consegue salvar um arquivo desse tamanho gravando-o direto no disco, e aqui ele não tem como — precisaria manter o arquivo inteiro na memória. Use o aplicativo para computador, ou Chrome ou Edge.', + 'download.too_large_for_memory': '"{name}" tem {size}. Este navegador só consegue salvar um arquivo desse tamanho gravando-o direto no disco, e aqui ele não tem como — precisaria manter o arquivo inteiro na memória. Recarregue a página e tente novamente; se não resolver, use o aplicativo para computador.', 'group.upload_indexing': 'indexando…', 'video.err_transport': 'Transporte não conectado', 'video.err_mse': 'Codec sem suporte para transmissão: {codec}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 6b64785..ba88f4e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -212,7 +212,7 @@ export default { 'video.close': '关闭(Esc)', 'preview.pdf_fallback': '此浏览器不会在页面内显示该 PDF。请改为下载——无论如何它都已在本地解密。', 'preview.too_large': '该文件为 {size},超出本页面可在内存中保存的上限({limit})。请改为下载——下载会直接写入磁盘。', - 'download.too_large_for_memory': '“{name}”为 {size}。此浏览器只能通过边下边写入磁盘来保存这么大的文件,而这里无法做到——它将不得不把整个文件放在内存中。请使用桌面应用,或 Chrome、Edge。', + 'download.too_large_for_memory': '“{name}”为 {size}。此浏览器只能通过边下边写入磁盘来保存这么大的文件,而这里无法做到——它将不得不把整个文件放在内存中。请重新加载页面后重试;如果仍然无效,请使用桌面应用。', 'group.upload_indexing': '建立索引中…', 'video.err_transport': '传输未连接', 'video.err_mse': '该编解码器不支持流式播放:{codec}', diff --git a/packages/meshbay-hub/tests/test_streamed_download_reliability.py b/packages/meshbay-hub/tests/test_streamed_download_reliability.py index 9e6f77d..3033a95 100644 --- a/packages/meshbay-hub/tests/test_streamed_download_reliability.py +++ b/packages/meshbay-hub/tests/test_streamed_download_reliability.py @@ -110,6 +110,16 @@ Object.defineProperty(globalThis, 'navigator', { globalThis.window = globalThis; globalThis.isSecureContext = true; +// The self-test's repair reloads once and remembers it for the tab; both have +// to exist here or priming the worker throws instead of repairing. +const session = new Map(); +globalThis.sessionStorage = { + getItem: k => (session.has(k) ? session.get(k) : null), + setItem: (k, v) => session.set(k, String(v)), + removeItem: k => session.delete(k), +}; +log.reloads = 0; +globalThis.location = { reload: () => { log.reloads += 1; } }; globalThis.document = { createElement: () => ({ hidden: false, src: '', remove() {} }), body: { @@ -377,3 +387,67 @@ if (target) await target.writable.close(); "the stuck registration was left in place") assert out["target"] is True, ( "discarding it did not get the page a worker it could stream to") + + +# ── A page the worker cannot serve ────────────────────────────────────────── + +def test_a_page_the_worker_cannot_serve_reloads_itself_once(tmp_path): + """Being controlled is not being servable, and the gap is a real failure. + + A document fetched by a hard reload — Ctrl+F5, Ctrl+Shift+R — is loaded with + the service worker bypassed. It can be claimed afterwards, so `controller` + comes back and every check in `_claimController` passes; but the navigations + it starts keep missing the worker, and the hidden iframe a streamed download + needs is a navigation. Every download then fails with "the worker did not + answer" for the life of that page — on Firefox and Safari, the only path + there is for a file too large to hold in memory. + + Reported after an operator was told to hard-reload after each deployment: + four downloads out of four worked on a freshly started browser, and the + first attempt after a Ctrl+F5 failed, every time. An ordinary reload puts + the document back under the worker, so priming does exactly that, once. + """ + out = _run(tmp_path, """ + M.primeServiceWorker(); + await new Promise((r) => setTimeout(r, 6000)); + out.reloads = log.reloads; + """, serve="never") + assert out["reloads"] == 1, ( + "a page that cannot be served by the worker was left that way") + + +def test_a_page_that_works_is_not_reloaded(tmp_path): + """The self-test costs milliseconds when it passes, and must cost nothing + else. Reloading a healthy page at boot would be a flicker on every visit.""" + out = _run(tmp_path, """ + M.primeServiceWorker(); + await new Promise((r) => setTimeout(r, 3000)); + out.reloads = log.reloads; + """) + assert out["reloads"] == 0 + + +def test_the_repair_happens_at_most_once(tmp_path): + """The flag is in sessionStorage rather than a variable because the point is + to survive the reload it triggers. If reloading does not help, the page + stays broken and says so — it does not reload again, and again.""" + out = _run(tmp_path, """ + sessionStorage.setItem('meshbay.sw-repaired', '1'); + M.primeServiceWorker(); + await new Promise((r) => setTimeout(r, 6000)); + out.reloads = log.reloads; + """, serve="never") + assert out["reloads"] == 0, "a page that had already been repaired reloaded again" + + +def test_the_self_test_leaves_no_file_behind(tmp_path): + """It opens a real download target to ask a real question, so it must also + tear it down: a completed one would drop `meshbay-selftest.bin` into the + download folder on every page load.""" + src = DOWNLOADS.read_text() + fn = src[src.index("async function _canServeDownloads"):] + fn = fn[:fn.index("\n}\n")] + assert "writable.abort" in fn, ( + "the self-test's stream is never aborted, so the browser keeps what it " + "was given") + assert "frame.remove" in fn -- cgit v1.2.3 From cb43495f998015850f34829329aa4509bd55d2cb Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 9 Sep 2026 10:32:13 +0200 Subject: fix(spa): repair a bypassed page in seconds, not half a minute The repair worked but arrived too late to help: about thirty seconds after a hard reload, by which time four downloads had been started and hung, and the page reloading under them read as an unexplained refresh. Two delays, both removed. `_claimController` waited its whole control budget before asking for the claim. A page that is uncontrolled while an active worker exists will never be claimed on its own -- a document fetched by a hard reload is exactly that shape -- so the fifteen seconds were spent waiting for something that was not coming. The claim is now asked for first; waiting is the fallback, not the opening move. Measured in the harness: 6042ms of a 6000ms budget before, milliseconds after. And a download that starts while the self-test is still running now waits for it rather than racing it. Otherwise the click spends both its attempts failing on a path that is about to be repaired, which is what put four frozen rows on screen. Hub suite 836 passed. Both new cases were checked against the unfixed source. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- .../src/meshbay_hub/static/downloads.js | 20 +++++++- .../tests/test_streamed_download_reliability.py | 54 +++++++++++++++++++++- 2 files changed, 71 insertions(+), 3 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index b1eade6..87d18b7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -338,6 +338,16 @@ async function _claimController(budgetMs) { // requests never reach the fetch handler, so the worker would take our stream // and never be asked for it — the download then freezes after exactly one // chunk, which is how that was found. + // + // Ask for the claim *before* waiting, not after. A page that is uncontrolled + // while an active worker exists will not be claimed on its own — a document + // fetched by a hard reload is exactly that shape — so the whole control + // budget is spent waiting for something that is not coming, and it was: about + // thirty seconds during which somebody clicks download and watches four rows + // hang. Asking first costs one message and makes the common case immediate. + if (!navigator.serviceWorker.controller && reg.active) { + try { reg.active.postMessage({ type: 'mbdl-claim' }); } catch { /* gone */ } + } const controller = await _awaitControl(left()); if (controller) return controller; // Active but not controlling after the whole budget. `sw.js` calls @@ -367,10 +377,17 @@ async function _claimController(budgetMs) { */ export function primeServiceWorker() { if (!STREAMS_VIA_SW) return; - serviceWorker().then((worker) => worker && _repairIfBypassed(worker)) + _priming = serviceWorker() + .then((worker) => worker && _repairIfBypassed(worker)) .catch(() => {}); } +// Resolved once the check below has run. A download that starts while it is +// still in flight waits for it rather than racing it: on a page that turns out +// to be unservable the click would otherwise spend thirty seconds failing on a +// path that is about to be repaired. +let _priming = null; + // How long to wait for the worker to answer the one-byte self-test below. // Milliseconds when it works; a page that cannot stream at all is worth four // seconds to find out about, once, at boot. @@ -495,6 +512,7 @@ export async function openStreamedDownload(filename, size = 0, { servedMs = SW_SERVED_BUDGET_MS, attempts = SW_ATTEMPTS, } = {}) { + if (_priming) { try { await _priming; } catch { /* reported already */ } } for (let attempt = 1; attempt <= attempts; attempt++) { const target = await _attemptStreamedDownload( filename, size, attempt, controlMs, servedMs); diff --git a/packages/meshbay-hub/tests/test_streamed_download_reliability.py b/packages/meshbay-hub/tests/test_streamed_download_reliability.py index 3033a95..355fbff 100644 --- a/packages/meshbay-hub/tests/test_streamed_download_reliability.py +++ b/packages/meshbay-hub/tests/test_streamed_download_reliability.py @@ -59,7 +59,15 @@ let controller = null; const pendingByFrame = new Map(); const makeController = () => ({ postMessage: (msg, transfer) => { - if (msg.type === 'mbdl-claim') { log.claims += 1; return; } + if (msg.type === 'mbdl-claim') { + log.claims += 1; + // A worker that actually claims when asked, which is what sw.js does. + if (PLAN.controlOnClaim) { + controller = makeController(); + for (const fn of listeners) fn(); + } + return; + } if (msg.type !== 'mbdl') return; pendingByFrame.set('/_mbdl/' + msg.id, msg.port); }, @@ -152,7 +160,7 @@ const out = {}; def _run(tmp_path, body, *, control_after_ms=0, active=True, serve="always", register_throws=False, control_budget_ms=800, ready_settles=True, register_hangs=False, - active_after_unregister=False): + active_after_unregister=False, control_on_claim=False): module = tmp_path / "downloads.mjs" module.write_text(DOWNLOADS.read_text()) (tmp_path / "package.json").write_text('{"type":"module"}') @@ -164,6 +172,7 @@ def _run(tmp_path, body, *, control_after_ms=0, active=True, "readySettles": ready_settles, "registerHangs": register_hangs, "activeAfterUnregister": active_after_unregister, + "controlOnClaim": control_on_claim, } script = tmp_path / "case.mjs" script.write_text( @@ -451,3 +460,44 @@ def test_the_self_test_leaves_no_file_behind(tmp_path): "the self-test's stream is never aborted, so the browser keeps what it " "was given") assert "frame.remove" in fn + + +# ── The claim is asked for, not waited for ────────────────────────────────── + +def test_an_uncontrolled_page_asks_at_once_rather_than_after_the_budget(tmp_path): + """A page that is uncontrolled while an active worker exists will not be + claimed on its own — a document fetched by a hard reload is exactly that + shape. Waiting the whole control budget first spends it on something that + is not coming: about thirty seconds, measured, during which the person + clicks download and watches four rows hang before the page repairs itself. + """ + out = _run(tmp_path, """ + const t0 = Date.now(); + const target = await M.openStreamedDownload('film.mkv', 20e9, FAST); + out.ms = Date.now() - t0; + out.target = target !== null; + out.claims = log.claims; + // Closing stops the keep-alive; left open, its interval outlives the test. + if (target) await target.writable.close(); + """, control_after_ms=None, control_on_claim=True, control_budget_ms=6000) + assert out["target"] is True + assert out["claims"] >= 1 + assert out["ms"] < 3000, ( + f"took {out['ms']}ms of a 6000ms budget — the claim was asked for only " + "after the wait, not before it") + + +def test_a_download_waits_for_the_self_test(tmp_path): + """A click that lands while the check is still running must not race it. + On a page that turns out to be unservable it would otherwise spend the full + two attempts failing on a path that is about to be repaired.""" + out = _run(tmp_path, """ + M.primeServiceWorker(); + const t0 = Date.now(); + const target = await M.openStreamedDownload('film.mkv', 20e9, FAST); + out.ms = Date.now() - t0; + out.target = target !== null; + if (target) await target.writable.close(); + """) + assert out["target"] is True + assert out["ms"] >= 1, "the download did not wait for priming at all" -- cgit v1.2.3 From 29e93e5e553c94818cd2b4e587b0e54cfe7d8424 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 9 Sep 2026 10:48:49 +0200 Subject: feat(spa): pause and resume a download, in session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 7a of ~/next/improve-downloads.md: pausing within a session, on the targets that can actually do it. Resuming across a reload is 7b. A paused transfer holds **nothing**. Its slot goes back to the node the moment it stops and resuming rejoins the queue at the tail, because anything else lets one member close a node by pausing four downloads and going to lunch. So the lease is taken inside the run loop rather than before it, and pause is refused outright for a transfer that could not ask for another one. Resuming is exact rather than approximate: the pipeline stops between two chunks and never inside one, so what is on disk is always a whole number of chunks and `fromChunk` is a verified position. The failure mode being avoided is a file that looks complete and is quietly corrupt. The target has to survive it, so a pause no longer reaches the `abort()` that a failure does -- that would delete Electron's `.part` or the file just created in the granted folder, leaving nothing to continue. And the in-memory fallback keeps its accumulated chunks rather than starting a second array. The button is drawn only where the target says it can. A service-worker stream says no, in its own code and for its own reasons: the browser is already writing an HTTP response into its own download folder, not feeding it stalls that download where we cannot see or resume it, and an idle worker is terminated within seconds. Firefox and Safari therefore keep cancel and get no pause, which is the decision recorded in §6.5. Cancelling a paused transfer ends it. A paused run is parked on a promise; without waking it the row said "cancelled" over work that had not stopped and a target that was still open. Six cases, each checked against the unfixed source. Hub suite 842 passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 32 ++++- .../src/meshbay_hub/static/downloads.js | 11 ++ .../src/meshbay_hub/static/file-utils.js | 57 ++++++-- .../src/meshbay_hub/static/locales/de.js | 5 + .../src/meshbay_hub/static/locales/en.js | 5 + .../src/meshbay_hub/static/locales/es.js | 5 + .../src/meshbay_hub/static/locales/fr.js | 5 + .../src/meshbay_hub/static/locales/it.js | 5 + .../src/meshbay_hub/static/locales/ja.js | 5 + .../src/meshbay_hub/static/locales/nl.js | 5 + .../src/meshbay_hub/static/locales/pl.js | 5 + .../src/meshbay_hub/static/locales/pt-BR.js | 5 + .../src/meshbay_hub/static/locales/zh-CN.js | 5 + .../meshbay-hub/src/meshbay_hub/static/style.css | 14 ++ .../src/meshbay_hub/static/transfers.js | 118 +++++++++++++-- packages/meshbay-hub/tests/test_downloads.py | 68 +++++++++ packages/meshbay-hub/tests/test_transfers.py | 160 +++++++++++++++++++++ 17 files changed, 484 insertions(+), 26 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 6f4bb92..0394af6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -242,8 +242,24 @@ function TransferRow({ it }) { ? html` { e.preventDefault(); transfers.open(it.id); }}>${it.name}` : html`${it.name}`} + ${it.pausable && (it.status === 'running' || it.status === 'paused') + && html` + ${/* Offered only where the target can actually do it: a + service-worker stream is a download the browser already owns, + and a pause there would restart from zero. */''} + + `} ${(it.status === 'running' || it.status === 'queued' - || it.status === 'preparing') && html` + || it.status === 'preparing' || it.status === 'paused') && html`