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:
@@ -0,0 +1,140 @@
|
||||
// Sequential: these tests assert on the raw-mode controller's deferred teardown,
|
||||
// which is driven by queueMicrotask + a real 20ms pending-escape flush timer.
|
||||
// File-level parallelism can perturb the microtask/timer interleaving relative to
|
||||
// other apps mounting/unmounting on the shared process, so we keep them serial.
|
||||
|
||||
import { PassThrough } from "node:stream";
|
||||
import { defineComponent, h, nextTick, shallowRef } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { createApp, Text, useInput } from "@vue-tui/runtime";
|
||||
import { makeFakeWritable } from "./test-streams.ts";
|
||||
|
||||
// A fake stdin that, like a real PTY, reflects the last setRawMode call in `isRaw`
|
||||
// AND records every setRawMode call in `history`. The shared test-streams stdin
|
||||
// does not track `isRaw`, which hides the prevRaw re-capture corruption (FIX B):
|
||||
// on a real terminal, a deferred setRawMode(false) leaves isRaw=true, so a same-
|
||||
// tick re-acquire snapshots prevRaw=true.
|
||||
function makeRawTrackingStdin(): {
|
||||
stream: NodeJS.ReadStream;
|
||||
rawMode: { current: boolean; history: boolean[] };
|
||||
} {
|
||||
const rawMode = { current: false, history: [] as boolean[] };
|
||||
const s = new PassThrough() as unknown as NodeJS.ReadStream;
|
||||
Object.assign(s, {
|
||||
isTTY: true,
|
||||
isRaw: false,
|
||||
setRawMode(this: NodeJS.ReadStream, mode: boolean) {
|
||||
(this as { isRaw: boolean }).isRaw = mode;
|
||||
rawMode.current = mode;
|
||||
rawMode.history.push(mode);
|
||||
return this;
|
||||
},
|
||||
setEncoding(this: NodeJS.ReadStream) {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
(s as { ref?: () => void }).ref = () => {};
|
||||
(s as { unref?: () => void }).unref = () => {};
|
||||
return { stream: s, rawMode };
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
// FIX A (P6): a same-tick useInput swap must not leak buffered parser state into
|
||||
// the replacement. Ink clears input state synchronously on the last release
|
||||
// (clearInputState — App.tsx:212-216) and defers only the terminal toggle.
|
||||
// Port of Ink test/components.tsx:855-909.
|
||||
test.sequential("swapping useInput components clears pending parser state (no leaked partial escape)", async () => {
|
||||
const receivedByB: string[] = [];
|
||||
const showA = shallowRef(true);
|
||||
|
||||
const StepA = defineComponent(() => {
|
||||
useInput(() => {});
|
||||
return () => h(Text, null, () => "A");
|
||||
});
|
||||
|
||||
const StepB = defineComponent(() => {
|
||||
useInput((input) => {
|
||||
receivedByB.push(input);
|
||||
});
|
||||
return () => h(Text, null, () => "B");
|
||||
});
|
||||
|
||||
const Root = defineComponent(() => {
|
||||
return () => (showA.value ? h(StepA, { key: "a" }) : h(StepB, { key: "b" }));
|
||||
});
|
||||
|
||||
const stdout = makeFakeWritable();
|
||||
const stderr = makeFakeWritable();
|
||||
const { stream: stdin } = makeRawTrackingStdin();
|
||||
|
||||
const app = createApp(Root);
|
||||
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
|
||||
await nextTick();
|
||||
|
||||
// Buffer a partial escape sequence (CSI start, no final byte). The parser
|
||||
// holds "\x1b[" as pending and schedules a 20ms flush timer.
|
||||
stdin.emit("data", "\x1b[");
|
||||
|
||||
// Swap StepA -> StepB in the same tick: A unmounts (refs -> 0, queues the
|
||||
// deferred disable), B mounts (refs 0 -> 1, re-attaches the data listener).
|
||||
showA.value = false;
|
||||
await nextTick();
|
||||
|
||||
// Let the queued microtask AND the 20ms pending-escape flush run.
|
||||
await Promise.resolve();
|
||||
await wait(40);
|
||||
|
||||
// Ink: the replacement receives nothing — the stale "\x1b[" must not leak.
|
||||
expect(receivedByB).toEqual([]);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
// FIX B (P7): after a sync setRawMode(false)->(true) swap then teardown, the
|
||||
// terminal must be restored (final setRawMode call is false). The previous
|
||||
// prevRaw-restore would re-capture prevRaw=true while raw mode was still active
|
||||
// (deferred disable), leaving the terminal in raw mode on exit. Ink unconditionally
|
||||
// setRawMode(false) on disable (App.tsx:218-222).
|
||||
test.sequential("final raw-mode teardown restores the terminal (setRawMode(false)) after a sync re-acquire swap", async () => {
|
||||
const active = shallowRef(true);
|
||||
|
||||
// Two useInput components: dropping one and adding another in the same tick is
|
||||
// a release(false)+acquire(true) cycle while the deferred disable is pending.
|
||||
const Listener = defineComponent(() => {
|
||||
useInput(() => {});
|
||||
return () => h(Text, null, () => "x");
|
||||
});
|
||||
|
||||
const Root = defineComponent(() => {
|
||||
return () => (active.value ? h(Listener, { key: "a" }) : h(Listener, { key: "b" }));
|
||||
});
|
||||
|
||||
const stdout = makeFakeWritable();
|
||||
const stderr = makeFakeWritable();
|
||||
const { stream: stdin, rawMode } = makeRawTrackingStdin();
|
||||
|
||||
const app = createApp(Root);
|
||||
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
|
||||
await nextTick();
|
||||
|
||||
expect(rawMode.current).toBe(true);
|
||||
|
||||
// Sync swap: release -> acquire in the same tick. With the old code,
|
||||
// acquireRawMode re-captures prevRaw from the still-true isRaw -> prevRaw=true.
|
||||
active.value = false;
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
|
||||
// Raw mode must still be on across the swap (the deferred toggle is the whole
|
||||
// reason for the microtask — never break this).
|
||||
expect(rawMode.current).toBe(true);
|
||||
|
||||
// Now tear down. The final disable must leave the terminal NOT in raw mode.
|
||||
app.unmount();
|
||||
await Promise.resolve();
|
||||
await wait(5);
|
||||
|
||||
expect(rawMode.current).toBe(false);
|
||||
expect(rawMode.history.at(-1)).toBe(false);
|
||||
});
|
||||
Reference in New Issue
Block a user