Skip to content

test(cli): attribute the stdout-purity e2e probes to the serve child - #16462

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-15898-stdio-purity-child-attribution
Sep 7, 2026
Merged

test(cli): attribute the stdout-purity e2e probes to the serve child#16462
os-litant merged 1 commit into
mainfrom
claude/issue-15898-stdio-purity-child-attribution

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #15898

serve-stdio-stdout-purity.e2e.test.ts was the fifth and last file in the family that fetched a spawned os serve with no child-lifecycle attribution. Its boot() handler for child.on('exit') returns early on if (settled) return, so it feeds the READINESS promise only — a death after readiness was invisible to it, and the two beforeAll probes reached vitest as a bare TypeError: fetch failed with no exit code, no stdout and no stderr. This file runs on Test Core, a required shard, so it was the worst place to hold a red nobody could explain.

Both probes now route through the existing probeThroughChild() helper, the same shape the four siblings already use. No redesign, no new export, no sixth file.

Pre-state, measured on this tree rather than inherited

The card's tense was stale (it wrote the helper as "landing"); the helper is on main at packages/cli/test/helpers/serve-process.ts:1117. Re-measured here before editing:

measurement value
probeThroughChild occurrences in the target file 0
bare probes against the spawned child 2:360 POST auth/sign-in/email, :369 POST keys
if (settled) return early-returns feeding only the readiness promise 3:200 (timeout), :210 (output), :239 (exit)

Matched on fetch(, not await fetch( — the card's own correction about its recipe's reach. That distinction is between a blind tool and a wrong conclusion: the card's reasoning was sound, only its grep under-reached. Here both sites happened to be spelled await fetch(, so the wider pattern changed no count in this file — it was used because the narrower one cannot be trusted to, not because it moved the number.

serve-port-drift-notice.e2e.test.ts remains untouched: it fetches the neighbour fixture it owns at neighbour.port, never the spawned child.

The repair is demonstrated, not asserted

A conversion nobody demonstrated is a refactor. The prediction below was written to disk before either leg ran; both legs kill the child through the handle the file itself owns (never by name pattern — the process table is shared).

The mutation is identical in both legs: SIGKILL the child immediately after const base = ..., i.e. after readiness has settled — precisely the window if (settled) return makes invisible.

predicted observed
BEFORE (bare fetch) bare TypeError: fetch failed, cause ECONNREFUSED, naming no exit code, no signal, no stdout, no stderr TypeError: fetch failed / Caused by: Error: connect ECONNREFUSED 127.0.0.1:41851. Grep for exit code, signal or transcript: 0 hits
AFTER (probeThroughChild) os serve DIED while answering the sign-in probe on port N — exit code null, signal SIGKILL, not retried, transcript attached verbatim: os serve DIED while answering the sign-in probe on port 39907 — exit code null, signal SIGKILL. This is the CHILD's failure, not a dropped socket, so it is NOT retried
attempts exactly 1 (a dead child is never retried) attempt 1/3 — one only
captured output child stdout and stderr carried both markers present; stderr carries the full boot log

exit code null with signal SIGKILL is the honest reading of a signalled death — fateOf() reports the signal where there is no numeric code; a child that exits on its own reports the number instead. Both are attribution, which is the thing that was missing.

One place the observation was sharper than the prediction: the prediction expected the boot banner in the --- child stdout --- half. Observed, that half is empty and the whole boot log arrives on --- child stderr ---. That is not a shortfall — it is this file's own invariant confirmed in passing: serve-stdio-stdout-purity exists to pin that a healthy boot puts nothing but protocol frames on stdout.

Ablation hygiene: absolute-path trap ... EXIT INT TERM; the mutation was proven on disk by grepping the injected text (never the editor's exit code); each restore was proven by git hash-object equal to the HEAD blob and an empty git diff HEAD, and spelled git checkout HEAD -- path rather than the bare form, which restores from the index the other leg had polluted. The subject is the test file and a same-tree relative helper import, both read from source by vitest, so no dist leg exists to go stale.

Verification

  • Healthy control (unmutated, converted): vitest run test/serve-stdio-stdout-purity.e2e.test.ts — 1 file, 1 test passed, 10.32s.
  • Helper pin: test/serve-probe-child-attribution.test.ts — 7 passed.
  • Typecheck: pnpm --filter @objectstack/cli typecheck green. The package's tsconfig.json includes only src, so tsc --noEmit says nothing about this file; coverage was proven separately with --listFiles on tsconfig.test.json (the edited file and the helper both present). Raw diagnostics: 28 across 3 files, all pre-existing and ledgered, 0 attributable to this diff.
  • Gates: all 44 families from dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, each exit code captured before any pipe. 43 measured green. 1 NOT MEASURED, not a pass: check:dual-build-cjs-loads exits 3 PREREQUISITE NOT MET because 11 packages have no dist/ in this worktree (only the @objectstack/cli closure was built). It reads built output of published packages and this diff changes no package source. check:type-check-debt first exited 3 on a V8 OOM on the shared box; re-run with a larger heap it is measured and green (5 ledger entries, 55 raw errors, none above its recorded number).
  • Lint: narrowed to the changed file and the narrowing is measured, not assumed — population from eslint's own config, count from --format json (1 file, 0 errors, 0 warnings), and the config enables no type-aware linting anywhere (0 parserOptions.project, 0 projectService), so this diff cannot move the verdict on any file it did not touch.

Clause 2 — graded no

No newly exported symbol, no new key on any published payload, no packages/spec/src/** path. Test-only, through a helper that already exists with an unchanged signature. The label is therefore not hung here.

No changeset: this publishes nothing from any package, so it carries skip-changeset.


🤖 Generated with Claude Code

https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N


Generated by Claude Code

`serve-stdio-stdout-purity.e2e.test.ts` was the fifth and last file in the
family that fetched a spawned `os serve` with no child-lifecycle attribution.
Its `boot()` handler for `child.on('exit')` returns early on `if (settled)
return`, so it feeds the readiness promise only: a death after readiness was
invisible to it, and the two `beforeAll` probes reached vitest as a bare
`TypeError: fetch failed` with no exit code, no stdout and no stderr.

Route both probes — `POST auth/sign-in/email` and `POST keys` — through the
existing `probeThroughChild()` helper, the same shape the four siblings already
use. The body read goes inside the thunk (a connection torn down mid-body
rejects out of `res.json()`, not out of `fetch()`) and the assertions stay
outside it (the guard reads any throw as a transport failure).

No product change and no new export: test-only, through a helper already on
`main` with its own pin at `serve-probe-child-attribution.test.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions github-actions Bot added the size/s label Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 245c6a23cd14b39cfd4badd27b6db957c731cefcpackageMentionDocs.

@github-actions github-actions Bot added the tests label Sep 7, 2026
@os-litant os-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 7, 2026 — with Claude
@os-litant
os-litant marked this pull request as ready for review September 7, 2026 03:38
@os-litant
os-litant enabled auto-merge September 7, 2026 03:38
@os-litant
os-litant added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 2228859 Sep 7, 2026
39 checks passed
@os-litant
os-litant deleted the claude/issue-15898-stdio-purity-child-attribution branch September 7, 2026 04:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants