summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js58
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/sw.js15
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py20
-rw-r--r--packages/meshbay-hub/tests/test_streamed_download_reliability.py61
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")