inspector/scripts/lib/announced-child.mjs
cliffhall 09b35489aa test(scripts): cover the readiness-timeout teardown path (#2000 review)
Copilot's review is right that the fix was unproven: smoke:web:app's happy path
always receives the announcement, so `npm run ci` never exercised a child that
stays alive *through* the readiness timeout — exactly the case that orphaned a
live server holding its port. A regression there would be silent in CI and
would surface only as a stray process on a later run.

Extract the spawn/readiness ownership into scripts/lib/announced-child.mjs
(the same move prod-web-server.mjs made for teardownWebServer, and for the same
reason), and add scripts/lib/announced-child.test.mjs under `test:scripts`.
The tests drive real `node -e` children rather than spies, so the assertion is
that a real process is reachable and killable, not that a mock was called: the
timeout case asserts the child was published before the wait, is still alive
when the throw lands, and is actually stopped by kill. The early-exit and
spawn-failure paths are covered alongside it, plus the both-streams scan.

smoke-web-app.mjs keeps its behavior — startMcpServer() now delegates the
spawn and publishes the child via `onSpawn`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 12:22:24 -04:00

85 lines
3.7 KiB
JavaScript

/**
* Spawn a child process and wait for it to announce readiness on its output.
*
* Extracted from `scripts/smoke-web-app.mjs` so the failure path can be tested
* (#2000). A smoke script only ever exercises its own happy path: the real MCP
* test server always announces, so nothing in `npm run ci` proved that a child
* which stays alive *through* the readiness timeout is still reachable by the
* caller's teardown. That is precisely the case that used to orphan a live
* server holding its port, and it is invisible to the smoke itself.
*
* The ownership rule this encodes: the child is handed to `onSpawn` the moment
* it exists, **before** the readiness wait, so every throw path below leaves
* the caller holding a stoppable handle. Returning it only on success is what
* made the timeout leak — `spawnError` and `exited` were never affected (the
* child is already gone there), but they cost nothing to cover the same way.
*
* Both stdio channels are piped and scanned: a child that announces with
* `console.error` is missed entirely by a stdout-only scan, which then times
* out with an empty diagnostic. Piping both also keeps the child's noise out of
* the caller's output while still making it available in the failure message.
*/
import { spawn } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
/**
* @param {object} opts
* @param {string} opts.command Executable to spawn.
* @param {string[]} opts.args Arguments for it.
* @param {string} [opts.cwd] Working directory.
* @param {RegExp} opts.pattern Matched against the accumulated output;
* the first match ends the wait.
* @param {(child: import("node:child_process").ChildProcess) => void} opts.onSpawn
* Called synchronously with the child immediately after spawn, before any
* waiting. This is the caller's teardown handle.
* @param {string} opts.what Noun used in error messages ("MCP test server").
* @param {number} [opts.timeoutMs] Readiness budget (default 30s).
* @param {number} [opts.pollMs] Poll interval (default 250ms).
* @returns {Promise<{ child: import("node:child_process").ChildProcess, match: RegExpMatchArray }>}
*/
export async function startAnnouncedChild({
command,
args,
cwd,
pattern,
onSpawn,
what,
timeoutMs = 30_000,
pollMs = 250,
}) {
const child = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
// Publish before waiting — this is the whole point of the helper.
onSpawn(child);
let out = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (out += d));
let exited = false;
let spawnError = null;
// A spawn failure (e.g. an unbuilt/renamed entry) emits `error`, NOT `exit` —
// and with no `error` listener Node throws it uncaught, replacing the caller's
// diagnostic with a raw stack. `close` is listened to alongside `exit` for the
// same reason: it fires in cases `exit` does not, so a child that dies without
// an exit event can't leave the poll below spinning for the full budget.
child.on("error", (err) => (spawnError = err));
child.on("exit", () => (exited = true));
child.on("close", () => (exited = true));
const attempts = Math.max(1, Math.ceil(timeoutMs / pollMs));
for (let attempt = 0; attempt < attempts; attempt++) {
const match = out.match(pattern);
if (match) return { child, match };
if (spawnError) {
throw new Error(`could not spawn the ${what}: ${spawnError.message}`);
}
if (exited) throw new Error(`${what} exited early:\n${out}`);
await delay(pollMs);
}
throw new Error(
`${what} did not start within ${Math.round(timeoutMs / 1000)}s:\n${out}`,
);
}