Skip to content

Commit ab6dd32

Browse files
os-litantclaude
andauthored
feat(cli): os serve announces an exhausted port search, carrying the message the search threw (#12620) (#12660)
* feat(cli): os serve announces an exhausted port search, carrying the 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 * fix(cli): make the span claim only for the failure it is true of (#12620) 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 * test(cli): the no-span-claim arm was blind to the regression it exists 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3b4c56c commit ab6dd32

3 files changed

Lines changed: 505 additions & 7 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
feat(cli): `os serve` says so when its port search runs out of ports, in the words the search threw (#12620)
6+
7+
In development (`os dev`, `--dev`, or `NODE_ENV=development`) `os serve` walks
8+
forward from the requested port looking for a free one. That walk gives up after
9+
101 ports, and when it does it throws a message naming the problem exactly —
10+
which the caller then discarded, fell through, and bound the requested port
11+
anyway: the one port the search had *just proven* was taken.
12+
13+
That boot died on the kernel's raw `EADDRINUSE` with no explanation anywhere. It
14+
is the one shape in the whole port policy that reached **neither** half of this
15+
family's legibility work: the production `Port … is already in use` line lives in
16+
a branch this boot never enters, and the shifted-port notice (#12543) is printed
17+
only when the bound port differs from the requested one — on this path they are
18+
the same, because the search threw before it could assign. The accurate sentence
19+
existed and was thrown away one line earlier.
20+
21+
The fallthrough is unchanged and still deliberate — a developer whose whole next
22+
span is busy arguably does want the requested port attempted rather than a hard
23+
refusal, and whether it should refuse instead is a separate policy question
24+
(#11113). What changed is that it is no longer silent:
25+
26+
```
27+
⚠ Could not find an available port starting from 32869
28+
Development auto-shift probed 101 ports (32869–32969) and every
29+
one was busy, so this server is falling back to 32869 — the port the
30+
search has just proven is taken. The bind that follows will almost
31+
certainly fail with a raw EADDRINUSE from the kernel, and this notice
32+
is the only place that says why.
33+
Free a port in 32869–32969, or pick another via PORT=<port> (or --port <port>).
34+
```
35+
36+
The first line is the search's own thrown message, carried rather than
37+
paraphrased, so there is one spelling of that fact and not two that can drift
38+
apart. The width it reports is read from the same constant the walk uses, so the
39+
range it names is always the range it actually probed.
40+
41+
Written to **stderr**, like every other `os serve` diagnostic: `stdout` carries
42+
JSON-RPC frames whenever the stdio MCP transport is mounted, so nothing but
43+
protocol may go there. It prints only when the search is exhausted — an ordinary
44+
auto-shift and a production boot are both unchanged, byte for byte.
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #12620 — when the dev port search runs out of ports, `os serve` SAYS SO,
5+
* carrying the message the search itself threw.
6+
*
7+
* ## The defect was a discarded sentence, not a wrong behaviour
8+
*
9+
* `getAvailablePort` throws `Could not find an available port starting from
10+
* <n>` — a sentence that names the problem exactly. The caller's `catch` used
11+
* to read `// Ignore — fall through and try the requested port` and did exactly
12+
* that: it dropped the sentence and bound `requestedPort` anyway, the port the
13+
* search had just proven was taken. The boot then died on the kernel's raw
14+
* `EADDRINUSE` with nothing anywhere explaining it.
15+
*
16+
* ⛔ The fallthrough itself is NOT changed and this file does not argue for
17+
* changing it. Whether an exhausted search should refuse instead is #11113's
18+
* production/development policy split and belongs to that card.
19+
*
20+
* ## ⛔ Why this file binds no sockets
21+
*
22+
* The obvious test — hold 101 real ports and boot — is the wrong test: slow,
23+
* flaky, and hostile to a shared many-agent container whose ephemeral range is
24+
* already crowded. #12441 measured that contention taking a full CLI suite red
25+
* (`1 failed | 2101 passed`, clean on an isolated re-run), which is why
26+
* `serve-port-bind-probe.test.ts` exists at all. So the SEAM is driven instead:
27+
* `getAvailablePort` now takes its port probe as a parameter, defaulting to the
28+
* real one, and every runtime assertion below drives that parameter. Zero
29+
* sockets, zero spawns.
30+
*
31+
* ⚠️ That parameter is the change that made this card testable at all. Without
32+
* it the exhausted path is reachable only by exhausting a real range, which is
33+
* the test this card's ruling forbids.
34+
*
35+
* ## THE THREE-WAY DISCRIMINATION (the anti-vacuity requirement)
36+
*
37+
* A test asserting only "the notice appears when the search is exhausted"
38+
* passes just as green against code that prints it unconditionally. Three arms,
39+
* each pinned at the level it is actually decidable at:
40+
*
41+
* 1. **exhausted search** → the notice appears, carrying the thrown message.
42+
* RUNTIME, at the seam.
43+
* 2. **ordinary auto-shift** (`port !== requestedPort`, search SUCCEEDED) →
44+
* this notice does not appear; #12543's drift notice does. RUNTIME, at the
45+
* seam — and exactly, not by proxy: a successful search RETURNS, so the
46+
* `catch` never runs and the notice is unreachable by construction. The
47+
* drift half of that arm has its own landed runtime pin in
48+
* `test/serve-port-drift-notice.e2e.test.ts`, which spawns a real boot
49+
* against a real HTTP neighbour; it is not re-spawned here.
50+
* 3. **production branch** (`portAutoShiftAllowed` false) → neither notice;
51+
* the existing `Port … is already in use` line fires. STRUCTURAL, against
52+
* the live source: this notice's only call site is lexically inside the
53+
* `if (portAutoShiftAllowed)` block and the production line is in the
54+
* `else if`, so a production boot cannot reach it. The production line's
55+
* own runtime pin is landed in
56+
* `test/serve-node-env-production-default.e2e.test.ts`.
57+
*
58+
* ⚠️ Arm 3 is deliberately not a fourth spawner file. This package's own
59+
* `vitest.config.ts` records that its 39 spawner files carry 89.4% of its test
60+
* wall at a ~5.5-6.0s floor each, and arms 2 and 3 already hold the landed
61+
* runtime pins named above. What was missing from them was the discrimination
62+
* against THIS notice, and that is what the source-anchored arm supplies.
63+
*
64+
* ## The mutual-exclusion arm, and why it is not decoration
65+
*
66+
* The three notices are also asserted to be pairwise non-matching. One of those
67+
* pairs is load-bearing beyond legibility: `PORT_TAKEN_PATTERNS` in
68+
* `test/helpers/serve-process.ts` turns `/Port (\d+) is already in use/` and
69+
* `/EADDRINUSE[^\n]*?:(\d+)/` into a "port contention" verdict for every
70+
* spawner in this package. This notice names EADDRINUSE in prose on purpose —
71+
* that is what the operator is about to see — so if it ever grew a `:<digits>`
72+
* after that word, an exhausted-search boot would be mis-reported as a lost
73+
* port race by an unrelated file.
74+
*/
75+
76+
import { describe, it, expect } from 'vitest';
77+
import { readFileSync } from 'node:fs';
78+
import { resolve } from 'node:path';
79+
import { fileURLToPath } from 'node:url';
80+
81+
import { formatExhaustedPortSearchNotice, getAvailablePort } from './serve.js';
82+
83+
/** Seeded from `import.meta.url`, the spelling `check:cross-package-test-inputs` recognises. */
84+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
85+
86+
/** The live source, for the arms decided lexically rather than at runtime. */
87+
const SERVE_SOURCE = readFileSync(resolve(HERE, 'serve.ts'), 'utf8');
88+
89+
/**
90+
* Strip SGR escapes. `chalk` is inert under a non-TTY runner but not
91+
* guaranteed to be, and an assertion that only passes when colour happens to be
92+
* off is a flake waiting for the first person who runs this attached.
93+
*/
94+
const plain = (text: string): string => text.replace(/\u001B\[[0-9;]*m/g, '');
95+
96+
/** An arbitrary start port. Nothing is bound, so it only has to be a number. */
97+
const START = 34_500;
98+
99+
/** A probe that never finds a free port — the exhausted path, without a socket. */
100+
function alwaysBusy(): { probe: (port: number) => Promise<boolean>; probed: number[] } {
101+
const probed: number[] = [];
102+
return {
103+
probed,
104+
probe: async (port: number) => {
105+
probed.push(port);
106+
return false;
107+
},
108+
};
109+
}
110+
111+
/** #12543's notice, as its landed text. Held against the source below so it cannot go stale. */
112+
const DRIFT_NOTICE = /Port (\d+) is in use serving on (\d+) instead\./;
113+
/** #11113's production refusal — and the first of `PORT_TAKEN_PATTERNS`. */
114+
const PRODUCTION_REFUSAL = /Port (\d+) is already in use/;
115+
116+
describe('#12620: an exhausted port search is announced, in the words the search threw', () => {
117+
it('ARM 1 — the notice CARRIES the thrown message verbatim, as its headline', async () => {
118+
const { probe } = alwaysBusy();
119+
120+
const thrown = await getAvailablePort(START, probe).then(
121+
(port) => {
122+
throw new Error(`the search returned ${port} against a probe that never says yes`);
123+
},
124+
(err: unknown) => err,
125+
);
126+
127+
expect(thrown, 'the exhausted search no longer throws').toBeInstanceOf(Error);
128+
const message = (thrown as Error).message;
129+
130+
// Guard the guard: an empty message would make the assertion below
131+
// vacuously true.
132+
expect(message, 'the thrown message no longer names the start port').toContain(String(START));
133+
134+
const notice = plain(formatExhaustedPortSearchNotice(START, thrown));
135+
136+
// ⭐ The pin. Not "says something similar" — the error's own text, and as
137+
// the headline, so a reader meets it first rather than digging for it.
138+
expect(
139+
notice.split('\n').find((line) => line.includes('⚠')),
140+
'the notice paraphrases the thrown message instead of carrying it',
141+
).toBe(` ⚠ ${message}`);
142+
});
143+
144+
it('ARM 1 — the width it reports is the width it actually probed, endpoints included', async () => {
145+
const { probe, probed } = alwaysBusy();
146+
const thrown = await getAvailablePort(START, probe).catch((err: unknown) => err);
147+
148+
// ⚠️ The search is a plain contiguous `port++` walk. A neighbouring card
149+
// described it as skipping ports; it does not, and a notice written from
150+
// that reading would name a range it never looked at.
151+
expect(
152+
probed.every((port, i) => port === START + i),
153+
`the search is no longer contiguous: ${probed.slice(0, 5).join(',')}…`,
154+
).toBe(true);
155+
expect(probed[0], 'the walk no longer starts where it was asked to').toBe(START);
156+
157+
// ⭐ The anti-off-by-one arm, and the reason it compares against `probed`
158+
// rather than against a literal: an inaccurate number inside a diagnostic
159+
// that exists to be accurate would be this card's own defect. BOTH terms
160+
// are measured here — how many ports the walk touched, and the last one it
161+
// reached.
162+
const notice = plain(formatExhaustedPortSearchNotice(START, thrown));
163+
expect(notice).toContain(`${probed.length} ports (${START}${probed[probed.length - 1]})`);
164+
});
165+
166+
it('makes NO span claim for a rejection that is not an exhausted walk', async () => {
167+
// ⚠️ The `catch` in serve.ts catches every rejection, not only exhaustion.
168+
// `isPortAvailable` rejects synchronously with ERR_SOCKET_BAD_PORT for any
169+
// port outside 0–65535 — reachable when the walk crosses the ceiling, and
170+
// when `--port` text parses to NaN. Measured, not supposed: `net`'s
171+
// `listen()` throws for both, inside the probe's promise executor.
172+
//
173+
// ⭐ On those paths nothing was probed, so a body claiming a range would
174+
// print `NaN–NaN` and assert a search that never ran — an inaccurate
175+
// diagnostic inside the diagnostic added to stop exactly that.
176+
const badPort = new RangeError('options.port should be >= 0 and < 65536. Received NaN.');
177+
const notice = plain(formatExhaustedPortSearchNotice(Number.NaN, badPort));
178+
179+
// The thrown text is still carried — that ruling does not bend by branch.
180+
expect(notice).toContain(badPort.message);
181+
182+
// …but the claim that is false here is simply not made.
183+
//
184+
// ⚠️ The predicate here is `/probed/`, NOT `/probed \d+ ports/`, and the
185+
// difference is the whole test. Removing the branch guard does not produce
186+
// a WRONG number — it produces `undefined`, because the span body reads
187+
// `startPort`/`lastPort`/`probedCount` off an error that does not carry
188+
// them. A `\d+` pattern does not match `probed undefined ports`, so the
189+
// narrow spelling passed against precisely the regression this case exists
190+
// to catch. Measured: an ablation that neutered the guard left all eight
191+
// cases green.
192+
expect(notice, 'a span was claimed for a search that never walked one').not.toMatch(/probed/);
193+
expect(
194+
notice,
195+
'the notice rendered a placeholder where a number belongs',
196+
).not.toMatch(/undefined|NaN/);
197+
198+
// Anti-vacuity for the two negatives above: the real exhausted notice DOES
199+
// make both claims, so their absence here is a discrimination and not a
200+
// regex that stopped matching anything.
201+
const { probe } = alwaysBusy();
202+
const real = await getAvailablePort(START, probe).catch((err: unknown) => err);
203+
const exhausted = plain(formatExhaustedPortSearchNotice(START, real));
204+
expect(exhausted).toMatch(/probed \d+ ports/);
205+
expect(exhausted).toContain(`${START}–`);
206+
});
207+
208+
it('ARM 2 — a search that SUCCEEDS returns, so the notice is unreachable on the drift path', async () => {
209+
// Busy at the requested port, free at the next one: the ordinary auto-shift.
210+
const probed: number[] = [];
211+
const port = await getAvailablePort(START, async (candidate) => {
212+
probed.push(candidate);
213+
return candidate !== START;
214+
});
215+
216+
// The drift really is a drift — without this the arm proves nothing.
217+
expect(port, 'the probe did not produce a shift to discriminate against').not.toBe(START);
218+
expect(port).toBe(START + 1);
219+
expect(probed).toEqual([START, START + 1]);
220+
221+
// ⭐ THE DISCRIMINATION. `getAvailablePort` RESOLVED, so the caller's
222+
// `catch` — the only place this notice is written — cannot run. The notice
223+
// is not merely absent on this path; it is unreachable.
224+
//
225+
// What fires instead is #12543's drift notice, guarded on
226+
// `port !== requestedPort`: true here, and false on the exhausted path,
227+
// where the assignment never happens at all. Both guards are read from the
228+
// live source rather than described.
229+
expect(SERVE_SOURCE, 'the notice moved out of the catch that guards it').toMatch(
230+
/catch \(searchExhausted\) \{[\s\S]*?printDiagnostic\(formatExhaustedPortSearchNotice\(requestedPort, searchExhausted\)\);/,
231+
);
232+
expect(SERVE_SOURCE, "#12543's drift notice is no longer gated on a real shift").toContain(
233+
'if (port !== requestedPort) {',
234+
);
235+
expect(SERVE_SOURCE, "#12543's drift wording moved; this file's discrimination is stale").toMatch(
236+
/Port \$\{requestedPort\} is in use serving on \$\{port\} instead\./,
237+
);
238+
});
239+
240+
it('ARM 3 — the notice is inside the auto-shift branch, so production never reaches it', () => {
241+
const autoShift = SERVE_SOURCE.indexOf('if (portAutoShiftAllowed) {');
242+
const productionBranch = SERVE_SOURCE.indexOf('} else if (!(await isPortAvailable(requestedPort)))');
243+
const noticeCallSite = SERVE_SOURCE.indexOf('printDiagnostic(formatExhaustedPortSearchNotice(');
244+
const productionLine = SERVE_SOURCE.indexOf('is already in use.');
245+
246+
// Every anchor has to exist, or the ordering assertions below compare -1s
247+
// and pass while measuring nothing.
248+
expect(autoShift, 'the `portAutoShiftAllowed` branch head is gone').toBeGreaterThan(-1);
249+
expect(productionBranch, 'the production `else if` is gone').toBeGreaterThan(-1);
250+
expect(noticeCallSite, 'the exhausted-search notice has no call site').toBeGreaterThan(-1);
251+
expect(productionLine, 'the production in-use line is gone').toBeGreaterThan(-1);
252+
253+
// ⭐ The notice sits between the branch head and the `else if`; the
254+
// production line sits after it. A production boot never enters the block
255+
// this notice lives in.
256+
expect(noticeCallSite).toBeGreaterThan(autoShift);
257+
expect(noticeCallSite).toBeLessThan(productionBranch);
258+
expect(productionLine).toBeGreaterThan(productionBranch);
259+
});
260+
261+
it('ARM 3 — the sink is `printDiagnostic`, which is stderr (#7915 stdout purity)', () => {
262+
// `stdout` is the JSON-RPC channel whenever the stdio MCP transport is
263+
// mounted, which is what `serve-stdio-stdout-purity.e2e.test.ts` pins. A
264+
// diagnostic written anywhere else reds that suite from this file.
265+
expect(SERVE_SOURCE).toContain('printDiagnostic(formatExhaustedPortSearchNotice(');
266+
expect(SERVE_SOURCE, '`printDiagnostic` no longer writes to stderr').toMatch(
267+
/const printDiagnostic = \(text = ''\) => \{\s*\n\s*if \(!bootQuiet\) process\.stderr\.write/,
268+
);
269+
});
270+
271+
it('MUTUAL EXCLUSION — the three notices cannot be mistaken for one another', async () => {
272+
const { probe } = alwaysBusy();
273+
const thrown = await getAvailablePort(START, probe).catch((err: unknown) => err);
274+
const notice = plain(formatExhaustedPortSearchNotice(START, thrown));
275+
276+
expect(notice, "the exhausted notice reads as #12543's drift notice").not.toMatch(DRIFT_NOTICE);
277+
278+
// ⚠️ Load-bearing beyond legibility: this pattern is the first of
279+
// `PORT_TAKEN_PATTERNS` in `test/helpers/serve-process.ts`, which every
280+
// spawner in this package uses to decide that a boot lost a port race.
281+
expect(notice, 'the exhausted notice reads as the production refusal').not.toMatch(
282+
PRODUCTION_REFUSAL,
283+
);
284+
285+
// The other half of that helper's pattern.
286+
expect(notice, 'the notice now trips the EADDRINUSE contention pattern').not.toMatch(
287+
/EADDRINUSE[^\n]*?:(\d+)/,
288+
);
289+
290+
// …and both patterns are live instruments, not dead regexes: each must
291+
// still match the text it was written for, or the two negatives above
292+
// prove nothing at all.
293+
expect(' ⚠ Port 3000 is in use — serving on 3001 instead.').toMatch(DRIFT_NOTICE);
294+
expect(' ✗ Port 3000 is already in use.').toMatch(PRODUCTION_REFUSAL);
295+
});
296+
297+
it('carries a non-Error rejection intact rather than rendering it as [object Object]', () => {
298+
// The probe is injectable, so the seam can now reject with anything. The
299+
// ruling is to carry the text the seam produced, whatever it is.
300+
const notice = plain(formatExhaustedPortSearchNotice(START, 'probe socket exploded'));
301+
expect(notice).toContain('probe socket exploded');
302+
expect(notice).not.toContain('[object Object]');
303+
});
304+
});

0 commit comments

Comments
 (0)