fix(runtime): guard writeToStdout/writeToStderr against post-teardown writes (Ink parity, G20) (#49)
* fix(runtime): guard writeToStdout/writeToStderr against post-teardown writes (Ink parity, G20) Return early if teardownStarted, mirroring Ink ink.tsx:673/702, so a write after unmount (e.g. a stray useStdout().write or console.log routed through writeToStdout after teardown) cannot run clear()/write/restore on an already-torn-down renderer and corrupt the restored terminal state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(parity): ledger — G20 pr-open, reconcile G19 merged Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -53,8 +53,8 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor
|
|||||||
| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | merged | `fix/parity-border-dim` | #44 |
|
| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | merged | `fix/parity-border-dim` | #44 |
|
||||||
| G17 | render-lifecycle-reconciler | Screen-reader live-path edges: <Static> still grid-painted (Ink linearizes, skipStaticElements:false) + empty SR frame gets a trailing newline (Ink writes wrapped output directly) | P3 | merged | `fix/parity-sr-edges` | #45 |
|
| G17 | render-lifecycle-reconciler | Screen-reader live-path edges: <Static> still grid-painted (Ink linearizes, skipStaticElements:false) + empty SR frame gets a trailing newline (Ink writes wrapped output directly) | P3 | merged | `fix/parity-sr-edges` | #45 |
|
||||||
| G18 | render-lifecycle-reconciler | No signal-based teardown — terminal corrupted on SIGINT/SIGTERM/SIGHUP (Ink signal-exit at mount) | P1 | merged | `fix/parity-signal-teardown` | #47 |
|
| G18 | render-lifecycle-reconciler | No signal-based teardown — terminal corrupted on SIGINT/SIGTERM/SIGHUP (Ink signal-exit at mount) | P1 | merged | `fix/parity-signal-teardown` | #47 |
|
||||||
| G19 | box-layout-border | Dynamic removal of most yoga style props does not reset to default (stale layout) | P2 | pr-open | `fix/parity-yoga-reset` | #48 |
|
| G19 | box-layout-border | Dynamic removal of most yoga style props does not reset to default (stale layout) | P2 | merged | `fix/parity-yoga-reset` | #48 |
|
||||||
| G20 | stdout-stderr-stdin-size-cursor | writeToStdout/writeToStderr lack an isUnmounted/teardown guard (post-teardown writes corrupt terminal) | P2 | todo | — | — |
|
| G20 | stdout-stderr-stdin-size-cursor | writeToStdout/writeToStderr lack an isUnmounted/teardown guard (post-teardown writes corrupt terminal) | P2 | pr-open | `fix/parity-write-after-unmount` | #49 |
|
||||||
| G21 | text-wrap-transform | Nested <Transform> in <Text> gets hardcoded index 0 vs child sibling position (squash path) | P3 | todo | — | — |
|
| G21 | text-wrap-transform | Nested <Transform> in <Text> gets hardcoded index 0 vs child sibling position (squash path) | P3 | todo | — | — |
|
||||||
| G22 | app-exit-instances-animation-sr | SR role dedup inherits grandparent role; Ink dedups only vs immediate parent | P3 | todo | — | — |
|
| G22 | app-exit-instances-animation-sr | SR role dedup inherits grandparent role; Ink dedups only vs immediate parent | P3 | todo | — | — |
|
||||||
| G23 | app-exit-instances-animation-sr | <Transform> under <Box> SR-joins children with newline; Ink concatenates | P3 | todo | — | — |
|
| G23 | app-exit-instances-animation-sr | <Transform> under <Box> SR-joins children with newline; Ink concatenates | P3 | todo | — | — |
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* G20 — writeToStdout/writeToStderr must no-op after teardown (Ink parity).
|
||||||
|
*
|
||||||
|
* Ink ink.tsx:673/702 returns early when the instance is unmounted so that a
|
||||||
|
* write after teardown cannot run clear()/write/restore on an already-torn-down
|
||||||
|
* renderer and corrupt the terminal. vue-tui must mirror that guard.
|
||||||
|
*/
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import { expect, test } from "vite-plus/test";
|
||||||
|
import { createApp, Text, useStdout, useStderr } from "@vue-tui/runtime";
|
||||||
|
|
||||||
|
function makeTtyStream(): NodeJS.WriteStream & { chunks: string[] } {
|
||||||
|
const s = new PassThrough() as unknown as NodeJS.WriteStream & { chunks: string[] };
|
||||||
|
Object.assign(s, { columns: 80, rows: 24, isTTY: true, chunks: [] as string[] });
|
||||||
|
s.on("data", (chunk: Buffer) => s.chunks.push(chunk.toString()));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeStdin(): NodeJS.ReadStream {
|
||||||
|
const s = new PassThrough() as unknown as NodeJS.ReadStream;
|
||||||
|
Object.assign(s, {
|
||||||
|
isTTY: true,
|
||||||
|
setRawMode() {
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
setEncoding() {
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(s as any).ref = () => {};
|
||||||
|
(s as any).unref = () => {};
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("writeToStdout: pre-unmount write works, post-unmount write is suppressed", async () => {
|
||||||
|
const stdout = makeTtyStream();
|
||||||
|
const stderr = makeTtyStream();
|
||||||
|
const stdin = makeFakeStdin();
|
||||||
|
|
||||||
|
let writeRef: ((data: string) => void) | undefined;
|
||||||
|
|
||||||
|
const App = defineComponent(() => {
|
||||||
|
const { write } = useStdout();
|
||||||
|
writeRef = write;
|
||||||
|
return () => <Text>frame</Text>;
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.mount({ stdout, stdin, stderr, debug: false, exitOnCtrlC: false });
|
||||||
|
|
||||||
|
// Wait for initial render to settle
|
||||||
|
await new Promise<void>((r) => setTimeout(r, 60));
|
||||||
|
|
||||||
|
// Control: a write BEFORE unmount should produce output
|
||||||
|
stdout.chunks.length = 0;
|
||||||
|
writeRef!("before-unmount\n");
|
||||||
|
const beforeChunks = stdout.chunks.join("");
|
||||||
|
expect(beforeChunks, "pre-unmount write must produce output").toContain("before-unmount");
|
||||||
|
|
||||||
|
// Now unmount (triggers teardown)
|
||||||
|
app.unmount();
|
||||||
|
|
||||||
|
// Clear captured chunks and attempt a write AFTER unmount
|
||||||
|
stdout.chunks.length = 0;
|
||||||
|
writeRef!("after-unmount\n");
|
||||||
|
const afterChunks = stdout.chunks.join("");
|
||||||
|
|
||||||
|
// The guard must prevent any write to stdout after teardown
|
||||||
|
expect(
|
||||||
|
afterChunks,
|
||||||
|
`post-unmount write must be suppressed; got: ${JSON.stringify(afterChunks)}`,
|
||||||
|
).not.toContain("after-unmount");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writeToStderr: pre-unmount write works, post-unmount write is suppressed", async () => {
|
||||||
|
const stdout = makeTtyStream();
|
||||||
|
const stderr = makeTtyStream();
|
||||||
|
const stdin = makeFakeStdin();
|
||||||
|
|
||||||
|
let writeRef: ((data: string) => void) | undefined;
|
||||||
|
|
||||||
|
const App = defineComponent(() => {
|
||||||
|
const { write } = useStderr();
|
||||||
|
writeRef = write;
|
||||||
|
return () => <Text>frame</Text>;
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.mount({ stdout, stdin, stderr, debug: false, exitOnCtrlC: false });
|
||||||
|
|
||||||
|
// Wait for initial render to settle
|
||||||
|
await new Promise<void>((r) => setTimeout(r, 60));
|
||||||
|
|
||||||
|
// Control: a write BEFORE unmount should produce output on stderr
|
||||||
|
stderr.chunks.length = 0;
|
||||||
|
writeRef!("before-unmount-err\n");
|
||||||
|
const beforeChunks = stderr.chunks.join("");
|
||||||
|
expect(beforeChunks, "pre-unmount stderr write must produce output").toContain(
|
||||||
|
"before-unmount-err",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now unmount (triggers teardown)
|
||||||
|
app.unmount();
|
||||||
|
|
||||||
|
// Clear captured chunks and attempt a write AFTER unmount
|
||||||
|
stderr.chunks.length = 0;
|
||||||
|
writeRef!("after-unmount-err\n");
|
||||||
|
const afterChunks = stderr.chunks.join("");
|
||||||
|
|
||||||
|
// The guard must prevent any write to stderr after teardown
|
||||||
|
expect(
|
||||||
|
afterChunks,
|
||||||
|
`post-unmount stderr write must be suppressed; got: ${JSON.stringify(afterChunks)}`,
|
||||||
|
).not.toContain("after-unmount-err");
|
||||||
|
});
|
||||||
@@ -481,6 +481,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeToStdout(data: string) {
|
function writeToStdout(data: string) {
|
||||||
|
// Mirror Ink ink.tsx:673: return early after teardown so a late write
|
||||||
|
// (e.g. a stray useStdout().write after unmount) cannot run
|
||||||
|
// clear()/write/restore on an already-torn-down renderer.
|
||||||
|
if (teardownStarted) return;
|
||||||
if (debug) {
|
if (debug) {
|
||||||
stdout.write(data + frameState.fullStaticOutput + frameState.lastOutput);
|
stdout.write(data + frameState.fullStaticOutput + frameState.lastOutput);
|
||||||
return;
|
return;
|
||||||
@@ -500,6 +504,9 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeToStderr(data: string) {
|
function writeToStderr(data: string) {
|
||||||
|
// Mirror Ink ink.tsx:702: return early after teardown so a late write
|
||||||
|
// cannot corrupt the restored terminal state.
|
||||||
|
if (teardownStarted) return;
|
||||||
if (debug) {
|
if (debug) {
|
||||||
stderr.write(data);
|
stderr.write(data);
|
||||||
stdout.write(frameState.fullStaticOutput + frameState.lastOutput);
|
stdout.write(frameState.fullStaticOutput + frameState.lastOutput);
|
||||||
|
|||||||
Reference in New Issue
Block a user