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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user