aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_downloads.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-08 22:53:53 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-08 22:53:53 +0200
commit051100ca32f72dd8f28489e1b6cb084f321b3b54 (patch)
tree728d982351d70b0b01652bb28f950ce449edd542 /packages/meshbay-hub/tests/test_downloads.py
parenta15c3912008b7dc444c7fc6a2b4ccf782c647215 (diff)
downloadmeshbay-051100ca32f72dd8f28489e1b6cb084f321b3b54.tar.gz
fix(hub): keep the download worker alive, and never hang on a dead sink
A download froze part-way through, on Firefox, with an empty console and a node that stayed perfectly healthy. Three separate measurements cleared the node (615 MB pulled whole over MNP), the transport (three files interleaved on one connection, 1.5 GB, all whole) and the service worker (three concurrent 150 MB streams in real Firefox 154) — because none of them was wrong. The empty console was the evidence. `_sendAndWait` logs every timeout, so no chunk request had expired: the client was not waiting on the node. Of the three awaits left on that path only one was unbounded. **A service worker with no event for about thirty seconds is terminated**, and `respondWith(new Response(stream))` does not extend its life while the response is still being written. The reader vanished mid-file and `writable.write()` then never resolved and never rejected — no error, no log, no failed transfer, just a progress bar that stops. The first stress probe wrote 450 MB in two seconds and passed: fast enough to hide it entirely. Measured in Firefox 154, writing 1 MB every 2 s: without the ping it stalled at 17 MB after 59 s; with it, 40 MB in 80 s, complete. - the page pings the worker every 10 s while it writes, and the worker answers. Receiving a message is an event, and an event resets the timer; - that interval stops itself after two minutes with no write. A target can be opened and never written to — a transfer cancelled while it waits for a slot never runs, so nothing calls close() or abort() — and a timer nobody clears pings for the life of the page. It also kept the Node test process alive for ever, which is the same defect wearing a louder symptom; - `writable.write()` is bounded at 60 s and fails with a message naming the chunk. That does not fix whatever stopped a sink; it turns an unexplainable freeze into a failed transfer that says so, which is the difference between a mystery and a bug report. Also: `Content-Disposition` lost a filename to a single apostrophe. `encodeURIComponent` leaves `'` alone and `'` is the delimiter in RFC 5987's `filename*=<charset>'<lang>'<value>`, so the header became unparseable and Firefox named the file after the URL — 449 MB of film arrived complete as "mtsshk9w-ohqty535". `(`, `)` and `*` get the same treatment, and a plain ASCII `filename=` rides alongside. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub/tests/test_downloads.py')
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py146
1 files changed, 146 insertions, 0 deletions
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*=<charset>'<lang>'<value>`. 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