Files
vue-tui/packages/runtime-tests/integration/pty/helpers/term.ts
T
Yunfei He 216a7021a0 fix(runtime): restore terminal on signal exit via signal-exit (Ink parity, G18, HIGH) (#47)
* fix(runtime): restore terminal on signal exit via signal-exit (Ink parity, G18)

Previously nothing routed a process signal to teardown(): SIGINT-as-signal,
SIGTERM or SIGHUP killed the process with the cursor hidden, the alternate
screen active and raw mode on, leaving the terminal corrupted.

Mirror Ink (ink.tsx:426): register signal-exit's onExit(teardown,
{alwaysLast:false}) at interactive mount, storing the unsubscribe fn, and
call it first thing in teardown() (ink.tsx:765) so the handler is removed on
unmount()/exit() and can't leak or double-run. teardown() stays idempotent
(teardownStarted guard) so a signal-triggered teardown plus a later unmount
won't double-run, and we don't prevent the process from exiting. Only the
live interactive, non-debug mount registers — render-to-string /
non-interactive paths never touch process signal handlers; registration is
guarded against double-registration.

Uses signal-exit v4 (named onExit export; ships ESM + types, so no
@types/signal-exit needed). PTY test sends SIGINT/SIGTERM/SIGHUP to a mounted
alt-screen app and asserts the captured output ends with show-cursor
(\x1b[?25h) + leave-alt-screen (\x1b[?1049l).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Review follow-ups (3 fixes): register signal-exit whenever interactive
(drop the !debug gate so debug-but-interactive apps, which still enter the
alt-screen/hide the cursor, restore on signal — Ink ink.tsx:426); add
!teardownStarted to the registration so a spent app instance does not
re-register on a same-instance remount (the next unmount() returns early at
the teardownStarted guard before it could unsubscribe — a leak); and make
the PTY test prove the SIGNAL drove teardown (fixture never self-unmounts, so
restore bytes can only come from the signal path) with a debug-mode signal
test, an exit-anchored waitForOutput drain, and a bounded retry for the
async-flush race under saturated runners.

Review follow-ups (2 fixes): synchronous restore flush on signal — the
signal-exit teardown path now writes the restore escapes (show-cursor,
leave-alt-screen, disable-kitty) via fs.writeSync to the stdout fd so they
reach the terminal before signal-exit re-raises the signal (a buffered async
stream.write could be lost on abrupt exit); the normal unmount path keeps async
writes. Removed the config-wide retry:3 from vitest.pty.config.ts (it masked
the whole PTY suite) and scoped a retry:2 to the signal-teardown describe only,
for the residual parent-side node-pty onData read-race under a saturated runner.

* chore(parity): ledger — G18 pr-open

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 06:04:51 +08:00

132 lines
4.0 KiB
TypeScript

import process from "node:process";
import { createRequire } from "node:module";
import path from "node:path";
import url from "node:url";
const require = createRequire(import.meta.url);
const { spawn } = require("node-pty") as typeof import("node-pty");
const fixturesDir = url.fileURLToPath(new URL("../fixtures", import.meta.url));
const term = (fixture: string, args: string[] = []) => {
let resolve: (value?: unknown) => void;
let reject: (error?: Error) => void;
const exitPromise = new Promise((resolve2, reject2) => {
resolve = resolve2;
reject = reject2;
});
// Resolves with the raw exit info (code + signal) no matter how the process
// dies — used by signal-teardown tests where a SIGTERM/SIGINT kill is the
// expected outcome and a non-zero/signalled exit must not reject.
let exitInfoResolve: (info: { exitCode: number; signal?: number }) => void;
const exitInfoPromise = new Promise<{ exitCode: number; signal?: number }>((r) => {
exitInfoResolve = r;
});
let readyResolve: () => void;
const readyPromise = new Promise<void>((r) => {
readyResolve = r;
});
// Pending output-watchers: each resolves once the accumulated output matches
// its predicate. node-pty can fire onExit BEFORE the final onData chunk is
// delivered, so trailing bytes written during teardown (cursor restore,
// leave-alt-screen) may arrive after the exit event — especially under CI
// contention. Tests that assert on those bytes must wait for them, not for
// exit. Checked on every onData chunk below.
const outputWatchers = new Set<() => void>();
const env: Record<string, string> = {
...(process.env as Record<string, string>),
NODE_NO_WARNINGS: "1",
CI: "false",
FORCE_COLOR: "3",
};
// First arg is often the desired rows count for viewport tests
const rowsArg = args.length > 0 ? Number(args[0]) : NaN;
const rows = Number.isFinite(rowsArg) && rowsArg > 0 ? rowsArg : 24;
const ps = spawn("node", ["--import=tsx", path.join(fixturesDir, `${fixture}.tsx`), ...args], {
name: "xterm-color",
cols: 100,
rows,
cwd: fixturesDir,
env,
});
const result = {
write(input: string) {
void readyPromise.then(() => {
ps.write(input);
});
},
// Send a process signal to the child once it has signalled readiness, so
// signal-driven teardown is exercised against a fully mounted app.
kill(signal: string) {
void readyPromise.then(() => {
ps.kill(signal);
});
},
output: "",
waitForExit: async () => exitPromise,
waitForExitInfo: async () => exitInfoPromise,
// Resolve once the accumulated output satisfies `predicate`, rejecting after
// `timeoutMs`. Use this (not waitForExitInfo) when asserting on bytes the
// child emits during teardown right before exit, which node-pty may deliver
// after the exit event.
waitForOutput: async (predicate: (output: string) => boolean, timeoutMs = 10000) =>
new Promise<void>((res, rej) => {
const check = () => {
if (predicate(result.output)) {
outputWatchers.delete(check);
clearTimeout(timer);
res();
return true;
}
return false;
};
const timer = setTimeout(() => {
outputWatchers.delete(check);
rej(
new Error(
`waitForOutput timed out after ${timeoutMs}ms. Output:\n${JSON.stringify(result.output)}`,
),
);
}, timeoutMs);
if (check()) return;
outputWatchers.add(check);
}),
};
ps.onData((data) => {
result.output += data;
if (result.output.includes("__READY__")) {
readyResolve();
}
for (const watcher of outputWatchers) {
watcher();
}
});
ps.onExit(({ exitCode, signal }) => {
exitInfoResolve({ exitCode, signal });
if (exitCode === 0) {
resolve();
return;
}
reject(new Error(`Process exited with non-zero exit code: ${exitCode}`));
});
return result;
};
export default term;