aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js200
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js37
-rw-r--r--packages/meshbay-hub/tests/harness/boot_guard_probe.py260
-rw-r--r--packages/meshbay-hub/tests/test_boot_guard.py122
5 files changed, 621 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 6ac5cdb..3e23961 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -180,6 +180,10 @@ _HTML = """\
and app.js's own relative imports inherit the prefix, which is the only
way the module graph is guaranteed not to be a mixture of two builds.
See _asset_version() and VersionedStatics. -->
+ <!-- First, and not a module: if the graph below never links, no module code
+ runs at all, and this is what keeps the page from being silently blank.
+ It draws nothing unless #app is still empty ten seconds from now. -->
+ <script src="/a/{v}/boot-guard.js"></script>
<!-- Argon2id (WebAssembly, inlined) — WebCrypto has no memory-hard KDF, and the
keypair bundle needs one: it is protected by the passphrase alone and sits
on every node its owner joins (C4). Vendored, see static/vendor/PROVENANCE.md -->
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js b/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js
new file mode 100644
index 0000000..f5e3aa1
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js
@@ -0,0 +1,200 @@
+/**
+ * The page can never stay silently blank.
+ *
+ * A reader once had nothing but white for an evening: the shell arrived, the
+ * modules were served from the browser's own store so not one request reached
+ * the hub, and nothing ever rendered. No message, no error on screen, nothing
+ * in the server's log to look at — the same account worked in a private window
+ * and in another browser, which is the signature of something wrong in this
+ * origin's stored state rather than in what was deployed. Clearing the site's
+ * data fixed it. Clearing the *cache*, three times, had not: a service worker
+ * and IndexedDB are not the cache, and nothing on screen said so.
+ *
+ * This answers the *silence*, not whatever caused it. A blank page is a bug
+ * report nobody can write, and a phone has no console to open.
+ *
+ * **A classic script, deliberately, and first.** The failure it guards against
+ * includes the module graph never linking — one bad module and no module code
+ * runs at all, so a guard inside `app.js` would be part of what failed. This
+ * one has no imports and cannot be stopped by them.
+ *
+ * It does nothing when the application mounts, which is the ordinary case: it
+ * looks once, late, and speaks only to a reader already staring at nothing.
+ */
+(function () {
+ 'use strict';
+
+ // Long enough that a cold phone on a slow network is never interrupted —
+ // argon2's wasm, forty modules and a catalogue — and short enough that
+ // nobody sits in front of white wondering. The app mounts in well under two
+ // seconds when it mounts at all.
+ var GIVE_UP_MS = 10000;
+ var problems = [];
+
+ function note(what) {
+ if (problems.length < 5) problems.push(String(what).slice(0, 200));
+ }
+
+ // Capture phase: a `<script>` or stylesheet that fails to load fires `error`
+ // at the element, and that one does not bubble.
+ window.addEventListener('error', function (e) {
+ if (e && e.target && e.target !== window && e.target.src) {
+ note('could not load ' + String(e.target.src).replace(/^https?:\/\/[^/]+/, ''));
+ } else if (e && e.message) {
+ note(e.message);
+ }
+ }, true);
+
+ window.addEventListener('unhandledrejection', function (e) {
+ var r = e && e.reason;
+ note((r && (r.message || r)) || 'a promise rejected');
+ });
+
+ var FR = (navigator.language || '').toLowerCase().indexOf('fr') === 0;
+ var TEXT = FR ? {
+ title: 'MeshBay n’a pas pu démarrer',
+ body: 'L’application s’est arrêtée avant d’afficher quoi que ce soit. '
+ + 'Cela vient presque toujours des données que ce navigateur garde pour ce site.',
+ retry: 'Réessayer',
+ reset: 'Réinitialiser les données de ce site',
+ warn: 'Vous devrez vous reconnecter. Vos fichiers et vos groupes ne sont pas '
+ + 'touchés : ils vivent sur les nœuds, pas ici.',
+ doing: 'Réinitialisation…',
+ blocked: 'Un autre onglet de ce site garde la base ouverte. Fermez les autres '
+ + 'onglets meshbay, puis réessayez.',
+ } : {
+ title: 'MeshBay could not start',
+ body: 'The application stopped before it drew anything. This is almost always '
+ + 'the data this browser keeps for this site.',
+ retry: 'Try again',
+ reset: 'Reset this site’s data',
+ warn: 'You will have to sign in again. Your files and groups are untouched: '
+ + 'they live on the nodes, not here.',
+ doing: 'Resetting…',
+ blocked: 'Another tab of this site is holding the database open. Close the '
+ + 'other meshbay tabs, then try again.',
+ };
+
+ /**
+ * Deleting a database waits for every connection to it to close, and another
+ * tab of this site is a connection. Unwatched, `deleteDatabase` then does
+ * nothing at all and says nothing — so this page would reload into the state
+ * it had just promised to clear, which is the bug this file exists to end.
+ * Blocked deletions are named and handed back.
+ */
+ function dropDatabase(name) {
+ return new Promise(function (res) {
+ var req;
+ try { req = indexedDB.deleteDatabase(name); } catch (e) { return res(null); }
+ var settled = false;
+ var end = function (v) { if (!settled) { settled = true; res(v); } };
+ req.onsuccess = function () { end(null); };
+ req.onerror = function () { end(null); };
+ req.onblocked = function () { end(name); };
+ setTimeout(function () { end(name); }, 2500);
+ return undefined;
+ });
+ }
+
+ /** Everything this origin holds, and the service worker with it. */
+ function resetSiteData(done) {
+ var waiting = [];
+ var blocked = [];
+ try { localStorage.clear(); } catch (e) { /* private mode */ }
+ try { sessionStorage.clear(); } catch (e) { /* private mode */ }
+
+ if (window.indexedDB) {
+ var names = indexedDB.databases
+ ? indexedDB.databases().then(function (dbs) {
+ return (dbs || []).map(function (d) { return d && d.name; }).filter(Boolean);
+ }).catch(function () { return ['meshbay', 'meshbay_keys']; })
+ // Safari and older engines have no `databases()`; these are ours.
+ : Promise.resolve(['meshbay', 'meshbay_keys']);
+ waiting.push(names.then(function (list) {
+ return Promise.all(list.map(dropDatabase)).then(function (results) {
+ results.forEach(function (n) { if (n) blocked.push(n); });
+ });
+ }).catch(function () {}));
+ }
+
+ if (window.caches && caches.keys) {
+ waiting.push(caches.keys().then(function (keys) {
+ return Promise.all((keys || []).map(function (k) { return caches.delete(k); }));
+ }).catch(function () {}));
+ }
+
+ if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) {
+ waiting.push(navigator.serviceWorker.getRegistrations().then(function (regs) {
+ return Promise.all((regs || []).map(function (r) { return r.unregister(); }));
+ }).catch(function () {}));
+ }
+
+ // Bounded: a hung unregister must not leave the reader on "Resetting…" for
+ // ever, which would be this bug wearing a different hat.
+ var fired = false;
+ var finish = function () { if (!fired) { fired = true; done(blocked); } };
+ Promise.all(waiting).then(finish).catch(finish);
+ setTimeout(finish, 4000);
+ }
+
+ function el(tag, style, text) {
+ var n = document.createElement(tag);
+ if (style) n.setAttribute('style', style);
+ if (text) n.textContent = text;
+ return n;
+ }
+
+ function show(root) {
+ var BTN = 'display:block;width:100%;margin:8px 0;padding:12px 16px;font:inherit;'
+ + 'font-size:15px;border-radius:8px;border:1px solid #c9ccd1;background:#fff;'
+ + 'color:#111;cursor:pointer';
+ var box = el('div', 'max-width:34em;margin:12vh auto;padding:0 20px;'
+ + 'font:15px/1.55 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#111');
+
+ box.appendChild(el('h1', 'font-size:20px;margin:0 0 12px', TEXT.title));
+ box.appendChild(el('p', 'margin:0 0 16px;color:#444', TEXT.body));
+
+ var retry = el('button', BTN, TEXT.retry);
+ retry.addEventListener('click', function () { location.reload(); });
+ box.appendChild(retry);
+
+ var reset = el('button', BTN + ';border-color:#b23;color:#b23', TEXT.reset);
+ reset.addEventListener('click', function () {
+ reset.disabled = true;
+ reset.textContent = TEXT.doing;
+ resetSiteData(function (blocked) {
+ if (!blocked.length) { location.reload(); return; }
+ // Reloading here would be a lie: the database is still there, held by
+ // another tab, and the page would come back exactly as broken.
+ reset.disabled = false;
+ reset.textContent = TEXT.reset;
+ var why = el('p', 'margin:12px 0 0;color:#b23;font-size:13px',
+ TEXT.blocked + ' (' + blocked.join(', ') + ')');
+ why.setAttribute('data-blocked', blocked.join(','));
+ box.appendChild(why);
+ });
+ });
+ box.appendChild(reset);
+
+ box.appendChild(el('p', 'margin:12px 0 0;color:#666;font-size:13px', TEXT.warn));
+
+ if (problems.length) {
+ // Verbatim, and on screen. Nothing else reaches a reader who cannot open
+ // a console, and it is the first thing anybody diagnosing this will ask.
+ var pre = el('pre', 'margin:16px 0 0;padding:10px;background:#f3f4f6;'
+ + 'border-radius:6px;font-size:12px;white-space:pre-wrap;word-break:break-word;'
+ + 'color:#333', problems.join('\n'));
+ box.appendChild(pre);
+ }
+
+ root.appendChild(box);
+ }
+
+ setTimeout(function () {
+ var root = document.getElementById('app');
+ // Mounted, which is the ordinary case and the end of this script's
+ // involvement. A later render replaces whatever is here anyway.
+ if (!root || root.firstChild) return;
+ show(root);
+ }, GIVE_UP_MS);
+}());
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
index dbd5037..eead457 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
@@ -18,6 +18,7 @@ const IDB_NAME = 'meshbay';
// disagreeing about the version is a `VersionError` thrown at whichever of
// them happens to run second.
const IDB_VERSION = 2;
+const OPEN_DB_TIMEOUT_MS = 5000;
const IDB_STORE = 'group_indexes';
const IDB_PLAYLISTS = 'playlists';
@@ -27,9 +28,32 @@ function navigate(path) {
// ── IndexedDB cache ─────────────────────────────────────────────────────────
+/**
+ * The database, opened — or refused, but never left hanging.
+ *
+ * A version upgrade waits for every other connection to this database to
+ * close. A second tab of this site holding version 1 open is enough to stop
+ * it, and `indexedDB.open` then fires **neither** `success` nor `error`: it
+ * fires `blocked`, and if nothing handles that the promise never settles. Every
+ * `await openDB()` behind it waits for ever, which reads as a feature that
+ * silently does nothing rather than as a failure anybody can see.
+ *
+ * So `blocked` is heard, and a deadline covers the rest. Callers already treat
+ * a rejection as "no local cache this time" and carry on.
+ */
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(IDB_NAME, IDB_VERSION);
+ let settled = false;
+ const done = (fn, arg) => { if (!settled) { settled = true; fn(arg); } };
+ // Generous: the other tab is asked to close and usually does within a
+ // frame. This is the backstop for the one that cannot — a page suspended
+ // on a phone, say — not a latency budget.
+ const deadline = setTimeout(() => done(reject, new Error(
+ 'IndexedDB open timed out (another tab may hold an older version open)')),
+ OPEN_DB_TIMEOUT_MS);
+ req.onblocked = () => done(reject, new Error(
+ 'IndexedDB upgrade blocked by another tab of this site'));
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
@@ -43,8 +67,17 @@ function openDB() {
db.createObjectStore(IDB_PLAYLISTS);
}
};
- req.onsuccess = () => resolve(req.result);
- req.onerror = () => reject(req.error);
+ req.onsuccess = () => {
+ clearTimeout(deadline);
+ // Giving up does not cancel the request: the other tab eventually closes,
+ // the upgrade goes through, and this fires with a live connection nobody
+ // is waiting for. Left open it squats the database — blocking the next
+ // upgrade *and* any attempt to delete it, which is the failure this
+ // whole guard exists to end, arriving through the back door.
+ if (settled) { try { req.result.close(); } catch { /* already gone */ } return; }
+ done(resolve, req.result);
+ };
+ req.onerror = () => { clearTimeout(deadline); done(reject, req.error); };
});
}
diff --git a/packages/meshbay-hub/tests/harness/boot_guard_probe.py b/packages/meshbay-hub/tests/harness/boot_guard_probe.py
new file mode 100644
index 0000000..01751ba
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/boot_guard_probe.py
@@ -0,0 +1,260 @@
+#!/usr/bin/env python3
+"""
+The shell cannot end up silently blank, and there is a way out of it.
+
+A reader spent an evening on a white page: the shell arrived, every module came
+from the browser's own store so not one request reached the hub, and nothing
+rendered. Clearing the site's data fixed it — clearing the *cache*, three times,
+did not, because the cache is not where a service worker or IndexedDB lives.
+
+What is measured here is the guard, not the cause. Three things have to hold:
+it says nothing when the application mounts; it appears, with the failure named
+on screen, when the module graph does not link; and its reset button genuinely
+empties this origin — a button that claims to and does not would be worse than
+none.
+
+The last case is why the guard is a classic script and not part of the module
+graph: it has to survive the graph failing to link, which is exactly when a
+reader needs it.
+
+ boot_guard_probe.py
+
+Prints JSON: one entry per case.
+"""
+
+import http.server
+import json
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8763
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+# The shell, as `webapp.py` builds it: the guard first, as a classic script,
+# then the module graph. `?fail=1` asks for a module that is not there.
+SHELL = r"""<!doctype html><html><head><meta charset=utf-8></head><body>
+<div id="app"></div>
+<script src="/boot-guard.js"></script>
+<script type="module" src="/__case.js"></script>
+</body></html>"""
+
+MOUNTS = r"""// A successful mount: the guard must stay out of the way.
+document.getElementById('app').appendChild(
+ Object.assign(document.createElement('p'), { textContent: 'the application' }));
+"""
+
+BLOCKED = r"""<!doctype html><html><head><meta charset=utf-8></head><body>
+<script type="module">
+// Version 1, held open, as a second tab of this site running yesterday's build
+// holds it. The shipped `openDB` wants version 2: the upgrade cannot proceed
+// while this connection lives, and `indexedDB.open` then fires neither
+// `success` nor `error`.
+import { openDB } from '/hub-client.js';
+const held = await new Promise((res, rej) => {
+ const r = indexedDB.open('meshbay', 1);
+ r.onupgradeneeded = () => r.result.createObjectStore('group_indexes', { keyPath: 'groupId' });
+ r.onsuccess = () => res(r.result);
+ r.onerror = () => rej(r.error);
+});
+const started = Date.now();
+let outcome = 'never settled';
+try {
+ await openDB();
+ outcome = 'resolved';
+} catch (err) {
+ outcome = 'rejected: ' + (err && err.message);
+}
+window.__result = { outcome, ms: Date.now() - started, held: !!held };
+// Let go: the reset case below needs a database nobody is holding.
+held.close();
+</script></body></html>"""
+
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><div id="frames"></div><script>
+const cases = [];
+const post = (o) => fetch('/log', { method: 'POST', body: JSON.stringify(o) });
+addEventListener('error', (e) => post({ error: 'page error: ' + (e.message || e) }));
+addEventListener('unhandledrejection',
+ (e) => post({ error: 'rejection: ' + ((e.reason && (e.reason.stack || e.reason.message)) || e.reason) }));
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const add = (src) => {
+ const f = document.createElement('iframe');
+ f.src = src;
+ f.style.cssText = 'width:420px;height:700px;border:0;display:block';
+ document.getElementById('frames').appendChild(f);
+ return new Promise((res) => { f.addEventListener('load', () => res(f)); });
+};
+
+(async () => {
+ try {
+ const ok = await add('/shell?case=mounts');
+ const broken = await add('/shell?case=fails');
+ // Started now, read after the wait: it settles well inside it.
+ const blocked = await add('/blocked');
+
+ const w = broken.contentWindow;
+ w.localStorage.setItem('mb_auth', '{"token":"x"}');
+ await new Promise((res) => {
+ const r = w.indexedDB.open('meshbay', 1);
+ r.onupgradeneeded = () => r.result.createObjectStore('group_indexes', { keyPath: 'groupId' });
+ r.onsuccess = () => { r.result.close(); res(); };
+ r.onerror = () => res();
+ });
+
+ // The guard looks once, late. Waiting it out is the measurement.
+ await sleep(11000);
+
+ const guardIn = (f) => {
+ const h = f.contentDocument.getElementById('app');
+ return { text: h ? h.textContent : '', buttons:
+ [...f.contentDocument.querySelectorAll('#app button')].map((b) => b.textContent) };
+ };
+ const resetButton = (f) => [...f.contentDocument.querySelectorAll('#app button')]
+ .find((x) => /r.initialis|reset/i.test(x.textContent));
+
+ const a = guardIn(ok);
+ cases.push({ case: 'the application mounted', text: a.text.slice(0, 80),
+ buttons: a.buttons });
+
+ const b = guardIn(broken);
+ cases.push({ case: 'the module graph did not link',
+ text: b.text.slice(0, 400), buttons: b.buttons });
+
+ cases.push({ case: 'a blocked database upgrade gives up instead of hanging',
+ result: blocked.contentWindow.__result || 'never settled' });
+
+ // ── a reset another tab is holding open ──────────────────────────────
+ //
+ // `deleteDatabase` waits for every other connection to close, silently: it
+ // neither fails nor completes. A reset that does not watch for that
+ // reloads into the state it has just claimed to clear.
+ // No version: whatever the database is at now. Asking for a particular one
+ // is how this fixture quietly stopped holding anything — the store had
+ // moved to 2 underneath it and the open failed instead of connecting.
+ const holder = await new Promise((res) => {
+ const r = indexedDB.open('meshbay');
+ r.onsuccess = () => res(r.result);
+ r.onerror = () => res(null);
+ });
+ let reloadedAnyway = false;
+ broken.addEventListener('load', () => { reloadedAnyway = true; }, { once: true });
+ const r1 = resetButton(broken);
+ let told = null;
+ if (r1) {
+ r1.click();
+ await sleep(6000);
+ const m = broken.contentDocument.querySelector('#app [data-blocked]');
+ told = m ? m.getAttribute('data-blocked') : null;
+ }
+ cases.push({ case: 'a reset another tab is blocking says so',
+ clicked: !!r1, told, reloadedAnyway,
+ stillOffersReset: !!resetButton(broken) });
+
+ // ── and with nothing in its way ──────────────────────────────────────
+ if (holder) holder.close();
+ const r2 = resetButton(broken);
+ let after = null;
+ if (r2) {
+ const reloaded = new Promise((res) => broken.addEventListener('load', res, { once: true }));
+ r2.click();
+ await Promise.race([reloaded, sleep(9000)]);
+ await sleep(1500);
+ // Read from here, not from the frame: the frame is reloading, and a
+ // window mid-navigation answers for whichever document happens to be
+ // current. Same origin, so this is the same store.
+ let dbs = null;
+ try { dbs = (await indexedDB.databases()).map((d) => d.name); } catch (e) { dbs = 'n/a'; }
+ after = { auth: localStorage.getItem('mb_auth'), dbs };
+ }
+ cases.push({ case: 'the reset button empties this origin', clicked: !!r2, after });
+
+ fetch('/log', { method: 'POST', body: JSON.stringify({ cases }) });
+ } catch (err) {
+ fetch('/log', { method: 'POST',
+ body: JSON.stringify({ error: String((err && err.stack) || err) }) });
+ }
+})();
+</script></body></html>"""
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ if self.path == "/log":
+ RECORDS.append(json.loads(self.rfile.read(length).decode()))
+ else:
+ self.rfile.read(length)
+ self.send_response(204)
+ self.end_headers()
+
+ def _send(self, body: bytes, ctype: str, code: int = 200) -> None:
+ self.send_response(code)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ path, _, query = self.path.partition("?")
+ if path == "/":
+ self._send(PAGE.encode(), "text/html; charset=utf-8")
+ elif path == "/blocked":
+ self._send(BLOCKED.encode(), "text/html; charset=utf-8")
+ elif path == "/shell":
+ self._send(SHELL.encode(), "text/html; charset=utf-8")
+ elif path == "/__case.js":
+ # The referring frame decides: one mounts, the other is missing —
+ # a 404 on a module, which is what a graph that will not link looks
+ # like from the outside.
+ if "case=fails" in (self.headers.get("Referer") or ""):
+ self._send(b"not here", "text/plain", 404)
+ else:
+ self._send(MOUNTS.encode(), "text/javascript")
+ else:
+ asset = (STATIC / path.lstrip("/")).resolve()
+ if not str(asset).startswith(str(STATIC)) or not asset.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ self._send(asset.read_bytes(), "text/javascript")
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=900,900",
+ f"http://127.0.0.1:{PORT}/"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(600):
+ if RECORDS:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ if not RECORDS:
+ print(json.dumps({"error": "no measurement"}), file=sys.stderr)
+ return 1
+ print(json.dumps(RECORDS[0], indent=1, ensure_ascii=False))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_boot_guard.py b/packages/meshbay-hub/tests/test_boot_guard.py
new file mode 100644
index 0000000..d802a03
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_boot_guard.py
@@ -0,0 +1,122 @@
+"""
+The page cannot end up silently blank, and there is a way out of it.
+
+A reader spent an evening on a white screen. The shell arrived on every reload,
+every module was served from the browser's own store so not one request reached
+the hub, and nothing rendered: no message, no error, nothing in the server log
+to look at. The same account worked in a private window and in another browser
+— the signature of something wrong in this origin's stored state rather than in
+what was deployed. Clearing the site's data fixed it; clearing the *cache*,
+three times, had not, because a service worker and IndexedDB are not the cache.
+
+What is measured here is the silence, not its cause — that evidence was
+destroyed by the fix, necessarily. A blank page is a bug report nobody can
+write, and on a phone there is no console to open.
+
+`boot-guard.js` is a classic script, loaded before the module graph, because the
+failure it guards against includes the graph never linking: one bad module and
+no module code runs at all, so a guard inside `app.js` would be part of what
+failed.
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "boot_guard_probe.py"
+
+
+@pytest.fixture(scope="module")
+def cases():
+ if shutil.which("google-chrome") is None:
+ pytest.skip("Chrome is not available")
+ proc = subprocess.run([sys.executable, str(HARNESS)],
+ capture_output=True, text=True, timeout=300)
+ data = json.loads(proc.stdout)
+ assert "error" not in data, f"probe failed: {proc.stdout}{proc.stderr}"
+ return {c["case"]: c for c in data["cases"]}
+
+
+def test_the_guard_says_nothing_when_the_application_mounts(cases):
+ # The ordinary case, and the one that matters most: a guard that draws over
+ # a working application would be a worse bug than the one it is for.
+ c = cases["the application mounted"]
+ assert c["buttons"] == [], f"the guard drew over a mounted application: {c}"
+ assert "application" in c["text"]
+
+
+def test_a_graph_that_does_not_link_shows_a_message_not_a_blank_page(cases):
+ c = cases["the module graph did not link"]
+ assert len(c["text"]) > 40, f"still effectively blank: {c['text']!r}"
+
+
+def test_the_failure_is_named_on_screen(cases):
+ # Verbatim, on screen, because a phone has no console and this is the first
+ # thing anybody diagnosing it will ask for.
+ c = cases["the module graph did not link"]
+ assert "__case.js" in c["text"], (
+ f"the module that failed is not named anywhere: {c['text']!r}")
+
+
+def test_a_way_out_is_offered(cases):
+ c = cases["the module graph did not link"]
+ assert len(c["buttons"]) == 2, c["buttons"]
+ joined = " ".join(c["buttons"]).lower()
+ assert "essayer" in joined or "try" in joined
+ assert "initialis" in joined or "reset" in joined
+
+
+def test_the_reset_button_really_empties_this_origin(cases):
+ """A button that claims to clear and does not would be worse than none.
+
+ So it is clicked, for real, against a seeded `localStorage` and a seeded
+ database — and what it leaves behind is what is read back.
+ """
+ c = cases["the reset button empties this origin"]
+ assert c["clicked"], "no reset button to click"
+ assert c["after"]["auth"] is None, (
+ f"localStorage survived the reset: {c['after']}")
+ assert c["after"]["dbs"] == [], (
+ f"a database survived the reset: {c['after']}")
+
+
+def test_a_reset_another_tab_is_blocking_says_so(cases):
+ """Deleting a database waits for every other connection to close — silently.
+
+ Unwatched, `deleteDatabase` neither fails nor completes, so a reset that
+ reloads regardless comes back to exactly the state it claimed to clear. The
+ reader would then have tried the one thing that works, watched it appear to
+ work, and still be staring at the same page.
+ """
+ c = cases["a reset another tab is blocking says so"]
+ assert c["clicked"], "no reset button to click"
+ assert c["told"] == "meshbay", (
+ f"the blocked database was not named on screen: {c}")
+ assert not c["reloadedAnyway"], "it reloaded into the state it had not cleared"
+ assert c["stillOffersReset"], "the reader is left with no way to try again"
+
+
+def test_a_blocked_database_upgrade_gives_up_instead_of_hanging(cases):
+ """`indexedDB.open` fires neither `success` nor `error` when an upgrade is
+ blocked by another connection — it fires `blocked`, and unhandled that
+ leaves the promise unsettled for ever.
+
+ Version 2 arrived with the playlists, so every browser that had used this
+ site before it has a version 1 to upgrade, and a second tab holding one open
+ is all it takes.
+ """
+ c = cases["a blocked database upgrade gives up instead of hanging"]
+ assert c["result"] != "never settled", c
+ assert c["result"]["held"], "the fixture did not hold a connection open"
+ assert c["result"]["outcome"].startswith("rejected:"), c["result"]
+ # `blocked` fires the moment the upgrade is attempted, so this is immediate.
+ # The deadline behind it is a backstop for the case where even `blocked`
+ # never arrives — tolerating five seconds here would let the backstop stand
+ # in for the handler and the handler could be deleted unnoticed.
+ assert "blocked" in c["result"]["outcome"], (
+ f"gave up on a timeout rather than on the blocked event: {c['result']}")
+ assert c["result"]["ms"] < 1000, f"it took {c['result']['ms']}ms to give up"