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 = () => {