fix(runtime): attach the stdin input listener per-controller so two apps share one stdin (#118)

The terminal raw-mode toggle is refcounted per-stdin (a WeakMap shared across
controllers) so one app's unmount can't drop raw mode while another still needs
it — vue's deliberate improvement over Ink, whose per-App counts let the first
unmount disable raw for everyone. But the "data" input listener was ALSO gated
on that shared refcount, so when two apps (separate createApp/stdout) shared one
stdin, only the first app's handleData ever attached and the second was
permanently deaf — and it didn't even self-heal when the first unmounted (Ink at
least does, via its per-App readable listeners).

Make the "data" listener (and its synchronous clearInputState cleanup: parser
reset, pending-escape flush, listener detach) PER-CONTROLLER, gated on this
controller's own localRefs, while keeping the raw-mode enable/disable on the
shared refcount. Because vue uses the "data" (push) event, every listener gets
every chunk, so both apps now receive input — strictly better than Ink's
"readable" (pull) model where the first-registered listener drains the buffer.

Single-app behavior is byte-identical: with one controller localRefs and the
shared refs move 1:1, so the same-tick swap (parser reset + listener re-attach)
fires at exactly the same moments as before.

Test: two apps sharing one stdin both receive a keystroke, and the second keeps
receiving after the first unmounts while raw mode stays enabled (shared ref);
raw mode disables only when the last app unmounts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-01 11:23:55 +08:00
committed by GitHub
parent 835e026336
commit c6be1f5a7a
2 changed files with 94 additions and 16 deletions
@@ -177,3 +177,67 @@ test("teardown disables raw mode synchronously so a signal exit can't leave the
expect(setRawModeCalls).toEqual([true, false]);
expect(refCount()).toBe(0);
});
// Two independent apps (separate createApp/stdout) sharing ONE stdin. The
// terminal raw-mode toggle is refcounted per-stdin (shared) so the first app's
// unmount can't drop raw mode while the second still needs it — vue's deliberate
// improvement over Ink, whose per-App counts let the first unmount disable raw
// for everyone. But the INPUT listener is per-controller: each app attaches its
// own "data" handler, so BOTH receive every keystroke (vue's push model has no
// drain race), and the second keeps receiving after the first unmounts.
//
// Before the per-controller-listener fix the "data" handler was attached only
// when the SHARED refcount went 0→1, so the second app's handler never attached
// and it was permanently deaf (worse than Ink, which at least self-heals when
// the first app unmounts).
test("two apps sharing one stdin both receive input; the second keeps receiving after the first unmounts", async () => {
const aKeys: string[] = [];
const bKeys: string[] = [];
const AppA = defineComponent(() => {
useInput((input) => aKeys.push(input));
return () => <Text>a</Text>;
});
const AppB = defineComponent(() => {
useInput((input) => bKeys.push(input));
return () => <Text>b</Text>;
});
const stdout1 = makeFakeWritable();
const stdout2 = makeFakeWritable();
const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin();
const appA = createApp(AppA);
const appB = createApp(AppB);
appA.mount({ stdout: stdout1, stdin, debug: true, exitOnCtrlC: false });
appB.mount({ stdout: stdout2, stdin, debug: true, exitOnCtrlC: false });
await settle();
// Raw mode enabled exactly once (shared refcount); both apps hold the one ref.
expect(setRawModeCalls).toEqual([true]);
expect(refCount()).toBe(1);
// Both apps receive the same keystroke.
(stdin as unknown as PassThrough).write("z");
await settle();
expect(aKeys).toEqual(["z"]);
expect(bKeys).toEqual(["z"]);
// First app unmounts: raw mode must STAY on (B still holds the shared ref),
// and B must keep receiving — A must not.
appA.unmount();
await settle();
expect(setRawModeCalls).toEqual([true]);
expect(refCount()).toBe(1);
(stdin as unknown as PassThrough).write("y");
await settle();
expect(aKeys).toEqual(["z"]);
expect(bKeys).toEqual(["z", "y"]);
// Second app unmounts: now raw mode is disabled and the ref released.
appB.unmount();
await settle();
expect(setRawModeCalls).toEqual([true, false]);
expect(refCount()).toBe(0);
});
+30 -16
View File
@@ -1499,13 +1499,13 @@ function createStdinController(
}
const state = getRawModeState(stdin);
if (state.refs === 0) {
// If a same-tick swap left raw mode physically enabled (its disable is
// still queued), don't re-ref or re-toggle — just cancel the pending
// disable. Ink (App.tsx:331-344) skips stdin.ref()/setRawMode(true) here
// when isRawModeAlreadyEnabled; re-issuing them is a redundant ioctl AND
// an unbalanced ref() (the deferred disable would bail on refs>0 and never
// unref). setEncoding('utf8') and the data listener still run: encoding is
// idempotent and the listener was detached synchronously in releaseRawMode.
// SHARED (per-stdin) terminal raw-mode enable. If a same-tick swap left
// raw mode physically enabled (its disable is still queued), don't re-ref
// or re-toggle — just cancel the pending disable. Ink (App.tsx:331-344)
// skips stdin.ref()/setRawMode(true) when isRawModeAlreadyEnabled;
// re-issuing them is a redundant ioctl AND an unbalanced ref() (the
// deferred disable would bail on refs>0 and never unref). setEncoding is
// idempotent so it stays here.
const alreadyEnabled = state.pendingDisable;
state.pendingDisable = false;
if (!alreadyEnabled) {
@@ -1513,6 +1513,17 @@ function createStdinController(
appCtx.setRawMode(true);
}
if (typeof (stdin as any).setEncoding === "function") (stdin as any).setEncoding("utf8");
}
if (localRefs === 0) {
// PER-CONTROLLER input listener. Each controller (one per render tree)
// attaches its OWN handleData → its OWN parser → its OWN event emitter,
// gated on THIS controller's ref count, NOT the shared one. So two apps
// sharing one stdin both receive every keystroke: vue's 'data' (push)
// event broadcasts to every listener — unlike Ink's 'readable' (pull)
// model where the first-registered listener drains the buffer and a
// second same-stdin app stays deaf until the first unmounts
// (App.tsx:278-313). The terminal raw-mode toggle above stays shared so
// one app's unmount can't drop raw mode while another still needs it.
stdin.on("data", handleData);
}
state.refs++;
@@ -1538,19 +1549,22 @@ function createStdinController(
const state = getRawModeState(stdin);
state.refs = Math.max(0, state.refs - 1);
localRefs = Math.max(0, localRefs - 1);
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.)
if (localRefs === 0) {
// PER-CONTROLLER: stop THIS controller owning input SYNCHRONOUSLY when its
// own last useInput releases, matching Ink's clearInputState
// (App.tsx:212-216,357): reset its parser, cancel its pending-escape flush,
// and detach its 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.)
// Gated on localRefs, not the shared refcount: another app on the same
// stdin keeps its own listener and parser intact.
inputParser.reset();
clearPendingFlush();
stdin.off("readable", handleReadable);
stdin.off("data", handleData);
// Defer ONLY the terminal raw-mode toggle (Ink defers just disableRawMode,
}
if (state.refs === 0) {
// Defer ONLY the SHARED 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