Skip to content

fix(launcher): eliminate port TOCTOU via child-reported handshake bind; stop forcing --debug (#401) - #480

Closed
ranxianglei wants to merge 1 commit into
masterfrom
2026-08-31_launcher-port-handshake
Closed

ranxianglei wants to merge 1 commit into
masterfrom
2026-08-31_launcher-port-handshake

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Problem

The launcher used a two-step probe-release-rebind for proxy ports: findFreePort bound the preferred port in the parent process, closed it, then passed the number to the child which bound it again — classic TOCTOU:

  • another bili grabbing the port inside the window → probeHealth passed against the foreign instance and client + MCP config silently attached to an instance with different routes/windows/compress settings (undetectable to the user);
  • any other program grabbing it (worse on Windows where Hyper-V reserves port ranges) → the health probe burned the full 20s timeout before erroring;
  • no rebind retry in either case.

Additionally the launcher forced debug: true on every spawned proxy (introduced in 422fc6c for diagnosability), so every bili omp / bili codex / ... ran at max log level (~12MB/day on one dev machine).

Fix

Child-reported bind (kills the race at its root). The parent no longer probes any port. Each launch spawns bili start --port <preferred> --handshake-file <per-launch tmp file> (new flag; also settable via BILI_HANDSHAKE_FILE). After binding, the child atomically publishes {origin, pid} (tmp file + rename) to that handshake file. Only our own child knows the per-launch path, so the reported origin is ours by construction — attaching to a foreign instance becomes impossible. The parent cross-checks pid against the spawned child before attaching as defense-in-depth (full instanceId handshake tracked separately in #403).

Stolen preferred port → atomic fallback instead of hang. In handshake mode, an EADDRINUSE on the preferred port makes the server re-listen on port 0 (OS-assigned, atomic — zero probe window), log a clear warn, and report that origin through the handshake file. Plain bili start conflict behavior (hint + exit 1) is unchanged.

Fast failure. The parent now watches for child exit during both wait phases (handshake + health). A dead child fails in well under 5s with a clear error pointing at the child log (/tmp/bili-proxy-<launchId>.log) instead of hanging 20s.

Log-level parity. Removed the forced debug: true; spawned proxies inherit their log level exactly like a plain bili start (--debug flag / ACP_DEBUG env / config file). The per-launch /tmp/bili-proxy-<launchId>.log redirect (the actual intent of 422fc6c) is kept.

Tests

  • tests/launcher.test.ts: ensureProxyRunning suite rewritten around the handshake protocol — attach-to-reported-origin (+ cleanup), race simulation (blocker occupies the preferred port; fake child falls back; asserts the parent only ever probes its own child's origin), fast-fail (child exits immediately → rejects in <5s with zero health probes, child killed, error carries the log path), pid ownership-mismatch refusal, debug-flag inheritance (--debug present iff requested).
  • tests/handshake-fallback.test.ts (new): integration test driving real startServer with the preferred port occupied by a blocker — asserts the handshake file carries the OS-assigned origin + correct pid, health returns 200 there, and the blocker is untouched.

Verification

  • npm run typecheck clean
  • npm test: 869/870 pass — the single failure (resolveClientCommand: codex/claude resolve to themselves) reproduces identically on pristine master in this environment (sandbox has /usr/bin/codex installed; pre-existing, unrelated)
  • npm run build clean
  • Real end-to-end race flow against the built dist: blocker holds the preferred port → real child falls back, parent attaches to the child's actual origin in 369ms, health 200, fallback warn present in the child log, startup log = 7 lines (default level, no forced debug)

…OU; stop forcing --debug

Parent-side findFreePort probed the preferred port, released it, then let the
child re-bind (TOCTOU, #401): another bili grabbing it made probeHealth pass
and silently attached client+MCP to a foreign instance; any other grabber
burned the full 20s health timeout. No retry either way.

- launcher: drop findFreePort; each launch passes a per-launch
  --handshake-file (BILI_HANDSHAKE_FILE). The child publishes {origin, pid}
  atomically after binding, so the origin is ours by construction; parent
  cross-checks pid before attaching and fails fast (<5s) with the child log
  path if the child exits before reporting/bind/health.
- server: in handshake mode, EADDRINUSE on the preferred port falls back to
  an OS-assigned port (atomic bind, zero probe window) with a clear warn.
  Plain bili start conflict behavior (hint + exit 1) is unchanged.
- launcher: no longer force debug:true on spawned proxies; the child inherits
  its log level exactly like a plain bili start (--debug flag / ACP_DEBUG env
  / config file). Per-launch /tmp/bili-proxy-<launchId>.log kept.
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 Reviewed PR #480 end-to-end (full diff + comparison against current master) before changing anything. Headline: this PR is largely already in master via a different, more complete implementation, so it should not be merged as-is. Details below.

What #480 does

One commit (b03e52b) on base 4729609:

  • Kills the port TOCTOU by having the child self-bind and report {origin, pid} through a per-launch --handshake-file (BILI_HANDSHAKE_FILE); parent attaches only to its own child's reported origin; EADDRINUSE → fall back to an OS-assigned port 0.
  • Removes the forced debug: true.
  • Adds fast-fail when the spawned child exits before binding (<5s instead of a 20s hang).
  • Rewrites tests/launcher.test.ts around the handshake protocol + adds tests/handshake-fallback.test.ts.

Why it's superseded (the important part)

Master already contains an independent fix for the same problem, landed ~5h after this branch was cut:

So two of #480's three goals (kill the TOCTOU, debug parity) are already merged, via a different mechanism (shared instance file + token, vs a per-launch handshake file). Merging #480 would introduce a second, competing handshake mechanism and it conflicts with master in src/launcher.ts, src/server.ts, and tests/launcher.test.ts (all changed-in-both since the base).

The one genuinely-new piece

Fast-fail on child exit. Master's spawn path still waits the full SPAWN_WAIT_MS = 20000 if the child dies before becoming healthy — there is no child.on("exit") watch inside ensureProxyRunning (the only exit handler lives in runClient, src/launcher.ts:1747). #480's early-exit detection (fail in ms, error points at the child log) is NOT in master and is worth keeping.

Recommendation

  1. Close fix(launcher): eliminate port TOCTOU via child-reported handshake bind; stop forcing --debug (#401) #480 as superseded by 实例治理伞形:身份/验活/attach-or-spawn/优雅退出/状态卫生 —— 合并修复 #394 #403 #407 #414 #410 #406 #417/launcher 端口 TOCTOU(探测-释放-重bind 无重试):可静默挂上陌生 bili 或 20s 挂死;且被启动代理强制 --debug 制造 12MB/天日志 #407 — do not merge (redundant competing mechanism + conflicts).
  2. Land the fast-fail delta as a small standalone PR on current master. I'm extracting exactly that now (adapted to master's instance-file flow, with a test) and will link it here.

Baseline verified on master before all of the above: npm run typecheck clean; launcher suite 115/116 — the single failure (resolveClientCommand: codex/claude resolve to themselves) is the pre-existing env-dependent one (this sandbox has /usr/bin/codex) and reproduces identically on pristine master, unrelated to this change.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 Follow-up — I extracted the one non-duplicated piece into a standalone PR so nothing valuable from #480 is lost:

→ PR #481: fix(launcher): fail fast when the spawned proxy child exits pre-bind
#481

This is the only gap #480 had that master still lacks:

  • ensureProxyRunning now watches the spawned child's exit event and breaks the health-poll loop early, so a startup crash reports in ms instead of burning the full SPAWN_WAIT_MS = 20000.
  • The error carries the exit code/signal and points at the per-launch log.
  • New test: child fires exit(1) right after spawn → rejects in well under 5s with exited before becoming healthy (code 1) + the log path.
  • Success path untouched — a live child never emits exit during polling, so normal startup is unchanged.

Verified against current master: npm run typecheck clean · full suite 947/948 (the single failure is the pre-existing env-dependent resolveClientCommand: codex/claude resolve to themselves) · npm run build clean.

Recommended disposition for #480 itself: close as superseded by #417 (c2567cc, instance governance) — its handshake-file TOCTOU fix and --debug removal are already merged through that work, and landing #480 would introduce a competing handshake mechanism plus conflicts in src/launcher.ts / src/server.ts / tests/launcher.test.ts. I've left #480 open for you to close; say the word if you'd rather I pull anything else out of it first.

@ranxianglei

Copy link
Copy Markdown
Owner Author

本 PR 基于 v0.1.67(4729609)开发;此后 master 已通过其他 PR 落地了覆盖 #401 全部三点的等价或更完整方案,本 PR 的 per-launch 握手文件设计与之重叠,关闭为 superseded:

#401 问题点 master 上的对应实现
探测-释放-重bind TOCTOU(父进程 findFreePort 后 close,子进程再 bind) c2567cc(feat: instance governance,#417):无探测-释放——子进程自己 bind,经 BILI_LAUNCH_TOKEN 把真实 origin/port/pid/instanceId 写回 instance 文件;父进程只在 token 匹配时 attach(src/launcher.ts ensureProxyRunning,"#407: no probe-release-rebind")
端口被抢 → 静默挂到陌生 bili token 归属核对 + health 里的 instanceId 比对(probeExistingInstance);stale record 不能为任何 origin 背书。测试:tests/launcher.test.ts:694 "ensureProxyRunning: launchToken handshake returns the child's real port (#407)"
被非 bili 软件占端口 → 20s 挂死、无重试 子进程侧 EADDRINUSE 重试 MAX_LISTEN_ATTEMPTS = 17,递增后回退 ephemeral,带明确 warn 日志;手动 bili start 保持 fail-fast
launcher 强制 --debug 制造大日志 proxyStartArgs 仅在 opts.debug 时加 --debug(不再强制)
(附加)f34200e(#446/#526,今日合入):未显式给 --port 时 launcher 直接走 OS ephemeral 端口,私有代理不再蹲 8787

验收标准对照:(a) 竞态下只 attach 自家子进程(token match)✅;(b) 默认等级日志量与 bili start 同级(强制 debug 已移除)✅。

若后续发现 master 方案仍有缺口(例如子进程死亡时父进程仍要等满 SPAWN_WAIT_MS 才报错——本 PR 有 <5s fast-fail,可作为小补丁单独提),再开新票。

@ranxianglei ranxianglei closed this Sep 4, 2026
antclt pushed a commit to antclt/billion-context that referenced this pull request Sep 7, 2026
Salvages the one non-duplicated improvement from superseded PR ranxianglei#480 (its handshake-file TOCTOU fix and --debug removal are already in master via ranxianglei#417 instance governance). ensureProxyRunning now watches the spawned child's exit event and breaks the health-poll loop early, so a startup crash reports in ms instead of burning the full SPAWN_WAIT_MS (20s) window. Error carries the exit code/signal and points at the per-launch log.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant