aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py146
-rw-r--r--packages/meshbay-hub/tests/test_streamed_download_reliability.py17
2 files changed, 159 insertions, 4 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
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