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(setRawModeCalls).toEqual([true, false]);
|
||||||
expect(refCount()).toBe(0);
|
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);
|
const state = getRawModeState(stdin);
|
||||||
if (state.refs === 0) {
|
if (state.refs === 0) {
|
||||||
// If a same-tick swap left raw mode physically enabled (its disable is
|
// SHARED (per-stdin) terminal raw-mode enable. If a same-tick swap left
|
||||||
// still queued), don't re-ref or re-toggle — just cancel the pending
|
// raw mode physically enabled (its disable is still queued), don't re-ref
|
||||||
// disable. Ink (App.tsx:331-344) skips stdin.ref()/setRawMode(true) here
|
// or re-toggle — just cancel the pending disable. Ink (App.tsx:331-344)
|
||||||
// when isRawModeAlreadyEnabled; re-issuing them is a redundant ioctl AND
|
// skips stdin.ref()/setRawMode(true) when isRawModeAlreadyEnabled;
|
||||||
// an unbalanced ref() (the deferred disable would bail on refs>0 and never
|
// re-issuing them is a redundant ioctl AND an unbalanced ref() (the
|
||||||
// unref). setEncoding('utf8') and the data listener still run: encoding is
|
// deferred disable would bail on refs>0 and never unref). setEncoding is
|
||||||
// idempotent and the listener was detached synchronously in releaseRawMode.
|
// idempotent so it stays here.
|
||||||
const alreadyEnabled = state.pendingDisable;
|
const alreadyEnabled = state.pendingDisable;
|
||||||
state.pendingDisable = false;
|
state.pendingDisable = false;
|
||||||
if (!alreadyEnabled) {
|
if (!alreadyEnabled) {
|
||||||
@@ -1513,6 +1513,17 @@ function createStdinController(
|
|||||||
appCtx.setRawMode(true);
|
appCtx.setRawMode(true);
|
||||||
}
|
}
|
||||||
if (typeof (stdin as any).setEncoding === "function") (stdin as any).setEncoding("utf8");
|
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);
|
stdin.on("data", handleData);
|
||||||
}
|
}
|
||||||
state.refs++;
|
state.refs++;
|
||||||
@@ -1538,19 +1549,22 @@ function createStdinController(
|
|||||||
const state = getRawModeState(stdin);
|
const state = getRawModeState(stdin);
|
||||||
state.refs = Math.max(0, state.refs - 1);
|
state.refs = Math.max(0, state.refs - 1);
|
||||||
localRefs = Math.max(0, localRefs - 1);
|
localRefs = Math.max(0, localRefs - 1);
|
||||||
if (state.refs === 0) {
|
if (localRefs === 0) {
|
||||||
// Stop owning input SYNCHRONOUSLY on the last release, matching Ink's
|
// PER-CONTROLLER: stop THIS controller owning input SYNCHRONOUSLY when its
|
||||||
// clearInputState (App.tsx:212-216,357): reset the parser, cancel the
|
// own last useInput releases, matching Ink's clearInputState
|
||||||
// pending-escape flush, and detach the data/readable listeners NOW — so a
|
// (App.tsx:212-216,357): reset its parser, cancel its pending-escape flush,
|
||||||
// partial escape buffered before a same-render useInput swap cannot leak
|
// and detach its data/readable listeners NOW — so a partial escape buffered
|
||||||
// into the replacement. (A same-tick re-acquire re-attaches the listener
|
// before a same-render useInput swap cannot leak into the replacement. (A
|
||||||
// with a fresh parser; deferring this is the bug — the gated microtask
|
// same-tick re-acquire re-attaches the listener with a fresh parser.)
|
||||||
// below short-circuits when refs is back >0, so the reset never ran.)
|
// Gated on localRefs, not the shared refcount: another app on the same
|
||||||
|
// stdin keeps its own listener and parser intact.
|
||||||
inputParser.reset();
|
inputParser.reset();
|
||||||
clearPendingFlush();
|
clearPendingFlush();
|
||||||
stdin.off("readable", handleReadable);
|
stdin.off("readable", handleReadable);
|
||||||
stdin.off("data", handleData);
|
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
|
// 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
|
// the old before mounting the new, so refs briefly hits 0. Disabling
|
||||||
// synchronously would drop raw mode between the two mounts; the microtask
|
// synchronously would drop raw mode between the two mounts; the microtask
|
||||||
|
|||||||
Reference in New Issue
Block a user