fix(editor): the white screen was React unmounting its own crash reporter
All three prod crash cascades (v0.1.19–21) ended white for the same reason: the final trap lands inside a child's EFFECT (an embind call reached through a react-query subscription), React unmounts the entire root, and the fatal overlay + console panel die with the tree they were built to survive. - WasmErrorBoundary: crash-capable children live inside it; the fatal screen and the console panel live OUTSIDE and keep rendering. WasmTool state (logs included) survives a descendant render/effect throw. - The fatal screen is now an actual blue screen (:( + solid blue), and every promotion path (window error, unhandled rejection, worker error, boundary) auto-opens the console — the log is the only account of what was loading. - tests/web/fatal-overlay.spec.ts pins the contract: a terminal uncaught error after boot ⇒ visible fatal overlay + open console with the [fatal] record. Green locally (23.9s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
This commit is contained in:
parent
208cb67959
commit
92e02a54f9
2 changed files with 122 additions and 24 deletions
52
tests/web/fatal-overlay.spec.ts
Normal file
52
tests/web/fatal-overlay.spec.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The blue screen of death must actually appear (WSOD hardening, async/16
|
||||||
|
* companion). Three prod crash reports in a row (v0.1.19–21) ended in a WHITE
|
||||||
|
* page because the crash killed the very UI built to report it: the final
|
||||||
|
* trap landed inside a React effect, React unmounted the root, and the fatal
|
||||||
|
* overlay + console panel died with it. The fix is layered — window/worker
|
||||||
|
* error listeners promote a `fatal` state rendered OUTSIDE a WasmErrorBoundary
|
||||||
|
* that absorbs descendant render/effect throws — and this spec pins the
|
||||||
|
* user-visible contract:
|
||||||
|
*
|
||||||
|
* a terminal-looking uncaught error after boot ⇒ the blue fatal overlay is
|
||||||
|
* VISIBLE and the console log panel is OPEN (the log is the only account of
|
||||||
|
* what was loading; a collapsed console behind a mystery screen is useless).
|
||||||
|
*
|
||||||
|
* The throw is synthesized (a real wasm trap is not deterministically
|
||||||
|
* available), but it exercises the identical promotion path: uncaught error →
|
||||||
|
* terminal() match → promote → overlay + console.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCOPE = 'default';
|
||||||
|
|
||||||
|
test('a terminal uncaught error raises the blue fatal overlay with the console open', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(300000); // one full pcbnew wasm boot
|
||||||
|
|
||||||
|
await page.goto(`/${SCOPE}/projects/demo/demo.kicad_pcb?user=fatal-probe`);
|
||||||
|
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
|
||||||
|
await expect
|
||||||
|
.poll(() => page.title(), {
|
||||||
|
message: 'editor never reached the expected title',
|
||||||
|
timeout: 120000,
|
||||||
|
intervals: [1000],
|
||||||
|
})
|
||||||
|
.toMatch(/demo — PCB Editor/i);
|
||||||
|
|
||||||
|
// Synthesize the trap: an uncaught error whose message matches the
|
||||||
|
// terminal signature set (Firefox's bare wasm trap spelling included).
|
||||||
|
await page.evaluate(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
throw new Error('index out of bounds');
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByTestId('fatal-overlay')).toBeVisible({ timeout: 10000 });
|
||||||
|
// The console panel auto-opened and carries the promotion record.
|
||||||
|
await expect(page.locator('pre').filter({ hasText: '[fatal]' })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -849,6 +849,37 @@ async function waitForWxUi(win: ToolWindow, timeoutMs = 25_000): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a poisoned wasm runtime from taking the React tree down with it.
|
||||||
|
*
|
||||||
|
* The v0.1.21 prod crash logs showed the actual white-screen mechanism: after
|
||||||
|
* a wasm trap, some child's EFFECT calls into the dead runtime (an embind
|
||||||
|
* entry via a react-query subscription), the throw lands in React's commit,
|
||||||
|
* and React unmounts the whole root — destroying the fatal overlay AND the
|
||||||
|
* console panel, the two things built to report exactly this. The boundary
|
||||||
|
* absorbs descendant render/effect throws: it reports up (the parent promotes
|
||||||
|
* its fatal screen, which lives OUTSIDE this boundary) and renders nothing in
|
||||||
|
* place of the dead subtree. WasmTool's own state — logs included — survives.
|
||||||
|
*/
|
||||||
|
class WasmErrorBoundary extends React.Component<
|
||||||
|
{ onFatal: (msg: string) => void; children: React.ReactNode },
|
||||||
|
{ dead: boolean }
|
||||||
|
> {
|
||||||
|
state = { dead: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { dead: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(err: unknown) {
|
||||||
|
this.props.onFatal(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.state.dead ? null : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Boots a KiCad tool directly in this React document (no iframe): builds the
|
* Boots a KiCad tool directly in this React document (no iframe): builds the
|
||||||
* Emscripten `Module` config, injects the proven harness scripts (wx.js +
|
* Emscripten `Module` config, injects the proven harness scripts (wx.js +
|
||||||
|
|
@ -1193,19 +1224,24 @@ export function WasmTool({
|
||||||
/RuntimeError|\babort(ed)?\b|\bindex out of bounds|indirect call signature|memory access out of bounds|unreachable executed|null function or function signature/i.test(
|
/RuntimeError|\babort(ed)?\b|\bindex out of bounds|indirect call signature|memory access out of bounds|unreachable executed|null function or function signature/i.test(
|
||||||
msg,
|
msg,
|
||||||
);
|
);
|
||||||
|
// Promote to the fatal screen AND pop the console open: the log panel is
|
||||||
|
// the only account of what was loading, so a fatal must never leave it
|
||||||
|
// collapsed behind a mystery blue screen.
|
||||||
|
const promote = (kind: string, msg: string) => {
|
||||||
|
append(`[fatal] ${kind}: ${msg}`);
|
||||||
|
append(dumpTrace());
|
||||||
|
setFatal(msg);
|
||||||
|
setShowLog(true);
|
||||||
|
};
|
||||||
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 ?? "");
|
||||||
if (!terminal(msg)) return;
|
if (!terminal(msg)) return;
|
||||||
append(`[fatal] window error: ${msg}`);
|
promote("window error", msg);
|
||||||
append(dumpTrace());
|
|
||||||
setFatal(msg);
|
|
||||||
};
|
};
|
||||||
const onRejection = (e: PromiseRejectionEvent) => {
|
const onRejection = (e: PromiseRejectionEvent) => {
|
||||||
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason ?? "");
|
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason ?? "");
|
||||||
if (!terminal(msg)) return;
|
if (!terminal(msg)) return;
|
||||||
append(`[fatal] unhandled rejection: ${msg}`);
|
promote("unhandled rejection", msg);
|
||||||
append(dumpTrace());
|
|
||||||
setFatal(msg);
|
|
||||||
};
|
};
|
||||||
// With PROXY_TO_PTHREAD, main()/wx/timers — and therefore every asyncify
|
// With PROXY_TO_PTHREAD, main()/wx/timers — and therefore every asyncify
|
||||||
// trap in this family — throw INSIDE a pthread worker. A worker's uncaught
|
// trap in this family — throw INSIDE a pthread worker. A worker's uncaught
|
||||||
|
|
@ -1218,9 +1254,7 @@ export function WasmTool({
|
||||||
const onWorkerError = (e: ErrorEvent) => {
|
const onWorkerError = (e: ErrorEvent) => {
|
||||||
const msg = String(e.message ?? "");
|
const msg = String(e.message ?? "");
|
||||||
if (!terminal(msg)) return;
|
if (!terminal(msg)) return;
|
||||||
append(`[fatal] worker error: ${msg}`);
|
promote("worker error", msg);
|
||||||
append(dumpTrace());
|
|
||||||
setFatal(msg);
|
|
||||||
};
|
};
|
||||||
const PatchedWorker = function (
|
const PatchedWorker = function (
|
||||||
this: unknown,
|
this: unknown,
|
||||||
|
|
@ -1974,6 +2008,18 @@ export function WasmTool({
|
||||||
<div ref={containerRef} id="main-window" className="absolute inset-0 h-full w-full" />
|
<div ref={containerRef} id="main-window" className="absolute inset-0 h-full w-full" />
|
||||||
<div id="window-container" />
|
<div id="window-container" />
|
||||||
|
|
||||||
|
{/* Everything that can throw into React's commit lives INSIDE the
|
||||||
|
boundary; the fatal screen and the console panel live OUTSIDE it, so
|
||||||
|
a runtime death can no longer white-screen the very UI that reports
|
||||||
|
it (see WasmErrorBoundary). */}
|
||||||
|
<WasmErrorBoundary
|
||||||
|
onFatal={(msg) => {
|
||||||
|
append(`[fatal] react tree died: ${msg}`);
|
||||||
|
append(dumpTrace());
|
||||||
|
setFatal(msg);
|
||||||
|
setShowLog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{oomExhausted && (
|
{oomExhausted && (
|
||||||
<MemoryExhaustedDialog
|
<MemoryExhaustedDialog
|
||||||
onOpenNewTab={() => respawnInNewTab()}
|
onOpenNewTab={() => respawnInNewTab()}
|
||||||
|
|
@ -2305,35 +2351,35 @@ export function WasmTool({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</WasmErrorBoundary>
|
||||||
|
|
||||||
{/* Terminal failure — z-35, ABOVE the boot overlay but below the console
|
{/* Terminal failure — z-35, ABOVE the boot overlay but below the console
|
||||||
panel, and independent of `ready` so a post-boot runtime death still
|
panel, OUTSIDE the error boundary, and independent of `ready`: a
|
||||||
says something instead of blanking the page. */}
|
post-boot runtime death gets a proper blue screen instead of a blank
|
||||||
|
page, with the console panel forced open beneath it. */}
|
||||||
{fatal && (
|
{fatal && (
|
||||||
<div
|
<div
|
||||||
data-testid="fatal-overlay"
|
data-testid="fatal-overlay"
|
||||||
className="absolute inset-0 z-[35] flex flex-col items-center justify-center gap-3 bg-[#1a1a2e]/95 text-white"
|
className="absolute inset-0 z-[35] flex flex-col items-center justify-center gap-3 bg-[#1e3a8a] text-white"
|
||||||
>
|
>
|
||||||
<p className="font-mono text-sm text-red-300">Something went wrong</p>
|
<p className="font-mono text-4xl text-white/90">:(</p>
|
||||||
<p className="max-w-lg px-6 text-center font-mono text-xs text-red-200/80">
|
<p className="font-mono text-sm text-white">
|
||||||
|
The editor hit an unrecoverable error and stopped.
|
||||||
|
</p>
|
||||||
|
<p className="max-w-lg px-6 text-center font-mono text-xs text-blue-100/90">
|
||||||
{fatal}
|
{fatal}
|
||||||
</p>
|
</p>
|
||||||
<p className="max-w-md px-6 text-center font-mono text-xs text-white/50">
|
<p className="max-w-md px-6 text-center font-mono text-xs text-blue-200/60">
|
||||||
The editor stopped. Open the console below for the full log — it
|
The console below records what was loading when this happened —
|
||||||
records what was loading when this happened.
|
please copy it into a bug report.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
className="rounded border border-white/30 px-3 py-1 text-xs hover:bg-white/10"
|
className="rounded border border-white/40 px-3 py-1 text-xs hover:bg-white/10"
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
>
|
>
|
||||||
Reload
|
Reload
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="rounded border border-white/30 px-3 py-1 text-xs hover:bg-white/10"
|
|
||||||
onClick={() => setShowLog(true)}
|
|
||||||
>
|
|
||||||
Show console
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue