Skip to content

feat(cli): os serve announces an exhausted port search, carrying the message the search threw (#12620) - #12660

Merged
os-litant merged 4 commits into
mainfrom
claude/issue-12620-exhausted-port-search-notice
Aug 27, 2026
Merged

feat(cli): os serve announces an exhausted port search, carrying the message the search threw (#12620)#12660
os-litant merged 4 commits into
mainfrom
claude/issue-12620-exhausted-port-search-notice

Conversation

@os-litant

@os-litantos-litant commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes#12620

getAvailablePort gives up after a bounded walk and throws a message that names
the problem exactly. The caller's catch { /* Ignore — fall through */ } threw
that message away, fell through, and bound requestedPort anyway — the one port
the search had just proven was taken. The boot then died on the kernel's raw
EADDRINUSE with the accurate explanation discarded one line earlier.

This is the one shape in the whole port policy that reaches neither half of
the family's legibility work: the production Port … is already in use line
lives in the else if this boot never enters, and #12543's drift notice is gated
on port !== requestedPort, which is false here because the assignment threw
before it could happen. Both halves are correct; the gap is exactly between them.

What this does and does not change

The fallthrough stays. Whether an exhausted search should refuse instead is
#11113's production/development policy split, and answering it here would be
answering a different card. Only the silence is repaired. Nothing about what
os servedoes is changed — an ordinary auto-shift and a production boot are
byte-identical to before.

The notice carries the thrown error's own message as its headline rather than
paraphrasing it, so one fact keeps one spelling instead of two that can drift.
Everything after the headline is a different fact — how wide the search was,
what happens next, what to do:

 ⚠ Could not find an available port starting from 32869
Development auto-shift probed 101 ports (32869–32969) and every
one was busy, so this server is falling back to 32869 — the port the
search has just proven is taken. The bind that follows will almost
certainly fail with a raw EADDRINUSE from the kernel, and this notice
is the only place that says why.
Free a port in 32869–32969, or pick another via PORT=PORTNUM (or --port PORTNUM).

⚠️ Transcription note: the last line really prints an angle-bracketedport
placeholder, matching the production refusal it sits beside. GitHub's body
sanitizer eats short angle-bracket fragments — including inside a fenced block —
so it is spelled PORTNUM above. The source and the changeset carry the real
bytes; this substitution exists only so the quoted line is not silently truncated
here.

Channel is printDiagnosticstderr, re-derived from the merged code rather
than from any card's description of it: the same helper, stream and boot position
as both sibling notices. stdout carries JSON-RPC frames whenever the stdio MCP
transport is mounted, which is what serve-stdio-stdout-purity.e2e.test.ts pins;
that suite was run on this branch and passes.

The span claim is made only for the failure it is true of

The catch catches every rejection from the search, not only an exhausted
walk — so a body that always claimed a probed range would sometimes assert a
search that never ran. That is reachable, and measured against net rather than
supposed: isPortAvailable rejects synchronously with ERR_SOCKET_BAD_PORT for
any port outside 0–65535, which happens when the walk crosses the ceiling and
when --port text turns into NaN under parseInt. Unguarded, the notice would
have printed probed 101 ports (NaN–NaN) — an inaccurate diagnostic inside the
diagnostic added to stop exactly that.

So the walk throws a PortSearchExhaustedError carrying its own startPort,
lastPort and probedCount, and the span sentence is reachable only through it.
Its message is unchanged, so the sentence the notice carries verbatim is
untouched and the exhausted-path output is byte-identical either way; the type
says which failure this is, not the same thing differently. Any other rejection
still carries its own message as the headline — that ruling does not bend by
branch — with a body that claims no range.

The two numbers, measured here rather than inherited

101 ports, contiguous, no skip. The walk probes startPort through
startPort + 100inclusive — the guard fires only after port has already
been incremented past the last port checked. Counted by instrumenting the loop
and reading the probe sequence back, not by reading the source and reasoning:
ports probed: 101 · first/last: 3000/3100 · contiguous: true.

It is a plain port++ walk with no skip mechanism. (A neighbouring
changeset's example shows 32869 → 32871; that is reachable on a contiguous
walk — it just means 32870 was busy too — so there is nothing to correct there.
But a diagnostic written from a "skip" reading would name a range the search
never looked at.)

The numbers in the notice come off the error the walk recorded them on, never
re-derived from requestedPort — one source, so there is no second one to
disagree. An off-by-one inside a diagnostic that exists to be accurate would be
this card's own defect, so it is pinned against the measured probe sequence
rather than against a literal.

Testing — the seam, not 101 sockets

⛔ No test here binds a port. getAvailablePort now takes its probe as a
parameter (defaulting to the real one); that injection is what made the exhausted
path reachable at all. The alternative — holding 101 real ports — is slow, flaky
and hostile to a shared container whose ephemeral range is already crowded;
#12441 measured exactly that contention taking this suite red at
1 failed | 2101 passed, clean on an isolated re-run.

packages/cli/src/commands/serve-exhausted-port-search-notice.test.ts pins the
three-way discrimination, each arm at the level it is actually decidable at:

armoutcomehow pinned
exhausted searchnotice appears, carrying the thrown messageruntime, at the seam
ordinary auto-shiftthis notice does not appear; #12543's drift notice doesruntime, at the seam — a successful search returns, so the catch never runs and the notice is unreachable by construction
production branchneither; the existing in-use line firesstructural, against live source — the call site is lexically inside if (portAutoShiftAllowed), the production line is in the else if

Arm 3 is deliberately not a fourth spawner file: this package's own
vitest.config.ts records its 39 spawner files carrying 89.4% of its test wall at
a ~5.5–6.0s floor each, and arms 2 and 3 already hold landed runtime pins —
test/serve-port-drift-notice.e2e.test.ts and
test/serve-node-env-production-default.e2e.test.ts. What those lacked was the
discrimination against this notice, which the source-anchored arm supplies. The
first of those was run on this branch and passes.

A mutual-exclusion arm asserts the three notices are pairwise non-matching. One
pair is load-bearing beyond legibility: PORT_TAKEN_PATTERNS in
test/helpers/serve-process.ts turns /Port (\d+) is already in use/ and
/EADDRINUSE[^\n]*?:(\d+)/ into a port-contention verdict for every spawner in
this package. This notice names EADDRINUSE on purpose, so a colon followed by
digits after that word would make an exhausted boot mis-report as a lost port
race in an unrelated file. That is asserted, not merely avoided.

Ablation — the tests are shown able to go red

Four mutations, each proven on disk by hash before its run and proven restored
byte-identical to the HEAD blob after it (restore via git checkout HEAD --,
never a bare git checkout --, which restores from the index). No build step is
involved and that is measured, not assumed: the test imports ./serve.js
relatively, so vitest transforms the source — and these mutations turning the
suite red is what establishes it.

mutationresult
hardcode the reported count one low — this card's own defect shape1 failed | 7 passed
paraphrase the headline instead of carrying the thrown text1 failed | 7 passed
restore the original silent discard3 failed | 5 passed
neuter the instanceof guard, so the span is claimed for every rejection1 failed | 7 passed
final control, restored tree8 passed (8), tree byte-clean vs HEAD

⚠️The fourth mutation initially came back GREEN, and that was a real defect in
this PR's own test.
The negative assertion read /probed \d+ ports/. Removing
the guard does not produce a wrong number — it produces undefined, because
the span body reads its fields off an error that does not carry them, and \d+
does not match probed undefined ports. The narrow spelling passed against
precisely the regression it existed to catch. Widened to /probed/ plus an
explicit placeholder check, with the reasoning recorded at the assertion; the
positive control on the real notice is unchanged, so the negatives still
discriminate rather than matching nothing. Re-run after the fix: red, as it
should be.

Verification

All heavy runs went through scripts/pm/os-verify-lock.sh; every exit code was
captured before any pipe, and each verdict below is the gate's own, not a $?
read through tail. Re-run in full at 0ea410a0c, which is HEAD.

  • Full repo ESLintpnpm lint (eslint . --no-inline-config) exit 0. The
    whole sweep, so there is no narrowing to declare.
  • Gate families — all 26 derived by
    node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (24
    path-derived, plus the convention-triggered "adds a test file" set) exit 0,
    including check:slot-lookup, check:cross-package-test-inputs,
    check:engine-double-contract, check:where-matcher,
    check:type-check-coverage, check:type-check-debt, check:i18n,
    check:published-files and check:nul-bytes. One non-result:
    scripts/pm/check-half-states.mjs exits 3 = PREREQUISITE NOT MET — this
    container holds no valid GitHub credential, so it swept nothing. That is not a
    measurement in either direction; CI runs it with a real token.
  • Typecheckpnpm --filter @objectstack/cli typecheck exit 0, and the new
    test file is actually in the program: tsc --noEmit --listFiles lists both
    serve-exhausted-port-search-notice.test.ts and commands/serve.ts (a clean
    typecheck says nothing about a file no tsc program includes).
  • Suites — the new file plus every in-process suite that reads serve.ts:
    5 files / 48 tests passed. Plus the two e2e suites this change could
    plausibly red — serve-port-drift-notice.e2e ([finding] getAvailablePort returns a DIFFERENT port than requested and nothing says so — the drift is announced nowhere, and three test files have been silently talking past it #12543's arm) and
    serve-stdio-stdout-purity.e2e (the channel ruling) — 2 files / 3 tests passed.
  • Build — full workspace closure, 70 successful, 70 total, before the gates
    that require it.

Changeset

minor on @objectstack/cli, reasoned from AGENTS.md rather than reached for by
habit: this is published CLI source and the change is user-visible new output on
a path that previously printed nothing, which is a functional improvement rather
than a pure bug fix. skip-changeset would be wrong here. #12543 — the same
shape, the same file, landing in the same release — shipped minor, and these
two notices will read as one story in the release notes.

Generated by Claude Code

…message the search threw (#12620)
In development `os serve` walks forward from the requested port looking for a
free one. The walk gives up after 101 ports and throws a message that names the
problem exactly — and the caller's `catch { /* Ignore */ }` discarded it, fell
through, and bound the requested port anyway: the one port the search had just
proven was taken. The boot then died on the kernel's raw EADDRINUSE with the
accurate explanation thrown away one line earlier.
This is the one shape in the whole port policy that reaches NEITHER half of the
family's legibility work. The production `Port ... is already in use` line lives
in the `else if` this boot never enters, and the shifted-port notice is gated on
`port !== requestedPort`, which is false here because the assignment threw
before it could happen.
The fallthrough itself is unchanged and stays deliberate; whether an exhausted
search should refuse instead is #11113's production/development policy split and
is not decided here. Only the silence is repaired.
The notice CARRIES the thrown message as its headline rather than paraphrasing
it, so one fact keeps one spelling. The width it reports is derived from the
same constant the walk uses, so the range it names is always the range it
probed. Channel is `printDiagnostic` — stderr, the same helper, stream and boot
position as the two sibling notices; stdout carries JSON-RPC frames whenever the
stdio MCP transport is mounted.
`getAvailablePort` now takes its port probe as a parameter, defaulting to the
real one. That is what makes the exhausted path testable without holding 101
real ports — a test that would be slow, flaky and hostile to a shared container
whose ephemeral range is already crowded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 27, 2026
@github-actions

github-actionsBot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 9 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 3519f8d11fa537e1d9dce2f0f1dabd3555f571cf.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 23 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 3519f8d11fa537e1d9dce2f0f1dabd3555f571cfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 1859cdce4f3b6ab36f7c4f21078af21c85d54d4e — the merge of head 0ea410a0c5512285aed39f6cb2d7df2e56a1ba46 into base 3519f8d11fa537e1d9dce2f0f1dabd3555f571cf, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1859cdce4f3b6ab36f7c4f21078af21c85d54d4e && git checkout 1859cdce4f3b6ab36f7c4f21078af21c85d54d4e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3519f8d11fa537e1d9dce2f0f1dabd3555f571cf 0ea410a0c5512285aed39f6cb2d7df2e56a1ba46 && git checkout -B drift-repro 3519f8d11fa537e1d9dce2f0f1dabd3555f571cf && git merge --no-ff 0ea410a0c5512285aed39f6cb2d7df2e56a1ba46
node scripts/docs-audit/affected-docs.mjs --json 3519f8d11fa537e1d9dce2f0f1dabd3555f571cf

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3519f8d11fa537e1d9dce2f0f1dabd3555f571cf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

)
The `catch` this notice feeds catches EVERY rejection from the port search, not
only an exhausted walk — and the notice asserted a probed range unconditionally.
`isPortAvailable` rejects synchronously with ERR_SOCKET_BAD_PORT for any port
outside 0-65535, which is reachable two ways: a walk that starts high enough to
cross the ceiling, and `--port` text that `parseInt` turns into NaN. Measured
against `net` rather than supposed — `listen()` throws for both, inside the
probe's promise executor. On those paths nothing was exhausted, so the notice
would have printed `probed 101 ports (NaN-NaN)`: an inaccurate diagnostic
inside the diagnostic added to stop exactly that.
The walk now throws a `PortSearchExhaustedError` carrying its own startPort,
lastPort and probedCount, and the span sentence is reachable only through it.
Its message is unchanged, so the sentence the notice carries verbatim is
untouched and the exhausted-path output is byte-identical to before; the type
says WHICH failure this is, not the same thing differently. The numbers are read
off the error the walk recorded them on rather than re-derived from
`requestedPort`, so there is no second source that could disagree.
Any other rejection still carries its own message as the headline — that ruling
does not bend by branch — with a body that claims no range.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…s to catch (#12620)
An ablation that neutered the `instanceof PortSearchExhaustedError` guard — so
the span body runs for EVERY rejection, the exact regression the guard was added
to prevent — left all eight cases GREEN.
The predicate was `/probed \d+ ports/`. Removing the guard does not produce a
wrong NUMBER; it produces `undefined`, because the span body reads startPort,
lastPort and probedCount off an error that does not carry them. `\d+` does not
match `probed undefined ports`, so the assertion passed against the one shape it
was written for. The `NaN–` check missed it for the same reason.
Widened to `/probed/` plus an explicit placeholder check, and the reasoning is
recorded at the assertion so the narrow spelling does not come back. The
positive control on the real exhausted notice is unchanged, so the negatives
still discriminate rather than matching nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-litant@claude