Skip to content

chore: three small cleanups flagged in the v2.2.0 merge review - #2092

Merged
cliffhall merged 5 commits into
v2/mainfrom
v2/chore/2000-three-small-cleanups
Aug 23, 2026
Merged

chore: three small cleanups flagged in the v2.2.0 merge review#2092
cliffhall merged 5 commits into
v2/mainfrom
v2/chore/2000-three-small-cleanups

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #2000

Three independent items Copilot raised while reviewing the v2.2.0 milestone merge (#1993). None is worth its own card; each is a few lines.

1. clients/web/.npmignore's comment contradicted the root manifest

#1934 added clients/web/static to the root package.json "files" allowlist, but the explanatory comment still said the allowlist "restricts publishing to those two directories" (build/ and dist/) and that everything else under clients/web "stays out regardless". That was false — in a file that exists because a packaging contract was misread once already.

The comment now names all three directories and says why static/ ships: static/sandbox_proxy.html is a committed source file read from disk at runtime by server/sandbox-controller.ts as <runner dir>/../static/sandbox_proxy.html, so it must ship at exactly that path (#1859).

2. Unjustified as unknown as Response in useServers.test.tsx

The issue named one site; the file carried four. All are replaced with the preferred fix rather than a justification comment — a real Response over a real ReadableStream, so each double is structurally type-checked against the Response API:

Case Before After
!res.body guard { ok: true, body: null } new Response(null, { status: 200 }) — a Response built from null has a null .body
reader rejects mid-stream read: () => Promise.reject(…) a stream whose pull throws, so reader.read() rejects
two frames in one chunk hand-rolled getReader() double pull enqueues the chunk, then closes
priming comment + blocked second read hand-rolled getReader() double pull runs once per read, so the reads counting and the await secondRead park work identically

The last two keep their reads counters and their blocking semantics — pull is invoked once per read(), which is exactly what the doubles were emulating. This is the same pattern the #2006 CRLF tests in this file already use.

No test was changed in what it asserts; all 34 in the file still pass, and the file's coverage contribution is unchanged (the ≥90 gate passes).

3. scripts/smoke-web-app.mjs orphaned the test server on a readiness timeout

startMcpServer() returned { child, url } only once the announcement line matched. On the 30-second timeout — a child that is alive but never announces — it threw without handing the handle back, so mcpServer was unassigned, the caller's shutdown() could not stop it, and process.exit(1) left a live server possibly holding its port for a later run.

The child is now published to mcpServer the moment it is spawned, so teardown covers every throw path. The spawnError and exited paths never had the problem (the child is already gone there) but cost nothing to cover the same way.

Verification

npm run ci passes end to end from a clean install in the worktree — validate (incl. the three coverage/lockstep guards), the per-file ≥90 coverage gate, verify:build-gate, all smokes (smoke:web:app included), and the 115 Storybook play-function files.

No UI change, so no screenshots.

1. clients/web/.npmignore's explanatory comment still said the root "files"
   allowlist restricts publishing to build/ and dist/ and that everything else
   under clients/web "stays out regardless". #1934 added clients/web/static to
   that allowlist, so the claim was false — in a file that exists precisely
   because a packaging contract was misread once. Name static/ and say why it
   ships (read from disk at runtime by sandbox-controller.ts, at a path
   relative to the runner dir).

2. Replace the four unjustified `as unknown as Response` doubles in
   useServers.test.tsx with real Responses over real ReadableStreams: a
   null-body Response for the `!res.body` guard, a stream whose pull throws for
   the reader-rejects case, and pull-driven streams for the multi-frame and
   priming-comment cases (pull runs once per read, so the counting and the
   blocking second read behave exactly as the hand-rolled reader doubles did).
   The doubles are now structurally type-checked against the Response API.

3. scripts/smoke-web-app.mjs orphaned the MCP test server on a readiness
   timeout: startMcpServer() returned the child only once the announcement
   line matched, so a child that was alive but never announced left `mcpServer`
   unassigned, shutdown() could not stop it, and process.exit(1) left it
   holding its port. Publish the child to `mcpServer` the moment it is spawned,
   so every throw path is covered by teardown.

Closes #2000

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>
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Aug 23, 2026
@cliffhall
cliffhall requested a balanced review from Copilot August 23, 2026 16:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Addresses three cleanup items from the v2.2.0 merge review.

Changes:

  • Corrects the web package allowlist documentation.
  • Replaces unsafe test double casts with real Fetch API objects.
  • Makes the MCP smoke-test server reachable during timeout teardown.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
clients/web/.npmignore Documents all shipped web directories.
clients/web/src/test/core/react/useServers.test.tsx Uses real Response and ReadableStream instances.
scripts/smoke-web-app.mjs Publishes the child handle immediately for cleanup.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/smoke-web-app.mjs Outdated
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>
@cliffhall

Copy link
Copy Markdown
Member Author

Done — implemented as suggested, including the extraction.

Why it needed doing: the fix was unproven. smoke:web:app always receives the announcement on its happy path, so nothing in npm run ci exercised a child that stays alive through the readiness timeout — the exact 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.

What changed:

  • New scripts/lib/announced-child.mjs owns the spawn + readiness wait. Its contract is the invariant: the child is handed to onSpawn synchronously, before any waiting, so every throw path leaves the caller holding a stoppable handle. This mirrors the teardownWebServer extraction in prod-web-server.mjs, made for the same "the smoke exits immediately, so it cannot detect its own regression" reason.
  • New scripts/lib/announced-child.test.mjs, picked up by test:scripts (and therefore validate; verify:typecheck-coverage enforces that the glob still matches it).
  • startMcpServer() in smoke-web-app.mjs now delegates, publishing the child via onSpawn. Behavior is unchanged; the announced URL is still authoritative over the config port.

The tests drive real node -e children rather than spies, so the assertion is that a real process is reachable and killable rather than that a mock was called. The regression case uses a 300ms budget against a child sleeping 60s and asserts three things in order: the child was published before the wait, it is still alive when the throw lands (the bug's precondition — a dead child would prove nothing), and kill actually stops it. The early-exit path (asserting the child's own output reaches the message), the spawn-failure path, and the both-streams scan are covered alongside it.

npm run ci passes end to end from a clean install with these in place.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment thread scripts/lib/announced-child.test.mjs
Comment thread scripts/lib/announced-child.mjs
Two review findings from round 2, both correct.

1. The regression suite killed its child only after the assertions. If
   `assert.rejects` or either assertion failed, execution never reached the
   kill, leaving a 60-second child attached to the runner — a suite guarding a
   process leak would leak one itself, precisely on the run where the
   regression had come back. Teardown is now registered from `onSpawn` via
   `t.after`, so it runs on every path out of every case. That is the helper's
   own ownership rule applied to the tests; killing an already-exited child is
   a no-op, so the early-exit cases pay nothing for it.

2. Document the new shared helper, per the maintenance rules. AGENTS.md's
   scripts/lib tree entry now carries announced-child.mjs and what its contract
   buys, and the smoke:web:app paragraph records why the spawn/readiness step
   moved out of the smoke (same reason teardownWebServer left prod-web-server's
   stop()). README's test:scripts row names the new suite alongside
   resolve-node-bin's.

Not mirrored into .github/copilot-instructions.md: that file is a distillation
of the rules a reviewer cites against a diff, and deliberately excludes the
project-structure tree and tooling inventory.

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>
@cliffhall

Copy link
Copy Markdown
Member Author

Round 2 responses

1. Register teardown before awaiting the rejection — Fixed — you are right, and the irony was the point: a suite guarding a process leak would have leaked a 60-second child of its own, on exactly the run where the regression had come back (a failing assertion is when that child is most likely to still be alive).

Teardown is now registered from onSpawn via t.after, so it runs on every path out of every case, not just the success path. That is the helper's own ownership rule applied to the tests — the child is claimed the moment it exists, before anything can throw. A killOnTeardown(t, capture) factory does both jobs, so the early-exit and spawn-failure cases get the same protection for free (killing an already-exited child is a no-op).

The explicit kill + waitForExit inside the timeout case stays, because it is an assertion there rather than cleanup — it is what proves the published handle is actually stoppable. The t.after is then a no-op on that path.

2. Document the new helper — Done. Both updated in this PR:

  • AGENTS.md — the scripts/ tree entry now carries announced-child.mjs and states its contract (the child is published via onSpawn before the readiness wait, so teardown reaches it on every throw path — the timeout included, which is what orphaned a live server holding its port). The smoke:web:app paragraph records the extraction and why: the two mechanics it documents (both-streams scan, announced-URL-over-config-port) moved into the helper so the failure path became testable.
  • README.md — the test:scripts row now names announced-child.test.mjs alongside resolve-node-bin.test.mjs, with what it proves.

Deliberately not mirrored into .github/copilot-instructions.md: AGENTS.md defines that file as a distillation of the rules a reviewer cites against a diff, and explicitly lists the project-structure tree and tooling inventory as not review-relevant. A new scripts/lib helper is inventory, not a rule.

npm run ci is green end to end with these in place (validate, the ≥90 coverage gate, verify:build-gate, all seven smokes, and the 115 Storybook files).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

scripts/lib/announced-child.mjs:85

  • The readiness output is not checked after the final delay, so an announcement arriving during the last polling interval (for example, at 29.9s of the documented 30s budget) is incorrectly reported as a timeout. The same window also misclassifies a final-interval spawn error or exit. Check once at the deadline before throwing, or use a deadline loop that evaluates state after every wait.
    await delay(pollMs);
  }
  throw new Error(

…imeout

Copilot's suppressed round-3 comment is right. The attempt-counted loop read
the output and then slept, so nothing ever observed the last interval: an
announcement arriving at 29.9s of a 30s budget was reported as a timeout, and
a spawn error or early exit landing in that window was misattributed the same
way. Inherited from the original smoke, but wrong either way.

Replace it with a deadline loop that re-reads state after every wait, the last
one included, and clamp the final wait to the deadline so the budget is a real
bound rather than one poll longer.

The regression test sets pollMs === timeoutMs, which makes the distinction
deterministic rather than a race: the attempt-counted loop gets exactly one
check, at t≈0 before the child has finished starting, then throws; the deadline
loop re-reads after the wait and sees the announcement. The child announces
immediately, ~2s inside the window, so a loaded runner can't flip it. Verified
both directions — the test fails against the old loop and passes against the
new one — and `npm run test:scripts` was repeated to confirm it is stable under
the full 116-test parallel run (an earlier margin-based version of this test
was not).

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>
@cliffhall

Copy link
Copy Markdown
Member Author

Round 3 response — the suppressed comment (announced-child.mjs:85, final polling interval) was right, and is fixed in 6b4bf68.

The attempt-counted loop read the output and then slept, so nothing ever observed the last interval: an announcement at 29.9s of a 30s budget read as a timeout, and a spawn error or early exit landing in that window was misattributed the same way. Inherited from the original smoke, but wrong either way.

It is now a deadline loop that re-reads state after every wait, the last one included, with the final wait clamped to the deadline so the budget is a real bound rather than one poll longer.

On the test for it — the first version I wrote announced at ~350ms of a 500ms/250ms budget, i.e. it relied on a 100ms margin. That passed standalone and then failed inside npm run test:scripts, where 116 tests run in parallel and node startup eats the margin — so it would have been a flake, not a guard. The committed version sets pollMs === timeoutMs instead, which makes the distinction structural rather than timed: the attempt-counted loop gets exactly one check, at t≈0 before the child has finished starting, then throws; the deadline loop re-reads after the wait and sees it. The child announces immediately, ~2s inside the window.

Verified both directions (fails against the old loop, passes against the new) and repeated test:scripts three times under the full parallel run to confirm stability.

Full npm run ci stages all green: validate, the ≥90 coverage gate, verify:build-gate, all seven smokes, and the 115 Storybook files.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

@cliffhall cliffhall linked an issue Aug 23, 2026 that may be closed by this pull request
@cliffhall
cliffhall merged commit d459a08 into v2/main Aug 23, 2026
3 checks passed
@cliffhall
cliffhall deleted the v2/chore/2000-three-small-cleanups branch August 23, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Three small cleanups flagged in the v2.2.0 merge review

2 participants