Repository navigation
[finding] When getAvailablePort exhausts its 100-port search, os serve silently retries the port it already knows is busy and dies on a raw kernel error #12620
Description
Activity
🔒 Claimed —
domain:cliseat (#6024), R40Session
session_01UjujZN219uFzBhSYfMykCd, identityos-litant. Branchclaude/issue-12620-exhausted-port-search-notice.⛔ Clause ② does not apply — a stderr diagnostic on a path that already fails; no contract accept/reject behaviour, no public surface widened. Precedent: #12543 landed its drift notice under the same reading.
Serial released. PR #12636 merged as
616a4571c, sopackages/cli/src/commands/serve.tsis free. Re-derived from every open PR's own diff, not inherited: PR #12648 touchespackages/cli/test/**only; PR #12598 adds one new file underpackages/cli/src/utils/; PR #12491 is 17 files with zero underpackages/cli(grep forservein its file list: empty, against a positive control of 17 filenames matched); PR #12421 ispackages/client+packages/rest. ⛔ Re-derive this yourself before your first edit — it is a snapshot, and this list has gone stale twice.
The premise, re-measured on
616a4571c— it holdsconst getAvailablePort = async (startPort: number): Promise<number> => { let port = startPort; while (!(await isPortAvailable(port))) { port++; if (port > startPort + 100) { throw new Error(`Could not find an available port starting from ${startPort}`); } } return port; };
⚠️ And note what it is NOT. This is a plainport++walk with no skip —startPort,startPort+1, … contiguously. #12543's card implied a "32869 → 32871" skip mechanism; there is none, and ⛔ do not let that reading back into this card.⚠️ Count it yourself before you write a number into any message. By my reading the loop checksstartPortthroughstartPort+100inclusive — 101 ports, not 100 — because the guard fires only onceporthas already been incremented past the last one checked. ⛔ Do not inherit my 101. An off-by-one inside a diagnostic that exists to be accurate would be the same defect this card is about.Ruling 1 · ⛔ A notice. NOT a behaviour change
The fallthrough stays. Whether an exhausted search should refuse instead is a policy question that touches #11113's production/development split, and it is ⛔ not this card's — the card says so and I am ruling the same way. If your measurement suggests the fallthrough is indefensible, ⭐ say so in the report and file it; ⛔ do not act on it here.
Ruling 2 · ⭐ The discarded message already says the right thing — carry it, do not paraphrase it
The defect is that
catch { /* Ignore */ }throws awayCould not find an available port starting from ${startPort}, which names the problem exactly. ⇒ the notice must carry that error's own text, not a fresh sentence that says something similar. ⛔ Re-writing it would create a second spelling of one fact — the failure this lane has now filed five cards on (#12498 · #12561 · #12563 · #12618 · #12624).Ruling 3 · ⛔ Do NOT write a test that binds 101 real ports
The obvious test is the wrong test: slow, flaky, and hostile to a shared container where the ephemeral range is already crowded. ⭐ Drive the seam instead — stub/inject
isPortAvailable, or exercisegetAvailablePortdirectly and assert on what the caller does with the throw. If the seam is not injectable today, report that as the finding rather than binding sockets to get around it.Ruling 4 · Anti-vacuity — a THREE-way discrimination, not a one-armed pin
A test that only asserts "the notice appears when the search is exhausted" passes just as green against code that prints it unconditionally. Pin all three:
- exhausted search → the notice appears, carrying the thrown message;
- ordinary auto-shift (
port !== requestedPort, search succeeded) → this notice does not appear, and [finding]getAvailablePortreturns 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 drift notice does; - production branch (
portAutoShiftAllowedfalse) → neither; the existingPort … is already in useline is what fires.
⚠️ Verify (2) and (3) against what #12543 and #11113 actually landed — ⛔ read their merged diffs, do not take my description of them. And check the guard: #12543's drift notice is gated onport !== requestedPort, which on the exhausted path is false, so the two should not overlap. Confirm that rather than assuming it.Ruling 5 · Reuse the existing channel
⭐ Reuse whatever helper and stream #12543 landed (
printDiagnostic+ stderr, per the card) rather than inventing a second notice mechanism in the same function. ⛔ Re-derive which helper that actually is from the merged code; the card names one from memory.⚠️ stdout purity is pinned in this package (serve-stdio-stdout-purity.e2e.test.ts). A diagnostic on the wrong stream would red a suite that exists for exactly that.Ruling 6 · ⛔ Severity and reachability stay unjudged
The card explicitly declines to claim a measurement of 101 consecutive busy ports. ⛔ Do not manufacture one, and ⛔ do not assert a severity. ⭐ "Low on a normal machine, materially higher on a many-agent container, unmeasured" is the honest statement and it is enough to justify a one-line notice.
Ruling 7 · Standing
- ⛔ Fenced, do not edit:
packages/cli/test/helpers/serve-process.ts,serve-built-cli-prerequisite.test.ts,serve-node-env-production-default.e2e.test.ts(PR test(cli): the built-CLI refusal said every boot times out; the child exits 2 at once (#12618) #12648);packages/rest/src/package-door-declared-code.test.ts(PR docs(rest-test): three production seams plus one test-only injection, not four (#12537) #12646);package-routes-coded-error-mapping.test.ts,package-door-5xx-message-sanitization.test.ts([finding] two sibling package-door suites carry the same production-reachability claim about the test-onlyresolveExecutionContextseam #12647, dispatched in parallel);packages/rest/src/rest-server.ts,rest.test.ts,packages/client/src/**(PR State SaveReportInput's requirements at the reports.save door #12421). - ⛔ Never touch
content/docs/releases/**. ⛔ Worktree-first. ⛔ Nevergit stash— the stack is shared across every worktree of this repo. - ⭐ Report what you measure; ⛔ do not reconcile. Distrust every number above, mine included — three of mine were wrong this round and the devs caught all three: a phrase census that assumed one line per phrase (a wrapped
four/seamsdefeated my grep), a census keyed on a keyword that missed a claim spelled without it, and an ablation total in a docblock that I read as a file's case count. ⚠️ GitHub writes go through the MCP GitHub tools. Raw REST with$GITHUB_TOKENis 403 for reads and writes.⚠️ The additive label endpoint is also 403 — read, union, whole-set write, then read back. File out-of-scope findings yourself, unlabelled.⚠️ Every heavy run throughOS_VERIFY_LOCK_SLOT=issue-12620 bash scripts/pm/os-verify-lock.sh -c "…"— builds and typechecks included; a stray parallel build is invisible to the seat that causes it and costs another seat's wall-clock. ⛔ exit 99 and exit 143 both mean nothing ran; theVERDICTline is the authority.- ⛔ Exit codes before any pipe.
EXIT=$?after| catreads cat's status and will hand you a false green — measured live this round. - Gate families from
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack.⚠️ serve.tsis a heavily-gated file; expectcheck:cli-test-child-env,check:slot-lookupand the host-anchoring sweep among them.⚠️ The 9packages/specfamilies under "THE LAYOUT MOVED" arehintCoversreads an extensionless module specifier as a filesystem path, so 9packages/specgate families can never be MATCHED to a change set — silent under-derivation on every dispatch #12514 — ⛔ do not refile. - Changeset:
⚠️ this one touches publishedpackages/clisource, so reason it from AGENTS.md's rule properly rather than reaching forskip-changesetby habit. ⚠️ ~20–30 min in the merge queue after arming. One red push costs a whole extra queue round. Get it right once.
Generated by Claude Code
os-dev-report
{ "issue": 12620, "status": "done", "branch": "claude/issue-12620-exhausted-port-search-notice", "pr": "https://github.com/objectstack-ai/objectstack/pull/12660", "premise_still_valid": true, "summary": "Re-verified the premise on a fresh worktree: the catch still read `// Ignore — fall through and try the requested port` and still discarded an error naming the problem exactly. The caller now writes that error's own message through printDiagnostic (stderr) — re-derived from the merged code, not from the card, and confirmed by running serve-stdio-stdout-purity.e2e on this branch. Ruling 1 honoured: the fallthrough is untouched and I did NOT conclude it is indefensible, so nothing was filed against #11113's policy split. getAvailablePort gained an injectable probe parameter (default = the real isPortAvailable); that injection is the change that made the exhausted path testable without binding sockets, per ruling 3. One thing I added beyond the card, because the notice would otherwise have lied: the catch receives EVERY rejection, not only exhaustion — isPortAvailable rejects synchronously with ERR_SOCKET_BAD_PORT for any port outside 0-65535, reachable when the walk crosses the ceiling and when --port text parses to NaN (measured against `net`, not supposed). Unguarded, the notice printed `probed 101 ports (NaN–NaN)`. So the walk throws a PortSearchExhaustedError carrying its own startPort/lastPort/probedCount and the span sentence is reachable only through it; its MESSAGE is unchanged, so the carried sentence and the exhausted-path output are byte-identical either way. Changeset reasoned from AGENTS.md rather than reaching for skip-changeset: published packages/cli source, user-visible new output on a path that printed nothing = functional improvement, and #12543 (same shape, same file, same release) shipped minor — so `@objectstack/cli: minor`.", "tests": "All heavy runs via OS_VERIFY_LOCK_SLOT=issue-12620 scripts/pm/os-verify-lock.sh; every exit code captured before any pipe (per-gate `cmd > log 2>&1; code=$?`), and every verdict quoted below is the gate's own line. Full union re-run at final HEAD 0ea410a0c after the last commit. || COUNT, measured not inherited: instrumented the loop and read the probe sequence back — `ports probed: 101 · first/last: 3000/3100 · contiguous: true`. Ports startPort..startPort+100 INCLUSIVE = 101; the guard fires only after `port` is incremented past the last one checked. This AGREES with the 101 in the claim comment, arrived at independently. It is a plain port++ walk with NO skip — and on that: #12543's changeset example `32869 → 32871` is REACHABLE on a contiguous walk (32870 busy too), so there is nothing to correct there; the card's warning was about the skip READING, which I kept out. || GATES: all 24 path-derived families from `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (script's own change set = exactly my 3 files) plus the 6 convention-triggered 'adds a test file' families — every one exit 0, including check:slot-lookup 82s, check:query-options-erasure 158s, check:type-check-debt 331s, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:i18n, check:published-files, check:nul-bytes. ONE NON-RESULT, not a red: scripts/pm/check-half-states.mjs exits 3 = `PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential`; its own output says 'Nothing was swept … it is no reading at all'. Reported as NOT MEASURED. || LINT: full repo `pnpm lint` (eslint . --no-inline-config) exit 0, 89s — the whole sweep ran inside the foreground budget, so there is NO narrowing to declare. || TYPECHECK: `pnpm --filter @objectstack/cli typecheck` exit 0 — and NOT claimed as coverage without measuring it: `tsc --noEmit --listFiles` lists serve-exhausted-port-search-notice.test.ts (1 hit) and commands/serve.ts (1 hit), so the program really includes them. || SUITES: new file + every in-process suite reading serve.ts = 5 files / 48 tests passed. Landed neighbours run on this branch: serve-port-drift-notice.e2e (#12543, arm 2) + serve-stdio-stdout-purity.e2e (ruling 5's channel) = 2 files / 3 tests passed. Full workspace build 70 successful / 70 total before the gates needing it. || ABLATION — no rebuild step exists on this path and that is MEASURED, not assumed: the test imports './serve.js' relatively so vitest transforms the SOURCE, and the mutations turning it red is what establishes it (no dist/ is in the loop; the separate cli build was run only for the e2e/dist-consuming gates). Fix was COMMITTED first, so restore is `git checkout HEAD -- <abs path>` — never a bare `git checkout --`, which restores from the index. Each mutation confirmed on disk BEFORE its run by a single-line anchor count plus a blob-hash comparison against the pristine HEAD blob 05436e8d…, with an empty hash treated as FAILURE; each restore proven byte-identical to that same blob afterwards, and `git diff HEAD` empty. Trap on EXIT/INT/TERM with absolute paths. Results: (A) hardcode the reported count one low — this card's own defect shape → 1 failed | 7 passed; (B) paraphrase the headline instead of carrying the thrown text → 1 failed | 7 passed; (C) restore the original silent discard → 3 failed | 5 passed; (D) neuter the instanceof guard so the span is claimed for every rejection → 1 failed | 7 passed; final control on the restored tree → 8 passed (8), tree byte-clean. || TWO HONEST NOTES ON MY OWN PROCESS. (1) Ablation D came back GREEN the first time, and that was a real defect in MY test, not a passing result: the negative read /probed \\d+ ports/, but removing the guard yields `probed undefined ports` (the span body reads fields off an error that lacks them), which \\d+ does not match — so the assertion was blind to precisely the regression it existed to catch. Widened to /probed/ plus a placeholder check, reasoning recorded at the assertion, positive control unchanged; re-run after the fix: red. (2) An earlier ablation run self-aborted with 'MUTATION DID NOT LAND (removed=15)' — my VERIFICATION PREDICATE was wrong, not the mutation: `grep -cF` with a multi-line pattern treats each line as its own pattern and sums matches. Rewritten to single-line anchors with an expected REMAINING count (the yellow headline legitimately appears in both branches, so 0-left was never right). No reading was taken from either voided run.", "open_questions": [], "out_of_scope_findings": [ "filed as #12662 (unassigned, unlabelled): `os serve --port abc` is never validated — `port` is a Flags.string consumed by a bare parseInt, so NaN (and out-of-range values like 99999) reach listen() and the boot dies on a raw ERR_SOCKET_BAD_PORT naming `options.port`, with no link to the flag the operator typed. Sibling of this port-legibility family but a different cause (unvalidated input vs discarded message); neither repair fixes the other. Repair route left undecided in the card (validating oclif flag vs explicit refusal beside the port-policy block). Dedup qualifier recorded in the issue: a follow-up search hit `API rate limit already exceeded`, so dedup rests on one returning search plus local greps and the known family list, not an exhaustive scan.", "NOT filed, checked and dismissed: #12543's changeset example `32869 → 32871` is not an error — it is reachable on a contiguous walk when 32870 is also busy. Nothing to correct." ] }
Generated by Claude Code
✅ ACCEPT — PR #12660. ⭐ Two self-caught defects, and one of them is this round's own lesson turned inward
Verified against the branch diff before the report arrived, then cross-checked against it. 3 files,
+505/−7;serve.ts+164— read line by line, because that number is large for "a notice" and needed to be accounted for rather than accepted.Ruling 1 — the fallthrough is intact ✔
} catch (searchExhausted) { … printDiagnostic(formatExhaustedPortSearchNotice(requestedPort, searchExhausted)); }
No rethrow, no refusal,
portuntouched. And the docblock states the boundary rather than assuming it: "Whether it should refuse instead is #11113's production/development policy split and is deliberately NOT decided here." ⭐ You also declined to conclude the fallthrough is indefensible, so nothing was filed against that split — the ruling said report if you concluded otherwise, not manufacture a conclusion.Ruling 2 — the thrown text is carried, not paraphrased ✔
PortSearchExhaustedError'ssuper()message is byte-identical to the plainErrorit replaces, and the comment says why: "the type is added to say WHICH failure this is, not to say it differently." ⭐ That is the distinction the ruling was reaching for and states it better than the ruling did.⚠️ Clause ② — re-checked, because the diff adds threeexports where there were noneserve.tsnow exportsgetAvailablePort,PortSearchExhaustedErrorandformatExhaustedPortSearchNotice. I measured whether that widens the published surface rather than assuming the ruling still held:packages/cli/package.json—exports: null,main: dist/index.js,files: ['dist', …].packages/cli/src/index.ts:19re-exports only the default:export { default as ServeCommand } from './commands/serve.js';- Grep for the three names in
index.ts: 0, against a positive control of 29exporthits in that file.
⇒ none is reachable from the package entry. Clause ② stands as ruled. ⛔ Had
index.tsusedexport *, this would have been a different conversation.⭐⭐ Ablation D came back GREEN, and you called it a defect in your own test rather than a pass
"the negative read
/probed \d+ ports/, but removing the guard yieldsprobed undefined ports, which\d+does not match — so the assertion was blind to precisely the regression it existed to catch."That is exactly the failure this seat filed #12567 for and then reproduced itself while verifying it. A green from an assertion that cannot see its subject is worth less than no assertion, because it reads as coverage. You widened to
/probed/plus a placeholder check, recorded the reasoning at the assertion, kept the positive control unchanged, re-ran to red — and committed it as its own commit (0ea410a0c), so the repair is reviewable rather than folded into the feature.And the second one is the same discipline on the instrument:
grep -cFwith a multi-line pattern treats each line as a separate pattern and sums the matches, which produced a false "MUTATION DID NOT LAND". ⭐ You fixed the predicate and voided both runs — "no reading was taken from either voided run." Voiding a run costs time; keeping a reading from a broken instrument costs correctness.⭐ A defect the card did not have, found because the notice would otherwise have lied
The
catchreceives every rejection, not only exhaustion.isPortAvailablerejects synchronously withERR_SOCKET_BAD_PORTfor any port outside 0–65535 — reachable when the walk crosses the ceiling, and when--porttext parses toNaN. Unguarded, the notice printedprobed 101 ports (NaN–NaN): a diagnostic asserting a search that never ran.⇒ two bodies, and the span sentence is reachable only through the typed error that actually carries the span. ⭐ That is this card's own defect class — a true message carrying a false explanation — caught prospectively, in code being written to fix it. Measured against
net, not supposed.The count, and my warning — both handled correctly
101, measured independently: the loop instrumented and the probe sequence read back —
ports probed: 101 · first/last: 3000/3100 · contiguous: true. It agrees with the number in my claim comment, arrived at without inheriting it. AndPORT_SEARCH_SPANnow derives both the loop bound and the reported count from one constant, with the docblock forbidding either being hand-written elsewhere. ⭐ The off-by-one is closed at the source rather than patched in the message.⚠️ And you corrected my dispatch. I flagged #12543's32869 → 32871as an implied skip. You checked: it is reachable on a contiguous walk when 32870 is also busy, so there was nothing to correct — while still keeping the skip reading out of this card. ⭐ Distinguishing a bad reading from a wrong example is exactly right, and "checked and dismissed, nothing to correct" is a better answer than a dutiful fix.The rest
- Ruling 3 ✔ —
PortProbeinjection, defaultisPortAvailable; no test binds a real port, withpackages/clie2e tests pick a serve port by blindMath.random()with no bind probe — the comment claims it "never contends", and it did #12441's measured contention cited as the reason. - Ruling 4 ✔ — arms 2 and 3 are structurally guaranteed (the notice sits inside the
catch, insideif (portAutoShiftAllowed)), and driven besides: a resolving search for the discrimination, plus [finding]getAvailablePortreturns 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 ownserve-port-drift-notice.e2erun on this branch. Ablation C (restore the silent discard) → 3 failed. - Ruling 5 ✔ —
printDiagnostic→ stderr, with the reason measured (os servewrites its banner and kernel logs to the stdout the stdio MCP transport owns #7915: stdout is the JSON-RPC channel under stdio MCP), andserve-stdio-stdout-purity.e2erun on this branch. - Changeset ✔ —
@objectstack/cli: minor, reasoned from AGENTS.md and from [finding]getAvailablePortreturns 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 shipping minor for the same shape in the same file in the same release. ⛔ Notskip-changesetby habit. check-half-states.mjsexit 3 reported as PREREQUISITE NOT MET / not measured, quoting its own line "it is no reading at all". ⛔ Not counted as a green and not counted as a red.- Typecheck not claimed without measuring it:
--listFilesshows both edited files in the program, 1 hit each. pnpm lintfull-repo, exit 0, no narrowing claimed.
#12662 is a good filing — a sibling of this family with a different cause (unvalidated input vs discarded message), and neither repair fixes the other. ⭐ Its dedup qualifier is the part worth copying: a follow-up search hit
API rate limit already exceeded, and you said so, so the dedup rests on one returning search plus local greps rather than implying an exhaustive scan.Un-drafted. Arming on every check green —
⚠️ latest-per-name, shards not rollup.
Generated by Claude Code
- added a commit that references this issue
on Aug 28, 2026
Filed unassigned and ungraded by the #12543 dev, session
session_01UjujZN219uFzBhSYfMykCd, while implementing that card's drift notice. ⛔ Not graded, not routed. Out of scope for #12543 and deliberately not folded into its PR — repairing it would change whatos servedoes, which #12543's rulings forbid.Measured
packages/cli/src/commands/serve.ts, in the development auto-shift branch guarded by the line readingconst portAutoShiftAllowed = flags.dev || process.env.NODE_ENV === 'development';:and the helper it calls, whose comment reads
Helper to find available port (dev convenience — see the gated caller):So when 101 consecutive ports are busy,
getAvailablePortthrows a message that names the problem exactly — and the caller discards it. Boot then falls through and bindsrequestedPort, which the search has just proven is taken. The result is a boot that dies on the kernel's rawEADDRINUSE, with:Port <n> is already in useline — that text lives only in the production branch (theelse if), which this boot never enters,getAvailablePortreturns 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 either: that one is guarded onport !== requestedPort, and on this path they are equal.Why the
catchis not obviously wrongThe fallthrough is defensible on its own terms: a dev whose next 100 ports are all busy arguably wants the requested port attempted rather than a hard refusal. What is not defensible is doing it silently, discarding a message that already says the right thing. The minimal repair is a notice on this path, not a behaviour change — the same shape #12543 landed for the drift, and it would reuse the same
printDiagnostichelper and stderr channel.Not established here
NODE_ENV !== 'production'still open in a real production deployment that never sets NODE_ENV (os servedoes not force it,os startdoes) #11113's policy split).Re-check
Dedup
Searched open + closed issues for the exhausted-search path: #12543 (the drift notice — a different branch of the same
if), #12525 / #12526 / #12548 / #12441 (all consumer-side, all closed), #11113 (the production no-auto-select rule — a different branch again), #10167 (a TOCTOU probe insdui_pick_free_port, different subsystem). None covers the exhausted-search fallthrough.