fb43b6af8f
On the signal-exit teardown path signal-exit re-raises the signal
immediately after the callback returns ({alwaysLast:false}), so a
buffered async stream.write can be lost before the process dies.
teardown(true) already flushes show-cursor, leave-alt-screen and
disable-kitty synchronously via fs.writeSync, but the bracketed-paste
-disable escape \x1b[?2004l was still written with an async
stdout.write on both teardown sub-paths (usePaste's onScopeDispose
-> detach during originalUnmount(), and the stdin controller dispose
backstop). When dropped, the user's shell stays in bracketed-paste
mode and wraps later pastes in \x1b[200~ ... \x1b[201~.
Thread a sync flag through the paste teardown, mirroring kitty:
disableBracketedPaste(sync) writes via fs.writeSync(fd, ...) when sync;
the stdin controller dispose(sync) forwards it; teardown passes sync at
the dispose() call site. Because Vue's unmount runs detach (async, lost
on signal) before dispose() and zeroes the live count, dispose(sync)
re-issues paste-OFF synchronously whenever paste was ever enabled --
paste-OFF is idempotent, so the redundant write is harmless. The normal
(non-signal) unmount path stays async, unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
6.3 KiB
TypeScript
153 lines
6.3 KiB
TypeScript
// Sequential: drives the runtime's signal-exit teardown by emitting a real
|
|
// process signal (`process.emit("SIGINT")`). That goes through signal-exit's
|
|
// patched `process.emit`, which uses a PROCESS-GLOBAL singleton emitter that
|
|
// fires its `exit` handlers exactly ONCE per process — so this must not race a
|
|
// concurrent sibling that also mounts an interactive app and registers its own
|
|
// onExit handler. We also spy `process.kill` (process-global) to neutralize
|
|
// signal-exit's re-raise so the worker survives. Grouped here to document the
|
|
// global-state constraint, per CLAUDE.md.
|
|
//
|
|
// Bug (same failure class as cursor / alt-screen / kitty): on the signal-exit
|
|
// path signal-exit re-raises the signal IMMEDIATELY after the callback returns
|
|
// (`{alwaysLast:false}`), so a buffered ASYNC `stdout.write` of a terminal-
|
|
// restore escape can be lost before the process dies. `teardown(true)` already
|
|
// writes show-cursor / leave-alt-screen / disable-kitty SYNCHRONOUSLY
|
|
// (`fs.writeSync(fd, …)`). The bracketed-paste-disable escape `\x1b[?2004l`
|
|
// (control sequence: CSI ? 2004 l) was MISSED — it went out via async
|
|
// `stdout.write` on both signal sub-paths (usePaste's onScopeDispose → detach,
|
|
// and the stdin controller's dispose backstop). When dropped, the user's shell
|
|
// stays in bracketed-paste mode and wraps later pastes in `\x1b[200~…\x1b[201~`.
|
|
import { PassThrough } from "node:stream";
|
|
import * as fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { defineComponent } from "vue";
|
|
import { describe, test, expect, vi } from "vite-plus/test";
|
|
import { createApp, Text, usePaste } from "@vue-tui/runtime";
|
|
|
|
const PASTE_ON = "\x1b[?2004h";
|
|
const PASTE_OFF = "\x1b[?2004l"; // CSI ? 2004 l — bracketed-paste mode OFF
|
|
const SHOW_CURSOR = "\x1b[?25h";
|
|
|
|
function makeFakeStdin(): NodeJS.ReadStream {
|
|
const s = new PassThrough() as unknown as NodeJS.ReadStream;
|
|
Object.assign(s, {
|
|
isTTY: true,
|
|
setRawMode() {
|
|
return s;
|
|
},
|
|
setEncoding() {
|
|
return s;
|
|
},
|
|
ref() {},
|
|
unref() {},
|
|
});
|
|
return s;
|
|
}
|
|
|
|
// A TTY-ish stdout whose `.fd` is a REAL temp-file fd. This mirrors a real
|
|
// terminal — where the stream AND its numeric fd both point at the same tty —
|
|
// but lets the test SEPARATE the two write mechanisms the runtime uses on
|
|
// teardown:
|
|
// • async → `stdout.write(...)` → recorded in `asyncWrites`
|
|
// • sync → `fs.writeSync(stream.fd,…)` → lands in the temp file
|
|
// So a restore escape that appears in the temp file came through the SYNCHRONOUS
|
|
// path (the one that survives signal-exit's immediate re-raise); one that only
|
|
// appears in `asyncWrites` is the lost-on-signal async write.
|
|
function makeFdBackedStdout(): {
|
|
stdout: NodeJS.WriteStream;
|
|
asyncWrites: string[];
|
|
readSyncBytes: () => string;
|
|
cleanup: () => void;
|
|
} {
|
|
const filePath = path.join(os.tmpdir(), `vue-tui-sync-${process.pid}-${Date.now()}.bin`);
|
|
const fd = fs.openSync(filePath, "w+");
|
|
|
|
const inner = new PassThrough();
|
|
const asyncWrites: string[] = [];
|
|
const realWrite = inner.write.bind(inner);
|
|
const stdout = inner as unknown as NodeJS.WriteStream;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(stdout as any).write = (data: any, ...rest: any[]) => {
|
|
asyncWrites.push(String(data));
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return (realWrite as any)(String(data), ...rest);
|
|
};
|
|
Object.assign(stdout, { columns: 80, rows: 24, isTTY: true, fd });
|
|
|
|
return {
|
|
stdout,
|
|
asyncWrites,
|
|
readSyncBytes: () => fs.readFileSync(filePath).toString(),
|
|
cleanup: () => {
|
|
fs.closeSync(fd);
|
|
fs.rmSync(filePath, { force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
const PasteApp = defineComponent(() => {
|
|
usePaste(() => {});
|
|
return () => <Text>paste</Text>;
|
|
});
|
|
|
|
describe("bracketed-paste disable on signal exit", () => {
|
|
test("a signal-driven teardown writes the paste-OFF escape SYNCHRONOUSLY (Finding A parity)", async () => {
|
|
const { stdout, asyncWrites, readSyncBytes, cleanup } = makeFdBackedStdout();
|
|
const stdin = makeFakeStdin();
|
|
|
|
const app = createApp(PasteApp);
|
|
// interactive: true forces the signal-exit handler to register regardless of
|
|
// ambient CI/TTY detection (the resolved `interactive` flag gates it).
|
|
app.mount({ stdout, stdin, debug: false, exitOnCtrlC: false, interactive: true });
|
|
|
|
// Let usePaste's attach enable bracketed paste (writes \x1b[?2004h, async).
|
|
await new Promise<void>((r) => setTimeout(r, 60));
|
|
expect(asyncWrites.join("")).toContain(PASTE_ON);
|
|
|
|
// Drive the SIGNAL teardown path: process.emit goes through signal-exit's
|
|
// patched emit, which runs the runtime's onExit(() => teardown(true)).
|
|
// Neutralize the subsequent re-raise so the worker survives.
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
try {
|
|
process.emit("SIGINT", "SIGINT");
|
|
} finally {
|
|
killSpy.mockRestore();
|
|
}
|
|
|
|
const syncBytes = readSyncBytes();
|
|
cleanup();
|
|
|
|
// Sanity: the sync restore path ran at all (show-cursor flushed synchronously).
|
|
expect(syncBytes, "show-cursor must flush synchronously on signal").toContain(SHOW_CURSOR);
|
|
// The bug: the paste-OFF escape must ALSO go through the synchronous path,
|
|
// not only the async stdout.write that signal-exit's re-raise can drop.
|
|
expect(
|
|
syncBytes,
|
|
"\\x1b[?2004l must be written via fs.writeSync on the signal-exit path",
|
|
).toContain(PASTE_OFF);
|
|
});
|
|
|
|
test("the normal (non-signal) unmount still disables bracketed paste asynchronously", async () => {
|
|
const { stdout, asyncWrites, readSyncBytes, cleanup } = makeFdBackedStdout();
|
|
const stdin = makeFakeStdin();
|
|
|
|
const app = createApp(PasteApp);
|
|
app.mount({ stdout, stdin, debug: false, exitOnCtrlC: false, interactive: true });
|
|
|
|
await new Promise<void>((r) => setTimeout(r, 60));
|
|
expect(asyncWrites.join("")).toContain(PASTE_ON);
|
|
|
|
// Plain unmount() is the async teardown path — no behavior change here.
|
|
app.unmount();
|
|
await new Promise<void>((r) => setTimeout(r, 10));
|
|
|
|
const syncBytes = readSyncBytes();
|
|
cleanup();
|
|
|
|
// Paste-OFF still emitted, via the async stream.write (not the sync fd path).
|
|
expect(asyncWrites.some((w) => w.includes(PASTE_OFF))).toBe(true);
|
|
expect(syncBytes).not.toContain(PASTE_OFF);
|
|
});
|
|
});
|