Skip to content

fix(hub): 启动权归属不明导致 sync 静默失效——hub-client 的孤儿 Hub 顶掉 launchd - #50

Open
AmberCXX wants to merge 1 commit into
LinekForge:mainfrom
AmberCXX:fix/hub-start-ownership
Open

AmberCXX wants to merge 1 commit into
LinekForge:mainfrom
AmberCXX:fix/hub-start-ownership

Conversation

@AmberCXX

@AmberCXX AmberCXX commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

动机

两套机制都在"确保 Hub 在运行",互相不知道对方存在:

  1. launchd — com.forge-hub.plist,RunAtLoad + KeepAlive
  2. hub-client — ensureHubRunning() 探测到 Hub 不可达就自己启动:
const child = spawn(BUN_BINARY, [hubPath], {
  detached: true,   // 脱离父进程
  stdio: "ignore",
});
child.unref();

第 2 条造出的进程不是 launchd 的子进程。它抢到端口之后:

  • launchd 每 ThrottleInterval(30s)尝试拉起自己的实例,撞端口崩掉,无限循环。hub-stderr.log 里刷满 Failed to start server. Is port 9900 in use?
  • forge-hub sync 的 launchctl bootout 杀不到它(不归 launchd 管),随后的 bootstrap 同样撞端口失败——而 log("✓ Hub 已重启") 是无条件打印的

净效果:sync 报告重启成功,实际跑的仍是旧代码。

实测:一台机器上 Hub 进程连续存活 4 天 6 小时,跨越多次 sync 从未被替换:

PID 96381  STARTED Fri Sep 4 08:19:27  ELAPSED 04-06:35:53

ps -o ppid 显示它的父进程是某个 Claude Code session 的 hub-channel.ts,不是 launchd。尝试手动纠正也失败了三次,失败模式完全一致——只要有一个活着的 Claude Code session,ensureHubRunning() 就会在 Hub 消失的毫秒内重新 spawn,比 launchctl bootstrap 和 launchctl kickstart 都快:

尝试 方法 结果
1 bootstrap 后 kill 孤儿 端口 20s 未释放
2 kill 后等端口释放再 bootstrap 同上
3 bootstrap + kill + 立即 kickstart 新监听者 PPID 仍是 hub-client

也就是说,在装了 launchd plist 的机器上,只要有 CC session 在跑,Hub 就不可能归 launchd 管,而且无法从外部纠正。

改动概要

hub-client(根因) — 新增 hub-starter.ts:装了 plist 时走 launchctl kickstart,保证 Hub 始终是 launchd 的子进程;kickstart 失败(service 未 bootstrap)则回退到原来的 detached spawn。

cli.ts sync(止损) — 新增 waitUntil(),把"命令被接受"和"状态真的变了"分开:

  • bootout 之后轮询确认 Hub 真的下线;等不到就明确报告"它多半不是 launchd 启动的、bootout 管不到"并给出手动步骤,不再打印已重启
  • bootstrap 之后轮询确认 Hub 恢复响应,成功消息改为有条件打印

测试

hub-client/hub-starter.test.ts(4):

平台 plist 期望
darwin 有 launchd
darwin 无 spawn
linux 有 spawn
linux 无 spawn

cli.test.ts 新增 waitUntil(4):立即成立时不再轮询;中途翻转能捕获;超时返回 false;支持 async predicate。超时那条是关键——没有它,调用方会把"没等到"当成"成功了",正好是本 PR 要修的病。

影响范围

  • 动两处:hub-client/hub-channel.ts 的 ensureHubRunning()(新增 hub-starter.ts 决策函数)和 cli.ts 的 sync(新增 waitUntil());hub-server 不涉及。
  • 非 macOS 或没装 plist 时行为完全不变(chooseHubStarter 返回 spawn,走原路径)。
  • macOS + 装了 plist:Hub 启动路径从 detached spawn 变为 launchctl kickstart;kickstart 失败仍回退 spawn,所以最坏情况等于改动前。
  • sync 的输出语义变化:以前无条件打印「✓ Hub 已重启」,现在只在确认 Hub 真下线又真回来时才打印;等不到会给出手动步骤。依赖旧输出文案做判断的脚本需要注意。

self-test 结果

(cd hub-client && bunx tsc --noEmit && bun test)  → 7 pass / 0 fail
bun test cli.test.ts                              → 7 pass / 0 fail
(cd hub-server && bun test)                       → 356 pass / 0 fail
(cd forge-engine && bun test)                     → 32 pass / 0 fail
bun hub-test-harness/harness.ts                   → 8/8
fh hub self-test                                  → 8/8(2026-09-13 在含本 PR 的本地 main 部署上补跑)
  • 安全自检 grep -rE '@im\.wechat|ou_[a-f0-9]{16,}|sk-ant-…|sk-…|[0-9]{9,10}:[A-Za-z0-9_-]{35}' 对本 PR 改动文件 → 无命中。
  • 环境:macOS 14.6.1 · Bun 1.3.13

两套机制都在"确保 Hub 在运行",互相不知道对方存在:

1. launchd(com.forge-hub.plist,RunAtLoad + KeepAlive)
2. hub-client 的 ensureHubRunning():探测不可达就 spawn(..., {detached:true}) + unref()

第 2 条造出的进程不是 launchd 的子进程。它抢到端口后:

- launchd 每 ThrottleInterval(30s) 试着拉起自己的实例,撞端口崩掉,无限循环,
  hub-stderr.log 里刷满 "Failed to start server. Is port 9900 in use?"
- `forge-hub sync` 的 `launchctl bootout` 杀不到它(不归 launchd 管),随后的
  bootstrap 同样撞端口失败,而 `log("✓ Hub 已重启")` 是无条件打印的

净效果:**sync 报告重启成功,实际跑的仍是旧代码**。实测一台机器上 Hub 进程连续
存活 4 天 6 小时,跨越多次 sync 从未被替换;`ps -o ppid` 显示它的父进程是某个
Claude Code session 的 hub-channel.ts,不是 launchd。

修复分两处:

**hub-client(根因)**:新增 hub-starter.ts,装了 plist 时走
`launchctl kickstart` 让 launchd 启动,保证 Hub 始终是 launchd 的子进程;
kickstart 失败(service 未 bootstrap)则回退到原来的 detached spawn。
非 macOS 或没装 plist 时行为完全不变。

**cli.ts sync(止损)**:新增 waitUntil(),把"命令被接受"和"状态真的变了"分开。
bootout 之后轮询确认 Hub 真的下线,等不到就明确报告"它多半不是 launchd 启动的、
bootout 管不到"并给出手动步骤,**不再打印已重启**;bootstrap 之后轮询确认 Hub
恢复响应,成功消息改为有条件打印。

测试:
- hub-client/hub-starter.test.ts(4):darwin+plist→launchd;darwin 无 plist→spawn;
  linux 有/无 plist 均→spawn
- cli.test.ts 新增 waitUntil(4):立即成立不轮询;中途翻转;超时返回 false;
  支持 async predicate

验证:
- (cd hub-client && bunx tsc --noEmit && bun test) → 7 pass / 0 fail
- bun test cli.test.ts → 7 pass / 0 fail
- (cd hub-server && bun test) → 356 pass / 0 fail
- (cd forge-engine && bun test) → 32 pass / 0 fail
- bun hub-test-harness/harness.ts → 8/8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pGyNVmhbA1Z1vRoxLZbFe
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