summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js454
1 files changed, 414 insertions, 40 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
index 90f88b0..c790394 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
@@ -155,9 +155,32 @@ 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,
+ // A held-open `FileSystemWritableFileStream`: pausing is simply not
+ // writing to it, and nothing is lost while nothing is written.
+ pausable: true,
// 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
// web page can start a desktop application, or show a file manager.
@@ -180,40 +203,281 @@ export const BLOB_LIMIT = 512 * 1024 * 1024;
// ── Streaming to disk without the File System Access API ────────────────────
const SW_PATH = '/sw.js';
-let _swReady = null;
+// Kept in step with sw.js's own PREFIX.
+const PREFIX_PATH = '/_mbdl/';
+
+// 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;
+// How often the page pokes the worker while a download is being written.
+// Firefox terminates a service worker that has had no event for roughly thirty
+// seconds, and a streaming response does not count as activity — so a download
+// that takes longer than that lost its reader half way through. Ten seconds
+// leaves a wide margin and costs one empty message.
+const SW_KEEPALIVE_MS = 10000;
+// And the ping stops on its own once nothing has been written for this long.
+// Well past any real gap between chunks, and short enough that an abandoned
+// 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
+// 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);
+ });
+}
+
+/**
+ * The promise's value, or `TIMED_OUT` once `ms` is spent.
+ *
+ * A rejection is still a rejection — the caller reports those — and the timer
+ * is cleared either way, so nothing is left running behind a fast answer.
+ */
+const TIMED_OUT = Symbol('timed out');
+
+function _within(promise, ms) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => resolve(TIMED_OUT), ms);
+ promise.then((v) => { clearTimeout(timer); resolve(v); },
+ (err) => { clearTimeout(timer); reject(err); });
+ });
+}
+
+async function _claimController(budgetMs) {
+ // Every wait in here is inside one budget. Neither of the first two used to
+ // have any deadline at all, and `_swPromise` is shared, so a single one of
+ // them left every download on the page waiting on the same promise for ever
+ // — four rows stuck at "preparing", with nothing in the node's journal
+ // because no transfer had been asked for yet.
+ const deadline = Date.now() + budgetMs;
+ const left = () => Math.max(0, deadline - Date.now());
+
+ let reg = await _within(
+ navigator.serviceWorker.register(SW_PATH, { scope: '/' }), left());
+ if (reg === TIMED_OUT) {
+ _lastFailure = `the worker did not register within ${budgetMs / 1000}s`;
+ return null;
+ }
+ // `register()` resolves as soon as the registration object exists, with
+ // nothing but an *installing* worker; `ready` is what waits for an active
+ // one. A worker that never finishes installing leaves `ready` pending
+ // indefinitely — measured on Firefox 154: an install handler that rejects
+ // leaves `ready` unsettled past ten seconds while `register()` returns in
+ // seven milliseconds.
+ let ready = await _within(navigator.serviceWorker.ready, left());
+ if (ready === TIMED_OUT && !reg.active) {
+ // A registration stuck with nothing but an installing worker does not heal
+ // on its own: every later visit finds the same registration and waits on
+ // the same `ready`. Left alone it is permanent, and it costs Firefox the
+ // only unbounded way it has to write a download to disk — so the stuck
+ // registration is thrown away and asked for once more, with its own budget,
+ // rather than reported and lived with.
+ console.warn('[MeshBay] the download worker never became active; '
+ + 'discarding the registration and asking again');
+ try {
+ await _within(reg.unregister(), budgetMs);
+ } catch (err) {
+ console.warn('[MeshBay] could not discard it:', err.message);
+ }
+ const again = await _within(
+ navigator.serviceWorker.register(SW_PATH, { scope: '/' }), budgetMs);
+ if (again === TIMED_OUT) {
+ _lastFailure = `the worker did not register within ${budgetMs / 1000}s`;
+ return null;
+ }
+ reg = again;
+ ready = await _within(navigator.serviceWorker.ready, budgetMs);
+ if (ready === TIMED_OUT && !reg.active) {
+ _lastFailure = `the worker did not become active within ${budgetMs / 1000}s`
+ + ', even after its registration was discarded';
+ return null;
+ }
+ }
+ // Past here `ready` may still be waiting on a *newer* worker that cannot
+ // install while an older one is perfectly able to serve. An active worker is
+ // all this path needs, so a stuck `ready` is not on its own a reason to give
+ // up a capability Firefox has nothing else to offer for.
+ //
+ // Being active is not being in control, either. 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 that was found.
+ //
+ // Ask for the claim *before* waiting, not after. A page that is uncontrolled
+ // while an active worker exists will not be claimed on its own — a document
+ // fetched by a hard reload is exactly that shape — so the whole control
+ // budget is spent waiting for something that is not coming, and it was: about
+ // thirty seconds during which somebody clicks download and watches four rows
+ // hang. Asking first costs one message and makes the common case immediate.
+ if (!navigator.serviceWorker.controller && reg.active) {
+ try { reg.active.postMessage({ type: 'mbdl-claim' }); } catch { /* gone */ }
+ }
+ const controller = await _awaitControl(left());
+ 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.
+ // This wait is deliberately outside the budget above: giving up here would
+ // cost Firefox the only unbounded way it has to write a download to disk.
+ 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;
+ _priming = _repairIfBypassed()
+ .then(() => serviceWorker())
+ .catch(() => {});
+}
+
+// Resolved once the check below has run. A download that starts while it is
+// still in flight waits for it rather than racing it: on a page that turns out
+// to be unservable the click would otherwise spend thirty seconds failing on a
+// path that is about to be repaired.
+let _priming = null;
+
+// Set for the life of this tab, so the repair below happens at most once and
+// can never become a reload loop.
+const REPAIRED_KEY = 'meshbay.sw-repaired';
+
+/**
+ * Was this document loaded with the service worker bypassed?
+ *
+ * A document fetched by a **hard** reload — Ctrl+F5, Ctrl+Shift+R — is loaded
+ * with the worker bypassed. It can still be claimed afterwards, so
+ * `navigator.serviceWorker.controller` comes back and every control check
+ * passes; but the navigations it starts keep missing the worker, and the hidden
+ * iframe a streamed download needs *is* a navigation. On Firefox and Safari
+ * that is the only way to write a file too large to hold in memory, so every
+ * download fails for the life of that page.
+ *
+ * Measured on Chrome, at document start, before anything registers:
+ *
+ * first visit controller false, registration false
+ * ordinary reload controller true, registration true
+ * hard reload controller false, registration true
+ *
+ * So being uncontrolled while an active registration already exists names the
+ * case exactly, and costs nothing to ask.
+ *
+ * The first version of this asked by *performing a download* — a four-byte
+ * stream through a hidden iframe — which was both unreliable and expensive:
+ * Chrome rations downloads a page starts without a user gesture to about three,
+ * so the test competed with the person's real downloads for that budget and its
+ * answer depended on how many had been spent. It reloaded a healthy page on
+ * every first visit, taking the group's WebRTC session down with it.
+ */
+const _controlledAtLoad = STREAMS_VIA_SW
+ && Boolean(navigator.serviceWorker.controller);
+// Started here, at module load, because `register()` would make the answer
+// true whatever it was.
+const _registeredAtLoad = STREAMS_VIA_SW
+ ? navigator.serviceWorker.getRegistration('/')
+ .then((reg) => Boolean(reg && reg.active)).catch(() => false)
+ : Promise.resolve(false);
+
+/** An ordinary reload puts the document back under the worker, so do that once. */
+async function _repairIfBypassed() {
+ if (_controlledAtLoad) return;
+ // Uncontrolled with nothing registered is a first visit, not a bypass: the
+ // worker is being installed right now and the page is fine after it claims.
+ if (!await _registeredAtLoad) return;
+ let repaired = false;
+ try { repaired = sessionStorage.getItem(REPAIRED_KEY) === '1'; } catch { /* blocked */ }
+ if (repaired) return;
+ console.warn('[MeshBay] this page was loaded with the download worker '
+ + 'bypassed (a hard reload does that) — reloading once to put it '
+ + 'back under the worker\u2019s control');
+ try { sessionStorage.setItem(REPAIRED_KEY, '1'); } catch { /* blocked */ }
+ location.reload();
+}
+
+async function serviceWorker(controlMs = SW_CONTROL_BUDGET_MS) {
+ if (!STREAMS_VIA_SW) {
+ _lastFailure = 'no service worker support in this browser';
+ return null;
+ }
+ 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;
+ });
}
- return _swReady;
+ 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 +493,59 @@ 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,
+} = {}) {
+ if (_priming) { try { await _priming; } catch { /* reported already */ } }
+ 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;
+}
+
+/**
+ * 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);
if (!worker) return null;
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
@@ -241,52 +556,111 @@ export async function openStreamedDownload(filename, size = 0) {
// 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]);
} 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;
}
+ // 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 = `/_mbdl/${id}`;
+ frame.src = `${PREFIX_PATH}${id}`;
document.body.appendChild(frame);
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 = '';
+ // Every few seconds for as long as this download is being written. Well
+ // inside the ~30 s Firefox allows an idle worker, and cheap: one postMessage
+ // with no payload. Cleared by close() and abort() below, so a finished
+ // download leaves no timer behind.
+ // Self-limiting, and that is not belt-and-braces: a target can be opened and
+ // then never written to — a transfer cancelled while it waits for a slot
+ // never runs, so nothing calls close() or abort() — and an interval 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 (the
+ // MessagePort above did exactly this a few hours earlier).
+ let lastWrite = Date.now();
+ const keepAlive = setInterval(() => {
+ if (Date.now() - lastWrite > SW_KEEPALIVE_IDLE_MS) {
+ clearInterval(keepAlive);
+ return;
+ }
+ try { worker.postMessage({ type: 'mbdl-ping' }); } catch { /* gone */ }
+ }, SW_KEEPALIVE_MS);
+ // 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,
+ // **Not pausable, and this is not a limitation of our code.** The browser
+ // is already writing an HTTP response into its own download folder: not
+ // writing to the stream stalls that download where we cannot see it or
+ // resume it, and an idle worker is terminated within seconds, taking the
+ // stream with it. A pause button here would restart from zero, which is
+ // worse than not offering one. §6.5 of ~/next/improve-downloads.md records
+ // what giving Firefox and Safari a resumable target would cost.
+ pausable: false,
writable: {
- write: (bytes) => writer.write(bytes),
+ write: (bytes) => { lastWrite = Date.now(); return writer.write(bytes); },
close: async () => {
+ clearInterval(keepAlive);
await writer.close();
setTimeout(() => frame.remove(), 2000);
},
abort: async (reason) => {
+ clearInterval(keepAlive);
try { await writer.abort(reason); } catch { /* already gone */ }
frame.remove();
},