Uh oh!
There was an error while loading. Please reload this page.
feat(cli): say so when a port is read as something other than what the text says (#12674) - #12692
Conversation
…e text says (#12674) `os serve` reads its port with `parseInt`, whose tolerance changes the answer rather than the spelling: `--port 3e3` binds port 3, `--port 0x0BB8` binds 3000, `--port 3000abc` binds 3000. The boot succeeds on a port the operator never named and nothing says so. The accept set is untouched — narrowing a published CLI's accepted input is a contract decision, left open on purpose. Only the silence is repaired: a notice naming the text that was set and the port it selected, printed when a strict reading of the trimmed text does not name that port. Whitespace, a leading `+` and leading zeros are not differences (`" 3000"` is what production PORT values look like); an exponent, a radix prefix, a fraction, a separator and trailing text are. The source spelling is shared with #12662's refusal so one input is named one way, and the notice goes to stderr like every other `os serve` diagnostic. 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 6a85b65b942669e607107884ebcbcb58e9c9f5a8 && git checkout 6a85b65b942669e607107884ebcbcb58e9c9f5a8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 07e64656593afb7c1915f5c97ea2e1587ef3b003 8d6f70499f9fbbcfb2f852d7d4796b4e7952d312 && git checkout -B drift-repro 07e64656593afb7c1915f5c97ea2e1587ef3b003 && git merge --no-ff 8d6f70499f9fbbcfb2f852d7d4796b4e7952d312
node scripts/docs-audit/affected-docs.mjs --json 07e64656593afb7c1915f5c97ea2e1587ef3b003
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12674
os servereads its port withparseInt, andparseInt's tolerance changes theanswer, not merely the spelling:
--port 3e3binds port 3,--port 0x0BB8binds 3000,
--port 3000abcbinds 3000. The boot succeeds, on a port the operatornever named, and nothing anywhere says so. That silence is what this repairs.
The accept set is untouched. Option C, per the ruling on this card: narrowing a
published CLI's accepted input is a contract decision and is deliberately left open;
serve-port-validation.test.ts's anti-narrowing table stays green, byte for byte, andno changeset states a FROM/TO. Nothing is refused here, and nothing binds differently.
The boundary, re-measured here
The notice fires on a difference, so what counts as agreement is the whole
precision of the card. Re-measured on this checkout with
node -e, Node v22.22.2 —parsedisparseInt(raw), unchanged:parseInt3000" 3000""3000 "+3000080803e31e100x0BB83000.03000abc0b1110o171_000Every number above agrees with the numbers on the card. Three of those rows were not
on it and are new here:
0b111and0o17each select port 0 — a kernel-assignedport, from text a reader would call 7 and 15 — and
1_000selects port 1.The line is drawn on the trimmed text, and that half is load-bearing in the noise
direction:
" 3000"is the shape an ordinary productionPORThas, and a boundarythat counted whitespace would print a notice at every boot of every such deployment.
A leading
+and leading zeros are not differences either. Everything else is,because it means the port was not read off the digits.
⛔ The boundary is not
Number(), which is the near-miss worth naming: it agreeswith
parseInton0x0BB8(both 3000 — measured), so a boundary built on it would beblind to a hex literal, one of the two coercions this exists to see.
Three-way discrimination (
serve-port-text-read-notice.test.ts, 10 arms)portTextReadNotice()returnsnull, at runtime, at the same seam— so an implementation that printed unconditionally cannot pass. The decision lives
inside the exported function for exactly that reason, and the call site is one
if, pinned lexically;abc,99999,"",-1,65536) → [finding]os serve --port abcis never validated —parseIntyields NaN and the boot dies on a rawERR_SOCKET_BAD_PORT#12662's refusalowns it, and the guard exits the process above this call site, so the two can never
both fire. Pinned both ways (
parseRequestedPortreturnsnull; the exit islexically upstream of the call site).
Plus mutual exclusion against all three sibling notices in both directions, with every
pattern exercised as a live instrument — including the two that are
PORT_TAKEN_PATTERNSintest/helpers/serve-process.ts, where a false match wouldreport a healthy boot as a lost port race. Zero sockets, zero spawns.
Ablation — three mutations, each anchored, each proven on disk by anchored counts
plus a
git hash-objectcomparison before the verdict was read, each restored withgit checkout HEAD --naming an ABSOLUTE path, under atrap … EXIT INT TERM, andproven back by blob hash and an empty
git diff HEAD:.trim()Number()The subject is imported relatively (
./serve.js→ the sibling TypeScript source, not apackage
exportspath), so nodist/sits in the resolution path and no rebuild legapplies; the mutations going red without one is itself the evidence that the source is
what runs.
Reuse, and what is deliberately not done
printDiagnostic→ stderr, like its three siblings (os servewrites its banner and kernel logs to the stdout the stdio MCP transport owns #7915;stdoutcarriesJSON-RPC frames whenever the stdio MCP transport is mounted).
serve-stdio-stdout-purity.e2e.test.tsruns green.--port "3e3"/PORT="3e3"/OS_PORT="3e3") is now onefunction,
spellPortInput, shared with [finding]os serve --port abcis never validated —parseIntyields NaN and the boot dies on a rawERR_SOCKET_BAD_PORT#12662's refusal — the rulePORT_SEARCH_SPAN's docblock established for numbers, applied to prose. The refusal'sown pins prove its output is unchanged.
3e3looks like 3000to a reader, but
3000abchas no second reading, and a guess would be wrong thefirst time it met one.
os devreadsflags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT')and forwards the result tothe
servechild as--portplus that raw text on argv, soPORT=3e3 os devreachesthis notice as
--port "3e3"— the text and the port are still exactly right, onlythe source label is the child's channel rather than the parent's. That is [finding]
os dev --portis unvalidated andos start --portis unbounded — both forward to theservechild on a channel that renames the operator's input #12673,which is blocked on the same accept-set question this card was scoped away from, so
dev.tsandstart.tsare untouched. (os startis measured too and does not haveit on this path: its
--portis aFlags.integer, parser/^-?\d+$/in@oclif/core4.13.3, so a spelling this notice would fire on is refused by the parentbefore any spawn.)
Verification
All of the below ran on
8d6f70499, the branch head, throughscripts/pm/os-verify-lock.sh; exit codes captured before any pipe.vitest run src/(@objectstack/cli, the whole non-spawner unit surface) — 98 files, 1144 tests passedvitest run test/serve-stdio-stdout-purity.e2e.test.ts test/serve-port-drift-notice.e2e.test.ts test/serve-port-readback.e2e.test.ts test/serve-port-bind-probe.test.ts— 4 files, 25 tests passedpnpm --filter @objectstack/cli typecheck— clean, andtsc --listFilesconfirmsboth edited files are in the program (this package excludes no tests)
pnpm lint(eslint . --no-inline-config, whole repo) — exit 0dispatch-gates.mjsfamilies for this change set, all green:check:nul-bytes,check:changeset-gate-self-tests,check:cross-package-test-inputs,check:i18n,check:i18n-coverage(OK (12 config(s), 602 baselined untranslated string(s), none new)),check:objectql-double-limit,check:objectui-changeset,check:page-declaration-shape,check:pm-half-states,check:published-files,check:route-envelope,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:query-options-erasure,check:type-check-coverage,check:type-check-debt(
--re-measure: OK — 31 ledger entr(ies) … none above its recorded number),check:engine-double-contract,check:where-matcher,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-ci-filter-parity,check-comment-mask-adoption,check-plugin-teardown-shape,release-rehearsal-clone --self-test,docs-audit/check-affected-docsscripts/pm/check-half-states.mjs— exit 3, NOT MEASURED (no GitHub credential inthe gate's environment; its own words: "Nothing was swept … it is no reading at
all"). Not a red.
Changeset:
minorfor@objectstack/cli— new operator-facing output on a publishedpackage, the grade #12620 and #12662 each took for the same shape.
Generated by Claude Code