From 80cb316cd737702a3331cf44152d1530dfc3250d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 5 Nov 2026 00:34:13 +0100 Subject: [PATCH] fix(p2): report TUI sandbox crashes with their stack The launcher printed one message for both a heap overrun and an uncaught exception. It now says which, and prints the sanitized, bounded stack of an uncaught exception or a throwing message handler after restoring the terminal. The TUI bundle keeps names so frames are readable. --- apps/p2/docs/p2-notes.md | 6 +++-- apps/p2/src/hosts/bundles.ts | 2 ++ apps/p2/src/hosts/tui-bootstrap.mjs | 12 +++++++-- apps/p2/src/hosts/tui-sandbox.ts | 39 ++++++++++++++++++++++++--- apps/p2/test/p2/tui-isolation.test.ts | 35 ++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 8 deletions(-) diff --git a/apps/p2/docs/p2-notes.md b/apps/p2/docs/p2-notes.md index 1a6397f0c..09e4ce860 100644 --- a/apps/p2/docs/p2-notes.md +++ b/apps/p2/docs/p2-notes.md @@ -221,8 +221,10 @@ launcher-drawn consent screen (worker IO suspended meanwhile). Unknown requests frame/queue limits. Every output write passes `tui-filter.ts` (64 KiB UTF-8/frame): text, CR/LF/tab, SGR, cursor movement/position, erase, cursor visibility, autowrap, synchronized output, bracketed paste, pi-tui alternate screen and scroll region. OSC 52/title/hyperlinks, DCS, APC/PM/SOS, kitty graphics, C1 controls, unknown CSI/private modes and malformed -or incomplete escapes are stripped. No partial escape can cross a frame boundary. Raw worker errors/streams never -reach the terminal. Rendering is copied from coding-agent's interactive mode and DurableTui (p2-tui-port.md P0c/P2; +or incomplete escapes are stripped. No partial escape can cross a frame boundary. Raw worker streams never reach the +terminal. A crash (an uncaught exception or a throwing message handler) is printed by the launcher after it restored +the terminal: the stack (frames name `p2-tui-bundle.mjs`), controls removed, at most 8 KiB (the bundle keeps function +and class names for this); a heap overrun says so instead. Rendering is copied from coding-agent's interactive mode and DurableTui (p2-tui-port.md P0c/P2; p2 imports neither coding-agent nor mobile): a fullscreen layout (transcript `ScrollView` over a dock of status lines, pending area, pi's editor with the working indicator in its border, and pi's footer), incremental transcript components keyed by the shared display items (`src/tui/components/`), pi's tool cards and renderers for bash, read, diff --git a/apps/p2/src/hosts/bundles.ts b/apps/p2/src/hosts/bundles.ts index b2344c900..51ea2d23f 100644 --- a/apps/p2/src/hosts/bundles.ts +++ b/apps/p2/src/hosts/bundles.ts @@ -108,6 +108,8 @@ export async function buildTuiBundle(plugins: readonly string[], directory = plu ], platform: "node", target: "node26", + // Readable function and class names in the stack the launcher prints when the sandbox crashes (~4% size). + keepNames: true, sourcemap: false, }); const source = result.outputFiles[0]!.text; diff --git a/apps/p2/src/hosts/tui-bootstrap.mjs b/apps/p2/src/hosts/tui-bootstrap.mjs index 98e3c46cc..f3431f43d 100644 --- a/apps/p2/src/hosts/tui-bootstrap.mjs +++ b/apps/p2/src/hosts/tui-bootstrap.mjs @@ -55,13 +55,21 @@ module.syncBuiltinESMExports(); // The bundle receives only this capability, not a port or Worker object. Raw stdout/stderr are captured by the launcher. const send = (value) => post({ kind: "event", value }); let handler; +// The thrown value is the bundle's: reading its stack may run its getters, so anything odd becomes a fixed text. +const describe = (error) => { + try { + return String(error instanceof Error ? (error.stack ?? error) : error); + } catch { + return "unprintable error"; + } +}; port.on("message", async (message) => { try { const result = await handler?.(message.value); if (message.id !== undefined) post({ kind: "reply", id: message.id, value: result }); - } catch { + } catch (error) { if (message.id !== undefined) post({ kind: "reply", id: message.id, failed: true }); - else post({ kind: "failed" }); + else post({ kind: "failed", error: describe(error) }); } }); // Owner-approved dynamic import: the single self-contained server bundle is evaluated only from memory. diff --git a/apps/p2/src/hosts/tui-sandbox.ts b/apps/p2/src/hosts/tui-sandbox.ts index f18a36d22..e63abc56c 100644 --- a/apps/p2/src/hosts/tui-sandbox.ts +++ b/apps/p2/src/hosts/tui-sandbox.ts @@ -1,13 +1,39 @@ import { Worker } from "node:worker_threads"; export const MAX_TUI_BUNDLE = 8 * 1024 * 1024; +const HEAP_LIMIT_MB = 96; export function requireTuiNode(version = process.versions.node): void { const [major = 0, minor = 0, patch = 0] = version.split(".").map(Number); if (major < 26 || (major === 26 && (minor < 5 || (minor === 5 && patch < 1)))) throw new Error("p2 TUI requires Node >=26.5.1 (on slayer: ~/.local/bin/node26, Node 26.11.1)"); } -/** Launcher-owned lifecycle. Worker streams and guest errors are never printed as terminal output. */ +/** The most of one guest error report the launcher prints. */ +const MAX_ERROR_REPORT = 8192; + +/** + * A guest error or stack as printable text: terminal controls and bidi overrides removed, bounded. The guest controls + * this text; only the launcher prints it, after restoring the terminal. + */ +function guestErrorText(text: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: the report keeps only newlines and tabs. + const clean = text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ""); + return clean.length > MAX_ERROR_REPORT ? `${clean.slice(0, MAX_ERROR_REPORT)}\n(truncated)` : clean; +} + +/** Why the worker's `error` event fired: the heap limit, or an exception the bundle did not catch. */ +function workerErrorMessage(error: unknown): string { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ERR_WORKER_OUT_OF_MEMORY") + return `TUI sandbox exceeded its memory limit (${HEAP_LIMIT_MB} MiB heap)`; + // Structured-cloned from the worker (wire input): an Error keeps its stack, anything else is the thrown value. + const text = error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error); + return `TUI sandbox crashed with an uncaught exception:\n${guestErrorText(text)}`; +} + +/** + * Launcher-owned lifecycle. Worker streams never reach the terminal; a crash is reported through `onFailure` with its + * sanitized stack, which the launcher prints after restoring the terminal. + */ export class TuiSandbox { readonly worker: Worker; readonly ready: Promise; @@ -40,7 +66,7 @@ export class TuiSandbox { stdout: true, stderr: true, resourceLimits: { - maxOldGenerationSizeMb: 96, + maxOldGenerationSizeMb: HEAP_LIMIT_MB, maxYoungGenerationSizeMb: 16, codeRangeSizeMb: 64, stackSizeMb: 4, @@ -77,9 +103,14 @@ export class TuiSandbox { clearTimeout(request.timer); if ("failed" in message && message.failed) request.reject(new Error("TUI sandbox request failed")); else request.resolve("value" in message ? message.value : undefined); - } else if (message.kind === "failed") fail("TUI sandbox handler failed"); + } else if (message.kind === "failed") + fail( + "error" in message && typeof message.error === "string" + ? `TUI sandbox message handler threw:\n${guestErrorText(message.error)}` + : "TUI sandbox message handler threw", + ); }); - this.worker.on("error", () => fail("TUI sandbox failed or exceeded its memory limit")); + this.worker.on("error", (error) => fail(workerErrorMessage(error))); this.worker.on("exit", () => { clearTimeout(this.startup); fail("TUI sandbox exited"); diff --git a/apps/p2/test/p2/tui-isolation.test.ts b/apps/p2/test/p2/tui-isolation.test.ts index 41a8e2a54..829c4848e 100644 --- a/apps/p2/test/p2/tui-isolation.test.ts +++ b/apps/p2/test/p2/tui-isolation.test.ts @@ -188,3 +188,38 @@ test( } }, ); + +test( + "a crash reports its sanitized stack: an uncaught exception and a throwing message handler", + { skip: !supported, timeout: 10000 }, + async () => { + for (const [source, expected] of [ + [ + `class Broken{render(){throw new TypeError("bad \\x1b]52;c;secret\\x07 render")}}export default()=>{setTimeout(()=>new Broken().render(),0)}`, + /^TUI sandbox crashed with an uncaught exception:\nTypeError: bad \]52;c;secret render\n\s+at Broken\.render \(p2-tui-bundle\.mjs:1:\d+\)/, + ], + [ + `export default({onMessage})=>onMessage(function handle(){throw new RangeError("no")})`, + /^TUI sandbox message handler threw:\nRangeError: no\n\s+at handle \(p2-tui-bundle\.mjs:1:\d+\)/, + ], + ] as const) { + let failure = ""; + const worker = new TuiSandbox(source, { + onMessage: () => {}, + onFailure: (message) => { + failure = message; + }, + }); + try { + await worker.ready; + worker.post(null); + const deadline = Date.now() + 5000; + while (!failure && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 20)); + assert.match(failure, expected); + assert(!failure.includes("data:") && !failure.includes("\x1b")); + } finally { + await worker.terminate(); + } + } + }, +);