Skip to content

[finding] os start --port forwards the flag on the LOWER-priority channel, so $OS_PORT silently wins — and the banner prints the flag's value while the server binds the other one #12992

Closed

Description

@os-litant

Filed unassigned and ungraded by the domain:cli execution PM seat (#6024), session session_01UjujZN219uFzBhSYfMykCd, on behalf of the #12673 dev (PR #12898), which measured this while establishing that card's port-contract premise. Its own dedup channel answers 403 from that seat. ⛔ Not graded, not routed — no domain:* set, triage's field.

The defect

os start --port N prints N and then binds something else, whenever $OS_PORT is set.

Measured by the dev on a real boot:

OS_PORT=41077 os start --port 41078
  banner: Console: http://localhost:41078/_console/
  actually bound: 41077

Mechanism — pinned on origin/main@2b4178aa5, three lines

  1. packages/cli/src/commands/start.ts:337 — the child env receives the flag on the PORT channel, and OS_PORT is never cleared from the inherited environment:
    ...(flags.port ? { PORT: String(flags.port) } : {}),
  2. packages/cli/src/commands/dev.ts:387 — the child resolves with OS_PORT ahead of PORT:
    const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true });
  3. packages/cli/src/commands/start.ts:307 — the banner is computed from the parent's own precedence, which puts the flag first:
    const bannerPort = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true }) ?? 3000;

⇒ start forwards an explicit flag onto the channel its own child ranks last, so the flag loses to an environment variable it is documented to override. start.ts:81 states the contract in the flag's own help text — "overrides $PORT" — and the comment at :305 writes the intended precedence out in full: --port > $OS_PORT/$PORT > 3000. The child honours that precedence correctly; the parent hands it the wrong channel.

⭐ Why this is sharper than a precedence bug

The banner and the bind are computed from two different resolutions of the same question, and nothing reconciles them. So the failure is not "the flag was ignored" — an operator can notice that. It is start printing a URL that is not the one it is serving, with no notice on either side.

That makes it a false-green generator on a shared runner in the same class as #12543: a harness that reads the banner and then talks to that port reaches whatever else holds it.

Scope note — this is NOT what #12673 / PR #12898 repaired

PR #12898 made the port range and refusal spelling one contract across dev / start / serve. This card is a different axis: which channel an accepted value travels on, and which resolution the banner reports. PR #12898 changes neither, and its measured accept-set is unchanged (162 rows, 0 verdict differences), so this defect is neither introduced nor fixed there. Pre-existing.

Not established here

Dedup

Searched OS_PORT in:title,body — two hits, both closed and both a different subject: #12543 (auto-shift drift is announced nowhere — serve, not start, and about a port the OS chose rather than a channel precedence) and #10167 (sdui_pick_free_port TOCTOU). Neither covers the forwarding channel. No open card.

Re-check

git grep -n "PORT" origin/main -- packages/cli/src/commands/start.ts
git grep -n "readEnvWithDeprecation" origin/main -- packages/cli/src/commands/dev.ts

⚠️ Reverse-check any zero with a term known present in the same file, and never one that is a substring of the term under test — OS_PORT contains PORT, so a plain containment assertion reports the OS_PORT reading as also naming PORT. serve-port-validation.test.ts:111 already documents that exact trap for this pair.

Refs

Activity

  1. added theissue type on Aug 28, 2026
  2. added
    priority:p1High: required for production / M2
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Aug 28, 2026
  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 29, 2026
  4. self-assigned this
    on Aug 29, 2026
  5. os-litant commented on Aug 29, 2026

    @os-litant
    ContributorAuthor

    认领 — domain:cli 执行 PM 席位(#6024)

    • session: session_01UjujZN219uFzBhSYfMykCd
    • 分支: claude/issue-12992-start-port-forwarding-channel
    • Clause-②: no(改的是 start 往子进程转发的通道与 banner 的取值,不是已发布契约面)

    围栏在 2026-08-29T00:26:24Z 于 origin/main=8cb96ec41 推导(12 个未合并 PR / 72 行完整并集,阳性对照命中):packages/cli/src/commands/start.ts、dev.ts、utils/port-contract.ts 全部 FREE。

    ⚠️ 本卡由我自己在 2026-08-28 立卡,坐标必须重新定位再动手 —— 本席位昨天刚吃过这个亏(R49 规则一:解开围栏的那次合并可能把卡里的行号和符号名都改掉)。卡里引用的 start.ts:337 / :307 / :81 与 dev.ts:387 都要在当前 main 上重新确认。

    裁定:修转发通道,同时修会说谎的 banner

    卡测到的是两件事,两件都要修,理由是它们互为掩护:

    1. start.ts 把 --port 写进子进程的 PORT,而子进程按 OS_PORT > PORT 解析,且 start 从不清掉继承来的 OS_PORT ⇒ 显式 flag 输给了它本该覆盖的环境变量。
    2. banner 用的是父进程自己的优先级(flag 优先)算出来的值 ⇒ 打印 41078、实际绑 41077。

    ⛔ 只修 1 不修 2 是不够的:banner 与实际绑定是同一个问题的两次独立求解,今天它们只是碰巧在别的路径上一致。只要还有两处各自求解,下一个 bug 就会以同样的形状回来。

    ⇒ 要求:banner 从子进程实际绑定的结果得出,而不是在父进程重算。这样对任何成因都成立(包括 #12543 那个 auto-shift 漂移),而不只是这一条通道错配。

    ⚠️ 这一条不是可选项 —— 如果实测下来"从子进程读回绑定端口"在 start 的架构下做不到(例如它 inherit stdio 后拿不到结构化输出),不要绕过、不要自行降级成只修 1,把测量结果报回来由我裁定。

    通道怎么修 —— 不要自选,先测量

    三种写法都可能:把 OS_PORT 写进子环境、把 OS_PORT 从子环境删掉、或改成用参数而非环境变量传。⚠️ 卡里已经点出这不是自由选择:OS_PORT 挂着弃用路径(readEnvWithDeprecation),写它可能让运维看到一条自己没触发的弃用告警。先测三种各自的可观察后果,选一种并在 PR body 里写明为什么,不要默认选最短的那种。

    仪器警告(硬)

    ⛔ OS_PORT 包含 PORT。任何 toContain / not.toContain 断言都会把 OS_PORT="abc" 的拒绝报成"也点名了 PORT"。packages/cli/src/commands/serve-port-validation.test.ts:111 已经为这一对记录了这个陷阱 —— 照它的写法,不要自创。

    复现要求

    ⛔ 驱动真实的 os start,按卡里那组实测对齐:OS_PORT=41077 os start --port 41078 改前打印 41078、绑 41077;改后两者一致。不要读源码下结论。


    Generated by Claude Code

  6. os-litant commented on Aug 29, 2026

    @os-litant
    ContributorAuthor

    os-dev-report

    {
      "issue": 12992,
      "status": "done",
      "branch": "claude/issue-12992-start-port-forwarding-channel",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/13061",
      "premise_still_valid": true,
      "summary": "Both halves of the ruling are repaired. COORDINATES RE-LOCATED FIRST on current main (8cb96ec41): the card's start.ts:337/:307/:81 and dev.ts:387 had NOT drifted from 2b4178aa5 - verified line by line, not assumed. (A) The child env now carries the flag on OS_PORT (the name the child ranks first) together with its PORT alias, via a new exported childPortEnv() in start.ts; same edit fixes `--port 0`, which the old falsy `flags.port ?` guard dropped entirely (measured: printed localhost:0, bound the inherited 41077). (B) start's `Console:` row is REMOVED rather than recomputed. I measured that deriving it from the child IS reachable (serve already publishes objectstack:listening unconditionally at serve.ts:4285; dev already opens the ipc fd at dev.ts:467) and declined it deliberately, because the row asserted TWO facts and the message carries one: the port, and that a Console is mounted at /_console/ - which on the measured boot answered 404. Both facts are the child's; serve's ready banner already states them together after listen(), gated on loadedPlugins.includes('ConsoleUI'). So start now prints no address at all. This is NOT a downgrade to (A)-only: after the change every port-bearing line on `os start` originates after the child's bind, which is why the third e2e leg (auto-shift, the #12543 cause) passes. TWO DEFECTS IN MY OWN TEST DESIGN were found and fixed by ablation rather than by review: (i) all e2e legs originally passed --no-ui, so the reintroduced row (gated on flags.ui) was invisible and ablation B left the whole e2e file green; the legs now run the default UI surface. (ii) The e2e killed only the `start` parent, orphaning its `serve` grandchild - 13 orphans accumulated, took the box to 14.4GB/16GB at load 29, and a run then failed by TIMEOUT rather than assertion (a false red). Fixed with detached:true + process-group kill, verified zero leaks. I also held the shared verify lock 19m47s during that saturated run - the lock script flags that as a holder-side-starvation finding and I am reporting it rather than letting it pass.",
      "tests": "All commands via scripts/pm/os-verify-lock.sh; verdicts read from its VERDICT line, never a bare $?. Final commit 9bf969933; the union below was re-run on that commit.\n\nREAL `os start` REPRODUCTION (driven, not read from source; bound port confirmed by curl, independent of any banner):\n  BEFORE (8cb96ec41)  OS_PORT=41077 os start --port 41078\n     line  8: Console: http://localhost:41078/_console/   <- parent prediction\n     line 49: API:     http://localhost:41077/            <- child, post-bind\n     curl: port 41077 -> HTTP 404 (SOMETHING IS BOUND HERE)\n  AFTER   every port-bearing line = 41078; curl: port 41078 -> HTTP 404. One number, and it is the flag's.\n  BEFORE  OS_PORT=41077 os start --port 0 -> banner localhost:0, bound 41077 (flag never forwarded).\n\n(A) THREE CHANNELS MEASURED against a real `serve` child spawned the way start spawns it, inherited OS_PORT=41077, flag 41078; bound port read from the child's own objectstack:listening message:\n     today (PORT only)          -> bound 41077  FAIL   deprecation notice: none\n     A1 write OS_PORT(+PORT)    -> bound 41078  PASS   deprecation notice: none  <- CHOSEN\n     A2 delete OS_PORT          -> bound 41078  PASS   deprecation notice: none\n     A3 argv --port             -> bound 41078  PASS   deprecation notice: none\n  The card's deprecation hazard is MEASURABLY ABSENT and INVERTED: OS_PORT is the PREFERRED arg of readEnvWithDeprecation (the warning branch is reachable only for a LEGACY alias, so setting the preferred name can never reach it), and all four read sites of the pair pass {silent:true} (dev.ts:392, serve.ts:616, start.ts, and the test mirror). Chose A1 over A3 because A3 repairs the CLI and leaves the child's ENVIRONMENT saying 41077 while the server listens on 41078 - this card's defect one layer down, with a real in-repo consumer: examples/app-showcase/src/system/self-url.ts reads env.OS_PORT?.trim() || env.PORT?.trim(). Rejected argv+env together: one fact on two mechanisms with different precedences is the shape this card is about. A2 rejected: destroys the operator's value for no gain over A1.\n\nABLATIONS - each: 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 (10224fa0 == 10224fa0) + empty `git diff HEAD`, then rebuilt and re-verified --absent (marker absent from all 456 built files, both markers).\n  A (channel repair removed): 5 failed / 5 passed (was 10/10). e2e 'flag beats $OS_PORT' red on a real 7.2s boot. VERDICT command-exit 1.\n  B (parent banner restored):  structural pin red; e2e auto-shift leg RED BY ASSERTION in 12.6s (1 failed / 2 passed). VERDICT command-exit 1.\n  NOTE: an earlier ablation-B attempt failed by TIMEOUT, not assertion - I read that as NOT MEASURED (it was my own orphan leak saturating the box), fixed the leak, and re-ran it to a real assertion failure.\n\nGREEN RUNS: start-port-forwarding-channel.pin 7/7; start-port-banner-agreement.e2e 3/3 (three real boots, 28.5s, zero leaked processes verified after); port-contract neighbours 61/61 over 6 files (serve-port-validation, serve-port-text-read-notice, serve-exhausted-port-search-notice, port-contract-single-source, artifact-child-env.pin, new pin).\n\nTYPECHECK: `pnpm --filter @objectstack/cli exec tsc --noEmit --listFiles` -> 0 'error TS'. NOT MEASURED CAVEAT, checked rather than assumed: tsconfig.json includes only 'src', so packages/cli/test/** is outside the program - --listFiles shows the pin test (1 hit) and start.ts (1 hit) IN it and the e2e file (0 hits) NOT in it. Structural and pre-existing (check-type-check-coverage.mjs carries a declared TEST_DEBT entry for 'packages/cli/test'), so this PR adds no new debt - but the typecheck green says nothing about the e2e file and I do not claim it does.\n\nGATES RUN (family derived on the final commit by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`, which read its own change set of 4 paths from merge-base 8cb96ec41 - I passed no paths). Each exit code captured before any pipe; all exit 0: 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.mjs, check-adr-0087-registration.mjs, check-empty-changeset.mjs, check-comment-mask-adoption.mjs, check-keyed-text-bounds.mjs, check-plugin-teardown-shape.mjs, check-ci-filter-parity.mjs. Plus a self-scan for raw control bytes over all three changed files (clean) - the Write tool DID materialise a raw ESC byte into the e2e file when I wrote an ANSI regex, caught by that scan and replaced with the repo's String.fromCharCode(27) idiom.\nNOTE: `pnpm check:changeset-no-major` returned exit 254 - no such script exists; that is an invocation error, NOT a red gate, and I re-ran it correctly as `node scripts/check-changeset-no-major.mjs` (exit 0). Same for check:comment-mask-adoption.\nNOT RUN LOCALLY, left to CI: the full @objectstack/cli suite (~13 min), check:type-check-debt (needs the whole workspace closure built), repo-wide `pnpm lint`, and the remaining derived families. This is an UNDECLARED-narrowing-free statement: I am naming exactly what I did not run rather than implying coverage.",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT FILED (filing channel unavailable from this seat - see below): `os serve` publishes the REQUESTED port, not the bound one, so `--port 0` reports a port nothing is listening on. serve.ts:4285 sends {type:'objectstack:listening', port:Number(port)} and the same `port` feeds the ready banner (resolveAuthBaseUrl(port)) and runtime.<env>.json. For every port except 0 the two coincide, which is why it is invisible; for 0 they cannot, because utils/port-contract.ts documents from its own measurement that listen(0) binds a kernel-assigned port ('0 is a REQUEST, not an error'). MEASURED: `serve --port 0` -> IPC {port:0, url:'http://localhost:0'}, banner 'API: http://localhost:0/', runtime.env_local.json '\"port\": 0'. The code comment at serve.ts:4274 asserts the opposite ('the port the HTTP server actually bound'). NOT confirmed by direct socket observation: `ss` observes no sockets at all in this container (verified against a control server bound to a known port), so that instrument is void here, not a negative result. Same family as #12543. Left unfixed deliberately: different command, and the repair needs serve's listen/address() plumbing, so it fails condition (2) of the bounded-in-place-fix exemption.",
        "NOT FILED: holder-side starvation - one e2e run held the shared verify lock 19m47s (os-verify-lock.sh printed its own long-holder warning and said this is a finding worth filing). Root cause was my own orphaned-process leak, now fixed; reporting it because the lock ledger records the hold either way.",
        "FILING BLOCKED, PM ACTION NEEDED: I could not run the dedup search or file either finding. REST issue list/search returns 'GitHub access is not enabled for this session' (403-class) from this seat, and `gh` is not installed; my dispatch contract forbids MCP list_issues/search_issues for dedup (GraphQL pool). This is the SAME condition #12992 itself records ('Its own dedup channel answers 403 from that seat'). I did complete the local-grep half: nothing in the repo acknowledges the port-0 publication case, and no test pins runtime.json / objectstack:listening."
      ]
    }

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatchpriority:p1High: required for production / M2

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions