fix(editor): DOM-level blue-screen floor — React can no longer white-screen a crash

v0.1.22's WasmErrorBoundary was still not enough: a commit-phase throw in
WasmTool's OWN effects unmounts the root, and no boundary below it helps.
fatal-screen.ts is the floor: plain-DOM blue screen with its own mirrored
log ring (append feeds recordFatalLog), installed at module import in
main.tsx — before and independent of React. It cooperates with the React
overlay: hidden while [data-testid="fatal-overlay"] exists, takes over via a
1Hz ensure-loop the moment it disappears. Fatal promotions also append the
asyncify flight-recorder dump so whichever screen survives carries the
targeting data.

fatal-overlay.spec.ts now also rips out the React root after the fatal and
asserts the DOM floor takes over with the mirrored [fatal] log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
This commit is contained in:
Gergő Törcsvári 2026-08-01 14:07:09 +02:00
commit eff5befd5d
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 209 additions and 4 deletions

View file

@ -114,6 +114,51 @@ crash-capable children live inside it; the fatal screen (now an actual blue
screen) and the console panel live OUTSIDE it and survive. All fatal screen) and the console panel live OUTSIDE it and survive. All fatal
promotions auto-open the console. Pinned by `tests/web/fatal-overlay.spec.ts`. promotions auto-open the console. Pinned by `tests/web/fatal-overlay.spec.ts`.
## Round 3 (2026-08-01 afternoon) — v0.1.22 still trapped: the target is the ROOT
The v0.1.22 prod log (console-export-2026-8-1_13-39-41) showed both guards
silent — because the fatal rewind's target is the ROOT context, which layer 2
exempted outright. Reading all four identical prod stacks precisely:
`maybeStopUnwind → Fibers.trampoline → finishContextSwitch → doRewind(root) →
unreachable`, fired from INSIDE a sleep-wake's own rewind. A fiber completes
its yield-back to main while main's yield wake is mid-rewind: two different
"resume main" paths interleaved in one tick — the wake's `doRewind(Y)` and
the fiber's `finishContextSwitch` rewind of `main+20` — replaying frames
over live state. The 8 ms-earlier "index out of bounds" is the wake side of
the same collision.
Root entry is legal and constant in healthy flow; ONLY the wake-window
overlap is fatal. **Layer 3: serialize, don't refuse.** The shim marks the
synchronous wake window (`Asyncify.__inSleepWake` around `wakeUp()`);
`finishContextSwitch(root)` inside that window is DEFERRED one macrotask
(`[wx-asyncify] root-entry-deferred` beacon) and re-fired via the trampoline
once the wake has settled. Ordering change only — nothing is dropped.
## The white screen, round 3 (fixed at the DOM level)
v0.1.22's boundary was still not enough: a commit-phase throw in WasmTool's
OWN effects unmounts the root — no boundary below it can help.
`web/standalone/src/wasm/fatal-screen.ts` is the floor: a plain-DOM blue
screen with its own mirrored log ring (`recordFatalLog` from `append`),
installed at module import in main.tsx, cooperating with the React overlay
(stays hidden while `[data-testid="fatal-overlay"]` exists, takes over the
moment it disappears — 1 Hz ensure-loop). `fatal-overlay.spec.ts` now also
kills the React root after the fatal and asserts the DOM floor appears.
## Flight recorder (round 3, targeting instrument)
The shim keeps a 96-entry ring of asyncify/fiber events (sleep entries with
state/currData/wake-depth, wakes with buffer + clobber info, every
finishContextSwitch with old→new/ROOT/wake-depth, refusals, deferrals) —
never printed in normal operation. On the FIRST trap signature it dumps the
ring plus full machine state (`Asyncify.state/currData/__inSleepWake`,
pending sleep buffers, `Fibers.nextFiber/trampolineRunning/root/valid/
parked/deferrals`) to the console (`[wx-asyncify] STATE` + `RECORDER`), and
`window.__wxAsyncifyDump()` returns it on demand. The WasmTool fatal
promotion appends the same dump into the in-page log, so the blue screen —
React or DOM-floor — carries it. The next prod export reads like a black-box
recording instead of a stack-shape puzzle.
## Verification ## Verification
- `fiber-resume-park.spec.ts` red on unguarded build (phase-3 poll dies), - `fiber-resume-park.spec.ts` red on unguarded build (phase-3 poll dies),

View file

@ -49,4 +49,17 @@ test('a terminal uncaught error raises the blue fatal overlay with the console o
await expect(page.locator('pre').filter({ hasText: '[fatal]' })).toBeVisible({ await expect(page.locator('pre').filter({ hasText: '[fatal]' })).toBeVisible({
timeout: 10000, timeout: 10000,
}); });
// While React's overlay is alive, the DOM floor stays out of the way.
await expect(page.getByTestId('fatal-screen-dom')).toHaveCount(0);
// Simulate what prod actually did three releases in a row: React unmounts
// its whole root after the fatal. The DOM-level floor must take over —
// blue screen + the mirrored log — instead of a white page.
await page.evaluate(() => {
document.querySelector('[data-testid="fatal-overlay"]')?.closest('#root, body > div')?.remove();
});
await expect(page.getByTestId('fatal-screen-dom')).toBeVisible({ timeout: 5000 });
await expect(page.locator('#pcbjam-fatal-screen pre')).toContainText('[fatal]', {
timeout: 5000,
});
}); });

View file

@ -131,6 +131,7 @@ import {
toggleChromeHidden, toggleChromeHidden,
useChromeHidden, useChromeHidden,
} from "@/lib/chrome-visibility"; } from "@/lib/chrome-visibility";
import { recordFatalLog, showFatalScreen } from "@/wasm/fatal-screen";
// Tools with the v2 items bridge (kicadCollabSnapshotItems/ApplyItems embind exports). // Tools with the v2 items bridge (kicadCollabSnapshotItems/ApplyItems embind exports).
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]); const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
@ -1086,10 +1087,12 @@ export function WasmTool({
// exposes the style bridge, mounts the floating panel. // exposes the style bridge, mounts the floating panel.
const [tunerMod, setTunerMod] = React.useState<TunerModule | null>(null); const [tunerMod, setTunerMod] = React.useState<TunerModule | null>(null);
const append = React.useCallback( const append = React.useCallback((msg: string) => {
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]), // Mirror into the React-independent fatal-screen ring: if React ever
[], // unmounts itself on a crash, the DOM floor still has the full log.
); recordFatalLog(msg);
setLogs((prev) => [...prev.slice(-800), msg]);
}, []);
// Loading/error chrome for library item fetches (open/save), driven by events // Loading/error chrome for library item fetches (open/save), driven by events
// the libs bridge dispatches (wasm/libs/source). The fetch is otherwise // the libs bridge dispatches (wasm/libs/source). The fetch is otherwise
@ -1230,8 +1233,17 @@ export function WasmTool({
const promote = (kind: string, msg: string) => { const promote = (kind: string, msg: string) => {
append(`[fatal] ${kind}: ${msg}`); append(`[fatal] ${kind}: ${msg}`);
append(dumpTrace()); append(dumpTrace());
// The asyncify flight recorder (handlesleep.js shim): event ring +
// machine state at death — the targeting data for the fiber trap.
const rec = (
window as Window & { __wxAsyncifyDump?: () => string }
).__wxAsyncifyDump?.();
if (rec) append(rec);
setFatal(msg); setFatal(msg);
setShowLog(true); setShowLog(true);
// Arm the React-independent floor too: it stays invisible while our
// overlay is up, and takes over the instant React dies.
showFatalScreen(msg);
}; };
const onError = (e: ErrorEvent) => { const onError = (e: ErrorEvent) => {
const msg = e.error instanceof Error ? `${e.error.message}` : String(e.message ?? ""); const msg = e.error instanceof Error ? `${e.error.message}` : String(e.message ?? "");
@ -2018,6 +2030,7 @@ export function WasmTool({
append(dumpTrace()); append(dumpTrace());
setFatal(msg); setFatal(msg);
setShowLog(true); setShowLog(true);
showFatalScreen(msg);
}} }}
> >
{oomExhausted && ( {oomExhausted && (

View file

@ -4,8 +4,14 @@ import { BrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import { initAnalytics } from "./lib/analytics"; import { initAnalytics } from "./lib/analytics";
import { initTheme } from "./lib/theme"; import { initTheme } from "./lib/theme";
import { installFatalScreenListeners } from "./wasm/fatal-screen";
import "./index.css"; import "./index.css";
// The React-independent blue-screen floor: installed before React mounts so a
// wasm trap can never end in a white page, even if React unmounts itself
// (which it did, three prod releases in a row — see async/16).
installFatalScreenListeners();
// Privacy-friendly analytics (Plausible), only when VITE_PLAUSIBLE_SRC is set. // Privacy-friendly analytics (Plausible), only when VITE_PLAUSIBLE_SRC is set.
initAnalytics(); initAnalytics();

View file

@ -0,0 +1,128 @@
/**
* DOM-level fatal screen — deliberately OUTSIDE React.
*
* Two iterations of React-side fixes (fatal state + window listeners in
* v0.1.21, WasmErrorBoundary in v0.1.22) still ended in a white page in prod:
* a commit-phase throw in WasmTool's OWN effects unmounts the root, and no
* boundary below it can help. This module owns the last line of defense:
* plain DOM, inline styles, its own copy of the log lines — nothing React
* can take down.
*
* Cooperation contract: while React's own fatal overlay
* ([data-testid="fatal-overlay"]) is in the document, this module stays
* invisible. Once a fatal has been signaled, a 1 Hz ensure-loop watches: the
* moment the React overlay is gone (root unmounted, tree died later), the
* DOM screen is built. Whichever layer survives, the user never sees white.
*/
import { dump } from "./load-trace";
const MAX_RING = 400;
const ring: string[] = [];
/** WasmTool's `append` mirrors every log line here (React-free copy). */
export function recordFatalLog(line: string): void {
ring.push(line);
if (ring.length > MAX_RING) ring.shift();
}
let messages: string[] = [];
let ensureTimer: number | undefined;
function buildDom(): void {
const doc = document;
if (doc.getElementById("pcbjam-fatal-screen")) return;
const root = doc.createElement("div");
root.id = "pcbjam-fatal-screen";
root.setAttribute("data-testid", "fatal-screen-dom");
root.style.cssText =
"position:fixed;inset:0;z-index:2147483000;background:#1e3a8a;color:#fff;" +
"display:flex;flex-direction:column;align-items:center;justify-content:center;" +
"gap:12px;font-family:ui-monospace,Menlo,monospace;padding:24px;box-sizing:border-box;";
const face = doc.createElement("div");
face.textContent = ":(";
face.style.cssText = "font-size:44px;opacity:.9;";
const title = doc.createElement("div");
title.textContent = "The editor hit an unrecoverable error and stopped.";
title.style.cssText = "font-size:14px;";
const err = doc.createElement("div");
err.setAttribute("data-fatal-extra", "");
err.textContent = messages.join("\n");
err.style.cssText =
"font-size:12px;color:#dbeafe;max-width:640px;text-align:center;white-space:pre-wrap;";
const hint = doc.createElement("div");
hint.textContent =
"The console below records what was loading when this happened — please copy it into a bug report.";
hint.style.cssText = "font-size:11px;color:#bfdbfe99;max-width:520px;text-align:center;";
const reload = doc.createElement("button");
reload.textContent = "Reload";
reload.style.cssText =
"border:1px solid #ffffff66;background:transparent;color:#fff;border-radius:4px;" +
"padding:4px 14px;font-size:12px;cursor:pointer;font-family:inherit;";
reload.onclick = () => window.location.reload();
const log = doc.createElement("pre");
log.style.cssText =
"position:absolute;bottom:0;left:0;right:0;max-height:38vh;overflow:auto;" +
"margin:0;padding:10px 12px;background:#000000d9;color:#86efac;" +
"font-size:11px;line-height:1.35;text-align:left;";
log.textContent = [...ring, "", dump()].join("\n");
root.append(face, title, err, hint, reload, log);
doc.body.appendChild(root);
}
function ensure(): void {
try {
// React's fatal overlay present ⇒ the tree survived and is reporting;
// stay out of the way. The loop keeps watching: if that overlay
// disappears (a later unmount), the floor goes up.
if (document.querySelector('[data-testid="fatal-overlay"]')) {
const dom = document.getElementById("pcbjam-fatal-screen");
if (dom) dom.remove();
return;
}
buildDom();
} catch {
/* the floor must never throw into anyone */
}
}
/** Signal a fatal. Idempotent; new distinct messages are appended. */
export function showFatalScreen(message: string): void {
if (!messages.includes(message)) messages.push(message);
if (ensureTimer === undefined) {
ensure();
ensureTimer = window.setInterval(ensure, 1000);
} else {
const err = document.querySelector("#pcbjam-fatal-screen [data-fatal-extra]");
if (err) err.textContent = messages.join("\n");
}
}
const TERMINAL =
/RuntimeError|\babort(ed)?\b|\bindex out of bounds|indirect call signature|memory access out of bounds|unreachable executed|null function or function signature/i;
/**
* Global last-resort listeners, installed once at module import time — i.e.
* before and independent of any React lifecycle.
*/
export function installFatalScreenListeners(): void {
const w = window as Window & { __pcbjamFatalListeners?: boolean };
if (w.__pcbjamFatalListeners) return;
w.__pcbjamFatalListeners = true;
window.addEventListener("error", (e) => {
const msg = e.error instanceof Error ? e.error.message : String(e.message ?? "");
if (TERMINAL.test(msg)) showFatalScreen(msg);
});
window.addEventListener("unhandledrejection", (e) => {
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason ?? "");
if (TERMINAL.test(msg)) showFatalScreen(msg);
});
}