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) {