fix(runtime): run teardown on synchronous mount() throw (#169)
mount() registers the app as the stdout owner (liveInstances.set) and then runs holdRawModeForLifetime(), kittyController.init(), and attachYoga()/ setWidth() — all of which can throw SYNCHRONOUSLY on a hostile terminal (setRawMode raises ERR_TTY_INIT_FAILED on some SSH/container PTYs that report isTTY=true; kitty enable's stdout.write can throw on a broken stream) — BEFORE the originalMount try/catch and before the exit/signal handlers are wired. A throw there skipped teardown(), leaving the liveInstances entry forever (poisoning the stdout: every later mount() hit the reuse guard and became an inert no-op), leaking the yoga root, and leaving raw mode / kitty on. Wrap those pre-mount steps in the same teardown-then-rethrow guard as originalMount. teardown() is idempotent and safe at this early stage (it derives all cleanup from the wired state set so far and guards on mountedAppContext). Also: assign mountedKittyController BEFORE init() so an auto-mode detection-query throw (after the stdin listener + timer are installed) is disposed, and record mountedRoot right after attachYoga (before setWidth) so the just-allocated yoga node is freed on a setWidth throw. The original error always survives and is rethrown to the caller. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+159
@@ -0,0 +1,159 @@
|
|||||||
|
// Sequential: asserts on the process-global live yoga-node count
|
||||||
|
// (yogaNodeTracker), which concurrent siblings that mount/unmount apps would
|
||||||
|
// perturb. Tests are it.sequential.
|
||||||
|
//
|
||||||
|
// Bug: a SYNCHRONOUS throw during mount() BEFORE the originalMount try/catch
|
||||||
|
// (e.g. stdin.setRawMode raising ERR_TTY_INIT_FAILED on a broken PTY, or kitty
|
||||||
|
// enable's stdout.write throwing) skipped teardown(). liveInstances kept the
|
||||||
|
// entry forever (poisoning the stdout: every later mount() hit the reuse guard
|
||||||
|
// and no-op'd), the yoga root leaked, and raw mode was left on.
|
||||||
|
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import { expect, test, vi, afterEach } from "vite-plus/test";
|
||||||
|
import { createApp, Text } from "@vue-tui/runtime";
|
||||||
|
import { yogaNodeTracker } from "@vue-tui/runtime/internal";
|
||||||
|
import { captureWrites, makeFakeWritable, makeFakeStdin } from "./test-streams.ts";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Spy on native process.stderr (where the reuse-guard warning is written). */
|
||||||
|
function spyOnGuardWarnings(): { warnings: string[]; restore: () => void } {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const spy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
|
||||||
|
warnings.push(typeof chunk === "string" ? chunk : String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return { warnings, restore: () => spy.mockRestore() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUARD_WARNING = "this stdout already has a live app";
|
||||||
|
|
||||||
|
test.sequential("a synchronous setRawMode throw during mount() runs teardown: rethrows + does not poison the stdout", async () => {
|
||||||
|
yogaNodeTracker.reset();
|
||||||
|
const liveBefore = yogaNodeTracker.snapshot().live;
|
||||||
|
|
||||||
|
const App = defineComponent(() => () => <Text>hello</Text>);
|
||||||
|
|
||||||
|
const stdout = makeFakeWritable();
|
||||||
|
const stderr = makeFakeWritable();
|
||||||
|
|
||||||
|
// A TTY stdin whose setRawMode throws — simulates Node's
|
||||||
|
// ERR_TTY_INIT_FAILED on an SSH/container PTY that reports isTTY=true.
|
||||||
|
const { stream: stdin } = makeFakeStdin();
|
||||||
|
const ttyError = new Error("ERR_TTY_INIT_FAILED");
|
||||||
|
(stdin as unknown as { setRawMode: (m: boolean) => unknown }).setRawMode = () => {
|
||||||
|
throw ttyError;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { restore } = spyOnGuardWarnings();
|
||||||
|
|
||||||
|
// (1) mount() must rethrow the injected error (the caller still sees it).
|
||||||
|
// rawMode defaults to "always" + interactive (TTY stdout) → the App acquires
|
||||||
|
// a lifetime raw-mode hold, which calls the throwing setRawMode.
|
||||||
|
const app1 = createApp(App);
|
||||||
|
expect(() => app1.mount({ stdout, stdin, stderr, interactive: true })).toThrow(
|
||||||
|
"ERR_TTY_INIT_FAILED",
|
||||||
|
);
|
||||||
|
|
||||||
|
// (2) The stdout is NOT poisoned: a subsequent mount() on the SAME stdout
|
||||||
|
// succeeds and renders, proving liveInstances was cleaned up by teardown()
|
||||||
|
// (before the fix this warned + no-op'd). Use a stdin that does NOT throw.
|
||||||
|
const { stream: stdin2 } = makeFakeStdin();
|
||||||
|
const writes = captureWrites(stdout);
|
||||||
|
const app2 = createApp(App);
|
||||||
|
app2.mount({ stdout, stdin: stdin2, stderr, debug: true, exitOnCtrlC: false });
|
||||||
|
await app2.waitUntilRenderFlush();
|
||||||
|
|
||||||
|
restore();
|
||||||
|
expect(writes.join("")).toContain("hello");
|
||||||
|
|
||||||
|
app2.unmount();
|
||||||
|
|
||||||
|
// (3) The yoga root allocated during the failed mount was freed (no leak):
|
||||||
|
// after the successful second app unmounts, live count is back to baseline.
|
||||||
|
expect(yogaNodeTracker.snapshot().live).toBe(liveBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.sequential("a synchronous stdout.write throw during kitty enable runs teardown (no poison)", async () => {
|
||||||
|
const App = defineComponent(() => () => <Text>kitty</Text>);
|
||||||
|
|
||||||
|
// A stdout whose write throws once kitty tries to enable the protocol.
|
||||||
|
const stdout = makeFakeWritable();
|
||||||
|
const enableError = new Error("BROKEN_STREAM_ON_KITTY_ENABLE");
|
||||||
|
const originalWrite = stdout.write.bind(stdout);
|
||||||
|
stdout.write = ((...args: unknown[]) => {
|
||||||
|
const chunk = String(args[0]);
|
||||||
|
// Kitty enable writes the push-flags CSI ("\x1b[>...u"); throw only on it.
|
||||||
|
if (chunk.includes("\x1b[>")) throw enableError;
|
||||||
|
return (originalWrite as Function)(...args);
|
||||||
|
}) as NodeJS.WriteStream["write"];
|
||||||
|
|
||||||
|
const stderr = makeFakeWritable();
|
||||||
|
const { stream: stdin } = makeFakeStdin();
|
||||||
|
const { warnings, restore } = spyOnGuardWarnings();
|
||||||
|
|
||||||
|
// (1) mount() rethrows the kitty-enable error.
|
||||||
|
const app1 = createApp(App);
|
||||||
|
expect(() =>
|
||||||
|
app1.mount({
|
||||||
|
stdout,
|
||||||
|
stdin,
|
||||||
|
stderr,
|
||||||
|
interactive: true,
|
||||||
|
kittyKeyboard: { mode: "enabled" },
|
||||||
|
}),
|
||||||
|
).toThrow("BROKEN_STREAM_ON_KITTY_ENABLE");
|
||||||
|
|
||||||
|
// (2) Not poisoned: a fresh mount on the same stdout must NOT warn (the
|
||||||
|
// registry entry was evicted by teardown). Repair the stream first.
|
||||||
|
stdout.write = originalWrite as NodeJS.WriteStream["write"];
|
||||||
|
const { stream: stdin2 } = makeFakeStdin();
|
||||||
|
const app2 = createApp(App);
|
||||||
|
app2.mount({ stdout, stdin: stdin2, stderr, debug: true, exitOnCtrlC: false });
|
||||||
|
expect(warnings.join("")).not.toContain(GUARD_WARNING);
|
||||||
|
restore();
|
||||||
|
app2.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.sequential("a throw AFTER attachYoga (during setWidth) still frees the yoga root", async () => {
|
||||||
|
// Targets the `mountedRoot = tuiRoot` ordering: attachYoga() has already
|
||||||
|
// allocated the root's yoga node, and setWidth(resolveSize(stdout).columns)
|
||||||
|
// throws. Recording mountedRoot BEFORE setWidth lets teardown's
|
||||||
|
// `if (mountedRoot) detachYoga(mountedRoot)` free that node.
|
||||||
|
yogaNodeTracker.reset();
|
||||||
|
const liveBefore = yogaNodeTracker.snapshot().live;
|
||||||
|
|
||||||
|
const App = defineComponent(() => () => <Text>after-attach</Text>);
|
||||||
|
|
||||||
|
// A TTY stdout whose `columns` getter throws — resolveSize() reads it to
|
||||||
|
// produce the setWidth() argument, so setWidth throws AFTER attachYoga ran.
|
||||||
|
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
|
||||||
|
const sizeError = new Error("COLUMNS_READ_FAILED");
|
||||||
|
Object.defineProperty(stdout, "columns", {
|
||||||
|
get() {
|
||||||
|
throw sizeError;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.assign(stdout, { rows: 100, isTTY: true });
|
||||||
|
|
||||||
|
const stderr = makeFakeWritable();
|
||||||
|
const { stream: stdin } = makeFakeStdin();
|
||||||
|
const { warnings, restore } = spyOnGuardWarnings();
|
||||||
|
|
||||||
|
// (1) mount() rethrows the setWidth error.
|
||||||
|
const app1 = createApp(App);
|
||||||
|
expect(() => app1.mount({ stdout, stdin, stderr, interactive: false })).toThrow(
|
||||||
|
"COLUMNS_READ_FAILED",
|
||||||
|
);
|
||||||
|
|
||||||
|
restore();
|
||||||
|
|
||||||
|
// (2) The yoga root allocated by attachYoga was freed (no leak): live count
|
||||||
|
// is back to baseline immediately after the failed mount.
|
||||||
|
expect(yogaNodeTracker.snapshot().live).toBe(liveBefore);
|
||||||
|
// No registry poison was introduced.
|
||||||
|
expect(warnings.join("")).not.toContain(GUARD_WARNING);
|
||||||
|
});
|
||||||
@@ -767,27 +767,63 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
|||||||
});
|
});
|
||||||
mountedStdinController = stdinController;
|
mountedStdinController = stdinController;
|
||||||
|
|
||||||
// rawMode 'always': the App itself acquires a lifetime raw-mode ref now, so
|
// These pre-mount steps can throw SYNCHRONOUSLY on a hostile/broken
|
||||||
// the refcount floor never drops to 0 while the app runs — raw mode is held
|
// terminal: holdRawModeForLifetime() → stdin.setRawMode(true) raises
|
||||||
// continuously regardless of which input composables come and go, and there
|
// ERR_TTY_INIT_FAILED when the ioctl fails (real on some SSH/container PTYs
|
||||||
// is no cooked-mode oscillation between input and no-input screens. Gated on
|
// that still report isTTY=true); kittyController.init() may stdout.write()
|
||||||
// interactive + isRawModeSupported (a TTY stdin): a non-interactive/piped run
|
// to enable the protocol (throws on a broken stream); attachYoga() allocates
|
||||||
// must not seize raw mode. The matching release happens in the controller's
|
// a WASM yoga node. liveInstances.set(stdout, app) already ran above, so a
|
||||||
// dispose() at teardown. (Diverges from Ink's lazy default — see
|
// throw HERE — before the originalMount try/catch and before the process-
|
||||||
// .agents/docs/ink-divergences.md.)
|
// exit / signal-exit handlers are wired — would leak the registry entry
|
||||||
if (rawMode === "always" && interactive && stdinController.isRawModeSupported) {
|
// (poisoning the stdout: every later mount() hits the reuse guard and
|
||||||
stdinController.holdRawModeForLifetime();
|
// no-ops), leak the yoga root, and leave raw mode / kitty on. Wrap these in
|
||||||
|
// the same teardown-then-rethrow guard as originalMount so teardown()
|
||||||
|
// (idempotent; safe at this early stage — it derives all cleanup from the
|
||||||
|
// wired state set so far) restores everything and frees the registry entry,
|
||||||
|
// while the caller still sees the original error.
|
||||||
|
let kittyController: ReturnType<typeof createKittyKeyboardController>;
|
||||||
|
let tuiRoot: ReturnType<typeof createRoot>;
|
||||||
|
try {
|
||||||
|
// rawMode 'always': the App itself acquires a lifetime raw-mode ref now, so
|
||||||
|
// the refcount floor never drops to 0 while the app runs — raw mode is held
|
||||||
|
// continuously regardless of which input composables come and go, and there
|
||||||
|
// is no cooked-mode oscillation between input and no-input screens. Gated on
|
||||||
|
// interactive + isRawModeSupported (a TTY stdin): a non-interactive/piped run
|
||||||
|
// must not seize raw mode. The matching release happens in the controller's
|
||||||
|
// dispose() at teardown. (Diverges from Ink's lazy default — see
|
||||||
|
// .agents/docs/ink-divergences.md.)
|
||||||
|
if (rawMode === "always" && interactive && stdinController.isRawModeSupported) {
|
||||||
|
stdinController.holdRawModeForLifetime();
|
||||||
|
}
|
||||||
|
|
||||||
|
kittyController = createKittyKeyboardController(stdin, stdout);
|
||||||
|
// Register BEFORE init(): in auto mode, init() installs a stdin 'data'
|
||||||
|
// listener + a 200ms detection timer and only THEN writes the support
|
||||||
|
// query ("\x1b[?u") — which can throw on a broken stream. Assigning
|
||||||
|
// mountedKittyController first lets teardown's dispose() (which calls
|
||||||
|
// cancelDetection: removes the listener, clears the timer) run on that
|
||||||
|
// throw, instead of leaking a dangling stdin listener until the timer fires.
|
||||||
|
mountedKittyController = kittyController;
|
||||||
|
kittyController.init(options.kittyKeyboard, interactive);
|
||||||
|
|
||||||
|
tuiRoot = createRoot(appContext);
|
||||||
|
attachYoga(tuiRoot);
|
||||||
|
// Record the root BEFORE setWidth so teardown's `if (mountedRoot)
|
||||||
|
// detachYoga(mountedRoot)` frees the just-allocated yoga node even if
|
||||||
|
// setWidth (or anything below) throws.
|
||||||
|
mountedRoot = tuiRoot;
|
||||||
|
tuiRoot.yoga.setWidth(resolveSize(stdout).columns);
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
teardown(); // best-effort: free yoga, restore raw mode/kitty, evict registry entry
|
||||||
|
} catch {
|
||||||
|
// A failing best-effort restore must NOT replace `err` — the ORIGINAL
|
||||||
|
// pre-mount error must survive and be rethrown (mirrors the
|
||||||
|
// originalMount catch below).
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const kittyController = createKittyKeyboardController(stdin, stdout);
|
|
||||||
kittyController.init(options.kittyKeyboard, interactive);
|
|
||||||
mountedKittyController = kittyController;
|
|
||||||
|
|
||||||
const tuiRoot = createRoot(appContext);
|
|
||||||
attachYoga(tuiRoot);
|
|
||||||
tuiRoot.yoga.setWidth(resolveSize(stdout).columns);
|
|
||||||
mountedRoot = tuiRoot;
|
|
||||||
|
|
||||||
// Reset accumulated static output when the <Static> identity changes
|
// Reset accumulated static output when the <Static> identity changes
|
||||||
// (unmount, remount via key change) so stale items are not replayed.
|
// (unmount, remount via key change) so stale items are not replayed.
|
||||||
tuiRoot.onStaticChange = () => {
|
tuiRoot.onStaticChange = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user