diff --git a/packages/runtime-tests/integration/lifecycle/mount-throw-teardown.sequential.test.tsx b/packages/runtime-tests/integration/lifecycle/mount-throw-teardown.sequential.test.tsx new file mode 100644 index 0000000..687e3aa --- /dev/null +++ b/packages/runtime-tests/integration/lifecycle/mount-throw-teardown.sequential.test.tsx @@ -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(() => () => hello); + + 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(() => () => kitty); + + // 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(() => () => after-attach); + + // 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); +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 3b1d1c0..b5dffc9 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -767,27 +767,63 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp }); mountedStdinController = stdinController; - // 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(); + // These pre-mount steps can throw SYNCHRONOUSLY on a hostile/broken + // terminal: holdRawModeForLifetime() → stdin.setRawMode(true) raises + // ERR_TTY_INIT_FAILED when the ioctl fails (real on some SSH/container PTYs + // that still report isTTY=true); kittyController.init() may stdout.write() + // to enable the protocol (throws on a broken stream); attachYoga() allocates + // a WASM yoga node. liveInstances.set(stdout, app) already ran above, so a + // throw HERE — before the originalMount try/catch and before the process- + // exit / signal-exit handlers are wired — would leak the registry entry + // (poisoning the stdout: every later mount() hits the reuse guard and + // 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; + let tuiRoot: ReturnType; + 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 identity changes // (unmount, remount via key change) so stale items are not replayed. tuiRoot.onStaticChange = () => {