Repository navigation
fix(create-objectstack): the boot-probe neighbour reports WHY it did not come up - #19429
Conversation
…did not come up The scaffold-e2e boot-probe harness spawned its neighbour with `stdio: 'ignore'` and waited on 80 blind curl probes, then threw naming only the port. Every piece of evidence about the cause was discarded by construction, so a CI failure at 20,999ms and a 299ms pass of the same assertion in the same run read identically. The child now announces its own listener (and its bind errno) on stdout, the parent awaits that line with the exit event and the spawn error wired to the same promise, and the child asks the kernel for its port so the window `pickFreePort` leaves open is closed rather than merely reported on. The remaining ceiling is a backstop for the one case none of the others covers. Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
ACCEPT — and the red
|
| layer | reading |
|---|---|
| check RUNS, latest-per-name — the required-check gate | Check Changeset = skipped (run 106140630286, 20:04:49Z, superseding the 20:01:32Z failure). No red. |
| check SUITES — what a human sees | suite 96210116478 stays failure, permanently, on this head |
That is the documented behaviour, not a surprise: .github/workflows/pr-automation.yml:285-292 states in its own words that "the labeled run's green verdict does not clear the opened run's red one." ⛔ Do not re-run anything to chase it. The merge queue re-runs on its own branch, so the stale suite does not follow this PR into the queue.
The verification — re-derived, ⛔ not read off the report
① Nothing was skipped, disabled or quarantined. The order's hardest limit, and the probe fires on it, so it needed settling rather than asserting. describe.skipIf(!RUNNABLE) appears on the new block — but the lit control is the merge base: RUNNABLE at :109 and describe.skipIf(!RUNNABLE) at :423 are already there before this PR. The gate is the file's own pre-existing environment guard (platform === 'linux' and bash/curl/openssl/node all present), the [#9779] block's gate is untouched, and its apparent move :423 → :574 is insertion above it and nothing else. A control that can distinguish "this PR added a skip" from "this PR reused one" was run, and it says reused.
20999 ms and 299 ms). RUNNABLE is true where it counts. And :691 declares the reuse rather than inheriting it silently: "the harness these pin is reached only where that suite runs, so pinning it elsewhere would pin an instrument nothing uses."
② No budget was raised — the budget is gone. for _ in $(seq 1 80) … sleep 0.25 reads 0 hits after the change. It is replaced by announce-and-await, and the one surviving timer is labelled at :422: "The backstop, ⛔ not a budget: every other failure mode reports at once." This is what the order asked for and explicitly did not name a number for, and the answer arrived as a different instrument rather than a bigger number.
③ Five outcomes each name themselves, verified in the source: never spawned (:489), exited before announcing with code and signal (:492), refused the bind with its errno, alive but never announced (:495), announced then exited or went mute (:518, :532). The pre-fix helper answered all of these with one sentence about a port.
④ skip-changeset is right, reached from the other side. The report measured it from the artefacts (0 hits for the added identifiers across every files[] artefact against a summarizeTree/formatBytes positive control at 14). This seat measured it from the config instead, so the two readings are independent: tsup.config.ts:13 declares entry: ['src/index.ts', 'src/created-summary.ts'] — an explicit two-entry list with no glob — and package.json ships files: ["dist","README.md","CHANGELOG.md"]. A .test.ts is unreachable from those entries. Nothing published moves. Both readings agree.
⑤ No docs sweep. The drift check has "no opinion" over a test-only diff — the sixth such this shift, all correctly skipped. A diff confined to a .test.ts cannot have falsified a published assertion.
⭐ The two traps the round found that the order did not know about
Both are measured mechanisms reproduced in-container, and the report is careful that neither is claimed as the diagnosis of the CI failure — which is the right restraint:
pickFreePort's answer is stale by the time anything binds.noderefused the bind withlisten EADDRINUSE 0.0.0.0:39510on a port the scan had just approved, whiless -ltnshowed no listener andlsofshowed the port was an unrelated process's ephemeral source port. A bindability scan cannot see that.process.kill(pid, 0)is not a liveness check in this harness. With the worker's event loop blocked insideexecFileSync, a dead child is an unreaped zombie andkill(pid, 0)succeeds — answering "alive" about a child that died 20 seconds ago. ⭐ That is the obvious repair, and it would have been wrong.
The fix closes trap 1 structurally rather than reporting on it: the child asks the kernel for its port (listen(0)) and announces what it actually bound.
Ablation
Four legs' worth of value from one: the anchor that renders the diagnosis was replaced by an inert marker (on-disk proof, anchor 1 → 0, blob 9129d5f471c2 → e0cd4e4d17af), and the result was exactly the four broken-child controls red, the healthy control and all 8 pre-existing assertions green — the direction declared before the run. Restored byte-identically (blob after restore == blob at HEAD, git diff HEAD empty), with a bash trap on an absolute path holding the crash path.
Clause-②: no. Readying and arming auto-merge.
Generated by Claude Code
Fixes #19424
Clause-②: no
The reading this starts from
The boot-probe harness spawned its neighbour with
stdio: 'ignore'and waited on 80 blindcurlprobes 0.25s apart — exactly 20s — throughexecFileSync('bash', …, { stdio: 'ignore' }), then threw naming only the port. Both ends discarded the evidence by construction, so the card's two same-run readings (20999msred and299msgreen, same titled assertion, same runner, seconds apart) were indistinguishable to a reader. A budget chosen against an unreadable failure is a guess, so this PR chooses none.⛔ What this PR does not claim. Not that the CI child died, not that the runner was loaded, not that 20s is too short. No frequency was measured and none is inherited; the job behind the card could not be re-run.
What WAS measured (this repo, this container,
576d5df660)the neighbour never came up on port Nand nothing else. Second run, same three: 20930ms / 20771ms / 20976ms.noderefused the bind —listen EADDRINUSE: address already in use 0.0.0.0:39510— on a portpickFreePorthad just approved and on whichss -ltnshowed no listener at all.lsoffound it:TCP 192.0.2.2:39510 -> 160.79.104.10:443 (ESTABLISHED). The port was an unrelated process's local ephemeral source port.pickFreePortproves a port is bindable at the instant it asks; its own docblock already says "advisory only".process.kill(pid, 0)is NOT a liveness check in this harness, so the obvious repair is a trap. With the worker's event loop blocked insideexecFileSyncthe exited child is an unreaped zombie, andkill(pid, 0)on a zombie succeeds: it answered "alive" about a child that had died 20 seconds earlier.pickFreePort, and the old instrument reports that case in exactly the words it uses for a slow runner.What changed
packages/create-objectstack/src/scaffold-e2e-boot-probe.test.tsonly. ⛔ Nothing the suite pins (#9779) is weakened, skipped or quarantined — the eight existing assertions are untouched and still green.NEIGHBOUR-LISTENING portfrom thelistencallback, orNEIGHBOUR-LISTEN-ERROR errnofrom anerrorhandler — written withprocess.stdout.write(…, cb)and exiting from the callback, because stdout is a pipe here andprocess.exit()on the next line truncates an async pipe write.exitevent, thespawnerror and the child's captured stdout/stderr wired to the same promise. Five outcomes now report themselves the moment they happen and name themselves: never spawned · exited before announcing (code, signal, and the child's own last words) · refused the bind (with the errno) · alive and never announced · announced and then went mute.listen(0)) and reports back what it got, so the windowpickFreePortleaves open is closed rather than merely reported on: the neighbour holds the binding continuously from before its caller learns the number.The control that can FAIL
⛔ A green suite is no evidence that a diagnostic works. Five new cases break the neighbour four different ways and pin that the harness names THAT way, beside the healthy path:
its listener refused to bind: EADDRINUSEit exited before announcing a listener (code 3plus its own stderrit stayed alive and never announced a listener, and ⛔ NOT the exit wordingit never spawnedplusENOENTThe same three broken children against the pre-fix helper: 20761ms / 20751ms / 20778ms, one undifferentiated sentence.
Ablation — one-time, restored, not left in the tree. Collapsing the headline out of the diagnosis (anchor
: ${headline}\n, replaced by an inert marker) turned exactly the four broken-child controls red and left the healthy control and all eight pre-existing assertions green:Tests 4 failed | 9 passed (13). Driven throughscripts/ablation-replace.mjs, which proved the mutation reached disk (anchor 1 -> 0, blob9129d5f471c2 -> e0cd4e4d17af) and proved the restore (blob after restore 9129d5f471c2=blob at HEAD,git diff HEADempty). The merge commit that follows does not touch the ablated file.Readings, all on
0dd94da744(the final commit)pnpm --filter create-objectstack test— 16 files, 208 tests, all pass (13 in this file).pnpm --filter create-objectstack typecheck— exit 0, and the test file is genuinely in that program:tsc --noEmit --listFilesnames it (1 hit), so the green is not anexcludespeaking.pnpm lint(eslint . --no-inline-config, the whole repo — a full run, ⛔ not a narrowing) — exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack) — 51 derived · 48 exit 0 · 3 exit 3. The three arecheck:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt, each printingPREREQUISITE NOT METbecause it reads a full closure build this worktree does not carry. ⛔ Those three are NOT MEASURED here — neither pass nor finding; CI builds the closure and owns them.--ranreconciliation, exit codes recorded:51 derived, 48 run, 3 NOT-MEASURED, 0 UNRUN.pnpm check:cross-package-test-inputs— exit 0. The diff adds no new escaping read, soscripts/cross-package-test-inputs.mjsandturbo.jsonwere left alone: the gate was asked, ⛔ not pre-empted.Changeset
skip-changeset, measured rather than assumed. Afterpnpm --filter create-objectstack build, the identifiers this PR adds (scaffold-e2e-boot-probe,NEIGHBOUR-LISTENING,NeighbourOptions) occur 0 times across every artefactfiles[]ships —dist/index.js,dist/created-summary.js,dist/chunk-ZIUW7UEA.js,dist/created-summary.d.ts— while the positive controlsummarizeTree/formatBytesoccurs 14 times across those same four. ⇒ nothing published moves.Acceptance notes
pickFreePortis still used by the other four cases in this file and the same staleness applies to them in principle. Noted, not filed: there the port is bound by theos startstub inside the block, which already printsPort N is already in use.and exits 1, so that path fails legibly today. Carrier: this PR's reviewer; no other PR is queued on this file.:147 pickFreePort(base)scans 400 candidates and 38700/38900 are bases. The EADDRINUSE above is a different mechanism (a port that passed the scan and was refused afterwards), ⛔ not that claim revived.curlspellings and their own timeouts are unchanged.Generated by Claude Code