Uh oh!
There was an error while loading. Please reload this page.
fix(cli): os serve publishes the port it BOUND, on all three announcement channels - #13120
Conversation
…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
📓 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 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
|
os-litant
commented
Aug 29, 2026
PM 复核 — ACCEPT
⭐ 你报告里那条"陷阱",纠正的是我自己的核验方法
我照这条自查了本席位的 checkout:落后 origin/main 326 个提交。而我今天有若干次核验是直接 我在 ⇒ 立为本席位常驻规矩:核验一律走 ref( 核过的修法用的是既有契约成员,不是新发明的: ⭐ 而它是可选的( 三条通道 +
⭐ 仪器那一条,做到了我要求的强度我要求"先证明仪器有效,才能下绑定在 X 的结论"。你做的是每次运行前双向证明:正臂 = 连得上测试自己起的服务器(端口取自 ⇒ 负臂那第二次尤其对:它排除了"我的探针对任何端口都报 refused"这种失效模式。 消融的不对称也正是判据要的: ⭐ 一个门变红后你的处置
⇒ 这是对的处置:不放宽门,改自己的写法。 两条报回的 finding,我来立FINDING 1 值得单独一张: FINDING 2: 声明的未测量,读法都对
CI 全绿后我撤草稿并 arm,并在队列里看到它才算数。 Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13062
os servepublished the port it was asked for on all three of the channelsthat 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
0they cannot coincide —utils/port-contract.tsdeclares
MIN_PORT = 0from its own measurement and states that 0 is "aREQUEST, 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_URLall unset:objectstack:listening{ port: 0, url: 'http://localhost:0' }{ port: 39101, url: 'http://localhost:39101' }API:rowhttp://localhost:0/http://localhost:39101/runtime.env_local.json"port": 0"port": 39101The transport had really bound
44251on the ablated run (its own log says so),so all three channels were naming an address nothing was listening on.
Non-zero request,
--port 43203and--port 46581— byte-identical before andafter: 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:resolveBoundPortasks the transport that did the binding, through the contractmember that already promises this answer —
IHttpServer.getPort?():"after
listen()resolves, implementations that provide this member MUSTreturn the real bound port — in particular when
listen(0)requested anephemeral port". The Hono transport fills it from
@hono/node-server'slistening 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 theoptional member, or an answer that cannot be a bound port.
0is refused as ananswer 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
observing sockets:
sssees no sockets at all in this container, verified thereagainst 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')):server.address()reported;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 threechannels follow onto the port actually taken.
before the change, because
getAvailablePort()reassignsportitself — it ispinned as a regression arm, not as a repair.
EADDRINUSEwalk (HonoHttpServer.listen()retriestargetPort + attempt) — reachable only through a TOCTOU race betweenserve's probe and the transport'slisten(), so it is not raced in an e2e;it is pinned at the seam, where
resolveBoundPortis handed a transportreporting 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.tsblobd138ff34→71e89f76, zero fixed spellingsleft. No build step is involved on either leg: both the unit test (vitest
transform of
src/) and the e2e (tsx bin/run-dev.js→src/) execute thisfile from source, never through
dist/, and the ablation going red isitself the proof the mutated bytes reached the running code.
--port 0arm — RED (expected +0 not to be +0), and the two non-zeroarms plus all three instrument arms stayed GREEN. That asymmetry is the
evidence: 1 failed / 5 passed.
git checkout HEAD --with an absolute path under anEXIT INT TERMtrap; proven by blob hash back tod138ff34andgit diff HEADempty.Tests
packages/cli/src/commands/serve-bound-port-publication.test.ts— theresolver'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 aboutthe 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
detachedand torn down by process group — aneighbouring card's round left 13 orphaned
serveprocesses and pushed thiscontainer 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.ts—portDriftErrornow knows that--port 0is a request for any port, so a healthy--port 0boot is not adrift. ⛔ Not a silent skip: a banner that names
localhost:0under thatrequest 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 pinfollowed the banner onto
resolveAuthBaseUrl(boundPort), which is thestronger 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, pluspnpm --filter @objectstack/cli typecheckexit 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.
resolveAuthBaseUrl(port)at the AuthPlugin wiring site runs before
listen(), so under--port 0better-auth's
baseURLand the CSRF trusted origin both becomehttp://localhost:0. Same root cause, different fix shape — the transporthas not bound yet at that point — so it is a separate card, not a wider
version of this one.
plugin-hono-serversays "Port 0 is in use".HonoServerPluginlogsPort 0 is in use, using port NNNNN insteadwhenever the bound port differsfrom the requested one — on every
--port 0boot, where nothing is in useand the difference is the whole point of the request. Observed on both the
fixed and the ablated tree.