From 5e25639130370778ae4e886ecf22ce8ee67d639d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 13:10:21 +0000 Subject: [PATCH 1/3] test(cli): hoist the serve-probe child-lifecycle attribution and pin it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more e2e files here fetched a spawned `os serve` with no read of the child's fate on the failing path: `child.on('exit')` fed the READINESS promise only, so a death after readiness reached vitest as a bare `TypeError: fetch failed` with no exit code, no stdout and no stderr. The idiom that repaired the fourth file is hoisted into `test/helpers/serve-process.ts` as `probeThroughChild()` and all four files now consume it, so the family has one definition rather than four copies — and one pin, `serve-probe-child-attribution.test.ts`, which drives a real child that accepts the whole request and then FINs. No skip, no todo, no quarantine, no timeout bump. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- packages/cli/test/helpers/serve-process.ts | 237 +++++++++++ ...serve-mcp-capability-collision.e2e.test.ts | 129 ++++-- .../test/serve-mcp-stdio-answers.e2e.test.ts | 61 ++- ...ve-node-env-production-default.e2e.test.ts | 214 ++-------- .../serve-probe-child-attribution.test.ts | 371 ++++++++++++++++++ .../test/serve-process-child-env.e2e.test.ts | 48 ++- 6 files changed, 823 insertions(+), 237 deletions(-) create mode 100644 packages/cli/test/serve-probe-child-attribution.test.ts diff --git a/packages/cli/test/helpers/serve-process.ts b/packages/cli/test/helpers/serve-process.ts index 49b5d9ec05..37deedfd2d 100644 --- a/packages/cli/test/helpers/serve-process.ts +++ b/packages/cli/test/helpers/serve-process.ts @@ -927,3 +927,240 @@ export function runServe( child.on('exit', () => finish()); }); } + +// ───────────────────────────────────────────────────────────────────────── +// CHILD-LIFECYCLE ATTRIBUTION FOR AN HTTP PROBE AGAINST A SPAWNED `os serve` +// +// ## The defect this section removes +// +// A file here boots `os serve`, waits for its banner, and then `fetch`es it. +// Every one of them fed `child.on('exit')` into the READINESS promise ONLY, so +// once readiness had settled a later death was invisible: the rejection reached +// vitest as a bare `TypeError: fetch failed` with no exit code, no stdout and no +// stderr. One such occurrence was recorded in a merge-group run and could not be +// attributed at all — the probe had discarded the only two artefacts that decide +// between "the child died" and "a live child dropped a connection". +// +// ## What the recorded signature actually said +// +// Three shapes measured side by side on this container, with the same request +// the origin probes send: +// +// nothing listening on the port ECONNREFUSED, and NO socket object +// (bytesWritten/bytesRead both undefined) +// listener accepts, then FINs UND_ERR_SOCKET "other side closed", +// bytesWritten 321, bytesRead 0 +// server torn down mid-request UND_ERR_SOCKET "other side closed", +// bytesWritten 345, bytesRead 0 +// +// `bytesWritten: 349, bytesRead: 0` — what the merge-group run recorded — says +// the connection was ESTABLISHED, the whole request was delivered, and the peer +// closed it without answering. Only the second and third match; the FIRST is +// what a genuine readiness race looks like. So the child was serving when the +// request arrived, and something ended the connection after it. ⛔ The repair is +// therefore not a longer wait and ⛔ not a bigger timeout. +// +// It matters that a silent death is reachable at all: there is no +// `process.on('uncaughtException')` and no `'unhandledRejection'` handler +// anywhere in this repo's product source, so a late async throw inside the child +// — or an OOM kill on a six-shard CI box — takes the process down with no HTTP +// answer and exactly this client-side signature. +// +// ## The bound, and what it can and cannot hide +// +// ⛔ The retry is not the first move and cannot be reached before the child has +// been asked whether it is alive. On a transport failure the probe waits up to +// `settleMs` for the child's `exit` event — the FIN reaches the client on the +// network's schedule and `exit` arrives on the event loop's, so reading +// `exitCode` at the instant `fetch` rejects reports a dead child as ALIVE — and +// then: +// +// • child EXITED ⇒ ⛔ never retried. Fails at once, naming the exit code and +// signal and quoting the child's whole stdout and stderr. +// • failure is NOT `UND_ERR_SOCKET` ⇒ ⛔ never retried. Rethrown with the +// child's output attached. ECONNREFUSED (nothing listening), a timeout, a +// DNS or a TLS fault each still fail on their first occurrence. +// • child ALIVE and `UND_ERR_SOCKET` ⇒ absorbed, up to `attempts` in total. +// +// ⛔ CANNOT hide a wrong ANSWER. The loop absorbs transport failures only; a +// `200` where the pin wants `403` is returned to the caller untouched, on the +// first attempt, every time. Nor can it hide a boot that never reaches the +// banner: that is the caller's readiness rejection, which this does not touch. +// +// ⚠️ CAN hide a LIVE `os serve` that occasionally destroys a connection without +// answering it. That is a real product-defect class and this bound does not +// remove it. What it does is make every occurrence print +// `[