diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-09 12:43:08 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-09 12:43:08 +0200 |
| commit | dee57df42a525cead93fa30b4e7fa38a489d5b11 (patch) | |
| tree | 544f952e55f23205c26492d5cc76fe37118f3e98 /packages/meshbay-hub | |
| parent | 4f5d3d4ac151874f03c6fcc451d6b1d5bb1efb78 (diff) | |
| download | meshbay-dee57df42a525cead93fa30b4e7fa38a489d5b11.tar.gz | |
fix(spa): wake the download worker before handing it a stream
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub')
4 files changed, 148 insertions, 6 deletions
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") |