Uh oh!
There was an error while loading. Please reload this page.
fix(cli): os serve refuses a port that cannot be a port, naming what the operator set (#12662) - #12676
Conversation
…the operator set (#12662) `--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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 75831a38a2188b7289fa7f447fed8df660698c29 && git checkout 75831a38a2188b7289fa7f447fed8df660698c29
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 68bf4efc6ac3e18385496432012eaf9aba09cc39 6989019bb5fa03f19e56b6e93a23d32343ba34ca && git checkout -B drift-repro 68bf4efc6ac3e18385496432012eaf9aba09cc39 && git merge --no-ff 6989019bb5fa03f19e56b6e93a23d32343ba34ca
node scripts/docs-audit/affected-docs.mjs --json 68bf4efc6ac3e18385496432012eaf9aba09cc39
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12662
--portwas aFlags.stringwhose only consumer was a bareparseInt.os serve --port abctherefore becameNaN, travelled the whole port policy untouched, and reached the reallisten()— which refused it at the socket layer withERR_SOCKET_BAD_PORT, a sentence aboutoptions.portbounded at 0 and below 65536. The operator mistyped a flag and got back an error naming an internal option, raised from a code path with no connection to the thing they typed.--port 99999parses fine and died in the same place, andPORT=abc/OS_PORT=abcare the same defect through a different door.The bounds are measured, not copied
node -e "require('net').createServer().listen(N)", this checkout, Node v22.22.2:01,3,1023,3000,65534,6553565536,70000,-1,NaN,3000.5ERR_SOCKET_BAD_PORTSo
0is accepted: a floor of1would have refused a value that boots today. And the ceiling is 65535, one less than the exclusive65536the kernel's own sentence names. Both numbers live in one pair of constants and the refusal interpolates them, the rule #12620 landed in this file forPORT_SEARCH_SPAN.Why not a validating
Flags.integer({ min, max })— measured, then not takenTwo measurements, both against this repo's
@oclif/core4.13.3.1. It cannot see the environment. In
lib/parser/parse.jsthe default branch's value function simply returnsflag.default; unlike the argv andflag.envbranches it never callsparseFlagOrThrowError. Confirmed at runtime — an integer flag withmin: 0, max: 65535:PORT/OS_PORTarrive through thatdefault, so an integer flag would have guarded--portalone and left two of the three reported paths dying exactly as before — one third of the card.2. It would narrow what boots.
Flags.integer's parser is/^-?\d+$/, which refuses" 3000","3000 ","3000.0","0x0BB8","+3000"and"3e3"— every one of whichparseIntaccepts and every one of which boots a server today. Leading whitespace on a productionPORTis the realistic one.So
parseIntstays the reader and only the refusal is added, at the point all three inputs converge and ahead of the port-conflict policy: one guard covers the development auto-shift, the production refusal and a boot that enters neither, and it runs before anything probes or binds.metadata.flags.port.setFromDefault(oclif's own record) is what separates--portfrom the environment; the env half mirrorsreadEnvWithDeprecation('OS_PORT', 'PORT')and is pinned against it,OS_PORT=""included.The accepted-input set is unchanged, and that is a test
serve-port-validation.test.tscarries a 17-row table of what each spelling does today (parseIntvalue, and whether that number reaches a bound socket). Every row that boots today must still return the same port; every row that dies atlisten()today must be refused. That arm reds the moment anyone tightens this to integer-flag semantics — which is where such a change should have to come and argue, because it would narrow a published CLI's accepted input.One consequence of keeping
parseIntis preserved rather than hidden:--port 3e3still binds port 3, not 3000. That is a different defect — a value accepted as something other than what it says, rather than a value that dies — and repairing it narrows the accept set, so it is filed as #12674 with the contract question stated rather than answered here.Known limit, stated rather than papered over
os devforwards its own--port(and$PORT, promoted to a flag) to theservechild on argv;os startforwards its--portasPORTin the child's environment. On those spawns this names the channel the value arrived on, which is not always the one the operator typed. Filed separately as #12673 — both parent commands own their own flag validation.Verification
All of it on
6989019bb, the tree of the commit in this PR.pnpm --filter @objectstack/cli exec vitest run— 196 files, 2227 tests, all passing (the whole CLI suite, because this is a boot path).pnpm --filter @objectstack/cli typecheck—TYPECHECK_EXIT=0. This package'stsconfig.jsonhas noexclude, so the new test file is inside the program (the build tsconfig excludes tests, deliberately).pnpm lint(eslint . --no-inline-config, repo-wide) — exit 0, 56s.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack: 24 path-matched + 6 convention-triggered families run locally, all exit 0 — includingcheck:cross-package-test-inputs,check:type-check-coverage,check:type-check-debt(ratchet, re-run with the closure built),check:i18n,check:i18n-coverage,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:nul-bytes, and the changeset family.node scripts/pm/check-half-states.mjsexits 3 —PREREQUISITE NOT MET, this container has no usable GitHub credential for bare REST. That is NOT MEASURED, not a red.Ablation — no sockets bound; the guard is what is deleted, and the mutation was confirmed on disk (anchored grep counts for injected and removed text) before any verdict was read, with restore proven by blob hash and an empty
git diff HEAD, never an exit code. This is a source-resolved suite, so no rebuild is involved in either leg; the mutation and the restore both act on the file the test imports:`abc` is no longer refused: expected NaN to be null,expected 99999 to be null,65536 was taken from the message instead of measured--portthe refusal would name --port, but the value came from elsewherethe port guard has no call site: expected -1 to be greater than -1One negative assertion was widened during the work rather than loosened:
OS_PORT="abc"containsPORT="abc", so a plainnot.toContainpair reported the OS_PORT refusal as also naming PORT. The anchor now disqualifies a match preceded by_; a message that really named both would still be caught.Changeset
minoron@objectstack/cli, derived rather than copied: this adds an observable refusal path and a new operator-facing message to a published package — a functional improvement, not a silent internal repair, so AGENTS.md's "pure bug fixes do not require a changeset" does not cover it. It removes and renames nothing and narrows no accepted input, so it is not breaking and carries no ADR-0087 marker (check-adr-0087-registrationagrees: no declared-breaking changeset in this diff). #12620 landedminorfor the sibling notice in this file; this arrives at the same answer by its own route.Generated by Claude Code
Generated by Claude Code