Repository navigation
chore: three small cleanups flagged in the v2.2.0 merge review #2092
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
031991c
chore: three small cleanups from the v2.2.0 merge review
cliffhall 09b3548
test(scripts): cover the readiness-timeout teardown path (#2000 review)
cliffhall 9cade95
test(scripts): kill the probe child on every path; document the helper
cliffhall 6b4bf68
fix(scripts): observe the final polling interval before declaring a t…
cliffhall d186f09
Merge branch 'v2/main' into v2/chore/2000-three-small-cleanups
cliffhall File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| /** | ||
| * 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}`, | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| /** | ||
| * Tests for the spawn/readiness ownership helper (#2000). | ||
| * | ||
| * The invariant under test is the one a smoke script can never check itself: | ||
| * a child that stays alive *through* the readiness timeout must still be | ||
| * reachable by the caller's teardown. `smoke:web:app`'s happy path always gets | ||
| * the announcement, so a regression here would be silent in `npm run ci` and | ||
| * would surface only as a stray process holding a port on a later run. | ||
| * | ||
| * Every case drives a real `node -e` child rather than a spy, so the assertion | ||
| * is about a real process being killable, not about a mock's call log. | ||
| */ | ||
|
|
||
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { startAnnouncedChild } from "./announced-child.mjs"; | ||
|
|
||
| /** Wait for a real child to exit (or resolve immediately if it already has). */ | ||
| function waitForExit(child) { | ||
| if (child.exitCode !== null || child.signalCode !== null) | ||
| return Promise.resolve(); | ||
| return new Promise((resolve) => child.once("close", resolve)); | ||
| } | ||
|
|
||
| test("hands the child to onSpawn before waiting, and returns the match", async () => { | ||
| let seen = null; | ||
| const { child, match } = await startAnnouncedChild({ | ||
| command: process.execPath, | ||
| args: ["-e", 'console.error("listening at http://127.0.0.1:9999")'], | ||
| pattern: /listening at (http:\/\/\S+)/i, | ||
| onSpawn: (c) => (seen = c), | ||
| what: "probe", | ||
| timeoutMs: 10_000, | ||
| pollMs: 25, | ||
| }); | ||
| assert.equal( | ||
| seen, | ||
| child, | ||
| "onSpawn received the same child that was returned", | ||
| ); | ||
| assert.equal(match[1], "http://127.0.0.1:9999"); | ||
| child.kill("SIGKILL"); | ||
| await waitForExit(child); | ||
| }); | ||
|
|
||
| test("scans stdout as well as stderr", async () => { | ||
| const { child, match } = await startAnnouncedChild({ | ||
| command: process.execPath, | ||
| args: ["-e", 'console.log("ready on 4242")'], | ||
| pattern: /ready on (\d+)/, | ||
| onSpawn: () => {}, | ||
| what: "probe", | ||
| timeoutMs: 10_000, | ||
| pollMs: 25, | ||
| }); | ||
| assert.equal(match[1], "4242"); | ||
| child.kill("SIGKILL"); | ||
| await waitForExit(child); | ||
| }); | ||
|
|
||
| test("a non-announcing child is still reachable and killable after the timeout", async () => { | ||
| // The regression: a child that is alive but never announces. The throw must | ||
| // not be the only thing that happens — the caller must already hold the | ||
| // handle, or `process.exit(1)` leaves this running. | ||
| let published = null; | ||
| await assert.rejects( | ||
| startAnnouncedChild({ | ||
| // Sleeps well past the budget and prints nothing matching. | ||
| command: process.execPath, | ||
| args: ["-e", "setTimeout(() => {}, 60000)"], | ||
|
cliffhall marked this conversation as resolved.
|
||
| pattern: /never going to match/, | ||
| onSpawn: (c) => (published = c), | ||
| what: "probe", | ||
| timeoutMs: 300, | ||
| pollMs: 25, | ||
| }), | ||
| /probe did not start within/, | ||
| ); | ||
|
|
||
| assert.ok(published, "the child was published before the readiness wait"); | ||
| assert.equal( | ||
| published.exitCode, | ||
| null, | ||
| "and it is still alive, as in the bug", | ||
| ); | ||
|
|
||
| published.kill("SIGKILL"); | ||
| await waitForExit(published); | ||
| assert.ok( | ||
| published.exitCode !== null || published.signalCode !== null, | ||
| "teardown could actually stop it", | ||
| ); | ||
| }); | ||
|
|
||
| test("reports a child that exits before announcing, with its output", async () => { | ||
| let published = null; | ||
| await assert.rejects( | ||
| startAnnouncedChild({ | ||
| command: process.execPath, | ||
| args: ["-e", 'console.error("boom: bad config"); process.exit(3)'], | ||
| pattern: /never going to match/, | ||
| onSpawn: (c) => (published = c), | ||
| what: "probe", | ||
| timeoutMs: 10_000, | ||
| pollMs: 25, | ||
| }), | ||
| (err) => | ||
| /probe exited early/.test(err.message) && | ||
| /boom: bad config/.test(err.message), | ||
| ); | ||
| assert.ok(published, "published even on the early-exit path"); | ||
| await waitForExit(published); | ||
| }); | ||
|
|
||
| test("reports a spawn failure rather than throwing it uncaught", async () => { | ||
| await assert.rejects( | ||
| startAnnouncedChild({ | ||
| command: "definitely-not-an-executable-2000", | ||
| args: [], | ||
| pattern: /never/, | ||
| onSpawn: () => {}, | ||
| what: "probe", | ||
| timeoutMs: 10_000, | ||
| pollMs: 25, | ||
| }), | ||
| /could not spawn the probe:/, | ||
| ); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.