fix(scripts): make smoke:tui assert the TUI survives its first frame (#2147)
`smoke:tui` spawned the launcher with `stdio: ["ignore", …]`, waited for
"MCP Servers" in the output, printed OK, and SIGTERMed. It never looked at
the child again.
The child was already dead. Ink mounts `useInput`, `useInput` needs raw
mode, and raw mode is a property of the file descriptor — with stdin on
/dev/null the TUI painted one frame and exited 1 about 40ms later with
"Raw mode is not supported on the current process.stdin". Measured here:
first frame at 396ms, exit 1 at 433ms. The smoke won that race and
reported success, so what it asserted was *first paint*, not survival —
contradicting its own header ("boots and renders without crashing").
This is not environment-specific: `stdio: ["ignore", …]` makes the child's
stdin /dev/null whether or not the developer's terminal is a TTY, so this
smoke has never verified a running TUI on any machine. And the race cut
both ways — an `exit` processed first failed the run, which reads as flake
rather than as the standing defect.
Two pieces:
- `scripts/lib/pty.mjs` gives the child a real terminal via `script(1)`,
no dependency. The three flavors are not interchangeable (BSD takes
argv; util-linux takes one shell string plus `-e`; busybox that string
without `-e`), so the invocation is built and unit-tested rather than
written inline. A platform with no `script(1)` skips loudly instead of
running without one, which would be a guaranteed failure rather than a
weaker check.
- `scripts/lib/render-smoke.mjs` is the fix: the child must still be
running `surviveMs` (default 2s) after first paint. Without that, a
future harness change reintroduces the same false green and nothing
notices — which is why it lives in a module `test:scripts` drives
against a stub that paints the marker and immediately exits. Today's
harness passes that stub; this one fails it, with a diagnostic that
distinguishes "died after painting" from "died before painting".
The smoke additionally fails if the raw-mode error appears in a passing
run, so a future Ink that degrades to a warning instead of throwing can't
ship a TUI with no keyboard input past this gate.
Verified end to end: with a `script` shim that allocates no pty, the smoke
now fails with "it painted one frame, it did not run"; with the real one
the TUI is still running 2s after its first frame and never logs the
raw-mode error.
The `process.env.CI` skip stays, but its comment is corrected: the PTY
removes the technical blocker it used to cite, so the smoke is local-only
by decision now, not by capability. Whether it joins GitHub CI is out of
scope here (#2146 is keeping the other local-only smokes out on purpose).
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
5c26da7433
commit
b6baa30d7b
1 changed files with 757 additions and 192 deletions
136
scripts/lib/pty.mjs
Normal file
136
scripts/lib/pty.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Wrap a command so it runs attached to a pseudoterminal (#2147).
|
||||
*
|
||||
* `smoke:tui` spawns the Ink TUI with `stdio: ["ignore", …]`, which makes the
|
||||
* child's stdin `/dev/null`. Ink mounts `useInput`, `useInput` needs raw mode,
|
||||
* and raw mode is a property of the *file descriptor* — so the child threw
|
||||
* "Raw mode is not supported on the current process.stdin" and exited 1 about
|
||||
* 40ms after painting its first frame, on every machine, TTY or not. A parent
|
||||
* cannot assert raw mode on a child's behalf; the fd has to be a terminal.
|
||||
*
|
||||
* `script(1)` allocates one, with no dependency to install. Measured here on
|
||||
* darwin: without it the TUI paints at ~400ms and is dead at ~440ms; with it
|
||||
* the same TUI is still running at 6s and never logs the raw-mode error.
|
||||
*
|
||||
* The invocation differs by flavor, which is why this is a module rather than
|
||||
* a string literal at the call site:
|
||||
*
|
||||
* BSD (macOS) script -q /dev/null <cmd> <args...> argv, no shell
|
||||
* util-linux script -qec "<cmd args...>" /dev/null ONE shell string
|
||||
* busybox script -qc "<cmd args...>" /dev/null as above, no -e
|
||||
*
|
||||
* The `-c` flavors take a single command *string* that the child shell parses,
|
||||
* so every word has to be quoted on the way in — an unquoted temp path with a
|
||||
* space would otherwise split into two arguments.
|
||||
*
|
||||
* `-e` (util-linux) makes `script` exit with the child's status rather than its
|
||||
* own. busybox has no such flag, so under it a crashed child reports 0. That is
|
||||
* survivable and deliberately not worked around: the smoke's assertion is the
|
||||
* child's `exit` *event*, not its code — the code only sharpens the diagnostic.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
/** Platforms whose `script` takes the BSD argv form. */
|
||||
const BSD_PLATFORMS = new Set(["darwin", "freebsd", "openbsd", "netbsd"]);
|
||||
|
||||
/**
|
||||
* Quote one argument for a POSIX shell command string.
|
||||
*
|
||||
* Single quotes are literal for everything except a single quote itself, which
|
||||
* has to leave the quoted run, emit an escaped quote, and re-enter it.
|
||||
*
|
||||
* @param {string} arg
|
||||
* @returns {string}
|
||||
*/
|
||||
export function shellQuote(arg) {
|
||||
return `'${String(arg).replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which `script` flavor to build for.
|
||||
*
|
||||
* The version output is consulted first because it is the only *evidence*:
|
||||
* "linux" does not imply util-linux (Alpine ships busybox, whose `script`
|
||||
* rejects `-e`). Platform is the fallback for the case where `script --version`
|
||||
* tells us nothing, which is itself informative — BSD `script` has no
|
||||
* `--version` and fails the probe.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.platform `process.platform`.
|
||||
* @param {string} [opts.versionOutput] Combined stdout+stderr of `script --version`.
|
||||
* @returns {"bsd" | "util-linux" | "busybox" | null} null when unsupported.
|
||||
*/
|
||||
export function scriptFlavorFor({ platform, versionOutput = "" }) {
|
||||
if (/util-linux/i.test(versionOutput)) return "util-linux";
|
||||
if (/busybox/i.test(versionOutput)) return "busybox";
|
||||
if (BSD_PLATFORMS.has(platform)) return "bsd";
|
||||
// Linux with an unidentifiable `script` is overwhelmingly util-linux; guess
|
||||
// it rather than skip the smoke. A wrong guess fails loudly with `script`'s
|
||||
// own usage error in the captured output, which is diagnosable — silently
|
||||
// skipping is the outcome this issue exists to stop rewarding.
|
||||
if (platform === "linux") return "util-linux";
|
||||
// win32 and anything else: no `script(1)`. `node-pty` is the portable answer
|
||||
// if this smoke ever has to run there.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the PTY-wrapped spawn arguments for a command.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.command
|
||||
* @param {string[]} [opts.args]
|
||||
* @param {"bsd" | "util-linux" | "busybox"} opts.flavor
|
||||
* @returns {{ command: string, args: string[] }}
|
||||
*/
|
||||
export function ptyCommand({ command, args = [], flavor }) {
|
||||
if (flavor === "bsd") {
|
||||
return { command: "script", args: ["-q", "/dev/null", command, ...args] };
|
||||
}
|
||||
if (flavor === "util-linux" || flavor === "busybox") {
|
||||
const line = [command, ...args].map(shellQuote).join(" ");
|
||||
const flags = flavor === "util-linux" ? "-qec" : "-qc";
|
||||
return { command: "script", args: [flags, line, "/dev/null"] };
|
||||
}
|
||||
throw new Error(`unknown script(1) flavor: ${flavor}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the local `script(1)` for its flavor.
|
||||
*
|
||||
* @param {(cmd: string, args: string[]) => { stdout?: string, stderr?: string }} [runner]
|
||||
* Injected for tests; defaults to a real `spawnSync`.
|
||||
* @returns {string} Combined output, or "" if `script` could not be run at all.
|
||||
*/
|
||||
export function probeScriptVersion(
|
||||
runner = (cmd, args) =>
|
||||
spawnSync(cmd, args, { encoding: "utf8", timeout: 5000 }),
|
||||
) {
|
||||
try {
|
||||
const r = runner("script", ["--version"]) ?? {};
|
||||
return `${r.stdout ?? ""}${r.stderr ?? ""}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PTY wrapper for this machine, or null if none is available.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.platform]
|
||||
* @param {() => string} [opts.probe]
|
||||
* @returns {{ flavor: string, wrap: (spec: { command: string, args?: string[] }) => { command: string, args: string[] } } | null}
|
||||
*/
|
||||
export function resolvePtyWrapper({
|
||||
platform = process.platform,
|
||||
probe = probeScriptVersion,
|
||||
} = {}) {
|
||||
const flavor = scriptFlavorFor({ platform, versionOutput: probe() });
|
||||
if (!flavor) return null;
|
||||
return {
|
||||
flavor,
|
||||
wrap: ({ command, args = [] }) => ptyCommand({ command, args, flavor }),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue