fix(runtime): raw-mode teardown matches Ink's clearInputState + disableRawMode (#101)

Two related fixes to the raw-mode controller, mirroring Ink's split
(App.tsx:212-224,357):

- Sync input-state clear (P6): on the last useInput release (refs→0), reset the
  input parser, clear the pending escape-flush timer, and detach the stdin
  listeners SYNCHRONOUSLY — only the terminal raw-mode toggle stays deferred.
  Previously everything was deferred in one microtask, so a same-tick useInput
  SWAP (old unmounts → refs 0 → queued; new mounts → refs 0→1; the queued reset
  then short-circuits on refs>0) left the parser un-reset and a partial escape
  buffered before the swap leaked into the replacement handler. Ink's
  clearInputState runs synchronously and unconditionally so this can't happen.

- Force raw-off (P7): the final disable now unconditionally setRawMode(false),
  matching Ink's disableRawMode. The previous prevRaw-restore re-captured
  stdin.isRaw at acquire while raw was still active on a sync false→true→false
  swap, snapshotting `true` and leaving the terminal in RAW mode after exit. No
  test locked the prevRaw-restore (an undocumented vue invention), so the field
  is removed entirely — eliminating the corruption and aligning with Ink.

The swap-keeps-raw-on behavior (deferred toggle short-circuits on refs>0) is
preserved. Tests lock the partial-escape no-leak on swap and the terminal being
restored (final setRawMode is false) via an isRaw-tracking stdin.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-31 22:25:42 +08:00
committed by GitHub
parent f99f23e346
commit c76a0f09b2
2 changed files with 172 additions and 16 deletions
+32 -16
View File
@@ -1280,14 +1280,13 @@ interface StdinController extends StdinContext {
interface RawModeState {
refs: number;
prevRaw: boolean | null;
}
const rawModeRegistry = new WeakMap<NodeJS.ReadStream, RawModeState>();
function getRawModeState(stdin: NodeJS.ReadStream): RawModeState {
let state = rawModeRegistry.get(stdin);
if (!state) {
state = { refs: 0, prevRaw: null };
state = { refs: 0 };
rawModeRegistry.set(stdin, state);
}
return state;
@@ -1455,7 +1454,6 @@ function createStdinController(
}
const state = getRawModeState(stdin);
if (state.refs === 0) {
state.prevRaw = (stdin as { isRaw?: boolean }).isRaw ?? false;
if (typeof stdin.ref === "function") stdin.ref();
if (typeof (stdin as any).setEncoding === "function") (stdin as any).setEncoding("utf8");
appCtx.setRawMode(true);
@@ -1484,18 +1482,32 @@ function createStdinController(
const state = getRawModeState(stdin);
state.refs = Math.max(0, state.refs - 1);
localRefs = Math.max(0, localRefs - 1);
if (state.refs === 0 && state.prevRaw !== null) {
// Defer the actual disable: when components swap (v-if key change),
// Vue unmounts the old before mounting the new, so refs briefly hits 0.
// Disabling synchronously would drop raw mode between the two mounts.
if (state.refs === 0) {
// Stop owning input SYNCHRONOUSLY on the last release, matching Ink's
// clearInputState (App.tsx:212-216,357): reset the parser, cancel the
// pending-escape flush, and detach the data/readable listeners NOW — so a
// partial escape buffered before a same-render useInput swap cannot leak
// into the replacement. (A same-tick re-acquire re-attaches the listener
// with a fresh parser; deferring this is the bug — the gated microtask
// below short-circuits when refs is back >0, so the reset never ran.)
inputParser.reset();
clearPendingFlush();
stdin.off("readable", handleReadable);
stdin.off("data", handleData);
// Defer ONLY the terminal raw-mode toggle (Ink defers just disableRawMode,
// App.tsx:359-368): when components swap (v-if/key change), Vue unmounts
// the old before mounting the new, so refs briefly hits 0. Disabling
// synchronously would drop raw mode between the two mounts; the microtask
// short-circuits if a replacement re-acquired in the meantime.
queueMicrotask(() => {
if (state.refs > 0 || state.prevRaw === null) return;
appCtx.setRawMode(state.prevRaw);
state.prevRaw = null;
stdin.off("readable", handleReadable);
stdin.off("data", handleData);
if (state.refs > 0) return;
// Unconditionally setRawMode(false) — Ink's disableRawMode (App.tsx:218-222)
// never restores a captured prior raw state. Restoring a captured prevRaw was a
// vue-only invention that corrupts on a sync re-acquire swap: it gets
// re-snapshotted as true (raw still active via the deferred toggle), leaving
// the terminal in raw mode after exit.
appCtx.setRawMode(false);
if (typeof stdin.unref === "function") stdin.unref();
inputParser.reset();
});
}
},
@@ -1512,9 +1524,13 @@ function createStdinController(
const state = getRawModeState(stdin);
state.refs = Math.max(0, state.refs - localRefs);
localRefs = 0;
if (state.refs === 0 && state.prevRaw !== null) {
appCtx.setRawMode(state.prevRaw);
state.prevRaw = null;
if (state.refs === 0) {
// Unconditionally setRawMode(false) on final teardown — Ink's
// disableRawMode (App.tsx:218-222) never restores a captured prior raw
// state. (Same rationale as releaseRawMode: a restored prevRaw could be
// the framework's own raw=true snapshotted during a sync swap, which
// would leave the terminal raw on exit.)
appCtx.setRawMode(false);
if (typeof stdin.unref === "function") stdin.unref();
inputParser.reset();
}