Skip to content

Commit c3b771c

Browse files
os-litantclaude
andauthored
fix(cli): os serve refuses a port that cannot be a port, naming what the operator set (#12662) (#12676)
`--port` was a string flag whose only consumer was a bare `parseInt`, so `--port abc` became `NaN`, travelled the whole port policy untouched, and reached the real `listen()` — which refused it at the socket layer with `ERR_SOCKET_BAD_PORT: options.port should be >= 0 and < 65536`. The operator mistyped a flag and got back an error naming an internal option, from a code path with no connection to the thing they typed. `--port 99999` died the same way, and `PORT=abc` / `OS_PORT=abc` are the same defect through another door. The value is now checked at the point all three inputs converge, before the port-conflict policy and before any socket exists. The refusal names which input was used, and states the range by interpolating the bounds the code enforces rather than a second hand-written copy of them. The bounds are measured, not copied: `listen(0)` binds a kernel-assigned port, so 0 is accepted; the ceiling is 65535, one less than the `< 65536` the kernel's own message names. `Flags.integer({ min, max })` was measured and not taken. oclif never runs a flag's parser over a `default`, and `PORT`/`OS_PORT` arrive through the default — so an integer flag would have guarded `--port` alone and left two of the three reported paths dying exactly as before. It would also have narrowed what boots: its `/^-?\d+$/` refuses `" 3000"`, `"3000.0"`, `"0x0BB8"`, `"+3000"` and `"3e3"`, all of which boot today. `parseInt` therefore remains the reader and only the refusal is added, so the accepted input set is unchanged. Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e4d2eb commit c3b771c

4 files changed

Lines changed: 651 additions & 8 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
fix(cli): `os serve` refuses a port that cannot be a port, naming what the operator set (#12662)
6+
7+
`--port` was a string flag whose only consumer was a bare `parseInt`. `os serve
8+
--port abc` therefore became `NaN`, travelled the whole port policy untouched,
9+
and reached the real `listen()` — which refused it at the socket layer:
10+
11+
```
12+
ERR_SOCKET_BAD_PORT: options.port should be >= 0 and < 65536. Received type number (NaN).
13+
```
14+
15+
An operator who mistyped a flag got back an error naming an internal option,
16+
raised from a code path with no connection to the thing they typed. `--port
17+
99999` parses fine and died in exactly the same place, and `PORT=abc` /
18+
`OS_PORT=abc` are the same defect through a different door.
19+
20+
The value is now checked before anything is done with it:
21+
22+
```
23+
✗ Invalid port: OS_PORT="abc"
24+
A port must be a whole number from 0 to 65535 — 0 is legal, and
25+
asks the kernel for any free port. Nothing was started, and no socket
26+
was opened.
27+
Correct OS_PORT in this process's environment (for example OS_PORT=3000),
28+
or override it with --port 3000.
29+
```
30+
31+
It names **which** input was used — `--port`, `PORT` or `OS_PORT` — because a
32+
refusal that only said "invalid port" would repeat the defect one level up. The
33+
range it states is interpolated from the bounds the code enforces, so the
34+
sentence cannot drift from the check. `0` is accepted: `listen(0)` binds a
35+
kernel-assigned port, so refusing it would have broken a working input in the
36+
name of validating it, and the ceiling is 65535 — one less than the `< 65536`
37+
the kernel's own message names.
38+
39+
The check sits ahead of the port-conflict policy, so all three boot paths (the
40+
development auto-shift, the production refusal, and a boot that enters neither)
41+
are covered by one guard, and all three inputs are covered with it: `PORT` and
42+
`OS_PORT` never reach flag parsing at all — they are read by the flag's
43+
`default`, which oclif never runs a flag's parser over.
44+
45+
**No value that boots today is refused.** `parseInt` remains the reader, so
46+
every spelling it tolerates — `" 3000"` with the leading whitespace production
47+
environments carry, `"3000.0"`, `"0x0BB8"`, `"+3000"` — still boots, on the same
48+
port, byte for byte. Only the values that used to die at the socket are refused,
49+
and now they are refused early, in the operator's own vocabulary. Written to
50+
**stderr** like every other `os serve` diagnostic: `stdout` carries JSON-RPC
51+
frames whenever the stdio MCP transport is mounted.

packages/cli/src/commands/serve-exhausted-port-search-notice.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,12 @@ describe('#12620: an exhausted port search is announced, in the words the search
166166
it('makes NO span claim for a rejection that is not an exhausted walk', async () => {
167167
// ⚠️ The `catch` in serve.ts catches every rejection, not only exhaustion.
168168
// `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.
169+
// port outside 0–65535 — reachable when the walk crosses the ceiling.
170+
// Measured, not supposed: `net`'s `listen()` throws there, inside the
171+
// probe's promise executor. (It used to be reachable a second way, from
172+
// `--port` text that parsed to NaN; #12662 refuses that value before the
173+
// port policy runs, so the crossing walk is the only route left. This case
174+
// is unaffected either way: it constructs the rejection directly.)
172175
//
173176
// ⭐ On those paths nothing was probed, so a body claiming a range would
174177
// print `NaN–NaN` and assert a search that never ran — an inaccurate

0 commit comments

Comments
 (0)