216a7021a0
* 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>
132 lines
4.0 KiB
TypeScript
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;
|