* 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>
Fifth CI run failed on ~15 resize/throttle/flush tests that pass locally. Root
cause: the GitHub runner sets CI=true, and vue-tui computes
`interactive = !isInCi && isTTY`. With CI=true, interactive is false, so the
resize listener, cursor control, and ANSI erases are never wired up — exactly
the behavior those tests assert. Locally CI is unset, so they passed (the
local-vs-CI trap, reproducible with `CI=true vp test run`).
Set env CI:"false" in both vitest configs. The PTY child helpers already force
CI=false per-spawn for this reason; the in-process suite (and the testing
harness's render()) needs the same. Verified: `CI=true vp run ci` on a fresh
checkout (no dist) now exits 0 with all 756 + 110 tests passing.
The fourth CI run failed broadly: ~15 timing-sensitive tests (throttle, flush,
resize clear-counts, rapid-input ordering) failed under sequence.concurrent on
the 4-core ubuntu runner. These assert render/commit counts driven by the
~32ms commit throttle; in-file concurrency starves them of wall-clock when many
share few cores. It passed locally only because dev machines have more cores —
the classic "works on my 12-core mac" trap.
Remove sequence.concurrent from both the main and PTY configs; keep
fileParallelism (pool: forks), which is the proven, stable win (PTY suite still
~3x faster than serial). The it.sequential/describe.sequential markers and
context-local expect become harmless no-ops under serial in-file execution.
Verified cold (no dist): vp run ci exits 0, 0 lint warnings, all tests pass.
With resize rendering now synchronous, no PTY test depends on wall-clock
timing, so concurrent execution is safe. Enable sequence.concurrent: true.
Each test already spawns its own isolated PTY subprocess (or in-process app)
with no shared state.
Verified stable: 12/12 runs green under sequence.concurrent with forks capped
to 4 (mimicking a 4-core CI runner) — the configuration that reliably flaked
before the resize fix. No it.sequential opt-outs are needed.
The PTY suite was the CI wall-clock bottleneck, run serially via
fileParallelism:false. The original reason for serializing was a node-pty
constraint — it needs child_process.fork(), not worker_threads — but that only
dictates the pool TYPE, not single-file execution. Each test already spawns its
own isolated PTY subprocess (helpers/term.ts, run.ts: no shared ports, temp
files, or mutable globals; cwd is the read-only fixtures dir), so files
parallelize safely.
Set pool:"forks" explicitly (the real node-pty requirement) and
fileParallelism:true. Measured: the PTY suite drops ~37s -> ~13s (~3x), and the
full `vp run ci` graph drops ~41s -> ~20s cold (no task cache, no prebuilt
dist). Verified stable across 9 isolated PTY runs (incl. maxForks capped to 4
to mimic a 4-core CI runner) and 3 cold full-graph runs — 110 PTY tests pass
every time, zero flakes. testTimeout stays 15s (slowest test ~2s) to absorb CPU
contention on smaller runners.
node-pty 1.1.0's POSIX_SPAWN_CLOEXEC_DEFAULT flag fails on macOS 26 (Tahoe).
Beta.13 fixes this. Rewrote helpers to match Ink's node-pty architecture,
removed python pty-spawn and force-tty workarounds, added check-pty guard
that skips all 82 tests when node-pty is unavailable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>