feat(editor): report uncaught errors to Better Stack
The editor reported nothing when a session died. Evidence lived only in-tab —
an 800-line React array behind a "Show console" button — so diagnosis meant
asking a user to paste a screenshot.
Better Stack's Error Tracking ingests the Sentry wire protocol, so this runs
the stock @sentry/browser against a Better Stack DSN. Sentry.init installs its
own window error/unhandledrejection handlers, so uncaught main-thread errors
and the wasm traps that escape emscripten's DOM event handlers are captured
with no instrumentation at the throw sites. Not their JS tag: it has no
beforeSend or fingerprint hooks, its runtime spawns workers from cross-origin
CDN hosts (this page is COEP: require-corp), and it ships session replay on by
default — which on a CAD canvas records customers' board geometry.
@sentry/browser is imported in exactly one file so the vendor stays swappable,
mirroring how lib/analytics.ts isolates Plausible.
Also replaces the terminal-signature regex with a shared, unit-tested predicate
(wasm/terminal-error.ts) used by BOTH the fatal overlay and the reporter, so
they cannot disagree. The regex was a type check written as a string match and
had three live holes: `RuntimeError` was listed but never appears IN
`.message`; Chrome's bare "unreachable" and "null function" matched nothing
(the v0.1.20 prod log is exactly those); and narrowing "table index is out of
bounds" to `\bindex out of bounds` for Firefox in 197f317 silently stopped
matching Chrome's spelling. Checking the TYPE — every trap in this family is a
WebAssembly.RuntimeError — covers all engines and ends the spelling chase; the
message patterns remain as a fallback for paths that lose the Error object,
such as a worker ErrorEvent crossing the realm boundary with error: null.
197f317's pthread-worker tap, promote() and Firefox findings are kept as-is.
Notes:
- Off unless VITE_ERRORS_DSN is set AND VITE_ALLOW_USER_OVERRIDE !== "1" (dev
servers and every Playwright harness set the latter, and production builds
never do), so a production DSN in a local .env still cannot report. With no
DSN the whole SDK is const-folded out: 1,193,080 vs 1,282,463 bytes of JS.
- browserApiErrors integration removed. It wraps setTimeout/rAF/addEventListener
in try/catch, which is exactly how KiCad-on-Emscripten drives its main loop.
- Console breadcrumbs off (collab/debug.ts's clog fires per Yjs update and would
evict the ring before any crash); dom/fetch/navigation breadcrumbs kept.
- beforeSend redacts token/apiKey/Bearer — collab/provider.ts puts the collab
token in the y-partyserver URL, so a connection-failure string carries a live
credential — and guards the cascade: one wedge produced 8 errors in prod, and
after the first terminal event the rest are dropped into cascade_count.
Verified end to end against the real EU host from a cross-origin-isolated page:
POST /api/<id>/envelope/ -> 200, and 4 terminal throws produce 1 event
(control: 1 throw, same count).
Privacy policy 9, cookie policy 6 and the licenses page are updated: Better
Stack is disclosed as an EU processor, and the licenses page now describes the
browser app's own JS dependencies, which it never did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
20e5eb941c
commit
1b08a5eb06
20 changed files with 535 additions and 18 deletions
|
|
@ -60,6 +60,20 @@ VITE_WASM_ROOT=/wasm
|
|||
# plausible.io script won't load under COEP require-corp.
|
||||
# VITE_PLAUSIBLE_SRC=https://plausible.io/js/pa-KjNS9YmidydULZTstsjRg.js
|
||||
|
||||
# Error tracking (Better Stack). Unset ⇒ nothing is reported (dev/checkout
|
||||
# default). Better Stack ingests the Sentry wire protocol, so this is a Sentry-
|
||||
# format DSN and the app runs the stock @sentry/browser SDK against their host:
|
||||
# https://<application-token>@<ingesting-host>/<application-id>
|
||||
# The token is public once it ships in the bundle — that is inherent to browser
|
||||
# error reporting, and it is write-only, so the exposure is quota abuse rather
|
||||
# than data. Deploys pass it via scripts/deploy/build-{editor,demo}.mjs
|
||||
# --errors-dsn; VITE_ERRORS_ENV tags which deploy an error came from.
|
||||
# NOTE: reporting also stays off whenever VITE_ALLOW_USER_OVERRIDE=1 (dev servers
|
||||
# and e2e harnesses set it), so a production DSN in a local .env still can't
|
||||
# pollute the dashboard.
|
||||
# VITE_ERRORS_DSN=
|
||||
# VITE_ERRORS_ENV=production
|
||||
|
||||
# Where a backend project's DOCUMENT content lives ("api" default | "ydoc").
|
||||
# Same /p/<project> URLs either way. "api": file bytes come from the REST
|
||||
# backend and a user save (File->Save in the editor) is uploaded back to it.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@sentry/browser": "^10.69.0",
|
||||
"@tanstack/react-query": "^5.62.11",
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import {
|
|||
type ToolFile,
|
||||
} from "@/wasm/kicad-runner";
|
||||
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
|
||||
import { errorMessage, isTerminalError } from "@/wasm/terminal-error";
|
||||
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
|
||||
import type {
|
||||
KicadCollabHandle,
|
||||
|
|
@ -1227,14 +1228,22 @@ export function WasmTool({
|
|||
// genuinely terminal signatures promote to the fatal overlay; ordinary app
|
||||
// errors must not hijack a working editor.
|
||||
React.useEffect(() => {
|
||||
// NOTE: matched against `e.error.message`, which is BARE — Firefox's first
|
||||
// trap is literally "index out of bounds" (no "RuntimeError", no "table")
|
||||
// and slipped through the original pattern; the v0.1.19 prod log opens with
|
||||
// exactly that message.
|
||||
const terminal = (msg: string) =>
|
||||
/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,
|
||||
);
|
||||
// The predicate lives in wasm/terminal-error.ts (unit-tested there) and is
|
||||
// shared with the error reporter, so the overlay and Better Stack can never
|
||||
// disagree about what "terminal" means.
|
||||
//
|
||||
// It checks the error's TYPE first — every trap in this family is a
|
||||
// `WebAssembly.RuntimeError` whatever the engine calls it — with the message
|
||||
// patterns kept only as a fallback for the paths that lose the Error object
|
||||
// (a worker ErrorEvent crosses the realm boundary with `error: null`).
|
||||
//
|
||||
// That ends the per-engine spelling chase this check kept losing. Matching
|
||||
// the message alone had three live holes: `RuntimeError` was listed but
|
||||
// never appears IN `.message`; Chrome's bare "unreachable" and "null
|
||||
// function" (the v0.1.20 prod log) matched nothing; and narrowing
|
||||
// "table index is out of bounds" to `\bindex out of bounds` for Firefox's
|
||||
// spelling silently stopped matching Chrome's. The type check covers all
|
||||
// of them, and the fallback pattern is now a superset of the old one.
|
||||
// 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.
|
||||
|
|
@ -1254,13 +1263,13 @@ export function WasmTool({
|
|||
showFatalScreen(msg);
|
||||
};
|
||||
const onError = (e: ErrorEvent) => {
|
||||
const msg = e.error instanceof Error ? `${e.error.message}` : String(e.message ?? "");
|
||||
if (!terminal(msg)) return;
|
||||
const msg = errorMessage(e.error, e.message);
|
||||
if (!isTerminalError(e.error, msg)) return;
|
||||
promote("window error", msg);
|
||||
};
|
||||
const onRejection = (e: PromiseRejectionEvent) => {
|
||||
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason ?? "");
|
||||
if (!terminal(msg)) return;
|
||||
const msg = errorMessage(e.reason);
|
||||
if (!isTerminalError(e.reason, msg)) return;
|
||||
promote("unhandled rejection", msg);
|
||||
};
|
||||
// With PROXY_TO_PTHREAD, main()/wx/timers — and therefore every asyncify
|
||||
|
|
@ -1272,8 +1281,8 @@ export function WasmTool({
|
|||
// from this realm, so every one gets an error tap.
|
||||
const NativeWorker = window.Worker;
|
||||
const onWorkerError = (e: ErrorEvent) => {
|
||||
const msg = String(e.message ?? "");
|
||||
if (!terminal(msg)) return;
|
||||
const msg = errorMessage(e.error, e.message);
|
||||
if (!isTerminalError(e.error, msg)) return;
|
||||
promote("worker error", msg);
|
||||
};
|
||||
const PatchedWorker = function (
|
||||
|
|
|
|||
|
|
@ -82,6 +82,29 @@ export const WAITLIST_URL =
|
|||
*/
|
||||
export const PLAUSIBLE_SRC = import.meta.env.VITE_PLAUSIBLE_SRC || null;
|
||||
|
||||
/**
|
||||
* Error tracking (Better Stack). Off unless a DSN is set, so a plain dev
|
||||
* checkout and any deploy that doesn't set the var report nothing.
|
||||
*
|
||||
* The DSN is in SENTRY wire format because Better Stack ingests that protocol —
|
||||
* we run the stock @sentry/browser SDK against their host. Named by role rather
|
||||
* than by vendor precisely because the two differ: `VITE_SENTRY_DSN` would
|
||||
* imply Sentry receives the data (it doesn't) and `VITE_BETTERSTACK_DSN` would
|
||||
* imply a Better Stack SDK (there isn't one). See lib/error-reporting.ts.
|
||||
*
|
||||
* Format: https://<application-token>@<ingesting-host>/<application-id>
|
||||
*
|
||||
* NOTE: this token ships in the client bundle and is public — that is inherent
|
||||
* to browser error reporting and true of Sentry's own DSNs. It is write-only
|
||||
* (it cannot read anything back), so the exposure is quota abuse, not data.
|
||||
*/
|
||||
export const ERRORS_DSN = import.meta.env.VITE_ERRORS_DSN || null;
|
||||
|
||||
/** Environment tag on reported errors. Deploys set it explicitly; a build that
|
||||
* forgot to should be obvious in the dashboard rather than silently blending
|
||||
* into production. */
|
||||
export const ERRORS_ENV = import.meta.env.VITE_ERRORS_ENV || "development";
|
||||
|
||||
/**
|
||||
* Where the standalone reads PROJECTS from (env VITE_PROJECT_SOURCE):
|
||||
* "remote" (default) — the @pcbjam/shared REST backend at API_BASE_URL.
|
||||
|
|
|
|||
140
web/standalone/src/lib/error-reporting.ts
Normal file
140
web/standalone/src/lib/error-reporting.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import * as Sentry from "@sentry/browser";
|
||||
import { APP_GIT_SHA, APP_TAG, ERRORS_DSN, ERRORS_ENV } from "./config";
|
||||
import { isTerminalSerializedError } from "@/wasm/terminal-error";
|
||||
|
||||
/**
|
||||
* Error reporting to Better Stack.
|
||||
*
|
||||
* Better Stack's Error Tracking ingests the SENTRY wire protocol, so we run the
|
||||
* stock @sentry/browser SDK pointed at a Better Stack DSN. No Better Stack SDK
|
||||
* is involved (they don't publish one for browsers; their alternative is a
|
||||
* remote <script> tag with no beforeSend/fingerprint hooks and session replay on
|
||||
* by default — which on a CAD canvas would record customers' board geometry).
|
||||
*
|
||||
* THIS IS THE ONLY FILE THAT IMPORTS @sentry/browser. Everything else stays
|
||||
* vendor-neutral, so swapping providers is a one-file change. Same isolation
|
||||
* rule lib/analytics.ts applies to Plausible.
|
||||
*
|
||||
* What this captures, and what it doesn't
|
||||
* --------------------------------------
|
||||
* Sentry.init installs its own window `error` / `unhandledrejection` handlers,
|
||||
* so uncaught main-thread errors are captured with no instrumentation at the
|
||||
* throw sites. That is deliberately the whole feature: the errors that actually
|
||||
* kill sessions are wasm traps escaping emscripten's DOM event handlers
|
||||
* (`dynCall_iiii` → trap → window.onerror), which this sees for free.
|
||||
*
|
||||
* It does NOT see inside Web Workers — occ-worker.js / ngspice-worker.js and the
|
||||
* KiCad pthread workers are separate realms with their own global scope, and
|
||||
* every console.error in this app lives in one of them. Forwarding those needs a
|
||||
* postMessage bridge; deferred until the data says those workers actually fail.
|
||||
*
|
||||
* Bundle cost: ZERO when no DSN is configured. `import.meta.env.VITE_ERRORS_DSN`
|
||||
* is const-folded at build time, so the guard below is statically false and
|
||||
* Rollup drops the whole @sentry/browser import — measured 1,193,080 vs
|
||||
* 1,282,463 bytes of JS (~26 KB gzipped) with and without. Dev checkouts and the
|
||||
* e2e preview build therefore ship none of it.
|
||||
*/
|
||||
|
||||
/** Reports per session — a crash loop must not bill as a thousand events. */
|
||||
const MAX_EVENTS_PER_SESSION = 20;
|
||||
|
||||
let sent = 0;
|
||||
/**
|
||||
* Set once a terminal wasm error has been reported. After a trap the module is
|
||||
* wedged, so every subsequent DOM event re-enters dead wasm and throws again — a
|
||||
* single production incident produced 8 identical-looking errors from one root
|
||||
* cause. Reporting each would triple-count the issue and read as 8 problems.
|
||||
*/
|
||||
let wasmDead = false;
|
||||
let cascade = 0;
|
||||
|
||||
/** Query params whose values are credentials. `?token=` in particular is live:
|
||||
* wasm/collab/provider.ts passes the collab token in the y-partyserver URL, so
|
||||
* any connection-failure message containing that URL carries a real secret. */
|
||||
const SECRET_PARAM = /([?&](?:token|apiKey|api_key|access_token|key|secret|sig|signature|password)=)[^&\s"']+/gi;
|
||||
const BEARER = /(Bearer\s+)[A-Za-z0-9._-]+/gi;
|
||||
|
||||
function redact(text: string): string {
|
||||
return text.replace(SECRET_PARAM, "$1[redacted]").replace(BEARER, "$1[redacted]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize error reporting. Idempotent; a hard no-op unless all three hold:
|
||||
*
|
||||
* 1. a DSN is configured (unset in dev checkouts and in any deploy that
|
||||
* doesn't pass one);
|
||||
* 2. we're in a browser (vitest imports this module transitively);
|
||||
* 3. the build does NOT allow `?user=` overrides — VITE_ALLOW_USER_OVERRIDE=1
|
||||
* is set by the dev server and every Playwright harness and NEVER by a
|
||||
* production build (see config.ts). Using it as a negative gate means a
|
||||
* developer who copies a production .env into a local checkout still can't
|
||||
* pollute the dashboard.
|
||||
*/
|
||||
export function initErrorReporting(): void {
|
||||
if (!ERRORS_DSN || typeof window === "undefined") return;
|
||||
if (import.meta.env.VITE_ALLOW_USER_OVERRIDE === "1") return;
|
||||
|
||||
Sentry.init({
|
||||
dsn: ERRORS_DSN,
|
||||
environment: ERRORS_ENV,
|
||||
// Same identity the version badge shows, which is also our GPLv3
|
||||
// corresponding-source pointer — so an error and the exact source it came
|
||||
// from share one key.
|
||||
release: `pcbjam@${APP_TAG ?? "dev"}+${APP_GIT_SHA?.slice(0, 7) ?? "local"}`,
|
||||
|
||||
// The defaults are MERGED with an `integrations: [...]` array, so removing
|
||||
// one requires the callback form.
|
||||
integrations: (defaults) => [
|
||||
...defaults.filter(
|
||||
(i) =>
|
||||
// BrowserApiErrors wraps setTimeout / setInterval /
|
||||
// requestAnimationFrame / addEventListener in try/catch to attach
|
||||
// better stack traces. KiCad-on-Emscripten drives its main loop
|
||||
// through exactly those (emscripten_async_call → setTimeout, and the
|
||||
// GAL refresh timer re-arms in a tight loop), so wrapping them adds a
|
||||
// closure per tick and changes stack identity in the middle of an
|
||||
// Asyncify-transformed call graph. Not worth it for prettier traces.
|
||||
i.name !== "BrowserApiErrors" && i.name !== "Breadcrumbs",
|
||||
),
|
||||
// Keep breadcrumbs — clicks, navigation and fetches are exactly the "what
|
||||
// was the user doing" trail that makes a lone stack trace actionable — but
|
||||
// drop CONSOLE capture. wasm/collab/debug.ts's clog is on by default in
|
||||
// production and fires on every Yjs update, so console breadcrumbs would
|
||||
// evict the whole ring with collab chatter before any crash happens.
|
||||
Sentry.breadcrumbsIntegration({ console: false }),
|
||||
],
|
||||
|
||||
beforeSend(event) {
|
||||
if (sent >= MAX_EVENTS_PER_SESSION) return null;
|
||||
|
||||
const ex = event.exception?.values?.[0];
|
||||
const message = ex?.value ?? event.message ?? "";
|
||||
// The live Error is gone by now — classify from the serialized frame,
|
||||
// whose `type` is the original constructor name. Matching on the message
|
||||
// alone would miss any trap whose wording we didn't anticipate.
|
||||
const terminal = isTerminalSerializedError(ex?.type, message);
|
||||
|
||||
// One wedge, one event. Later traps are the same corpse being kicked.
|
||||
if (terminal && wasmDead) {
|
||||
cascade++;
|
||||
return null;
|
||||
}
|
||||
if (terminal) {
|
||||
wasmDead = true;
|
||||
event.tags = { ...event.tags, wasm_terminal: "true" };
|
||||
}
|
||||
if (cascade > 0) {
|
||||
event.extra = { ...event.extra, cascade_count: cascade };
|
||||
}
|
||||
|
||||
if (ex?.value) ex.value = redact(ex.value);
|
||||
if (event.message) event.message = redact(event.message);
|
||||
// The SDK attaches the page URL automatically; scrub it too — editor URLs
|
||||
// are project paths and may carry query credentials.
|
||||
if (event.request?.url) event.request.url = redact(event.request.url);
|
||||
|
||||
sent++;
|
||||
return event;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -3,10 +3,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { initAnalytics } from "./lib/analytics";
|
||||
import { initErrorReporting } from "./lib/error-reporting";
|
||||
import { initTheme } from "./lib/theme";
|
||||
import { installFatalScreenListeners } from "./wasm/fatal-screen";
|
||||
import "./index.css";
|
||||
|
||||
// Error tracking (Better Stack), only when VITE_ERRORS_DSN is set. FIRST and
|
||||
// synchronous, so its window handlers are live before anything else runs —
|
||||
// including the fatal-screen floor below and WasmTool's 175–338 MB boot, where
|
||||
// the crashes worth catching happen.
|
||||
initErrorReporting();
|
||||
|
||||
// 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).
|
||||
|
|
|
|||
4
web/standalone/src/vite-env.d.ts
vendored
4
web/standalone/src/vite-env.d.ts
vendored
|
|
@ -32,6 +32,10 @@ interface ImportMetaEnv {
|
|||
readonly VITE_YJS_TOKEN?: string;
|
||||
/** Plausible pa-*.js script URL; unset ⇒ no tracking (dev default). */
|
||||
readonly VITE_PLAUSIBLE_SRC?: string;
|
||||
/** Error-tracking DSN (Sentry wire format, pointed at Better Stack); unset ⇒ no reporting (dev default). */
|
||||
readonly VITE_ERRORS_DSN?: string;
|
||||
/** Environment tag on reported errors: "production" | "demo" | "preview". Defaults to "development". */
|
||||
readonly VITE_ERRORS_ENV?: string;
|
||||
/** Management app origin (e.g. https://app.pcbjam.com); set ⇒ non-editor routes redirect there (lib/redirect.ts). */
|
||||
readonly VITE_APP_URL?: string;
|
||||
/** "1" ⇒ honor `?user=`/`?libowner=` identity overrides (dev/e2e harnesses only — NEVER production builds). */
|
||||
|
|
|
|||
115
web/standalone/src/wasm/terminal-error.test.ts
Normal file
115
web/standalone/src/wasm/terminal-error.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { errorMessage, isTerminalError, isTerminalSerializedError } from "./terminal-error";
|
||||
|
||||
/**
|
||||
* The strings below are the REAL ones observed on editor.pcbjam.com — a wedged
|
||||
* pcbnew session where every key/focus/resize event re-entered dead wasm. The
|
||||
* previous inline regex matched none of them, which is why the fatal overlay
|
||||
* never appeared.
|
||||
*/
|
||||
describe("isTerminalError", () => {
|
||||
it("matches a WebAssembly.RuntimeError by type, whatever the message says", () => {
|
||||
// The type is the load-bearing signal: `.message` is just "unreachable",
|
||||
// which the old regex (looking for "unreachable executed") missed.
|
||||
expect(isTerminalError(new WebAssembly.RuntimeError("unreachable"), "unreachable")).toBe(true);
|
||||
expect(isTerminalError(new WebAssembly.RuntimeError("null function"), "null function")).toBe(
|
||||
true,
|
||||
);
|
||||
// Even a message we have no pattern for is terminal if the type says so.
|
||||
expect(isTerminalError(new WebAssembly.RuntimeError("something new"), "something new")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches an Error named RuntimeError that crossed a realm boundary", () => {
|
||||
const err = new Error("unreachable");
|
||||
err.name = "RuntimeError";
|
||||
expect(isTerminalError(err, "unreachable")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the message when the Error object is unavailable", () => {
|
||||
// Worker ErrorEvents arrive with `error: null` — message is all we get.
|
||||
expect(isTerminalError(null, "RuntimeError: unreachable")).toBe(true);
|
||||
expect(isTerminalError(null, "null function or function signature mismatch")).toBe(true);
|
||||
expect(isTerminalError(null, "table index is out of bounds")).toBe(true);
|
||||
expect(isTerminalError(null, "memory access out of bounds")).toBe(true);
|
||||
expect(isTerminalError(null, "indirect call signature mismatch")).toBe(true);
|
||||
expect(isTerminalError(null, "Aborted(RuntimeError: unreachable)")).toBe(true);
|
||||
expect(isTerminalError(null, "abort")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match ordinary app errors — a working editor must not be hijacked", () => {
|
||||
expect(isTerminalError(new TypeError("x is not a function"), "x is not a function")).toBe(false);
|
||||
expect(isTerminalError(new Error("failed to fetch"), "failed to fetch")).toBe(false);
|
||||
expect(isTerminalError(null, "ResizeObserver loop completed with undelivered notifications"))
|
||||
.toBe(false);
|
||||
expect(isTerminalError(undefined, "")).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Engine-spelling matrix. This predicate replaced a regex that was chasing
|
||||
* per-engine wording and losing: 197f317 added Firefox's forms and in doing so
|
||||
* narrowed "table index is out of bounds" to `\bindex out of bounds`, which
|
||||
* stopped matching Chrome's. Every row below is a real production spelling —
|
||||
* they must ALL hold, in both engines, message-only (no Error object).
|
||||
*/
|
||||
it.each([
|
||||
["unreachable", "Chrome — v0.1.20 prod"],
|
||||
["null function", "Chrome — v0.1.20 prod"],
|
||||
["null function or function signature mismatch", "Chrome — full form"],
|
||||
["unreachable executed", "Chrome — older V8 wording"],
|
||||
["index out of bounds", "Firefox — v0.1.19 prod, what 197f317 fixed"],
|
||||
["table index is out of bounds", "Chrome — regressed by 197f317"],
|
||||
["memory access out of bounds", "both"],
|
||||
["indirect call signature mismatch", "both"],
|
||||
])("matches the trap spelling %j (%s)", (msg) => {
|
||||
expect(isTerminalError(null, msg)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match 'abort' inside an unrelated word", () => {
|
||||
// \b guards the alternative — "aborting" is fine, "collaborator" must not be.
|
||||
expect(isTerminalError(null, "collaborator joined")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression: the reporter classifies from a SERIALIZED event, where the live
|
||||
* Error is gone. An earlier version fell back to the message alone there, so a
|
||||
* RuntimeError with unanticipated wording was treated as an ordinary error and
|
||||
* the cascade guard never armed — one wedged session then reported N times.
|
||||
* Caught by a real end-to-end test, not by the unit tests, hence this block.
|
||||
*/
|
||||
describe("isTerminalSerializedError", () => {
|
||||
it("trusts the serialized type even when the message matches nothing", () => {
|
||||
expect(isTerminalSerializedError("RuntimeError", "test from local")).toBe(true);
|
||||
expect(isTerminalSerializedError("RuntimeError", "some wording we never saw")).toBe(true);
|
||||
});
|
||||
|
||||
it("still matches known wording when the type is absent or generic", () => {
|
||||
expect(isTerminalSerializedError(undefined, "unreachable")).toBe(true);
|
||||
expect(isTerminalSerializedError("Error", "null function")).toBe(true);
|
||||
expect(isTerminalSerializedError("Error", "memory access out of bounds")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match ordinary serialized errors", () => {
|
||||
expect(isTerminalSerializedError("TypeError", "x is not a function")).toBe(false);
|
||||
expect(isTerminalSerializedError("Error", "failed to fetch")).toBe(false);
|
||||
expect(isTerminalSerializedError(undefined, "")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorMessage", () => {
|
||||
it("prefers the Error's message", () => {
|
||||
expect(errorMessage(new Error("boom"), "ignored")).toBe("boom");
|
||||
});
|
||||
|
||||
it("stringifies a non-Error rejection reason", () => {
|
||||
expect(errorMessage("plain string reason")).toBe("plain string reason");
|
||||
expect(errorMessage(42)).toBe("42");
|
||||
});
|
||||
|
||||
it("uses the fallback when there is no error value", () => {
|
||||
expect(errorMessage(null, "event.message")).toBe("event.message");
|
||||
expect(errorMessage(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
89
web/standalone/src/wasm/terminal-error.ts
Normal file
89
web/standalone/src/wasm/terminal-error.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Is this error terminal — i.e. did the wasm instance just die?
|
||||
*
|
||||
* A wasm trap ("unreachable", "null function", "table index is out of bounds")
|
||||
* or an abort() leaves the module wedged: every later entry into wasm trips the
|
||||
* same fault, so the editor is unusable even though the page is still up. The
|
||||
* fatal overlay (components/WasmTool.tsx) and the error reporter both key off
|
||||
* this predicate, so they can never disagree about what "terminal" means.
|
||||
*
|
||||
* Why a TYPE check and not only a message match
|
||||
* ---------------------------------------------
|
||||
* This lived inline in WasmTool as a regex over `err.message`, and it silently
|
||||
* stopped matching. Two reasons, both instructive:
|
||||
*
|
||||
* 1. It listed `RuntimeError` as an alternative — but that is the error's
|
||||
* *type*, which never appears in `.message`. `String(err)` would contain
|
||||
* it; `err.message` does not.
|
||||
* 2. It listed `unreachable executed`, which was V8's old wording. Current
|
||||
* Chrome throws `RuntimeError: unreachable`, and `null function` was never
|
||||
* covered at all.
|
||||
*
|
||||
* Net effect in production: a wedged editor rendered a black canvas with no
|
||||
* overlay and no trace dump, which is exactly what the handler existed to
|
||||
* prevent. So the primary signal is now the type — `WebAssembly.RuntimeError`
|
||||
* is only ever thrown by a wasm trap — and the string patterns are a fallback
|
||||
* for the paths that surface a trap as a plain Error or a bare string (worker
|
||||
* `ErrorEvent`s cross the realm boundary with `error: null`, leaving only a
|
||||
* message).
|
||||
*
|
||||
* Widening to the type cannot "hijack a working editor" (the concern the
|
||||
* original guarded against): in this app a wasm trap always means the instance
|
||||
* is gone.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Message fragments that indicate a dead wasm instance, for the paths where the
|
||||
* Error object itself is unavailable. Kept deliberately narrow — an ordinary
|
||||
* app-level TypeError must not match, or the overlay would fire over a working
|
||||
* editor.
|
||||
*/
|
||||
const TERMINAL_MESSAGE =
|
||||
/\babort(ed)?\b|out of bounds|indirect call signature|null function|unreachable/i;
|
||||
|
||||
/**
|
||||
* @param err the caught value — `ErrorEvent.error`, `PromiseRejectionEvent.reason`,
|
||||
* or whatever a catch block received. May be null/undefined.
|
||||
* @param msg the best available message string (callers pass `err.message` when
|
||||
* `err` is an Error, else the event's own `message` field).
|
||||
*/
|
||||
export function isTerminalError(err: unknown, msg: string): boolean {
|
||||
// Primary signal: only a wasm trap produces one of these.
|
||||
if (typeof WebAssembly !== "undefined" && err instanceof WebAssembly.RuntimeError) {
|
||||
return true;
|
||||
}
|
||||
// Same check by name, for realms where the instanceof fails (an error that
|
||||
// crossed a worker/iframe boundary carries a different intrinsic).
|
||||
if (err instanceof Error && err.name === "RuntimeError") return true;
|
||||
return TERMINAL_MESSAGE.test(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same decision as `isTerminalError`, for an error that has already been
|
||||
* SERIALIZED and no longer exists as a live object — i.e. a reporter payload,
|
||||
* where the exception frame carries the constructor name and the message as two
|
||||
* plain strings.
|
||||
*
|
||||
* This exists because the object-based check above is unavailable there, and
|
||||
* falling back to the message alone reintroduces exactly the bug this module
|
||||
* was written to fix: a trap whose wording we didn't anticipate is silently
|
||||
* reclassified as an ordinary error. `type` is the serialized `err.name`, so
|
||||
* checking it is the faithful equivalent of the `instanceof` test.
|
||||
*/
|
||||
export function isTerminalSerializedError(
|
||||
type: string | undefined,
|
||||
value: string,
|
||||
): boolean {
|
||||
if (type === "RuntimeError") return true;
|
||||
return TERMINAL_MESSAGE.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a caught value to the message string `isTerminalError` expects.
|
||||
* Mirrors what the window handlers did inline, so both callers agree.
|
||||
*/
|
||||
export function errorMessage(err: unknown, fallback?: string): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (err != null) return String(err);
|
||||
return String(fallback ?? "");
|
||||
}
|
||||
Loading…
Reference in a new issue