Two findings, both of which quietly undo the PR.
1. `SMOKE_TUI_SURVIVE_MS` was unvalidated. `??` guards only nullish, so
an env var set to the empty string reaches `Number("")` -> 0 and a typo
reaches NaN; `setTimeout` fires immediately on both. The survival check
would then resolve before the child could possibly have died, printing
"still running 0ms later" and asserting nothing — the exact first-paint
false green this PR removes, restored by an empty environment variable,
on a green run.
Fixed at the module rather than only at the env-parse site, so every
caller is covered: `normalizeMs` (same shape as child-cleanup.mjs's
`normalizeGraceMs`, matching an existing policy rather than inventing a
second one) applied to all four durations before anything reads them,
which also stops the messages interpolating "NaNms". Defaults moved to
an exported DEFAULTS so the smoke and the module cannot drift.
`surviveMs: 0` is deliberately NOT honored as "skip the wait": no
caller wants that, and it is indistinguishable from the typo.
2. (suppressed comment) `spawnSync` REPORTS ENOENT in `result.error` — it
does not throw — so `probeScriptVersion`'s try/catch read a missing
binary as "ran, printed nothing". On linux that empty output fell
through to the util-linux guess, and the smoke then hard-failed
spawning a binary that does not exist instead of taking the documented
skip.
`probeScriptVersion` now returns `{ available, output }`, and
`resolvePtyWrapper` returns `{ ok: true, flavor, wrap }` or
`{ ok: false, reason }` — the two unavailability causes deserve
different skip messages, since "no script(1) on PATH" and "nobody has
verified this platform's invocation" send the reader elsewhere. A
non-zero exit is explicitly not unavailability: BSD `script` has no
`--version` and answers with its usage, which is the evidence the
flavor fallback reads.
Tests for both, since each finding lives entirely in a branch no happy
path reaches: the normalizeMs table (empty string, typo, undefined, -1,
Infinity, a numeric string, 0), an end-to-end run proving a
`surviveMs: Number("")` paint-then-die stub still fails, the
`result.error` ENOENT and timeout shapes, and both `resolvePtyWrapper`
failure reasons.
Verified: `SMOKE_TUI_SURVIVE_MS="" npm run smoke:tui` reports "still
running 2000ms later"; with PATH blanked, resolvePtyWrapper returns
`{ok: false, reason: "no `script(1)` on PATH"}` rather than guessing.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
180 lines
7.8 KiB
JavaScript
180 lines
7.8 KiB
JavaScript
/**
|
|
* 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 (`script -q FILE cmd args…`).
|
|
*
|
|
* Deliberately just these two. darwin is verified empirically here; freebsd is
|
|
* where darwin's implementation comes from and shares its usage. **netbsd and
|
|
* openbsd are excluded on purpose** — the BSDs do not agree on this, NetBSD's
|
|
* `script` takes the command through `-c` — and nobody has run either here. A
|
|
* guessed invocation is worse than none: it fails at `script` startup, which
|
|
* this smoke would then report as a TUI crash. They fall through to `null` and
|
|
* the smoke skips, saying so. Adding a `-c` flavor for them is a small change
|
|
* for whoever can actually verify it on the box.
|
|
*/
|
|
const BSD_PLATFORMS = new Set(["darwin", "freebsd"]);
|
|
|
|
/**
|
|
* 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 (no `script(1)` at all), and any platform whose invocation nobody has
|
|
// verified — aix, sunos, netbsd, openbsd. The caller skips loudly rather than
|
|
// guessing. `node-pty` is the portable answer if this 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)`: is it there, and what does it say about itself?
|
|
*
|
|
* **`spawnSync` does not throw on ENOENT** — it *returns* `{ error }` with
|
|
* empty output. So a `try`/`catch` alone cannot tell "no `script` on this box"
|
|
* from "a `script` that printed nothing", and collapsing both to `""` is
|
|
* actively harmful: on linux the empty output falls through to the
|
|
* `util-linux` guess, and `smoke:tui` then hard-fails spawning a binary that
|
|
* does not exist, instead of taking the documented skip. Hence the `available`
|
|
* flag rather than a bare string. The `catch` stays for a runner that really
|
|
* does throw (an injected one, or a future spawn shape).
|
|
*
|
|
* A non-zero exit is NOT unavailability: BSD `script` has no `--version` and
|
|
* answers `illegal option -- -` plus its usage on stderr — which is exactly the
|
|
* evidence `scriptFlavorFor` reads. Only `error` means "could not run it".
|
|
*
|
|
* @param {(cmd: string, args: string[]) => { stdout?: string, stderr?: string, error?: Error }} [runner]
|
|
* Injected for tests; defaults to a real `spawnSync`.
|
|
* @returns {{ available: boolean, output: string }}
|
|
*/
|
|
export function probeScriptVersion(
|
|
runner = (cmd, args) =>
|
|
spawnSync(cmd, args, { encoding: "utf8", timeout: 5000 }),
|
|
) {
|
|
try {
|
|
const r = runner("script", ["--version"]);
|
|
// `error` covers ENOENT, EACCES and the timeout; a missing result at all
|
|
// means the runner told us nothing, which is not evidence of a working one.
|
|
if (!r || r.error) return { available: false, output: "" };
|
|
return { available: true, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
|
|
} catch {
|
|
return { available: false, output: "" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a PTY wrapper for this machine, or say why there isn't one.
|
|
*
|
|
* The two ways it can be unavailable are worth telling apart in the skip
|
|
* message — "you have no `script(1)`" and "nobody has verified this platform's
|
|
* invocation" send the reader somewhere different — so the failure carries a
|
|
* `reason` rather than being a bare `null` the caller has to narrate.
|
|
*
|
|
* @param {object} [opts]
|
|
* @param {string} [opts.platform]
|
|
* @param {() => { available: boolean, output: string }} [opts.probe]
|
|
* @returns {{ ok: true, flavor: string, wrap: (spec: { command: string, args?: string[] }) => { command: string, args: string[] } }
|
|
* | { ok: false, reason: string }}
|
|
*/
|
|
export function resolvePtyWrapper({
|
|
platform = process.platform,
|
|
probe = probeScriptVersion,
|
|
} = {}) {
|
|
const { available, output } = probe();
|
|
if (!available) {
|
|
return { ok: false, reason: "no `script(1)` on PATH" };
|
|
}
|
|
const flavor = scriptFlavorFor({ platform, versionOutput: output });
|
|
if (!flavor) {
|
|
return {
|
|
ok: false,
|
|
reason: `no verified \`script(1)\` invocation for ${platform}`,
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
flavor,
|
|
wrap: ({ command, args = [] }) => ptyCommand({ command, args, flavor }),
|
|
};
|
|
}
|