mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
6bfea62e4b
commit
80cb316cd7
5 changed files with 86 additions and 8 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue