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 +// `[