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.
This commit is contained in:
Armin Ronacher 2026-11-05 00:34:13 +01:00
parent 6bfea62e4b
commit 80cb316cd7
5 changed files with 86 additions and 8 deletions

View file

@ -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,

View file

@ -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;

View file

@ -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.

View file

@ -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<void>;
@ -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");

View file

@ -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();
}
}
},
);