diff --git a/packages/runtime-tests/integration/composables/paste-disable-destroyed-stdout.test.tsx b/packages/runtime-tests/integration/composables/paste-disable-destroyed-stdout.test.tsx new file mode 100644 index 0000000..7f4f1db --- /dev/null +++ b/packages/runtime-tests/integration/composables/paste-disable-destroyed-stdout.test.tsx @@ -0,0 +1,136 @@ +/** + * B10 — the bracketed-paste-disable write (`\x1b[?2004l`) must be skipped when + * stdout is already destroyed/ended at teardown. + * + * On teardown, when bracketed paste was active, vue-tui writes the paste-OFF + * escape to stdout. Those writes were gated ONLY on `stdout.isTTY` — which stays + * cached-truthy after `destroy()`/`end()` — not on writability. On a teardown + * where stdout is already gone, the unguarded `.write()` hits a dead stream + * (ERR_STREAM_DESTROYED / write-after-end). Ink guards the same write on BOTH + * isTTY AND `!destroyed && !writableEnded` (App.tsx canWriteToStdout, + * lines 620/633-635). + * + * The runtime has two `?2004l` sites — the `setBracketedPasteMode(false)` disable + * branch (reached at teardown via usePaste's onScopeDispose → detach) and the + * stdin controller's `dispose()` backstop. Both must skip a dead stdout. + */ +import { PassThrough } from "node:stream"; +import { defineComponent } from "vue"; +import { describe, test, expect } from "vite-plus/test"; +import { createApp, Text, usePaste } from "@vue-tui/runtime"; + +const PASTE_ON = "\x1b[?2004h"; +const PASTE_OFF = "\x1b[?2004l"; + +// A TTY-ish stdout that mirrors a real terminal torn down underneath us: after +// hardDestroy() `isTTY` stays cached-truthy while the stream is no longer +// writable. It RECORDS every `?2004l` write attempt and whether the stream was +// already dead at the time — so the test asserts the runtime never even ATTEMPTS +// the paste-disable write on a dead stdout (the observable contract), rather than +// relying on whether a particular call site swallows the resulting throw. +function makeRecordingTtyStream(): NodeJS.WriteStream & { + chunks: string[]; + pasteOffWhileDead: number; + hardDestroy(): void; +} { + const inner = new PassThrough(); + const s = inner as unknown as NodeJS.WriteStream & { + chunks: string[]; + pasteOffWhileDead: number; + hardDestroy(): void; + }; + let dead = false; + s.chunks = []; + s.pasteOffWhileDead = 0; + inner.on("data", (chunk: Buffer) => s.chunks.push(chunk.toString())); + + const realWrite = inner.write.bind(inner); + s.write = ((data: string | Uint8Array, ...rest: unknown[]) => { + const str = String(data); + if (dead && str.includes(PASTE_OFF)) { + // The bug: a paste-disable write attempted on a destroyed stream. In a + // real terminal this throws ERR_STREAM_DESTROYED; reproduce that here. + s.pasteOffWhileDead++; + const err = new Error("write after destroy") as NodeJS.ErrnoException; + err.code = "ERR_STREAM_DESTROYED"; + throw err; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (realWrite as any)(str, ...rest); + }) as NodeJS.WriteStream["write"]; + + s.hardDestroy = () => { + dead = true; + inner.destroy(); + }; + + Object.assign(s, { columns: 80, rows: 24, isTTY: true }); + 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; + }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (s as any).ref = () => {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (s as any).unref = () => {}; + return s; +} + +const PasteApp = defineComponent(() => { + usePaste(() => {}); + return () => paste; +}); + +describe("bracketed-paste disable on destroyed stdout", () => { + test("teardown skips the paste-OFF write when stdout was destroyed while paste mode active", async () => { + const stdout = makeRecordingTtyStream(); + const stderr = makeRecordingTtyStream(); + const stdin = makeFakeStdin(); + + const app = createApp(PasteApp); + app.mount({ stdout, stdin, stderr, debug: false, exitOnCtrlC: false }); + + // Let the initial render + usePaste's attach (enables paste mode, writes + // \x1b[?2004h) settle. + await new Promise((r) => setTimeout(r, 60)); + expect(stdout.chunks.join("")).toContain(PASTE_ON); + + // Terminal torn down underneath us BEFORE teardown. isTTY stays truthy. + stdout.hardDestroy(); + + // Teardown must not even ATTEMPT the paste-OFF write on a dead stdout, at + // either site (usePaste detach + dispose backstop), and must not throw. + expect(() => app.unmount()).not.toThrow(); + expect( + stdout.pasteOffWhileDead, + "no \\x1b[?2004l write may be attempted on a destroyed stdout", + ).toBe(0); + }); + + test("live stdout still receives the paste-OFF escape at teardown", async () => { + const stdout = makeRecordingTtyStream(); + const stderr = makeRecordingTtyStream(); + const stdin = makeFakeStdin(); + + const app = createApp(PasteApp); + app.mount({ stdout, stdin, stderr, debug: false, exitOnCtrlC: false }); + + await new Promise((r) => setTimeout(r, 60)); + expect(stdout.chunks.join("")).toContain(PASTE_ON); + + // Do NOT destroy: a live stdout must still get the paste-disable escape. + app.unmount(); + + expect(stdout.chunks.join("")).toContain(PASTE_OFF); + }); +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index de3c754..e2592ef 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -1444,6 +1444,18 @@ function createStdinController( const FLUSH_DELAY = 20; // ms, matching Ink let bracketedPasteModeCount = 0; + // Write the bracketed-paste-disable escape only when stdout can still take it. + // `isTTY` stays cached-truthy after a stream is destroy()ed/end()ed, so gating + // the paste-OFF write on isTTY alone throws ERR_STREAM_DESTROYED on a teardown + // where stdout is already gone. Mirror Ink's `canWriteToStdout` guard + // (App.tsx:620/633-635): isTTY AND `!destroyed && !writableEnded`. Matches the + // render-level writeBestEffort helper, which isn't in this function's scope. + function disableBracketedPaste() { + const stdout = appCtx.stdout; + if (!stdout.isTTY || stdout.destroyed || stdout.writableEnded) return; + stdout.write("\x1b[?2004l"); + } + function clearPendingFlush() { if (pendingFlushTimer !== undefined) { clearTimeout(pendingFlushTimer); @@ -1670,8 +1682,8 @@ function createStdinController( } else { if (bracketedPasteModeCount === 0) return; bracketedPasteModeCount--; - if (bracketedPasteModeCount === 0 && appCtx.stdout.isTTY) { - appCtx.stdout.write("\x1b[?2004l"); + if (bracketedPasteModeCount === 0) { + disableBracketedPaste(); } } }, @@ -1725,8 +1737,8 @@ function createStdinController( stdin.off("readable", handleReadable); stdin.off("data", handleData); emitter.off("input", focusInputListener); - if (bracketedPasteModeCount > 0 && appCtx.stdout.isTTY) { - appCtx.stdout.write("\x1b[?2004l"); + if (bracketedPasteModeCount > 0) { + disableBracketedPaste(); } bracketedPasteModeCount = 0; if (appCtx.isRawModeSupported) {