Files
vue-tui/packages/runtime-tests/integration/lifecycle/raw-mode-ref-leak.sequential.test.tsx
T
Yunfei He 19c475ae33 fix(runtime): propagate thrown-undefined + non-Error messages from renderToString; don't leak stdin.ref on setRawMode throw (#203)
Three small confirmed fixes:

- renderToString swallowed a component that threw literal `undefined`: the
  `uncaughtError !== undefined` sentinel could not tell "threw undefined" from
  "no error", so it returned the normal frame instead of propagating. Track
  occurrence with a separate `errored` boolean (mirrors the live renderer's
  onErrorCaptured `errored` flag).

- renderToString wrapped a non-Error throw as `new Error(String(value))`, so
  `{ message: "detail" }` became "[object Object]". Re-throw a genuine Error
  (incl. cross-realm, via the `[object Error]` brand check) as-is, and wrap a
  true non-Error with `messageForNonError` so its message survives. Relocated
  `isErrorInput` from render.ts into error-overview.ts (next to
  messageForNonError) so both renderers share one source of truth.

- acquireRawMode called `stdin.ref()` before `setRawMode(true)`. On a hostile
  PTY setRawMode throws ERR_TTY_INIT_FAILED after the ref but before the refcount
  increments, so dispose's gated unref never ran and the ref'd stdin kept the
  event loop alive. Reorder setRawMode(true) before stdin.ref() so a throw
  leaves nothing ref'd.

Test-first: added reproducing tests for each (renderToString throw-undefined and
non-Error-message in render-to-string.test.tsx; ref/unref balance on a throwing
setRawMode in raw-mode-ref-leak.sequential.test.tsx), confirmed red, then green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:17:05 +08:00

75 lines
3.2 KiB
TypeScript

// Sequential: drives the raw-mode controller's deferred teardown (microtask +
// queued disable) and asserts on a per-stdin ref balance. File-level parallelism
// could let another app mount/unmount on the shared process and perturb the
// microtask/timer interleaving relative to this test, so we keep it serial.
//
// Bug C: acquireRawMode() called `stdin.ref()` BEFORE `appCtx.setRawMode(true)`.
// On a hostile SSH/container PTY setRawMode(true) raises ERR_TTY_INIT_FAILED —
// the ref() already happened, but `state.refs`/`localRefs` were not yet
// incremented, so dispose()'s unref (gated on refs) never ran. A ref'd stdin
// keeps the event loop alive, so a caller that CATCHES the mount error hangs at
// exit. The fix reorders setRawMode(true) BEFORE stdin.ref() so a throw leaves
// nothing ref'd; this test pins the ref/unref balance after the throw.
import { PassThrough } from "node:stream";
import { defineComponent, h } from "vue";
import { expect, test } from "vite-plus/test";
import { createApp, Text } from "@vue-tui/runtime";
import { makeFakeWritable } from "./test-streams.ts";
// A fake TTY stdin that COUNTS ref()/unref() calls so we can assert balance, and
// whose setRawMode throws like a broken PTY (ERR_TTY_INIT_FAILED).
function makeRefCountingThrowingStdin(): {
stream: NodeJS.ReadStream;
refs: { count: number; refCalls: number; unrefCalls: number };
} {
const refs = { count: 0, refCalls: 0, unrefCalls: 0 };
const s = new PassThrough() as unknown as NodeJS.ReadStream;
Object.assign(s, {
isTTY: true,
setRawMode() {
throw new Error("ERR_TTY_INIT_FAILED");
},
setEncoding(this: NodeJS.ReadStream) {
return this;
},
});
(s as { ref?: () => void }).ref = () => {
refs.count++;
refs.refCalls++;
};
(s as { unref?: () => void }).unref = () => {
refs.count--;
refs.unrefCalls++;
};
return { stream: s, refs };
}
test.sequential("a throwing setRawMode during acquire does not leave stdin ref'd (ref/unref balanced)", () => {
const App = defineComponent(() => () => h(Text, null, () => "x"));
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin, refs } = makeRefCountingThrowingStdin();
// rawMode "always" + interactive (TTY stdout) makes the App acquire a lifetime
// raw-mode hold during mount → holdRawModeForLifetime → acquireRawMode →
// setRawMode(true), which throws. mount() must rethrow (caller still sees it).
const app = createApp(App);
expect(() => app.mount({ stdout, stdin, stderr, interactive: true, rawMode: "always" })).toThrow(
"ERR_TTY_INIT_FAILED",
);
// The leak guard: a throwing setRawMode must leave the stdin's ref count at 0.
// Before the fix, ref() ran before the throwing setRawMode and dispose's unref
// (gated on refs that were never incremented) never fired → count stuck at 1.
expect(refs.count).toBe(0);
// Stricter: with setRawMode reordered BEFORE ref(), a throwing setRawMode means
// ref() is NEVER called (so there is nothing for unref to balance). Pinning both
// counts at 0 documents that the fix prevents the unbalanced ref rather than
// merely compensating for it afterwards.
expect(refs.refCalls).toBe(0);
expect(refs.unrefCalls).toBe(0);
});