`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>
225 lines
8.7 KiB
JavaScript
225 lines
8.7 KiB
JavaScript
/**
|
|
* Spawn a terminal app, wait for it to paint a marker, and then wait again to
|
|
* see whether it is still running (#2147).
|
|
*
|
|
* Extracted from `scripts/smoke-tui.mjs` for the same reason
|
|
* `announced-child.mjs` was extracted from `smoke-web-app.mjs` (#2000): a smoke
|
|
* script only ever exercises its own happy path, so the branch that matters
|
|
* most here — a child that paints the marker and *then* dies — is unreachable
|
|
* from the real TUI once it is fixed. Driving this from a test against a stub
|
|
* that paints and immediately exits 1 is what keeps the assertion honest.
|
|
*
|
|
* The defect it encodes against: `smoke:tui` used to resolve OK the instant the
|
|
* marker appeared and never looked at the child again. The TUI was exiting 1
|
|
* about 40ms later, every run, on every machine, and the smoke won that race
|
|
* and printed OK. So it asserted "painted once", while its own header claimed
|
|
* "boots and renders without crashing". Worse, the race cut both ways: had the
|
|
* `exit` event been processed first, the same run would have failed, which
|
|
* reads as flake rather than as the standing defect it was.
|
|
*
|
|
* Hence `surviveMs`. First paint is not survival, and nothing shorter than an
|
|
* explicit "still alive N ms later" check can tell the two apart.
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
|
|
/**
|
|
* Tail of a child's output, for quoting in a diagnostic. Slicing a terminal
|
|
* stream can land mid-CSI-sequence, so drop through the first newline rather
|
|
* than lead with an escape-code fragment that mangles the line after it.
|
|
*
|
|
* @param {string} output
|
|
* @param {number} [limit]
|
|
* @returns {string}
|
|
*/
|
|
export function outputTail(output, limit = 800) {
|
|
const tail = output.slice(-limit);
|
|
const nl = tail.indexOf("\n");
|
|
return output.length > limit && nl !== -1 ? tail.slice(nl + 1) : tail;
|
|
}
|
|
|
|
/**
|
|
* @param {object} opts
|
|
* @param {string} opts.command Executable to spawn.
|
|
* @param {string[]} [opts.args] Arguments for it.
|
|
* @param {object} [opts.spawnOptions] Merged into the `spawn` options
|
|
* (`stdio` is fixed — see below).
|
|
* @param {string} opts.marker Substring whose appearance in the
|
|
* child's output counts as first paint.
|
|
* @param {number} [opts.timeoutMs] Budget for first paint (default 15s).
|
|
* @param {number} [opts.surviveMs] How long after first paint the child
|
|
* must stay alive (default 2s).
|
|
* @param {number} [opts.exitGraceMs] SIGTERM → SIGKILL grace (default 5s).
|
|
* @param {number} [opts.drainMs] Post-exit pipe-drain cap (default 500ms).
|
|
* @param {(child: import("node:child_process").ChildProcess) => void} [opts.onSpawn]
|
|
* Called synchronously with the child, before any waiting — the caller's
|
|
* teardown handle on every failure path.
|
|
* @param {(message: string) => void} [opts.warn]
|
|
* @returns {Promise<{ code: 0 | 1, message: string, output: string }>}
|
|
* Resolves only after the child's streams have closed, so `output` is
|
|
* complete and the caller may safely remove a directory the child was using.
|
|
*/
|
|
export function runRenderSmoke({
|
|
command,
|
|
args = [],
|
|
spawnOptions = {},
|
|
marker,
|
|
timeoutMs = 15_000,
|
|
surviveMs = 2_000,
|
|
exitGraceMs = 5_000,
|
|
drainMs = 500,
|
|
onSpawn = () => {},
|
|
warn = (m) => console.warn(m),
|
|
}) {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(command, args, {
|
|
...spawnOptions,
|
|
// stdin is `ignore` (i.e. /dev/null) deliberately, even under a PTY: BSD
|
|
// `script` refuses to start when its own stdin is a pipe
|
|
// ("tcgetattr/ioctl: Operation not supported on socket"). It reads the
|
|
// immediate EOF and forwards one ^D into the pty before the app mounts,
|
|
// which is harmless — verified against the real TUI, which survives it.
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
// Publish before waiting, so every path below leaves the caller holding a
|
|
// stoppable handle rather than an orphan (the #2000 rule).
|
|
onSpawn(child);
|
|
|
|
let output = "";
|
|
let settled = false;
|
|
let finished = false;
|
|
let childExited = false;
|
|
let childClosed = false;
|
|
let markerAt = null;
|
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
let surviveTimer;
|
|
|
|
const t0 = Date.now();
|
|
const since = () => Date.now() - t0;
|
|
|
|
const finish = (code, message) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
resolve({
|
|
code,
|
|
message: typeof message === "function" ? message() : message,
|
|
output,
|
|
});
|
|
};
|
|
|
|
// Settle the verdict, then wait for the child to actually close before
|
|
// resolving (#1801). The caller's work dir doubles as the app's HOME, so it
|
|
// is still being written to when we signal; removing it while those writes
|
|
// land fails with ENOTEMPTY. Waiting on `close` rather than `exit` also
|
|
// covers a *spawn failure*, which emits `error` + `close` and never `exit`
|
|
// — and guarantees the pipes are drained, which is what makes the thunked
|
|
// messages below complete rather than truncated at the interesting line.
|
|
//
|
|
// Deliberately not `lib/child-cleanup.mjs`'s `stopChild()`: that resolves
|
|
// on the FIRST of exit/close, whereas this wait must keep going for `close`
|
|
// after `exit`, on a shorter re-armed deadline, for the drain.
|
|
const done = (code, message) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(renderTimer);
|
|
clearTimeout(surviveTimer);
|
|
if (childClosed) {
|
|
finish(code, message);
|
|
return;
|
|
}
|
|
const forceKill = setTimeout(() => child.kill("SIGKILL"), exitGraceMs);
|
|
|
|
let deadline;
|
|
const armDeadline = (ms, warning) => {
|
|
clearTimeout(deadline);
|
|
deadline = setTimeout(() => {
|
|
warn(`render-smoke — ${warning}`);
|
|
finish(code, message);
|
|
}, ms);
|
|
};
|
|
armDeadline(
|
|
exitGraceMs * 2,
|
|
`child did not close its output streams within ${exitGraceMs * 2}ms; giving up on the drain`,
|
|
);
|
|
|
|
// Once the child itself is gone only the pipe drain remains, so the wait
|
|
// drops to `drainMs`. `close` is bounded by whoever holds the stdio
|
|
// pipes, which can outlive the direct child: any descendant that
|
|
// inherited them keeps it pending.
|
|
const drainOnly = () => {
|
|
clearTimeout(forceKill);
|
|
armDeadline(
|
|
drainMs,
|
|
`child exited but held its output streams open for ${drainMs}ms (a descendant may have inherited them); giving up on the drain`,
|
|
);
|
|
};
|
|
if (childExited) drainOnly();
|
|
else child.once("exit", drainOnly);
|
|
|
|
child.once("close", () => {
|
|
clearTimeout(forceKill);
|
|
clearTimeout(deadline);
|
|
finish(code, message);
|
|
});
|
|
|
|
// Only signal a child that is still running: on the crash and
|
|
// spawn-failure paths there is nothing left to signal, and we are here
|
|
// purely to wait out the remaining `close`.
|
|
if (
|
|
child.exitCode === null &&
|
|
child.signalCode === null &&
|
|
!child.killed
|
|
) {
|
|
child.kill("SIGTERM");
|
|
}
|
|
};
|
|
|
|
const onData = (chunk) => {
|
|
output += chunk.toString();
|
|
if (markerAt !== null || !output.includes(marker)) return;
|
|
markerAt = since();
|
|
// First paint is not the verdict — it only starts the clock on the
|
|
// question this smoke actually asks.
|
|
surviveTimer = setTimeout(() => {
|
|
done(
|
|
0,
|
|
`rendered "${marker}" at ${markerAt}ms and was still running ${surviveMs}ms later`,
|
|
);
|
|
}, surviveMs);
|
|
};
|
|
|
|
child.stdout.on("data", onData);
|
|
child.stderr.on("data", onData);
|
|
|
|
// Registered before done()'s own one-shot listener, so this always runs
|
|
// first — done() can trust `childClosed` even when called from a handler.
|
|
child.on("close", () => {
|
|
childClosed = true;
|
|
});
|
|
|
|
child.on("exit", (code, signal) => {
|
|
childExited = true;
|
|
if (settled) return;
|
|
const how = signal ? `signal ${signal}` : `code ${code}`;
|
|
done(1, () =>
|
|
markerAt === null
|
|
? `child exited (${how}) before rendering "${marker}"\n${outputTail(output)}`
|
|
: `child rendered "${marker}" at ${markerAt}ms and then exited (${how}) ` +
|
|
`${since() - markerAt}ms later, inside the ${surviveMs}ms survival window — ` +
|
|
`it painted one frame, it did not run\n${outputTail(output)}`,
|
|
);
|
|
});
|
|
|
|
child.on("error", (err) => {
|
|
done(1, `failed to spawn ${command}: ${err.message}`);
|
|
});
|
|
|
|
const renderTimer = setTimeout(() => {
|
|
done(
|
|
1,
|
|
() =>
|
|
`child did not render "${marker}" within ${timeoutMs}ms\n${outputTail(output)}`,
|
|
);
|
|
}, timeoutMs);
|
|
});
|
|
}
|