Skip to content

test(cli): catch the silent port drift in three serve e2e spawners - #12552

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-12526-port-contention-legibility
Aug 26, 2026
Merged

test(cli): catch the silent port drift in three serve e2e spawners#12552
os-litant merged 2 commits into
mainfrom
claude/issue-12526-port-contention-legibility

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#12526

These three are in the SILENT-DRIFT column, not the loud one

The card originally asked for portContentionError() at these three sites, on the premise that they run in production posture where a taken port is a hard exit 1. Measured on origin/main, that premise does not hold, and the PM withdrew it (ruling 1's action and ruling 3's classification are withdrawn; this PR implements the re-aimed fix).

All three spawn serve -p PORT --dev. packages/cli/src/commands/serve.ts reads:

constportAutoShiftAllowed=flags.dev||process.env.NODE_ENV==='development';

flags.devalone opens the auto-select branch, whatever NODE_ENV is — and serve.ts also assigns NODE_ENV=development in-process for --dev. So a port taken between randomPort()'s bind probe and the spawn does not fail the boot: getAvailablePort() hops the child onto the next free port and it reports itself READY. portContentionError() lives in the child's exit handler, which never runs, so adding it here would have been dead code — a fix that passes every gate and changes nothing.

That is the strictly worse direction: a green boot on the wrong port. Every request each file makes afterwards goes to the port it reserved and no longer owns, so it measures whatever else took it.

Only serve-node-env-production-default.e2e.test.ts — which spawns without--dev, so unset NODE_ENV defaults to production — is in the loud column where portContentionError() has something to read.

Not a regression from #11707 / #12459

The --dev responsible has been on all three spawn lines since 83e6016fa, long predating #11707 / #12459, which changed NODE_ENV and never touched it. This was never read, not newly introduced. Same for the classification error itself, which is inherited rather than new.

What this changes

Each of the three files now reads the bound port back out of the child's own output — never out of what the harness reserved, which is #12441 ruling 2's principle applied to the failure that actually occurs — and refuses to hand back a boot that drifted. Two patterns, because either alone can be absent: the structured HTTP server started successfully log obeys OS_LOG_LEVEL, and the banner's API: line is what survives when it does not.

Resolution is now gated on having seen that port. That is part of the check, not an optimisation: resolving on waitFor alone would let a boot through before the child had said which port it took, and the drift check would then be a no-op on an already-settled promise.

Also corrected: the THEY REACH IT NOW paragraph in serve-node-env-production-default.e2e.test.ts, which called these three "this file's own shape". That sentence is the seed of the misclassification — leaving it standing guarantees the next reader re-derives the same wrong column from the tree.

packages/cli/test/helpers/serve-process.ts is deliberately untouched (held by #12525), and os serve's auto-shift rule is unchanged — auto-shifting in development is correct, and #11113 pins the production half.

Proof the check fires

Held the port the file reserves with an HTTP neighbour, then ran the real suite. The child auto-shifted and the harness refused it:

Error: PORT DRIFT: this file reserved port 37167, but the child bound 37168.
`os serve -p 37167 --dev` passes `--dev`, so `serve.ts`'s `portAutoShiftAllowed =
flags.dev || NODE_ENV === 'development'` opened the auto-select branch and
`getAvailablePort()` hopped the child off the port that was asked for. The boot
SUCCEEDED — on the wrong port.

Ablation discipline: the mutation was proven on disk before the run (git hash-object before 6a845fa6 / after afd25a47, injected marker count 1, port = randomPort(); count 0), restored under an EXIT/INT/TERM trap pinned to an absolute path, and the restore proven by an empty git diff HEAD plus the marker's absence. Without the fix this run is a green boot on port 37168 while the assertions talk to 37167.

Verification — all on the final commit 36b472381

  • pnpm --filter '@objectstack/cli...' build (package and closure) — VERDICT command-exit 0
  • The four affected suites: 4 files, 8 tests passed
  • Ratchet family, with the full workspace closure built as lint.yml does: check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 223.8s, 1843 raw tsc error(s) total, none above its recorded number
  • Gate families derived with scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (24 families): check:cli-test-child-env, check:test-source-alias, check:type-check-coverage, check:type-check-debt, check:cross-package-test-inputs, check-comment-mask-adoption, check-empty-changeset, check-ci-filter-parity, check-plugin-teardown-shape — all exit 0, each captured before any pipe

Two declared readings rather than silent claims:

  1. pnpm --filter @objectstack/cli typecheck does NOT cover these files.packages/cli/tsconfig.json is "include": ["src"], and --listFiles finds 0 of 4 edited files in that program. Its green says nothing about this diff. Real coverage came from compiling the four files directly under the same strict options: 4/4 in program, 0 errors, confirmed with --listFiles.
  2. Lint was narrowed to the four changed files (eslint --no-inline-config --format json, 4 files linted, 0 errors, 0 warnings) rather than the repo-wide pnpm lint. Type-aware linting is not enabled, so this diff cannot move the verdict on any untouched file; the full-repo sweep is CI's run.

Test-only diff (four files under packages/cli/test/), so no changeset — skip-changeset.


Generated by Claude Code

The three spawners pass `--dev`, and serve.ts reads
`portAutoShiftAllowed = flags.dev || NODE_ENV === 'development'` — flags.dev
alone opens the auto-select branch. So a port taken between randomPort()'s
bind probe and the spawn does not fail the boot: getAvailablePort() hops the
child onto another port and it reports itself READY, after which each file's
own requests reach whatever else holds the port it reserved.
Read the bound port back out of the child's OWN output and refuse to hand
back a boot that drifted. Also correct the classification in
serve-node-env-production-default's docblock, which called these three "this
file's own shape" — that sentence is what put them in the loud column.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions

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 1a685522618c7b6354ff3b53d5b423a833d32494packageMentionDocs.

@os-litantos-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 26, 2026 — with Claude
@os-litant
os-litant marked this pull request as ready for review August 26, 2026 10:35
@os-litant
os-litant added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 5fbd58eAug 26, 2026
37 checks passed
@os-litant
os-litant deleted the claude/issue-12526-port-contention-legibility branch August 26, 2026 10:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

2 participants

@os-litant@claude