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:
parent
9ca2ac1e52
commit
eff5befd5d
5 changed files with 209 additions and 4 deletions
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
128
web/standalone/src/wasm/fatal-screen.ts
Normal file
128
web/standalone/src/wasm/fatal-screen.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue