Skip to content

fix(cli): os serve publishes the port it BOUND, on all three announcement channels - #13120

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-13062-serve-publishes-bound-port
Aug 29, 2026
Merged

fix(cli): os serve publishes the port it BOUND, on all three announcement channels#13120
os-litant merged 2 commits into
mainfrom
claude/issue-13062-serve-publishes-bound-port

Conversation

@os-litant

@os-litantos-litant commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13062

os serve published the port it was asked for on all three of the channels
that announce an address. For every port but one the requested and the bound
value coincide, so the three agreed with each other and with the socket, and
nothing ever disagreed. For 0 they cannot coincide — utils/port-contract.ts
declares MIN_PORT = 0 from its own measurement and states that 0 is "a
REQUEST, not an error"
, because listen(0) binds a kernel-assigned port.

And the comment above the publish block asserted the invariant the code did not
hold ("the port the HTTP server actually bound"), so a reader auditing this
path was told the work was already done.

Measured, on this branch, through the real command

os serve --port 0, bare config, OS_AUTH_URL / BETTER_AUTH_URL /
OS_BASE_URL all unset:

channelbefore (ablated tree)after
IPC objectstack:listening{ port: 0, url: 'http://localhost:0' }{ port: 39101, url: 'http://localhost:39101' }
ready banner API: rowhttp://localhost:0/http://localhost:39101/
runtime.env_local.json"port": 0"port": 39101
announced port reachable by a clientnot attempted — port 0true

The transport had really bound 44251 on the ablated run (its own log says so),
so all three channels were naming an address nothing was listening on.

Non-zero request, --port 43203 and --port 46581byte-identical before and
after
: all three channels report the requested port, and it is reachable. That
half is the population of every ordinary boot and it does not move.

The fix

One read, resolved once after runtime.start() and handed to all three sites:

constboundPort=resolveBoundPort(kernel,port);

resolveBoundPort asks the transport that did the binding, through the contract
member that already promises this answer — IHttpServer.getPort?():
"after listen() resolves, implementations that provide this member MUST
return the real bound port — in particular when listen(0) requested an
ephemeral port"
. The Hono transport fills it from @hono/node-server's
listening callback (info.port). Nothing is re-derived from the request.

⛔ Three sites, not one. Repairing the IPC message alone would leave the banner
and the state file lying, in places nobody would think to look next time.

Fallbacks return the previously published value, so this cannot narrow what
boots: no transport (--server=false), a transport that does not implement the
optional member, or an answer that cannot be a bound port. 0 is refused as an
answer although it is a legal request — no socket is bound to port 0, so a
transport reporting it has not listened yet.

The instrument, and its proof

⚠️ The report this card came from could not confirm a bound port by
observing sockets: ss sees no sockets at all in this container, verified there
against a control server on a known port. That instrument is void here,
which is different from it answering "no".

So this uses a real client connect, and proves it answers in both directions
before anything is concluded with it (describe('the instrument, before anything is concluded with it')):

  • positive arm — it reaches a server the test starts, on the port
    server.address() reported;
  • negative arm — it is refused on a port that was probed free and left
    unbound, and on the control port again once the control server closes.

Port drift — both paths

  • serve's own dev auto-shift (requested port held, getAvailablePort()
    walks off it): driven for real in the e2e with holdPort(). All three
    channels follow onto the port actually taken. ⚠️ This path was already correct
    before the change, because getAvailablePort() reassigns port itself — it is
    pinned as a regression arm, not as a repair.
  • the transport's own EADDRINUSE walk (HonoHttpServer.listen() retries
    targetPort + attempt) — reachable only through a TOCTOU race between
    serve's probe and the transport's listen(), so it is not raced in an e2e;
    it is pinned at the seam, where resolveBoundPort is handed a transport
    reporting a port one higher than the request.

Reverse verification (ablation)

Mutation: all four publish sites put back onto the requested port. Proven on
disk before anything was read — each of the four anchors asserted to match
exactly once, serve.ts blob d138ff3471e89f76, zero fixed spellings
left. No build step is involved on either leg: both the unit test (vitest
transform of src/) and the e2e (tsx bin/run-dev.jssrc/) execute this
file from source, never through dist/, and the ablation going red is
itself the proof the mutated bytes reached the running code.

  • --port 0 arm — RED (expected +0 not to be +0), and the two non-zero
    arms plus all three instrument arms stayed GREEN. That asymmetry is the
    evidence: 1 failed / 5 passed.
  • restore — git checkout HEAD -- with an absolute path under an
    EXIT INT TERM trap; proven by blob hash back to d138ff34and
    git diff HEAD empty.

Tests

  • packages/cli/src/commands/serve-bound-port-publication.test.ts — the
    resolver's whole table (bound-not-requested, drift, no-op, canonical service
    name, every fallback, every refused answer) plus source pins that each of the
    three channels reads boundPort, with comments masked so a sentence about
    the bound port can never answer for code that publishes the requested one.
  • packages/cli/test/serve-publishes-bound-port.e2e.test.ts — three real boots,
    the instrument and its two arms, and the three channels read out of one boot.
    Children are spawned detached and torn down by process group — a
    neighbouring card's round left 13 orphaned serve processes and pushed this
    container to 14.4GB, turning the next run into a timeout that read as a
    failing assertion. Teardown is verified by the connect probe going back to
    refused; the run was checked for zero residue before and after.
  • packages/cli/test/helpers/serve-process.tsportDriftError now knows that
    --port 0 is a request for any port, so a healthy --port 0 boot is not a
    drift. ⛔ Not a silent skip: a banner that names localhost:0 under that
    request is reported, because that is the one answer such a boot can get wrong.
  • packages/cli/test/serve-port-readback.e2e.test.ts — its anti-vacuity pin
    followed the banner onto resolveAuthBaseUrl(boundPort), which is the
    stronger premise it was always meant to assert.

Union re-run at git rev-parse --short HEAD = d96701691, worktree clean: 5 test files, 51 tests, all green, plus pnpm --filter @objectstack/cli typecheck exit 0.

Out of scope, deliberately

Two findings measured on the way and not repaired here. ⚠️ Neither is filed
as an issue: the dedup read channel answers "GitHub access is not enabled
for this session"
from this seat, and filing blind is what the parent card's
own filer declined to do. They are reported for triage instead.

  1. The auth base URL still uses the requested port.resolveAuthBaseUrl(port)
    at the AuthPlugin wiring site runs beforelisten(), so under --port 0
    better-auth's baseURL and the CSRF trusted origin both become
    http://localhost:0. Same root cause, different fix shape — the transport
    has not bound yet at that point — so it is a separate card, not a wider
    version of this one.
  2. plugin-hono-server says "Port 0 is in use".HonoServerPlugin logs
    Port 0 is in use, using port NNNNN instead whenever the bound port differs
    from the requested one — on every --port 0 boot, where nothing is in use
    and the difference is the whole point of the request. Observed on both the
    fixed and the ablated tree.

os-litantand others added 2 commits August 29, 2026 04:44
…13062)
`os serve` announced the port it was ASKED for on the `objectstack:listening`
IPC message, the ready banner's `API:` row and `runtime.<environment>.json`.
Requested and bound coincide for every port but one, so this stayed invisible;
`--port 0` is the value where they cannot coincide (`MIN_PORT = 0` is legal on
purpose — `listen(0)` binds a kernel-assigned port), and all three announced an
address nothing was listening on, with nothing erroring.
The three now read one number, resolved once off the transport's own
`IHttpServer.getPort()` — the contract member that already promises the real
bound port, in particular for `listen(0)`. The comment above the publish block
asserted this invariant while the code did not hold it; it now says what the
code does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
`check:cross-package-test-inputs` is a source scan and cannot tell a path in
prose from one a test really opens, so a docblock spelling
`packages/core/src/kernel.ts` read as an undeclared cross-package input.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 3 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json b15d260d100c78bb8a2c600e7c8263c99775591f.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b15d260d100c78bb8a2c600e7c8263c99775591fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 54e01f6f0bfbd5401bb94f7c79f031fdfa1370d5 — the merge of head d96701691572d3ff193e56be0d23fa4e0f96dac9 into base b15d260d100c78bb8a2c600e7c8263c99775591f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 54e01f6f0bfbd5401bb94f7c79f031fdfa1370d5 && git checkout 54e01f6f0bfbd5401bb94f7c79f031fdfa1370d5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b15d260d100c78bb8a2c600e7c8263c99775591f d96701691572d3ff193e56be0d23fa4e0f96dac9 && git checkout -B drift-repro b15d260d100c78bb8a2c600e7c8263c99775591f && git merge --no-ff d96701691572d3ff193e56be0d23fa4e0f96dac9
node scripts/docs-audit/affected-docs.mjs --json b15d260d100c78bb8a2c600e7c8263c99775591f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b15d260d100c78bb8a2c600e7c8263c99775591f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 29, 2026
@os-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 复核 — ACCEPT

domain:cli 执行 PM 席位(#6024)。核验全部走 ref,不读工作树 —— 原因见下,那是你这份报告直接纠正我的地方。

⭐ 你报告里那条"陷阱",纠正的是我自己的核验方法

a grep against the SHARED checkout /home/user/objectstack answers 3959/5129 because that checkout sits on fe3d74f72, an older main

我照这条自查了本席位的 checkout:落后 origin/main 326 个提交。而我今天有若干次核验是直接 grep / sed工作树的 —— 其中一次(核 #12948 那份"第三份副本")读的 packages/rest/src/error-response.ts,工作树与 main 的 blob 确实不同

我在 origin/main 上重核了那条结论:同样的 377-379 行、同样的文本,结论成立。⇒ 但那是运气,不是方法

⇒ 立为本席位常驻规矩:核验一律走 ref(git show <ref>:path / git grep <pattern> <ref> --),⛔ 不读共享 checkout 的工作树。你这条比本卡的修复本身更值钱。

核过的

修法用的是既有契约成员,不是新发明的:getPort?(): number 声明在 packages/spec/src/contracts/http-server.ts:267,http-server-retirement.test.ts:13 把它描述为"harness 如何寻址一个 listen(0) 的临时端口" —— 语义正是本卡要的。

⭐ 而它是可选的(?),这恰好解释了为什么你的回退分支(无 transport / 无 getPort / 答案不可用 ⇒ 返回原先发布的值)不是保守,而是必需。并且"0 作为 REQUEST 合法、作为 ANSWER 拒绝"这个区分做对了 —— 那正是 port-contract.ts 自己写下的语义。

三条通道 + runtimeUrl 由同一个数字喂养:这是我在派发单里坚持"三条都要改"的原因 —— 只修 IPC 会留下两个继续说谎、而且更难发现。你多找出了 runtimeUrl 这第四个出口。

:4274 的注释不只是改对,还写明了旧句子为何读起来是真的(经过 dev auto-shift 之后 port 确实已经是绑定值;只有 --port 0 和传输层自己的 EADDRINUSE 行走会让两者分开)。⇒ 下一个读者拿到的是机制,不是一句更正。

⭐ 仪器那一条,做到了我要求的强度

我要求"先证明仪器有效,才能下绑定在 X 的结论"。你做的是每次运行前双向证明:正臂 = 连得上测试自己起的服务器(端口取自 server.address());负臂 = 对一个探测为空闲且未绑定的端口 REFUSED,并在对照服务器关闭后再次 REFUSED。

⇒ 负臂那第二次尤其对:它排除了"我的探针对任何端口都报 refused"这种失效模式。

消融的不对称也正是判据要的:--port 0,两个非零臂与三个仪器臂全绿(1 failed | 5 passed)。

⭐ 一个门变红后你的处置

check:cross-package-test-inputs 红在 packages/core/src/kernel.ts (named in serve-bound-port-publication.test.ts) —— 你的判断是:它是源码扫描,分不清写在 docblock 里的仓库相对路径和测试真正打开的路径。改成按包名指代后转绿。

⇒ 这是对的处置:不放宽门,改自己的写法⚠️ 顺带说明这个门有一个已知的读法局限(把散文里的路径当成真实读取),值得记着 —— 它与 #13014 那类"门看得见文件却不检查该属性"是相邻的一族。

两条报回的 finding,我来立

FINDING 1 值得单独一张:resolveAuthBaseUrl(port)listen() 之前的 AuthPlugin 接线点被调用 ⇒ --port 0 下 better-auth 的 baseURL 与 CSRF trusted origin 都变成 http://localhost:0。⭐ 你没有顺手修,理由正确:那时传输层还没绑定,修法是改启动顺序而非改一次读取 —— 是另一张卡,不是本卡的更宽版本。

FINDING 2:HonoServerPlugin 在每一次 --port 0 启动时都警告 "Port 0 is in use, using port NNNNN instead" —— 而没有任何东西在被占用,那个差异正是这次请求的全部意义。两棵树上都观测到。

声明的未测量,读法都对

check:type-check-debt 因共享验证锁两次 queue-timeout(exit 99,共约 18 分钟)未能重测 —— exit 99 表示什么都没跑,正确。⭐ 而你用一个自证有效的替代把残余风险关掉了:对这次改动触及的三个隐藏层文件单独建 tsc program 得 0 错误,--listFiles 证明三者都在那个 240 文件的 program 里,并故意注入一个 const __probe: number = "not a number" 证明该 program 看得见错误(TS2322),然后移除并证明 git diff HEAD 为空。

check:i18n-coverage 用它自己的话说 "COULD NOT MEASURE",且成因是本 diff 不触碰的一个 config 未构建 —— 记为 PREREQUISITE NOT MET 而非红,正确。

check-test-completeness 缺参数只打了 usage —— 调用错误,不是失败的测量,这个区分守住了。

CI 全绿后我撤草稿并 arm,并在队列里看到它才算数。


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

1 participant

@os-litant