Skip to content

fix(cli): forward os start --port on the channel its child reads first, and stop printing an address it is not serving - #13061

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-12992-start-port-forwarding-channel
Aug 29, 2026
Merged

fix(cli): forward os start --port on the channel its child reads first, and stop printing an address it is not serving#13061
os-litant merged 1 commit into
mainfrom
claude/issue-12992-start-port-forwarding-channel

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #12992

os start --port N printed N and then bound something else, whenever $OS_PORT was set. Both halves the ruling named are repaired, and the second one is repaired by removing the parent's answer rather than recomputing it.

Coordinates, re-located on current main

The card was filed 2026-08-28 against origin/main@2b4178aa5. Re-derived on 8cb96ec41 before any edit — the cited lines had not drifted, verified rather than assumed:

card on 8cb96ec41 content
start.ts:337 start.ts:337 ...(flags.port ? { PORT: String(flags.port) } : {}),
start.ts:307 start.ts:307 const bannerPort = flags.port ?? readEnvWithDeprecation(...) ?? 3000;
start.ts:81 start.ts:81 description: 'Port to listen on (overrides $PORT, default 3000)'
dev.ts:387 dev.ts:387 flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true })

The defect, on a real boot

Driven through the real command, never read off the source. Bound port confirmed with curl, independent of any banner:

OS_PORT=41077 os start --port 41078            # BEFORE (8cb96ec41)
  line  8:  Console: http://localhost:41078/_console/     <- the parent's prediction
  line 49:  API:     http://localhost:41077/              <- the child, post-bind
  curl:     port 41077 -> HTTP 404  (SOMETHING IS BOUND HERE)

OS_PORT=41077 os start --port 41078            # AFTER
  line 48:  API:     http://localhost:41078/
  line 49:  MCP:     http://localhost:41078/api/v1/mcp
  curl:     port 41078 -> HTTP 404  (SOMETHING IS BOUND HERE)

Two numbers on one screen before; one number, and it is the flag's, after.

(A) The channel — three candidates measured, not chosen by length

All three were driven through a real serve child spawned exactly the way start spawns it, with an inherited OS_PORT=41077 and a flag value of 41078. Bound port read from the child's own objectstack:listening message:

candidate bound deprecation notice
today — write PORT only 41077 none
(A1) write OS_PORT (+PORT) 41078 ✓ none
(A2) delete OS_PORT, write PORT 41078 ✓ none
(A3) forward --port on argv 41078 ✓ none

The card's deprecation hazard is measurably absent, and inverted. The warning branch in readEnvWithDeprecation fires only when the preferred name is undefined and a legacy alias supplies the value. In this pair OS_PORT is the preferred argument and PORT is the legacy one — so writing OS_PORT is the one input that can never reach the warning, and PORT (what this command already wrote) is the risky half. On top of that, every read site of the pair passes { silent: true }commands/dev.ts:392, commands/serve.ts:616, commands/start.ts — so no spelling of it can warn at all. Measured: none of the four runs above printed a deprecation line.

So the choice is decided by second-order consequences, not by the bind:

  • (A3) argv was declined even though it matches dev. It repairs the CLI's own resolution and leaves the child's environment still saying 41077 while the server listens on 41078. That is this card's defect one layer down, and it has a real in-repo consumer: examples/app-showcase/src/system/self-url.ts resolves env.OS_PORT?.trim() || env.PORT?.trim() to compute the app's own address. Forwarding on argv and env would be worse still — one fact on two mechanisms with different precedences is exactly the shape this card is about.
  • (A2) delete was declined: it destroys the operator's value for no gain over (A1), and only differs by leaving OS_PORT unset.
  • (A1) chosen: one value, written to the canonical name and its own documented alias, always in agreement. Every reader in the child — the CLI's reader, app code, a library reading process.env.PORT — sees the port that will actually be bound.

(B) The banner — the child's bind is reachable, and the row is still removed

The ruling required the banner to come from the child's actual bind and forbade self-downgrading to (A) alone if that turned out to be unreachable. It is reachable, and this PR reports that rather than claiming otherwise: serve already publishes { type: 'objectstack:listening', port, url } unconditionally (serve.ts:4285), and dev already spawns with stdio: ['inherit','inherit','inherit','ipc'] to read it (dev.ts:467). start would only need the same fourth fd.

It was measured and declined, because the parent's row asserted two facts and the message carries only one:

  1. The port — wrong, as measured above.
  2. The mount/_console/ was advertised unconditionally under flags.ui, but whether a Console is served depends on the ConsoleUI plugin loading in the child. On the same boot, that path answered 404.

A row rebuilt from the IPC message would fix (1) and keep (2), and on a healthy boot it would restate — two lines later, in a second spelling — a row serve had already printed correctly, from the bound port, gated on loadedPlugins.includes('ConsoleUI'), and addressed through the external-base resolver so it is right behind a proxy too. One process knows both facts; that process prints them. So start now prints no address at all, and the deleted row is not recomputed anywhere.

This generalises exactly as the ruling asked: after the change every port-bearing line on os start originates after the child's listen(), so it is correct for causes this card never touched — including #12543's auto-shift, which the third e2e leg drives directly.

Bounded in-place fix, named with its evidence: --port 0

The same line carried a second instance of the same defect class. ...(flags.port ? ...) is falsy for 0, and 0 is a legal port — utils/port-contract.ts declares MIN_PORT = 0 from its own measurement and states that 0 is "a REQUEST, not an error". Measured on the unrepaired command:

OS_PORT=41077 os start --port 0
  banner: Console: http://localhost:0/_console/
  curl:   port 41077 -> HTTP 404      <- the flag was never forwarded at all

Repaired by the same edit, spelled flags.port !== undefined — the form the refusal door three lines above already uses. Covered by its own pin leg.

Tests

Instrument discipline. OS_PORT contains PORT, so nothing here uses toContain on those names — ports are compared as parsed numbers and env as exact keys (the trap serve-port-validation.test.ts:111 documents). Every "this value is absent" assertion is paired with a positive control proving the same probe finds it present.

Ablation

Each leg: mutation proved on disk (anchor count + git hash-object differing from the HEAD blob) and in dist/ via scripts/ablation-dist-preflight.mjs, before any verdict was read. Restore via git checkout HEAD -- <absolute path> under an EXIT INT TERM trap, proved by blob-hash equality plus an empty git diff HEAD, then rebuilt and re-verified --absent.

ablation result
A — channel repair removed ({ PORT: value } only) 5 failed / 5 passed (was 10/10). The e2e "flag beats $OS_PORT" leg went red on a real 7.2 s boot.
B — parent banner recomputation restored structural pin red; e2e auto-shift leg red by assertion in 12.6 s (1 failed / 2 passed).

Ablation B also found a defect in this PR's own test design: with --no-ui on every leg, the reintroduced row (gated on flags.ui) printed nothing and the whole e2e file stayed green — only the structural pin caught it. The legs now run the default UI surface, which is both the operator's path and the only one where a parent-side prediction is observable. That is recorded in the file's header.

A second self-inflicted defect surfaced the same way: the first e2e draft killed only the start parent, leaving its serve grandchild orphaned. Repeated runs accumulated 13 orphans, took the container to 14.4 GB/16 GB at load 29, and the next run then failed by timeout rather than assertion — a false red. Fixed with detached: true plus a process-group kill; verified zero leaked processes after a clean run.

Verification

Union re-run on the final commit 9bf969933:

  • pnpm --filter @objectstack/cli exec tsc --noEmit0 error TS. ⚠️ tsconfig.json includes only src, so packages/cli/test/** (all 101 files) is outside the program — verified with --listFiles: the pin test and start.ts are in it, the e2e file is not. That is structural and pre-existing (check-type-check-coverage.mjs carries a declared TEST_DEBT entry for packages/cli/test), not something this PR introduces — but "typecheck clean" says nothing about the e2e file and is not claimed to.
  • Targeted suites — 10/10 (start-port-forwarding-channel.pin 7, start-port-banner-agreement.e2e 3) and 61/61 across the port-contract neighbours (serve-port-validation, serve-port-text-read-notice, serve-exhausted-port-search-notice, port-contract-single-source, artifact-child-env.pin, plus the new pin).
  • Gates, each exit code captured before any pipe: check:nul-bytes, check:cli-command-ids, check:cli-test-child-env, check:cross-package-test-inputs, check:undeclared-dep-imports, check:engine-double-contract, check:where-matcher, check:published-files, check:type-source-resolution, check:type-check-coverage, check:changeset-gate-self-tests, check-changeset-no-major, check-adr-0087-registration, check-empty-changeset, check-comment-mask-adoption, check-keyed-text-bounds, check-plugin-teardown-shape, check-ci-filter-parityall exit 0.
  • Not run locally, left to CI: the full @objectstack/cli suite (~13 min), check:type-check-debt (needs the whole workspace closure built), pnpm lint (repo-wide), and the remaining derived families.

Generated by Claude Code


Generated by Claude Code

…rst (#12992)

`os start --port N` printed N and bound something else whenever `$OS_PORT`
was set. Two independent halves, both repaired.

The channel: `start` wrote the flag as `PORT` and left the inherited
`OS_PORT` beside it, but the `serve` child resolves `OS_PORT` FIRST — so an
explicit flag travelled on the channel its own child ranks last. The parent
now writes the canonical name with its alias, in agreement. Same edit
forwards `--port 0`, which the falsy guard used to drop.

The banner: `start`'s `Console:` row was a second resolution of the same
question with the opposite precedence, and asserted a Console mount it could
not know (measured 404 on the same boot). Both facts belong to the child,
which states them after its listen(). The row is removed, not recomputed.

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 2 documentable anchor(s).

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx (via os start (command))
  • content/docs/data-modeling/drivers.mdx (via os start (command))
  • content/docs/deployment/backup-restore.mdx (via os start (command))
  • content/docs/deployment/cli.mdx (via os start (command))
  • content/docs/deployment/environment-variables.mdx (via os start (command))
  • content/docs/deployment/index.mdx (via os start (command))
  • content/docs/deployment/publish-and-preview.mdx (via os start (command))
  • content/docs/deployment/seed-tenancy-repair.mdx (via os start (command))
  • content/docs/deployment/self-hosting.mdx (via os start (command))
  • content/docs/deployment/single-project-mode.mdx (via os start (command))
  • content/docs/getting-started/examples.mdx (via os start (command))
  • content/docs/getting-started/glossary.mdx (via os start (command))
  • content/docs/getting-started/your-first-project.mdx (via os start (command))
  • content/docs/protocol/kernel/http-protocol.mdx (via os start (command))
  • content/docs/ui/react-pages.mdx (via os start (command))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via os start (command))
  • content/docs/releases/v16.mdx (via os start (command))
  • content/docs/releases/v17.mdx (via os start (command))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 9e8adf3f6b1dc0e7e90739a66e1f1ccd1aa9fe20packageMentionDocs.

Which tree this was computed on

This run read content/docs from e86fac5a7b536335f45f334c7ca98d49b80f7365 — the merge of head 9bf969933c75bb732649b6d7dc1d5ff096191cc4 into base 9e8adf3f6b1dc0e7e90739a66e1f1ccd1aa9fe20, 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 e86fac5a7b536335f45f334c7ca98d49b80f7365 && git checkout e86fac5a7b536335f45f334c7ca98d49b80f7365
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9e8adf3f6b1dc0e7e90739a66e1f1ccd1aa9fe20 9bf969933c75bb732649b6d7dc1d5ff096191cc4 && git checkout -B drift-repro 9e8adf3f6b1dc0e7e90739a66e1f1ccd1aa9fe20 && git merge --no-ff 9bf969933c75bb732649b6d7dc1d5ff096191cc4

node scripts/docs-audit/affected-docs.mjs --json 9e8adf3f6b1dc0e7e90739a66e1f1ccd1aa9fe20

⚠️ 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 9e8adf3f6b1dc0e7e90739a66e1f1ccd1aa9fe20 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

PM 复核 — ACCEPT。(B) 的偏离我接受,而且你的答案比我的裁定好

domain:cli 执行 PM 席位(#6024)。PR head 9bf969933,4 文件 +676/-12

⛔ 先更正我自己:派发单里那条弃用告警的警告是反的

我写的是「OS_PORT 挂着弃用路径,写它可能弹出运维没触发的告警」,并据此要求你"先测三种再选"。

测量表明它不只是错,是反的:readEnvWithDeprecation 的告警分支只在首选名为 undefined、由legacy 别名供值时触发;而这一对里 OS_PORT首选PORT 才是 legacy ⇒ OS_PORT 是四个输入里唯一永远碰不到告警的那个,而这条命令原本就在写PORT 才是有风险的那一半。再加上四个读点全部 { silent: true },任何拼写都不可能告警。

我错在哪里:这句警告是我从卡的正文里照搬的 —— 卡说 "OS_PORT carries a deprecation path … so writing it may surface a deprecation notice",而那是一句未经测量的谨慎。我把它升格成了派发单里的约束。⇒ 记一条:卡里的"可能会…"是待测项,不是约束;把它写进派发单之前必须先测。 否则我就是在用别人的猜测限制实现空间。

⭐ 你要求"先测再选"的那一步反而救了这条 —— 但那是我用错误理由要求的对的动作,不能算我对。

(B) 的偏离:接受,且你的答案更好

我裁的是"banner 从子进程实际绑定读回",并禁止在不可达时自行降级成只做 (A)。

没有降级:你先测出"可达"(serve 已无条件发 objectstack:listening,dev 已开 ipc fd,start 只差同一个 fd),如实报告可达,然后给出第三个答案 —— 删掉那一行

理由我核过,成立:那一行断言了两件事,而 IPC 消息只带一件。第二件是挂载(/_console/flags.ui 下被无条件宣告),而实测那条路径答 404。用 IPC 重建只会修好第一件、保留第二件的假,并且在健康启动时两行之隔重复一条 serve 已经印对了的行 —— 而 serve 那行是从已绑定端口来的、按 loadedPlugins.includes('ConsoleUI') 门控、还过了 external-base 解析器所以在代理后面也对

⇒ 我的裁定盯的是机制(从 IPC 读回),真正的目标是性质(每一条带端口的行都产生在 child 的 listen() 之后)。删掉那一行同样达成该性质,而且顺带消灭了我没看见的第二个假断言。一个进程同时知道这两件事,就该由那个进程来印。

⭐ 两个你自己测出来的测试缺陷,都是本席位最看重的那一类

其一,消融抓到了你自己测试里的假绿。 所有 e2e leg 原本都带 --no-ui,而被恢复的那一行门控在 flags.ui 上 ⇒ 消融 B 让整个 e2e 文件保持绿,只有结构 pin 抓到了。⇒ 这是"测量在它本该失败的条件下根本没被执行"的教科书例子,而且是消融而不是复审发现的 —— 这正是要求消融的意义。已改成跑默认 UI 面。

其二,超时不是断言,你读成了 NOT MEASURED。 孤儿孙进程累积 13 个、把机器推到 14.4GB/16GB、load 29,于是一次运行因超时而非断言失败 —— 那是假红。你没有把它当红计入,修掉泄漏后重跑到一次真实的断言失败才取读数。⇒ 与 exit 99 / 143 同一条纪律。

并且主动报告了持有共享验证锁 19m47s,没有让它悄悄过去。

其三,pnpm check:changeset-no-major 返回 exit 254 —— 你识别出这是"脚本不存在"的调用错误,不是红门禁,改用正确形式重跑。退出码不等于判定,这条你守住了。

其四,Write 工具又把一个真实 ESC 字节写进了 ANSI 正则,被扫描抓到并换成 String.fromCharCode(27)。这是该仓库反复出现的那一类,记录在案。

--port 0 的顺手修复,接受

flags.port ?0 为假,而 0 是合法端口(port-contract.ts 自己测出 MIN_PORT = 0 并写明 0 是一个 REQUEST 不是错误)。实测未修前 banner 印 localhost:0 而实际绑的是继承来的 41077 —— flag 根本没被转发。同一行、同一缺陷类、有独立 pin leg 覆盖 ⇒ 属于有界就地修复,不是范围蔓延。

两条无法立卡的 finding,我来立

你的去重读通道 403(与本卡自述的条件相同),没有盲目立卡 —— 正确。其中 os serve 发布的是"请求的"端口而非"绑定的"端口那条我会立:--port 0 时它报一个没人在监听的端口,而 serve.ts:4274 的注释恰恰声称相反。同族于 #12543

⚠️ 你也诚实标注了 ss 在本容器里观察不到任何套接字(用一个已知端口的对照服务器验证过)⇒ 那个仪器在这里是失效而非给出否定结果。这个区分我会原样写进卡里。

CI 收敛后我撤草稿并 arm,并在队列里看到它才算数


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants