aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js203
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/sw.js8
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py20
-rw-r--r--packages/meshbay-hub/tests/test_streamed_download_reliability.py267
5 files changed, 465 insertions, 42 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index c2858f4..9d64292 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -6,6 +6,7 @@ import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js';
import { ZipStream, entriesUnder } from './zipstream.js';
import { transfers, formatSpeed } from './transfers.js';
import * as platform from './platform.js';
+import * as downloads from './downloads.js';
import { Icon } from './icon.js';
import { formatSize } from './file-utils.js';
import {
@@ -941,6 +942,14 @@ const trayLabels = () => ({
// falls back to English rather than rejecting, so this cannot strand the page.
const mount = () => {
render(html`<${App} />`, document.getElementById('app'));
+ // Get the download worker registered and this page under its control now,
+ // rather than inside the first click on Download. On Firefox and Safari it is
+ // the only unbounded way to write a file to disk, and it used to be
+ // registered lazily — so the first download of a session paid install,
+ // activate and claim while somebody watched, and a claim that missed its
+ // budget sent the file to a path that cannot hold a film. Fire-and-forget:
+ // nothing renders differently for it, and a failure is retried on demand.
+ downloads.primeServiceWorker();
// After the catalogue, so the labels are in the right language. A no-op in a
// browser and on macOS. A language change reloads the page, which comes back
// through here, so nothing else has to watch for it.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
index 90f88b0..f620e15 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
@@ -155,8 +155,28 @@ export async function openTarget(filename) {
};
const name = await freeName(filename, exists);
const handle = await dir.getFileHandle(name, { create: true });
+ const writable = await handle.createWritable();
return {
- writable: await handle.createWritable(),
+ // `abort()` on a FileSystemWritableFileStream discards the swap file and
+ // leaves the target as it was — which here is the empty file
+ // `getFileHandle({create: true})` just made, before a single byte arrived.
+ // So every cancelled or failed download left a 0-byte file behind, and
+ // because `freeName` avoids collisions, three cancels left `film.mkv`,
+ // `film (2).mkv` and `film (3).mkv`, all empty, in the person's folder.
+ //
+ // Removing it is safe *here and only here*: `freeName` guarantees this name
+ // was not taken, so the file being deleted is one we created moments ago
+ // and nothing else. The `showSaveFilePicker` path in file-utils.js must not
+ // do the same — there the person may have picked an existing file, whose
+ // contents `abort()` correctly preserves.
+ writable: {
+ write: (bytes) => writable.write(bytes),
+ close: () => writable.close(),
+ abort: async (reason) => {
+ try { await writable.abort(reason); } catch { /* already gone */ }
+ try { await dir.removeEntry(name); } catch { /* already gone */ }
+ },
+ },
name,
// Reading it back is the only way a page can "open" a file it wrote: hand
// the bytes to a tab and let the browser decide what to do with them. No
@@ -180,40 +200,114 @@ export const BLOB_LIMIT = 512 * 1024 * 1024;
// ── Streaming to disk without the File System Access API ────────────────────
const SW_PATH = '/sw.js';
-let _swReady = null;
+
+// On Firefox and Safari this worker is not a nicety, it is the only unbounded
+// way to write a download to disk: the File System Access API does not exist
+// there, and OPFS is capped at 10% of the volume's size (measured on Firefox
+// 154: 389,233,459 bytes on a 3,892,334,592-byte volume, refused to the byte),
+// which a film can exceed. Everything below exists to make sure this path is
+// available when it is needed, because there is nothing underneath it.
+//
+// How long to wait for this page to become *controlled*. Generous on purpose:
+// the cost of waiting is a spinner, and the cost of giving up is a download
+// this browser then cannot do at all.
+const SW_CONTROL_BUDGET_MS = 15000;
+// How long to wait for the worker to confirm it answered the iframe.
+const SW_SERVED_BUDGET_MS = 15000;
+// A transient miss gets a second go with a fresh id and a fresh iframe.
+const SW_ATTEMPTS = 2;
+
+// Holds a *successful* controller, or an in-flight attempt. Never a failure —
+// see serviceWorker(). The previous version cached the rejected/null result
+// for the life of the page, so one slow first click (a cold worker, a busy
+// phone) left the tab unable to stream anything ever again, with no way back
+// but a reload nobody knew to do.
+let _swPromise = null;
+let _lastFailure = '';
+
+/** Why the streamed path last declined, for a message worth reading. */
+export function lastStreamFailure() { return _lastFailure; }
export const STREAMS_VIA_SW = typeof window !== 'undefined'
&& 'serviceWorker' in navigator
&& typeof TransformStream === 'function'
&& window.isSecureContext;
-async function serviceWorker() {
- if (!STREAMS_VIA_SW) return null;
- if (!_swReady) {
- _swReady = navigator.serviceWorker.register(SW_PATH, { scope: '/' })
- .then(() => navigator.serviceWorker.ready)
- .then(async (reg) => {
- // `reg.active` is not enough. A worker can be active while this page is
- // still uncontrolled, and an uncontrolled page's requests are never
- // handed to its fetch handler — so the worker would take our stream and
- // then never be asked for it. The iframe would 404, nothing would read
- // the stream, and the first write() would block for good: a download
- // stuck at one chunk.
- if (navigator.serviceWorker.controller) return navigator.serviceWorker.controller;
- // sw.js claims clients on activate, so control usually arrives within a
- // tick of registration. Wait briefly rather than give up at once.
- return await new Promise((resolve) => {
- const done = () => resolve(navigator.serviceWorker.controller || null);
- navigator.serviceWorker.addEventListener('controllerchange', done, { once: true });
- setTimeout(done, 3000);
- });
- })
- .catch(err => {
- console.warn('[MeshBay] service worker unavailable:', err.message);
- return null;
- });
+/** Resolves with the controller, or null once `budgetMs` is spent. */
+function _awaitControl(budgetMs) {
+ if (navigator.serviceWorker.controller) {
+ return Promise.resolve(navigator.serviceWorker.controller);
+ }
+ return new Promise((resolve) => {
+ let timer = 0;
+ const done = () => {
+ clearTimeout(timer);
+ navigator.serviceWorker.removeEventListener('controllerchange', done);
+ resolve(navigator.serviceWorker.controller || null);
+ };
+ navigator.serviceWorker.addEventListener('controllerchange', done);
+ timer = setTimeout(done, budgetMs);
+ });
+}
+
+async function _claimController(budgetMs) {
+ const reg = await navigator.serviceWorker.register(SW_PATH, { scope: '/' });
+ // `ready` resolves on an *active* registration; being active is not being in
+ // control. An uncontrolled page's requests never reach the fetch handler, so
+ // the worker would take our stream and never be asked for it — the download
+ // then freezes after exactly one chunk, which is how this was found.
+ await navigator.serviceWorker.ready;
+ const controller = await _awaitControl(budgetMs);
+ if (controller) return controller;
+ // Active but not controlling after the whole budget. `sw.js` calls
+ // `clients.claim()` on activate, so this is rare; when it happens the page
+ // was loaded before any worker existed and the claim was missed. Ask the
+ // active worker to claim again rather than declare the path unavailable.
+ if (reg.active) {
+ try { reg.active.postMessage({ type: 'mbdl-claim' }); } catch { /* gone */ }
+ return await _awaitControl(2000);
+ }
+ return null;
+}
+
+/**
+ * Register the worker and get this page controlled, now.
+ *
+ * Called at application start, not at the first download. Registration used to
+ * happen inside the first click, so that click paid install, activate and claim
+ * while somebody watched a button do nothing — and if the claim did not land
+ * inside the budget, the download fell through to a path that cannot hold a
+ * film. By the time anyone clicks anything, this has long since finished.
+ *
+ * Fire-and-forget by design: nothing waits on it, and a failure here is not
+ * fatal because `serviceWorker()` will simply try again.
+ */
+export function primeServiceWorker() {
+ if (!STREAMS_VIA_SW) return;
+ serviceWorker().catch(() => {});
+}
+
+async function serviceWorker(controlMs = SW_CONTROL_BUDGET_MS) {
+ if (!STREAMS_VIA_SW) {
+ _lastFailure = 'no service worker support in this browser';
+ return null;
}
- return _swReady;
+ if (navigator.serviceWorker.controller) return navigator.serviceWorker.controller;
+ if (!_swPromise) {
+ _swPromise = _claimController(controlMs).catch((err) => {
+ _lastFailure = 'service worker registration failed: ' + err.message;
+ console.warn('[MeshBay]', _lastFailure);
+ return null;
+ });
+ }
+ const controller = await _swPromise;
+ if (!controller) {
+ // Not remembered. The next attempt starts from scratch, which is the whole
+ // point: these failures are transient far more often than they are final.
+ _swPromise = null;
+ if (!_lastFailure) _lastFailure = 'the page did not come under the worker’s control';
+ }
+ return controller;
}
/**
@@ -229,8 +323,29 @@ async function serviceWorker() {
* Returns {writable, name} shaped like the File System Access one, or null if
* this browser cannot do it either.
*/
-export async function openStreamedDownload(filename, size = 0) {
- const worker = await serviceWorker();
+export async function openStreamedDownload(filename, size = 0, {
+ controlMs = SW_CONTROL_BUDGET_MS,
+ servedMs = SW_SERVED_BUDGET_MS,
+ attempts = SW_ATTEMPTS,
+} = {}) {
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ const target = await _attemptStreamedDownload(
+ filename, size, attempt, controlMs, servedMs);
+ if (target) return target;
+ // A miss is usually the worker having been asleep or the navigation losing
+ // a race, not this browser being unable. Falling through on the first miss
+ // is what sent large downloads to the in-memory floor.
+ if (attempt < attempts) {
+ console.warn(`[MeshBay] streamed download attempt ${attempt} missed `
+ + `(${_lastFailure}); retrying`);
+ }
+ }
+ return null;
+}
+
+async function _attemptStreamedDownload(filename, size, attempt,
+ controlMs, servedMs) {
+ const worker = await serviceWorker(controlMs);
if (!worker) return null;
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
@@ -252,8 +367,11 @@ export async function openStreamedDownload(filename, size = 0) {
[readable, chan.port2]);
} catch (err) {
// Transferable streams are what makes the backpressure work; without them
- // this would be a memory buffer wearing a stream's clothes.
- console.warn('[MeshBay] streams cannot be transferred here:', err.message);
+ // this would be a memory buffer wearing a stream's clothes. This one is
+ // final rather than transient — a browser does not grow the capability
+ // between two attempts — so it is reported as such.
+ _lastFailure = 'this browser cannot transfer a stream to the worker: ' + err.message;
+ console.warn('[MeshBay]', _lastFailure);
return null;
}
@@ -264,19 +382,30 @@ export async function openStreamedDownload(filename, size = 0) {
const answered = await Promise.race([
serving,
- new Promise((r) => setTimeout(() => r(false), 8000)),
+ new Promise((r) => setTimeout(() => r(false), servedMs)),
]);
if (!answered) {
// Some browsers refuse a download started from a hidden iframe, and an
- // uncontrolled page never reaches the worker at all. Say so and let the
- // caller fall back rather than hand back a sink nothing drains.
- console.warn('[MeshBay] the service worker never served the download; '
- + 'falling back');
+ // uncontrolled page never reaches the worker at all. Tear this attempt
+ // down completely — the stream, the port and the frame — so a retry starts
+ // clean rather than leaving a half-open sink behind.
+ _lastFailure = `the worker did not answer the download within `
+ + `${servedMs / 1000}s (attempt ${attempt})`;
+ console.warn('[MeshBay]', _lastFailure);
frame.remove();
+ try { chan.port1.close(); } catch { /* already gone */ }
try { await writable.abort('not served'); } catch { /* already gone */ }
return null;
}
+ _lastFailure = '';
+ // The port has delivered the one message it exists for. Closing it matters:
+ // an open MessagePort is a live handle, and one was leaked per download for
+ // the life of the page. (It is also what hung the Node harness in
+ // test_streamed_download_reliability.py — there the leak is a process that
+ // never exits, which is the same defect wearing a louder symptom.)
+ try { chan.port1.close(); } catch { /* already gone */ }
+
const writer = writable.getWriter();
return {
name: filename,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/sw.js b/packages/meshbay-hub/src/meshbay_hub/static/sw.js
index 119687d..0dd87d8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/sw.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/sw.js
@@ -24,6 +24,14 @@ self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim(
self.addEventListener('message', (event) => {
const data = event.data || {};
+ // 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
+ // for another claim and waits a moment longer.
+ if (data.type === 'mbdl-claim') {
+ event.waitUntil(self.clients.claim());
+ return;
+ }
if (data.type !== 'mbdl' || !data.id || !data.readable) return;
pending.set(data.id, {
readable: data.readable,
diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py
index 32d3e11..fc69fac 100644
--- a/packages/meshbay-hub/tests/test_downloads.py
+++ b/packages/meshbay-hub/tests/test_downloads.py
@@ -149,8 +149,12 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path):
# and was lifted into file-utils.js's downloadDirectory (docs/photos.md
# §3) so photos-app.js's own "zip this album" button calls the same
# implementation rather than a second one.
+ # Anchored on the call, not on how its result is bound: the assignment
+ # became a bare `target = ...` inside a try when _openDownloadTarget gained
+ # the ability to refuse an oversized download (test_memory_ceiling.py).
+ # What this test is about -- the `0` -- did not move.
app = (STATIC / "file-utils.js").read_text()
- zip_call = app[app.index("const target = await _openDownloadTarget(suggested"):]
+ zip_call = app[app.index("_openDownloadTarget(suggested"):]
zip_call = zip_call[:zip_call.index(");") + 2]
assert zip_call.rstrip().endswith(", 0);"), (
"the zip download announces a Content-Length it will not match")
@@ -201,9 +205,15 @@ def test_the_streamed_path_gives_up_rather_than_blocking_for_ever():
def test_an_uncontrolled_page_is_not_treated_as_ready():
"""`registration.active` says a worker exists, not that it will see our fetch."""
+ # Anchored on the streaming section rather than on one function: waiting
+ # for control moved into `_awaitControl`/`_claimController` when the budget
+ # became a parameter, and `serviceWorker()` no longer contains the words.
+ # The behaviour itself is executed in test_streamed_download_reliability.py;
+ # this stays as the cheap guard on the module's shape.
src = DOWNLOADS.read_text()
- fn = src[src.index("async function serviceWorker()"):]
- fn = fn[:fn.index("\n}")]
- assert "navigator.serviceWorker.controller" in fn
- assert "controllerchange" in fn, (
+ section = src[src.index("// ── Streaming to disk"):]
+ assert "navigator.serviceWorker.controller" in section
+ assert "controllerchange" in section, (
"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")
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