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 --- .../tests/test_streamed_download_reliability.py | 267 +++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 packages/meshbay-hub/tests/test_streamed_download_reliability.py (limited to 'packages/meshbay-hub/tests/test_streamed_download_reliability.py') diff --git a/packages/meshbay-hub/tests/test_streamed_download_reliability.py b/packages/meshbay-hub/tests/test_streamed_download_reliability.py new file mode 100644 index 0000000..fa346cb --- /dev/null +++ b/packages/meshbay-hub/tests/test_streamed_download_reliability.py @@ -0,0 +1,267 @@ +""" +The service-worker download path, which on Firefox and Safari is the only +unbounded way to write a file to disk. + +Neither of those browsers 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 on 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 — there is no floor under it that can hold a film. That is what makes +its reliability a correctness property rather than a nicety. + +The real module is imported under Node with the browser pieces it reaches +stubbed — `navigator.serviceWorker`, a document that "navigates" an iframe, and +Node's own TransformStream and MessageChannel, which are the real ones. What is +modelled is the environment; `serviceWorker()` and `openStreamedDownload()` are +executed, never reimplemented. + +Three failures are pinned, all of which shipped: + + - registration happened inside the first click, so that click paid install, + activate and claim while somebody watched a button do nothing; + - a null result was cached for the life of the page, so one slow first click + left the tab unable to stream anything again, curable only by a reload + nobody knew to do; + - one missed navigation fell straight through instead of retrying. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +DOWNLOADS = STATIC / "downloads.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not DOWNLOADS.exists(), + reason="node or the SPA sources are not available") + +# The stub browser. `plan` decides how the fake worker behaves, so one harness +# covers every case below. +PRELUDE = """ +const store = new Map(); +globalThis.localStorage = { + getItem: k => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: k => store.delete(k), +}; +const PLAN = %(plan)s; +const log = { registers: 0, claims: 0, navigations: 0, served: 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 +// exactly the confirmation the real sw.js sends from its fetch handler. +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') return; + pendingByFrame.set('/_mbdl/' + msg.id, msg.port); + }, +}); + +const listeners = new Set(); +// `globalThis.navigator` is read-only from Node 22 -- assigning to it is the +// mistake CLAUDE.md already records against test_locales.py. Define it. +Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + serviceWorker: { + get controller() { return controller; }, + register: async () => { + log.registers += 1; + if (PLAN.registerThrows) throw new Error('registration blocked'); + if (PLAN.controlAfterMs !== null) { + setTimeout(() => { + controller = makeController(); + for (const fn of listeners) fn(); + }, PLAN.controlAfterMs); + } + return {active: PLAN.active ? makeController() : null}; + }, + ready: Promise.resolve({}), + addEventListener: (type, fn) => { if (type === 'controllerchange') listeners.add(fn); }, + removeEventListener: (type, fn) => { listeners.delete(fn); }, + }, + }, +}); + +globalThis.window = globalThis; +globalThis.isSecureContext = true; +globalThis.document = { + createElement: () => ({ hidden: false, src: '', remove() {} }), + body: { + appendChild: (frame) => { + log.navigations += 1; + const port = pendingByFrame.get(frame.src); + const answer = PLAN.serveOnNavigation === 'always' + || (PLAN.serveOnNavigation === 'second' && log.navigations >= 2); + if (port && answer) { + log.served += 1; + setTimeout(() => { + port.postMessage({type: 'mbdl-serving', id: frame.src}); + // The worker's own copy of the port, dropped once answered. sw.js + // drops it with the pending entry; here it has to be explicit or the + // harness process never exits. + port.close(); + }, 0); + } + }, + }, +}; + +const M = await import('%(module)s'); +// Production waits 15 s for each; these cases are about which branch runs. +const FAST = {controlMs: %(control)d, servedMs: 400}; +const out = {}; +""" + + +def _run(tmp_path, body, *, control_after_ms=0, active=True, + serve="always", register_throws=False, control_budget_ms=800): + module = tmp_path / "downloads.mjs" + module.write_text(DOWNLOADS.read_text()) + (tmp_path / "package.json").write_text('{"type":"module"}') + plan = { + "controlAfterMs": control_after_ms, + "active": active, + "serveOnNavigation": serve, + "registerThrows": register_throws, + } + script = tmp_path / "case.mjs" + script.write_text( + (PRELUDE % {"plan": json.dumps(plan), "module": module.as_posix(), + "control": control_budget_ms}) + + body + + "\nout.log = log;\nconsole.log(JSON.stringify(out));\n") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, + timeout=120) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── A failure must never be cached ────────────────────────────────────────── + +def test_a_missed_claim_does_not_poison_the_page(tmp_path): + """ + The bug: `_swReady` held the null, so every later download in that tab got + it back without trying. One slow first click and the tab could not stream + again — on Firefox, that is every large download for the rest of the visit. + + Here the worker never takes control, so the first call fails; the second + must register again rather than return a remembered null. + """ + r = _run(tmp_path, """ + out.first = await M.openStreamedDownload('a.bin', 10, FAST) !== null; + const after = log.registers; + out.second = await M.openStreamedDownload('b.bin', 10, FAST) !== null; + out.registeredAgain = log.registers > after; + """, control_after_ms=None) + assert r["first"] is False and r["second"] is False + assert r["registeredAgain"] is True, "a failed attempt was cached" + + +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; + """) + assert r["a"] and r["b"] + assert r["log"]["registers"] <= 1, "re-registered on a page already controlled" + + +# ── Waiting for control, rather than giving up ────────────────────────────── + +def test_control_arriving_late_is_still_used(tmp_path): + """ + Control used to be waited for with a 3 s cap, inside the click. A cold + worker on a busy machine can take longer, and the old code called that a + browser that cannot stream. Scaled down here — the budget is a parameter, so + what is pinned is that a claim arriving after the first check is still used, + not the particular number of seconds. + """ + r = _run(tmp_path, """ + const t0 = Date.now(); + out.ok = await M.openStreamedDownload('film.mkv', 20e9, FAST) !== null; + out.waitedMs = Date.now() - t0; + """, 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" + + +def test_an_uncontrolled_page_asks_the_worker_to_claim_again(tmp_path): + """ + Active but not controlling — a page loaded before any worker existed, whose + claim was missed. Rather than declare the path unavailable, ask again. + """ + r = _run(tmp_path, """ + out.ok = await M.openStreamedDownload('a.bin', 10, FAST) !== null; + """, control_after_ms=None, active=True) + assert r["log"]["claims"] >= 1, "never asked the active worker to claim" + + +# ── Retrying a missed navigation ──────────────────────────────────────────── + +def test_a_missed_navigation_is_retried(tmp_path): + """ + The worker takes the stream and is then never asked for the URL. The page + used to give up at once; on Firefox that sends a film to the in-memory + 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; + """, serve="second") + assert r["ok"] is True, "one missed navigation ended the download" + assert r["log"]["navigations"] == 2 + + +def test_giving_up_says_why(tmp_path): + """ + A silent null is what made the original defect invisible. Whatever happens, + the reason has to be readable afterwards — it is what the refusal quotes. + """ + r = _run(tmp_path, """ + out.target = await M.openStreamedDownload('a.bin', 10, FAST); + out.why = M.lastStreamFailure(); + """, control_after_ms=None) + assert r["target"] is None + assert r["why"], "declined with no stated reason" + + +def test_a_registration_that_throws_is_reported_not_swallowed(tmp_path): + r = _run(tmp_path, """ + out.target = await M.openStreamedDownload('a.bin', 10, FAST); + out.why = M.lastStreamFailure(); + """, register_throws=True) + assert r["target"] is None + assert "registration" in r["why"] + + +# ── Wiring that the behavioural cases cannot see ──────────────────────────── + +def test_the_worker_is_primed_at_boot_not_at_the_first_click(tmp_path): + """ + Registration inside the first download is the whole reason the claim was + ever raced. `primeServiceWorker` has to be called where the app starts, and + from a module that actually imports it — `node --check` would not notice a + missing import, which is a mistake this repo has already shipped once. + """ + app = (STATIC / "app.js").read_text() + assert "downloads.primeServiceWorker()" in app, "nothing primes the worker" + assert "import * as downloads from './downloads.js'" in app, ( + "app.js calls downloads.primeServiceWorker() without importing downloads") + # In mount(), which runs at start-up — not inside a component or a handler. + mount = app[app.index("const mount = () => {"):] + assert "downloads.primeServiceWorker()" in mount[:mount.index("\n};")] + + +def test_the_worker_answers_a_re_claim(tmp_path): + """The page's last resort before declaring the path unavailable only works + if sw.js implements the other half.""" + sw = (STATIC / "sw.js").read_text() + assert "mbdl-claim" in sw and "clients.claim()" in sw -- 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/tests/test_streamed_download_reliability.py') 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/tests/test_streamed_download_reliability.py') 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/tests/test_streamed_download_reliability.py') 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/tests/test_streamed_download_reliability.py') 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 99ae7f6955ffc94cd973822d4e2fd5a8c952f563 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 9 Sep 2026 10:59:59 +0200 Subject: docs(spa): say that a download with no folder cannot be paused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from testing 7a: pause worked in the desktop app and no button appeared in Chrome. That is the design working — without a granted download folder, "save automatically" means the service worker, and that target is a download the browser already owns — but nothing anywhere said so, and choosing a folder looked like a question of where files land. So the Settings line now says what it costs not to choose one, in all ten catalogues. It renders only where a folder can be chosen at all, which is exactly the browsers the advice applies to. Also pins the tier table the pause button is drawn from: a granted folder, a save dialog and the desktop sink can be paused, a service-worker stream cannot. Four cases through the real `_openDownloadTarget`, and one more that reads the value off the real `downloads.js` rather than a stub of it -- the first version of these stubs did not carry the field at all, so the cases would have passed while checking nothing. Hub suite 847 passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- .../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 +- packages/meshbay-hub/tests/test_memory_ceiling.py | 63 ++++++++++++++++++---- .../tests/test_streamed_download_reliability.py | 19 +++++++ 12 files changed, 81 insertions(+), 21 deletions(-) (limited to 'packages/meshbay-hub/tests/test_streamed_download_reliability.py') 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 ae20687..fd63ddf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -317,7 +317,7 @@ export default { + 'auch wenn Sie eine Auswahl herunterladen.', 'settings.dl_folder': 'Ordner: {name}', 'settings.dl_no_folder': 'Kein Ordner ausgewählt — Downloads landen dort, wo Ihr ' - + 'Browser sie ablegt', + + 'Browser sie ablegt, und sie lassen sich nicht anhalten', 'settings.dl_choose': 'Ordner auswählen', 'settings.dl_change': 'Ändern', 'settings.dl_forget': 'Verwerfen', 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 7033778..e9d4072 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -315,7 +315,7 @@ export default { + 'including when you download a selection.', 'settings.dl_folder': 'Folder: {name}', 'settings.dl_no_folder': 'No folder chosen — downloads go wherever your browser ' - + 'puts them', + + 'puts them, and they cannot be paused', 'settings.dl_choose': 'Choose folder', 'settings.dl_change': 'Change', 'settings.dl_forget': 'Forget', 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 311ba1f..4fa19e7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -315,7 +315,7 @@ export default { + 'también cuando descarga una selección.', 'settings.dl_folder': 'Carpeta: {name}', 'settings.dl_no_folder': 'Ninguna carpeta elegida — las descargas van adonde las ' - + 'ponga su navegador', + + 'ponga su navegador, y no se pueden pausar', 'settings.dl_choose': 'Elegir carpeta', 'settings.dl_change': 'Cambiar', 'settings.dl_forget': 'Olvidar', 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 106e3cd..6fba91d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -316,7 +316,7 @@ export default { + 'un par fichier, y compris lorsque vous téléchargez une sélection.', 'settings.dl_folder': 'Dossier : {name}', 'settings.dl_no_folder': 'Aucun dossier choisi — les téléchargements vont là où ' - + 'votre navigateur les place', + + 'votre navigateur les place, et ne peuvent pas être suspendus', 'settings.dl_choose': 'Choisir un dossier', 'settings.dl_change': 'Changer', 'settings.dl_forget': 'Oublier', 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 a610683..76b3101 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -316,7 +316,7 @@ export default { + 'file, anche quando scarica una selezione.', 'settings.dl_folder': 'Cartella: {name}', 'settings.dl_no_folder': 'Nessuna cartella scelta — i download finiscono dove li ' - + 'colloca il browser', + + 'colloca il browser, e non si possono sospendere', 'settings.dl_choose': 'Scegli una cartella', 'settings.dl_change': 'Cambia', 'settings.dl_forget': 'Dimentica', 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 aed4ba0..cfc1125 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -313,7 +313,7 @@ export default { + 'まとめてダウンロードする場合も 1 ファイルにつき 1 回です。', 'settings.dl_folder': 'フォルダー:{name}', 'settings.dl_no_folder': 'フォルダーが選ばれていません。ダウンロードは' - + 'ブラウザーが決めた場所に保存されます', + + 'ブラウザーが決めた場所に保存され、一時停止できません', 'settings.dl_choose': 'フォルダーを選択', 'settings.dl_change': '変更', 'settings.dl_forget': '解除', 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 fe10e12..29cc566 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -317,7 +317,7 @@ export default { + 'ook wanneer u een selectie downloadt.', 'settings.dl_folder': 'Map: {name}', 'settings.dl_no_folder': 'Geen map gekozen — downloads komen terecht waar uw browser ' - + 'ze neerzet', + + 'ze neerzet, en ze kunnen niet worden gepauzeerd', 'settings.dl_choose': 'Map kiezen', 'settings.dl_change': 'Wijzigen', 'settings.dl_forget': 'Vergeten', 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 c2ba35c..38fe714 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -328,7 +328,7 @@ export default { + 'także przy pobieraniu zaznaczonych pozycji.', 'settings.dl_folder': 'Folder: {name}', 'settings.dl_no_folder': 'Nie wybrano folderu — pobrane pliki trafiają tam, gdzie ' - + 'umieszcza je przeglądarka', + + 'umieszcza je przeglądarka, i nie można ich wstrzymać', 'settings.dl_choose': 'Wybierz folder', 'settings.dl_change': 'Zmień', 'settings.dl_forget': 'Zapomnij', 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 83d179b..5942b77 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 @@ -317,7 +317,7 @@ export default { + 'arquivo, inclusive quando você baixa uma seleção.', 'settings.dl_folder': 'Pasta: {name}', 'settings.dl_no_folder': 'Nenhuma pasta escolhida — os downloads vão para onde o ' - + 'seu navegador os colocar', + + 'seu navegador os colocar, e não podem ser pausados', 'settings.dl_choose': 'Escolher pasta', 'settings.dl_change': 'Alterar', 'settings.dl_forget': 'Esquecer', 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 9acfb17..56af6c8 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 @@ -309,7 +309,7 @@ export default { 'settings.dl_ask_hint': '每个文件弹出一次“另存为”对话框——每个文件一次,' + '批量下载时也是如此。', 'settings.dl_folder': '文件夹:{name}', - 'settings.dl_no_folder': '未选择文件夹——下载内容会保存到浏览器指定的位置', + 'settings.dl_no_folder': '未选择文件夹——下载内容会保存到浏览器指定的位置,且无法暂停', 'settings.dl_choose': '选择文件夹', 'settings.dl_change': '更改', 'settings.dl_forget': '忘记', diff --git a/packages/meshbay-hub/tests/test_memory_ceiling.py b/packages/meshbay-hub/tests/test_memory_ceiling.py index d46cae3..1654825 100644 --- a/packages/meshbay-hub/tests/test_memory_ceiling.py +++ b/packages/meshbay-hub/tests/test_memory_ceiling.py @@ -90,9 +90,12 @@ const downloads = {{ // wrong failure entirely. lastStreamFailure: () => 'stubbed: no streamed target in this harness', getMode: () => {json.dumps(mode)}, - openTarget: async () => ({json.dumps(granted)} ? {{ name: 'g', writable: {{}} }} : null), + // `pausable` mirrors the real modules: a granted folder is a held-open file + // handle, a service-worker stream is a download the browser already owns. + openTarget: async () => + ({json.dumps(granted)} ? {{ name: 'g', writable: {{}}, pausable: true }} : null), openStreamedDownload: async () => - ({json.dumps(streamed)} ? {{ name: 's', writable: {{}} }} : null), + ({json.dumps(streamed)} ? {{ name: 's', writable: {{}}, pausable: false }} : null), }}; globalThis.window = {{}}; if ({json.dumps(picker)}) {{ @@ -115,7 +118,7 @@ try {{ {{ batched: {json.dumps(batched)} }}); outcome = r === null ? {{ kind: 'memory' }} : r === false ? {{ kind: 'cancelled' }} - : {{ kind: 'stream', name: r.name }}; + : {{ kind: 'stream', name: r.name, pausable: !!r.pausable }}; }} catch (err) {{ outcome = {{ kind: 'refused', name: err.name, message: err.message }}; }} @@ -164,17 +167,17 @@ def test_the_boundary_is_the_ceiling_itself(target_fn, tmp_path): def test_a_granted_folder_streams_whatever_the_size(target_fn, tmp_path): out = _run(target_fn, tmp_path, size=20 * GB, granted=True) - assert out == {"kind": "stream", "name": "g"} + assert (out["kind"], out["name"]) == ("stream", "g") def test_the_service_worker_streams_whatever_the_size(target_fn, tmp_path): out = _run(target_fn, tmp_path, size=20 * GB, streamed=True) - assert out == {"kind": "stream", "name": "s"} + assert (out["kind"], out["name"]) == ("stream", "s") def test_the_desktop_app_streams_whatever_the_size(target_fn, tmp_path): out = _run(target_fn, tmp_path, size=20 * GB, native=True) - assert out == {"kind": "stream", "name": "n"} + assert (out["kind"], out["name"]) == ("stream", "n") def test_a_browser_with_a_picker_is_offered_one_instead_of_being_refused( @@ -182,7 +185,7 @@ def test_a_browser_with_a_picker_is_offered_one_instead_of_being_refused( """Chrome/Edge: the file is large, nothing streamed yet, but Save As does. A refusal here would be this fix breaking a path that was never broken.""" out = _run(target_fn, tmp_path, size=20 * GB, picker=True) - assert out == {"kind": "stream", "name": "p"} + assert (out["kind"], out["name"]) == ("stream", "p") # ── One dialog per gesture, not one per file ──────────────────────────────── @@ -192,7 +195,7 @@ def test_the_first_of_a_batch_still_asks_where_to_save(target_fn, tmp_path): folder chooses it, for the download they actually clicked.""" out = _run(target_fn, tmp_path, size=20 * GB, mode="ask", picker=True, streamed=True) - assert out == {"kind": "stream", "name": "p"} + assert (out["kind"], out["name"]) == ("stream", "p") def test_the_rest_of_a_batch_stream_instead_of_asking(target_fn, tmp_path): @@ -207,7 +210,7 @@ def test_the_rest_of_a_batch_stream_instead_of_asking(target_fn, tmp_path): """ out = _run(target_fn, tmp_path, size=20 * GB, mode="ask", picker=True, streamed=True, batched=True) - assert out == {"kind": "stream", "name": "s"} + assert (out["kind"], out["name"]) == ("stream", "s") def test_a_batched_download_falls_back_to_the_dialog_rather_than_failing( @@ -218,7 +221,7 @@ def test_a_batched_download_falls_back_to_the_dialog_rather_than_failing( neither must the fix for one.""" out = _run(target_fn, tmp_path, size=20 * GB, mode="ask", picker=True, streamed=False, batched=True) - assert out == {"kind": "stream", "name": "p"} + assert (out["kind"], out["name"]) == ("stream", "p") def test_batching_never_pushes_a_large_file_into_memory(target_fn, tmp_path): @@ -277,7 +280,7 @@ def test_a_lost_gesture_streams_instead_of_failing(target_fn, tmp_path): """ out = _run(target_fn, tmp_path, size=20 * GB, picker="no-gesture", streamed=True, mode="ask") - assert out == {"kind": "stream", "name": "s"}, out + assert (out["kind"], out["name"]) == ("stream", "s"), out def test_a_lost_gesture_with_nothing_to_stream_to_still_refuses(target_fn, tmp_path): @@ -286,3 +289,41 @@ def test_a_lost_gesture_with_nothing_to_stream_to_still_refuses(target_fn, tmp_p out = _run(target_fn, tmp_path, size=20 * GB, picker="no-gesture", streamed=False, mode="ask") assert out["kind"] == "refused", out + + +# ── Which targets can be paused ───────────────────────────────────────────── +# +# `pausable` travels with the target rather than with the platform, because the +# same browser yields both answers on the same page: a granted folder is a +# held-open file, and a service-worker stream is a download the browser already +# owns. The widget draws its button from this and nothing else. + + +def test_a_granted_folder_can_be_paused(tmp_path, target_fn): + out = _run(target_fn, tmp_path, size=20 * GB, granted=True) + assert out["pausable"] is True + + +def test_a_save_dialog_can_be_paused(tmp_path, target_fn): + out = _run(target_fn, tmp_path, size=20 * GB, picker=True) + assert out["pausable"] is True + + +def test_the_desktop_sink_can_be_paused(tmp_path, target_fn): + out = _run(target_fn, tmp_path, size=20 * GB, native=True) + assert out["pausable"] is True + + +def test_a_service_worker_stream_cannot_be_paused(tmp_path, target_fn): + """Not a shortcoming of this code. The browser is already writing an HTTP + response into its own download folder: not feeding the stream stalls that + download where we can neither see nor resume it, and an idle worker is + terminated within seconds. Firefox and Safari have no other target, so they + get cancel and no pause — the browser's own download manager is where a + pause lives there, for as long as it works. + + This is also why Chrome shows no pause button until a download folder has + been granted: without one, "save automatically" means the service worker. + """ + out = _run(target_fn, tmp_path, size=20 * GB, streamed=True) + assert out["pausable"] is False diff --git a/packages/meshbay-hub/tests/test_streamed_download_reliability.py b/packages/meshbay-hub/tests/test_streamed_download_reliability.py index 355fbff..da745d0 100644 --- a/packages/meshbay-hub/tests/test_streamed_download_reliability.py +++ b/packages/meshbay-hub/tests/test_streamed_download_reliability.py @@ -501,3 +501,22 @@ def test_a_download_waits_for_the_self_test(tmp_path): """) assert out["target"] is True assert out["ms"] >= 1, "the download did not wait for priming at all" + + +def test_the_streamed_target_says_it_cannot_be_paused(tmp_path): + """The value the widget's pause button is drawn from, read off the real + module rather than a stub of it. + + It is false for a reason that is not about this code: the browser is already + writing an HTTP response into its own download folder, so not feeding the + stream stalls a download we can neither see nor resume, and an idle worker + is terminated within seconds. Firefox and Safari therefore get cancel and no + pause; Chrome gets one as soon as a download folder has been granted, which + yields a held-open file instead of this. + """ + out = _run(tmp_path, """ + const target = await M.openStreamedDownload('film.mkv', 20e9, FAST); + out.pausable = target && target.pausable; + if (target) await target.writable.close(); + """) + assert out["pausable"] is False -- cgit v1.2.3 From d4adb140b9e7250b0f02d9651e4b9db87b74df81 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 9 Sep 2026 11:21:50 +0200 Subject: fix(spa): stop reloading a healthy page at boot Reported from Chrome: connecting to a group triggered a page refresh within seconds, taking the WebRTC session down with it. The boot check added with the bypass repair asked its question by *performing a download* -- a four-byte stream through a hidden iframe. Chrome rations the downloads a page may start without a user gesture to about three, measured: on a first visit three consecutive attempts went served, served, refused. So the check competed with the person's own downloads for that budget, and its answer depended on how much of the budget was left. On a healthy page it concluded the worker could not serve, and reloaded. The same mistake the repair was written to fix, from the other side: paying a capability to obtain a diagnostic. The replacement costs nothing and asks nothing. Measured on Chrome, at document start, before anything registers: first visit controller false, registration false ordinary reload controller true, registration true hard reload controller false, registration true Being uncontrolled while an active registration already exists names a hard-reloaded document exactly, so that is now the whole of the evidence. A first visit is uncontrolled too and is not a bypass -- the worker is installing and will claim the page in a moment -- which is precisely the case that was reloading. Four cases, each checked against the unfixed source, including that priming performs no download at all. Hub suite 847 passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- .../src/meshbay_hub/static/downloads.js | 111 ++++++++------------- .../tests/test_streamed_download_reliability.py | 104 ++++++++++--------- 2 files changed, 99 insertions(+), 116 deletions(-) (limited to 'packages/meshbay-hub/tests/test_streamed_download_reliability.py') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index 6ee7cf0..ce1901c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -380,8 +380,8 @@ async function _claimController(budgetMs) { */ export function primeServiceWorker() { if (!STREAMS_VIA_SW) return; - _priming = serviceWorker() - .then((worker) => worker && _repairIfBypassed(worker)) + _priming = _repairIfBypassed() + .then(() => serviceWorker()) .catch(() => {}); } @@ -391,85 +391,58 @@ export function primeServiceWorker() { // 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. -const SELF_TEST_BUDGET_MS = 4000; -// Set for the life of this tab, so the repair below can happen at most once and +// Set for the life of this tab, so the repair below happens 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? + * Was this document loaded with the service worker bypassed? * - * 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. + * A document fetched by a **hard** reload — Ctrl+F5, Ctrl+Shift+R — is loaded + * with the worker bypassed. It can still be claimed afterwards, so + * `navigator.serviceWorker.controller` comes back and every control check + * passes; but the navigations it 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 every + * download fails for the life of that page. * - * 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. + * Measured on Chrome, at document start, before anything registers: * - * 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. + * first visit controller false, registration false + * ordinary reload controller true, registration true + * hard reload controller false, registration true + * + * So being uncontrolled while an active registration already exists names the + * case exactly, and costs nothing to ask. * - * 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. + * The first version of this asked by *performing a download* — a four-byte + * stream through a hidden iframe — which was both unreliable and expensive: + * Chrome rations downloads a page starts without a user gesture to about three, + * so the test competed with the person's real downloads for that budget and its + * answer depended on how many had been spent. It reloaded a healthy page on + * every first visit, taking the group's WebRTC session down with it. */ -async function _repairIfBypassed(worker) { +const _controlledAtLoad = STREAMS_VIA_SW + && Boolean(navigator.serviceWorker.controller); +// Started here, at module load, because `register()` would make the answer +// true whatever it was. +const _registeredAtLoad = STREAMS_VIA_SW + ? navigator.serviceWorker.getRegistration('/') + .then((reg) => Boolean(reg && reg.active)).catch(() => false) + : Promise.resolve(false); + +/** An ordinary reload puts the document back under the worker, so do that once. */ +async function _repairIfBypassed() { + if (_controlledAtLoad) return; + // Uncontrolled with nothing registered is a first visit, not a bypass: the + // worker is being installed right now and the page is fine after it claims. + if (!await _registeredAtLoad) return; 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)'); + console.warn('[MeshBay] this page was loaded with the download worker ' + + 'bypassed (a hard reload does that) — reloading once to put it ' + + 'back under the worker\u2019s control'); try { sessionStorage.setItem(REPAIRED_KEY, '1'); } catch { /* blocked */ } location.reload(); } diff --git a/packages/meshbay-hub/tests/test_streamed_download_reliability.py b/packages/meshbay-hub/tests/test_streamed_download_reliability.py index da745d0..bfd4a89 100644 --- a/packages/meshbay-hub/tests/test_streamed_download_reliability.py +++ b/packages/meshbay-hub/tests/test_streamed_download_reliability.py @@ -81,6 +81,10 @@ Object.defineProperty(globalThis, 'navigator', { value: { serviceWorker: { get controller() { return controller; }, + // What the document started with, which is the whole of the repair's + // evidence now. `getRegistration` is asked before anything registers. + getRegistration: async () => (PLAN.registeredAtLoad + ? {active: makeController()} : undefined), register: async () => { log.registers += 1; if (PLAN.registerThrows) throw new Error('registration blocked'); @@ -118,6 +122,8 @@ Object.defineProperty(globalThis, 'navigator', { globalThis.window = globalThis; globalThis.isSecureContext = true; +// Set before the module is imported, because it reads it at evaluation. +controller = %(controlled)s ? makeController() : null; // 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(); @@ -160,7 +166,8 @@ 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, control_on_claim=False): + active_after_unregister=False, control_on_claim=False, + controlled_at_load=False, registered_at_load=False): module = tmp_path / "downloads.mjs" module.write_text(DOWNLOADS.read_text()) (tmp_path / "package.json").write_text('{"type":"module"}') @@ -173,11 +180,13 @@ def _run(tmp_path, body, *, control_after_ms=0, active=True, "registerHangs": register_hangs, "activeAfterUnregister": active_after_unregister, "controlOnClaim": control_on_claim, + "registeredAtLoad": registered_at_load, } script = tmp_path / "case.mjs" script.write_text( (PRELUDE % {"plan": json.dumps(plan), "module": module.as_posix(), - "control": control_budget_ms}) + "control": control_budget_ms, + "controlled": json.dumps(controlled_at_load)}) + body + "\nout.log = log;\nconsole.log(JSON.stringify(out));\n") proc = subprocess.run(["node", str(script)], capture_output=True, text=True, @@ -398,68 +407,70 @@ if (target) await target.writable.close(); "discarding it did not get the page a worker it could stream to") -# ── A page the worker cannot serve ────────────────────────────────────────── +# ── A page loaded with the worker bypassed ────────────────────────────────── -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. +def test_a_hard_reloaded_page_reloads_itself_once(tmp_path): + """Uncontrolled at load while an active registration already exists is a + document fetched by a hard reload — Ctrl+F5, Ctrl+Shift+R — and nothing + else. Measured on Chrome at document start: a first visit has neither, an + ordinary reload has both, a hard reload has the registration and no + controller. - 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. + Such a page can still be claimed, so every control check passes; but the + navigations it starts keep missing the worker, and the hidden iframe a + streamed download needs is one. On Firefox and Safari that is the only way + to write a file too large to hold in memory. An ordinary reload undoes it. """ out = _run(tmp_path, """ M.primeServiceWorker(); - await new Promise((r) => setTimeout(r, 6000)); + await new Promise((r) => setTimeout(r, 200)); out.reloads = log.reloads; - """, serve="never") - assert out["reloads"] == 1, ( - "a page that cannot be served by the worker was left that way") + """, controlled_at_load=False, registered_at_load=True) + assert out["reloads"] == 1 -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.""" +def test_a_first_visit_is_not_a_bypass(tmp_path): + """Also uncontrolled at load, and perfectly healthy: the worker is being + installed right now and will claim the page in a moment. Reloading here + would be a flicker on everybody's first visit — and it was, taking the + group's WebRTC session down with it when it landed mid-connection.""" out = _run(tmp_path, """ M.primeServiceWorker(); - await new Promise((r) => setTimeout(r, 3000)); + await new Promise((r) => setTimeout(r, 200)); out.reloads = log.reloads; - """) + """, controlled_at_load=False, registered_at_load=False) + assert out["reloads"] == 0 + + +def test_a_controlled_page_does_not_reload(tmp_path): + """The ordinary case, which must cost nothing at all: no reload, and no + download spent asking. Chrome rations the downloads a page may start + without a user gesture to about three, and the first version of this check + asked its question by performing one — competing with the person's own + downloads for that budget.""" + out = _run(tmp_path, """ + M.primeServiceWorker(); + await new Promise((r) => setTimeout(r, 200)); + out.reloads = log.reloads; + out.navigations = log.navigations; + """, controlled_at_load=True, registered_at_load=True) assert out["reloads"] == 0 + assert out["navigations"] == 0, ( + "priming performed a download; that budget belongs to the person") 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.""" + to survive the reload it triggers, and because a page that is still bypassed + afterwards must stop rather than reload again, and again.""" out = _run(tmp_path, """ sessionStorage.setItem('meshbay.sw-repaired', '1'); M.primeServiceWorker(); - await new Promise((r) => setTimeout(r, 6000)); + await new Promise((r) => setTimeout(r, 200)); 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 + """, controlled_at_load=False, registered_at_load=True) + assert out["reloads"] == 0 # ── The claim is asked for, not waited for ────────────────────────────────── @@ -487,10 +498,9 @@ def test_an_uncontrolled_page_asks_at_once_rather_than_after_the_budget(tmp_path "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.""" +def test_a_download_waits_for_priming(tmp_path): + """A click that lands while priming is still running must not race it: on a + page about to reload, the attempt would fail for nothing.""" out = _run(tmp_path, """ M.primeServiceWorker(); const t0 = Date.now(); -- cgit v1.2.3 From dee57df42a525cead93fa30b4e7fa38a489d5b11 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 9 Sep 2026 12:43:08 +0200 Subject: fix(spa): wake the download worker before handing it a stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from Chrome: a download started while an upload was running took thirty seconds to begin, every time. The console named it exactly — /_mbdl/mtty5btz-sbmgdegx 404 () [MeshBay] the worker did not answer the download within 15s (attempt 1) A 404 from the hub means the request reached the *network*: the worker looked, found no entry for that id and let it through. So the worker was alive and controlling the page, and the message handing it the stream had simply never been processed. `pending` lives in the worker's memory, and a worker with nothing to do is terminated within tens of seconds. A WebRTC upload gives it no events at all, so minutes of uploading leave it dead; the stream posted to it is lost, silently, and the iframe then wakes it with nothing to find. `mbdl-ping` already existed for this exact reason -- sent every ten seconds *while* writing, because a streaming response does not count as activity. Nothing sent one before *starting*. So a download now wakes the worker and waits for the pong, and `sw.js` answers `mbdl-ready` once it has actually stored the entry, which the page waits for before navigating: confirmed rather than assumed. A worker that predates the ack sends nothing and the page navigates anyway, which is what it did before. This cause was measured and wrongly dismissed hours earlier, with an idle probe that made the worker work between its own attempts -- it never actually slept. A measurement that does not reproduce the conditions refutes nothing. The harness now models a worker that is asleep: a ping wakes it, and anything else posted while it sleeps is lost, which is what made the failure silent. `test_backpressure_is_real` read the first `worker.postMessage` in the function to check that the readable half is transferred rather than copied. The wake-up put a ping in front of it, so it began inspecting a call that carries only a port -- and kept passing. It now checks every post, each bounded by its own call, since the keep-alive ping transfers nothing at all. Same shape as the upload-seal contract this morning: a guard that reads "the first" stops guarding the moment something is inserted before it. Hub suite 851 passed. Both new cases 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 | 58 +++++++++++++++++++- packages/meshbay-hub/src/meshbay_hub/static/sw.js | 15 ++++++ packages/meshbay-hub/tests/test_downloads.py | 20 +++++-- .../tests/test_streamed_download_reliability.py | 61 +++++++++++++++++++++- 4 files changed, 148 insertions(+), 6 deletions(-) (limited to 'packages/meshbay-hub/tests/test_streamed_download_reliability.py') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index ce1901c..c790394 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -232,6 +232,16 @@ const SW_KEEPALIVE_MS = 10000; // 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; +// How long to spend waking the worker, and then confirming it holds the stream, +// before starting the navigation that has to find it. +// +// Both are answered in milliseconds when the worker is alive. They exist for +// when it is not: `pending` lives in the worker's memory, and one with nothing +// to do is terminated within tens of seconds — which a long upload spends +// without giving it a single event. A stream handed to a worker in that state +// is lost, and the iframe then wakes it with nothing to find, which is a 404 +// from the hub and fifteen seconds of silence per attempt. +const SW_WAKE_BUDGET_MS = 3000; // Holds a *successful* controller, or an in-flight attempt. Never a failure — // see serviceWorker(). The previous version cached the rejected/null result @@ -504,6 +514,35 @@ export async function openStreamedDownload(filename, size = 0, { return null; } +/** + * Get the worker running, and know that it is. + * + * `mbdl-ping` exists already — the page sends it every ten seconds *while* + * writing, because a streaming response does not count as activity and Firefox + * kills an idle worker mid-download. Nothing sent one before *starting* a + * download, which is the case that fails after a long upload has left the + * worker with nothing to do for minutes. + * + * Never fatal: a worker that does not answer may still be perfectly able to + * serve, and the caller finds that out the honest way. + */ +async function _wake(worker) { + const chan = new MessageChannel(); + const pong = new Promise((resolve) => { + chan.port1.onmessage = () => resolve(true); + }); + try { + worker.postMessage({ type: 'mbdl-ping' }, [chan.port2]); + } catch { + return false; + } + const awake = await Promise.race([ + pong, new Promise((r) => setTimeout(() => r(false), SW_WAKE_BUDGET_MS)), + ]); + try { chan.port1.close(); } catch { /* already gone */ } + return awake; +} + async function _attemptStreamedDownload(filename, size, attempt, controlMs, servedMs) { const worker = await serviceWorker(controlMs); @@ -517,12 +556,23 @@ async function _attemptStreamedDownload(filename, size, attempt, // backpressure that will never be relieved, which reads as a download frozen // after one chunk rather than as an error. const chan = new MessageChannel(); + let markReady = null; + const held = new Promise((resolve) => { markReady = resolve; }); const serving = new Promise((resolve) => { chan.port1.onmessage = (e) => { - if (e.data && e.data.type === 'mbdl-serving') resolve(true); + if (!e.data) return; + // The worker says it has the stream. Waiting for this is what stops the + // navigation racing a worker that was asleep when we posted. + if (e.data.type === 'mbdl-ready') markReady(true); + if (e.data.type === 'mbdl-serving') resolve(true); }; }); + // Wake it first, and wait for the answer. A worker that has been idle through + // a long upload is terminated, and a message posted to it in that state is + // lost — silently, which is the whole difficulty. + await _wake(worker); + try { worker.postMessage({ type: 'mbdl', id, filename, size, readable, port: chan.port2 }, [readable, chan.port2]); @@ -536,6 +586,12 @@ async function _attemptStreamedDownload(filename, size, attempt, return null; } + // Confirmed, not assumed. A worker that predates this sends no answer, and + // then navigating anyway is exactly what this code did before. + await Promise.race([ + held, new Promise((r) => setTimeout(r, SW_WAKE_BUDGET_MS)), + ]); + const frame = document.createElement('iframe'); frame.hidden = true; frame.src = `${PREFIX_PATH}${id}`; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/sw.js b/packages/meshbay-hub/src/meshbay_hub/static/sw.js index 309ecc1..5f663f9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/sw.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/sw.js @@ -89,6 +89,21 @@ self.addEventListener('message', (event) => { // stream and the page's first write blocks for good. port: data.port || null, }); + // Say so, on the port the page is already listening to. + // + // `pending` is in memory, and a worker with nothing to do is terminated: + // Chrome after tens of seconds, which a long upload spends without giving + // this worker a single event. A stream posted to a worker in that state is + // lost, the iframe then wakes it with no entry to find, and the request falls + // through to the network — measured as a 404 from the hub and thirty seconds + // of nothing, twice, before the download started at all. + // + // The page waits for this before navigating, so the entry is known to be here + // rather than hoped to be. A page talking to an older worker gets no answer + // and navigates anyway, which is what it did before. + if (data.port) { + try { data.port.postMessage({ type: 'mbdl-ready', id: data.id }); } catch { /* gone */ } + } // A tab that is closed before it navigates would leave a stream here for the // life of the worker. setTimeout(() => pending.delete(data.id), 60000); diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index 41ae5d3..afb85d6 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -175,9 +175,23 @@ def test_backpressure_is_real(tmp_path): # The transfer list may carry more than the stream — a reply port rides # along now — so this asserts that `readable` is transferred, not the exact # shape of the list. - transfer = fn[fn.index("worker.postMessage("):] - transfer = transfer[transfer.index("["):transfer.index("]") + 1] - assert "readable" in transfer, "the readable half must be transferred, not copied" + # + # And every `postMessage` in here, not the first: a ping is sent to wake the + # worker before it is handed anything, and it carries only a port. Reading + # the first one would have moved this check onto the ping the day it was + # added, leaving the stream unguarded while still passing. + posts = [] + rest = fn + while "worker.postMessage(" in rest: + rest = rest[rest.index("worker.postMessage("):] + # Bounded by the call's own end: the keep-alive ping transfers nothing + # at all, and reaching past it for a `[` would read the next call's. + posts.append(rest[:rest.index(");") + 2]) + rest = rest[len("worker.postMessage("):] + lists = [c[c.index("["):c.index("]") + 1] for c in posts if "[" in c] + assert len(posts) >= 2, "the wake-up and the stream are both posted from here" + assert any("readable" in t for t in lists), ( + "the readable half must be transferred, not copied") assert "writer.write(bytes)" in fn assert "return null" in fn, "a browser that cannot transfer streams must say so" diff --git a/packages/meshbay-hub/tests/test_streamed_download_reliability.py b/packages/meshbay-hub/tests/test_streamed_download_reliability.py index bfd4a89..e1b3800 100644 --- a/packages/meshbay-hub/tests/test_streamed_download_reliability.py +++ b/packages/meshbay-hub/tests/test_streamed_download_reliability.py @@ -50,15 +50,37 @@ globalThis.localStorage = { }; const PLAN = %(plan)s; const log = { registers: 0, claims: 0, navigations: 0, served: 0, - unregisters: 0 }; + unregisters: 0, wakes: 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 // exactly the confirmation the real sw.js sends from its fetch handler. let controller = null; const pendingByFrame = new Map(); +// Set before the controller exists, because the declaration below is what +// the temporal dead zone protects. +let asleep = PLAN.workerAsleep; const makeController = () => ({ postMessage: (msg, transfer) => { + // A worker with nothing to do is terminated, and `pending` goes with it. + // A ping wakes it; anything else posted while it sleeps is simply lost, + // which is what makes this failure silent. + if (asleep) { + if (msg.type === 'mbdl-ping') { + asleep = false; + log.wakes += 1; + if (msg.ports || (transfer && transfer[0])) { + const port = (transfer && transfer[0]) || null; + if (port) setTimeout(() => port.postMessage({type: 'mbdl-pong'}), 0); + } + } + return; + } + if (msg.type === 'mbdl-ping') { + const port = (transfer && transfer[0]) || null; + if (port) setTimeout(() => port.postMessage({type: 'mbdl-pong'}), 0); + return; + } if (msg.type === 'mbdl-claim') { log.claims += 1; // A worker that actually claims when asked, which is what sw.js does. @@ -70,6 +92,9 @@ const makeController = () => ({ } if (msg.type !== 'mbdl') return; pendingByFrame.set('/_mbdl/' + msg.id, msg.port); + // The worker says it has it, which is what the page waits for. + if (msg.port) setTimeout(() => msg.port.postMessage({type: 'mbdl-ready', + id: msg.id}), 0); }, }); @@ -167,7 +192,8 @@ 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, control_on_claim=False, - controlled_at_load=False, registered_at_load=False): + controlled_at_load=False, registered_at_load=False, + worker_asleep=False): module = tmp_path / "downloads.mjs" module.write_text(DOWNLOADS.read_text()) (tmp_path / "package.json").write_text('{"type":"module"}') @@ -181,6 +207,7 @@ def _run(tmp_path, body, *, control_after_ms=0, active=True, "activeAfterUnregister": active_after_unregister, "controlOnClaim": control_on_claim, "registeredAtLoad": registered_at_load, + "workerAsleep": worker_asleep, } script = tmp_path / "case.mjs" script.write_text( @@ -530,3 +557,33 @@ def test_the_streamed_target_says_it_cannot_be_paused(tmp_path): if (target) await target.writable.close(); """) assert out["pausable"] is False + + +# ── a worker that was asleep when we posted ───────────────────────────────── + +def test_a_sleeping_worker_is_woken_before_it_is_handed_a_stream(tmp_path): + """Reported from Chrome: a download started while an upload was running took + thirty seconds to begin, every time. + + `pending` lives in the worker's memory and a worker with nothing to do is + terminated — which is what a long upload leaves it, for minutes, since a + WebRTC transfer gives it no events at all. The stream posted to it was lost; + the iframe then woke it with nothing to find and the request fell through to + the network, measured in the console as a 404 from the hub and fifteen + seconds of silence, twice. + + `mbdl-ping` already existed — sent every ten seconds *while* writing, for + the same reason. Nothing sent one before *starting*. + """ + out = _run(tmp_path, """ + const target = await M.openStreamedDownload('film.mkv', 20e9, FAST); + out.target = target !== null; + out.wakes = log.wakes; + out.navigations = log.navigations; + if (target) await target.writable.close(); + """, worker_asleep=True) + assert out["target"] is True, "the download never started" + assert out["wakes"] == 1, "the worker was handed a stream while asleep" + assert out["navigations"] == 1, ( + f"took {out['navigations']} attempts — the first one was wasted on a " + "worker that had not been woken") -- cgit v1.2.3