From f4fd6db8faa15bf02f38a14b652cc46936f8d6bf Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 17 Sep 2026 03:40:52 +0200 Subject: spa: a blank page can never be silent again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit boot-guard.js is a classic script loaded before the module graph, so it survives the graph failing to link. If #app is still empty after ten seconds it names what failed and offers a reset of this origin — cache, storage, databases and the service worker, which clearing the cache does not touch. Two real defects found building it: openDB never settled when an upgrade was blocked by another tab, and a connection it gave up on stayed open and squatted the database. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 4 + .../src/meshbay_hub/static/boot-guard.js | 200 ++++++++++++++++ .../src/meshbay_hub/static/hub-client.js | 37 ++- .../meshbay-hub/tests/harness/boot_guard_probe.py | 260 +++++++++++++++++++++ packages/meshbay-hub/tests/test_boot_guard.py | 122 ++++++++++ 5 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js create mode 100644 packages/meshbay-hub/tests/harness/boot_guard_probe.py create mode 100644 packages/meshbay-hub/tests/test_boot_guard.py (limited to 'packages') 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. --> + + 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 ` + +""" + +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""" +""" + + +PAGE = r""" +
""" + + +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" -- cgit v1.2.3