From eaa6c78540fc0474379acbd2fbc4f900bc95394f Mon Sep 17 00:00:00 2001 From: AmberCXX Date: Tue, 8 Sep 2026 15:25:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(hub):=20=E5=90=AF=E5=8A=A8=E6=9D=83?= =?UTF-8?q?=E5=BD=92=E5=B1=9E=E4=B8=8D=E6=98=8E=E5=AF=BC=E8=87=B4=20sync?= =?UTF-8?q?=20=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88=E2=80=94=E2=80=94hub-cl?= =?UTF-8?q?ient=20=E7=9A=84=E5=AD=A4=E5=84=BF=20Hub=20=E9=A1=B6=E6=8E=89?= =?UTF-8?q?=20launchd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两套机制都在"确保 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) Claude-Session: https://claude.ai/code/session_017pGyNVmhbA1Z1vRoxLZbFe --- cli.test.ts | 34 +++++++++++++++++++- cli.ts | 57 ++++++++++++++++++++++++++++++++-- hub-client/hub-channel.ts | 40 ++++++++++++++++++++---- hub-client/hub-starter.test.ts | 22 +++++++++++++ hub-client/hub-starter.ts | 25 +++++++++++++++ 5 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 hub-client/hub-starter.test.ts create mode 100644 hub-client/hub-starter.ts diff --git a/cli.test.ts b/cli.test.ts index 000fa49..49268f3 100644 --- a/cli.test.ts +++ b/cli.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { cleanDirContents, HUB_INSTALL_PRESERVE_ENTRIES, syncApiTokenFileAt } from "./cli.js"; +import { cleanDirContents, HUB_INSTALL_PRESERVE_ENTRIES, syncApiTokenFileAt, waitUntil } from "./cli.js"; let tmpDir: string; @@ -75,3 +75,35 @@ describe("syncApiTokenFileAt", () => { expect(fs.existsSync(attackerTarget)).toBe(false); }); }); + +describe("waitUntil", () => { + test("returns true immediately when the predicate already holds", async () => { + let calls = 0; + const ok = await waitUntil(() => { calls += 1; return true; }, 1000, 10); + + expect(ok).toBe(true); + expect(calls).toBe(1); // 已经成立就不该再轮询 + }); + + test("returns true once the predicate flips partway through", async () => { + let n = 0; + const ok = await waitUntil(() => (n += 1) >= 3, 1000, 5); + + expect(ok).toBe(true); + expect(n).toBe(3); + }); + + test("returns false when the predicate never holds before the timeout", async () => { + const ok = await waitUntil(() => false, 60, 10); + + expect(ok).toBe(false); // 超时必须能报失败——否则调用方会把"没等到"当成"成功了" + }); + + test("awaits async predicates", async () => { + let n = 0; + const ok = await waitUntil(async () => (n += 1) >= 2, 1000, 5); + + expect(ok).toBe(true); + expect(n).toBe(2); + }); +}); diff --git a/cli.ts b/cli.ts index a2fd092..3463bef 100755 --- a/cli.ts +++ b/cli.ts @@ -305,9 +305,29 @@ function installCmd(): void { `); } +/** + * 轮询 predicate 直到为真或超时,返回是否在超时前成立。 + * + * 存在的理由:`launchctl bootout` / `bootstrap` 的退出码只说明命令被接受了, + * 不说明进程真的退出或真的起来了。把"发了命令"和"状态真的变了"分开, + * 否则就会出现"报告已重启、实际没换进程"。 + */ +export async function waitUntil( + predicate: () => boolean | Promise, + timeoutMs: number, + intervalMs = 250, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await predicate()) return true; + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + // ── sync ──────────────────────────────────────────────────────────────────── -function syncCmd(): void { +async function syncCmd(): Promise { log("🔄 Forge Hub sync\n"); // 1. Re-stage package snapshot from current source @@ -336,14 +356,45 @@ function syncCmd(): void { const uid = os.userInfo().uid; const domain = `gui/${uid}`; const label = `${domain}/com.forge-hub`; + const baseUrl = process.env.FORGE_HUB_URL ?? "http://localhost:9900"; + + // 有任何 HTTP 响应就算活着——状态码不重要,能应答就说明端口后面有 Hub + const hubResponding = async (): Promise => { + try { + await fetch(baseUrl, { signal: AbortSignal.timeout(1500) }); + return true; + } catch { + return false; + } + }; + try { execFileSync("launchctl", ["bootout", label], { stdio: "ignore" }); } catch { /* might not be bootstrapped */ } + + // bootout 的退出码只说明命令被接受,不说明进程已经退出。必须等它真的下线: + // 否则紧接着的 bootstrap 会撞上仍占着端口的旧进程,launchd 每 ThrottleInterval + // 崩一次,而这里照样打印"已重启"。 + if (!(await waitUntil(async () => !(await hubResponding()), 15_000, 500))) { + log("⚠️ 旧 Hub 仍在响应,bootout 没能让它下线——它多半不是 launchd 启动的"); + log(" (hub-client 在 Hub 不可达时会 detached spawn 一个,那种进程不是 launchd 的子进程,bootout 管不到)"); + log(" 运行时文件已同步到磁盘,但跑着的仍是旧代码。手动处理:"); + log(" lsof -nP -iTCP:9900 -sTCP:LISTEN # 找出占用者"); + log(` kill && launchctl bootstrap ${domain} ${LAUNCHD_PLIST}`); + return; + } + try { execFileSync("launchctl", ["bootstrap", domain, LAUNCHD_PLIST], { stdio: "inherit" }); - log("✓ Hub 已重启"); } catch { log(`⚠️ 无法重启 Hub。手动执行:launchctl bootout ${label} && launchctl bootstrap ${domain} ${LAUNCHD_PLIST}`); + return; + } + + if (await waitUntil(hubResponding, 20_000, 500)) { + log("✓ Hub 已重启(已验证重新响应)"); + } else { + log("⚠️ bootstrap 已提交,但 Hub 在 20s 内没有恢复响应——查 ~/.forge-hub/hub-stderr.log"); } } @@ -751,7 +802,7 @@ if (import.meta.main) { installCmd(); break; case "sync": - syncCmd(); + await syncCmd(); break; case "uninstall": uninstallCmd(); diff --git a/hub-client/hub-channel.ts b/hub-client/hub-channel.ts index 713f21c..b4c869a 100644 --- a/hub-client/hub-channel.ts +++ b/hub-client/hub-channel.ts @@ -19,7 +19,10 @@ import { import { z } from "zod/v4-mini"; import fs from "node:fs"; import path from "node:path"; -import { spawn } from "node:child_process"; +import { spawn, execFileSync } from "node:child_process"; +import os from "node:os"; + +import { chooseHubStarter, launchdPlistPath, LAUNCHD_LABEL } from "./hub-starter.js"; import { getSessionConfigPaths, isChannelMode, @@ -320,12 +323,37 @@ async function ensureHubRunning(): Promise { // Hub not running, try to start it log("Hub 未运行,尝试自动启动..."); try { - const hubPath = path.join(HUB_DIR, "hub.ts"); - const child = spawn(BUN_BINARY, [hubPath], { - detached: true, - stdio: "ignore", + const spawnDetached = (): void => { + const hubPath = path.join(HUB_DIR, "hub.ts"); + const child = spawn(BUN_BINARY, [hubPath], { + detached: true, + stdio: "ignore", + }); + child.unref(); + }; + + // 装了 launchd plist 就交给 launchd 启动。自己 detached spawn 会造出一个 + // launchd 管不到的孤儿:它占着端口,launchd 的 KeepAlive 每 ThrottleInterval + // 撞一次,而 `forge-hub sync` 的 bootout 也杀不到它——sync 会报"已重启", + // 实际跑的还是旧进程。没装 plist(或非 macOS)时保持原来的直接 spawn。 + const starter = chooseHubStarter({ + platform: process.platform, + plistExists: fs.existsSync(launchdPlistPath(os.homedir())), }); - child.unref(); + + if (starter === "launchd") { + const label = `gui/${process.getuid?.() ?? os.userInfo().uid}/${LAUNCHD_LABEL}`; + try { + execFileSync("launchctl", ["kickstart", label], { stdio: "ignore" }); + log("已请求 launchd 启动 Hub"); + } catch (err) { + // service 没 bootstrap 过时 kickstart 会失败——退回直接启动,至少能用 + logError(`launchctl kickstart 失败,回退到直接启动: ${String(err)}`); + spawnDetached(); + } + } else { + spawnDetached(); + } // Wait for Hub to start for (let i = 0; i < 20; i++) { diff --git a/hub-client/hub-starter.test.ts b/hub-client/hub-starter.test.ts new file mode 100644 index 0000000..7d06f97 --- /dev/null +++ b/hub-client/hub-starter.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; + +import { chooseHubStarter } from "./hub-starter.js"; + +describe("chooseHubStarter", () => { + test("delegates to launchd on macOS when the plist is installed", () => { + expect(chooseHubStarter({ platform: "darwin", plistExists: true })).toBe("launchd"); + }); + + test("spawns directly on macOS when no plist is installed", () => { + expect(chooseHubStarter({ platform: "darwin", plistExists: false })).toBe("spawn"); + }); + + test("spawns directly on linux even if a plist file happens to be present", () => { + // launchd 是 macOS 专有,别的平台上这个文件没有意义 + expect(chooseHubStarter({ platform: "linux", plistExists: true })).toBe("spawn"); + }); + + test("spawns directly on linux without a plist", () => { + expect(chooseHubStarter({ platform: "linux", plistExists: false })).toBe("spawn"); + }); +}); diff --git a/hub-client/hub-starter.ts b/hub-client/hub-starter.ts new file mode 100644 index 0000000..61dd78d --- /dev/null +++ b/hub-client/hub-starter.ts @@ -0,0 +1,25 @@ +/** + * Hub 启动权归属。 + * + * macOS 上装了 com.forge-hub.plist 时,hub 应该由 launchd 启动。hub-client 若自己 + * `spawn(..., { detached: true })` 再 `unref()`,会造出一个 launchd 管不到的孤儿: + * 它占着 Hub 端口,launchd 每 ThrottleInterval 撞一次、崩一次,而 `cli.ts sync` 的 + * `launchctl bootout` 同样杀不到它(不是 launchd 的子进程)——于是 sync 的"重启" + * 静默失效:磁盘上换成了新代码,跑着的还是旧进程。 + * + * 没装 plist(或非 macOS)时,自己 spawn 仍然是唯一可行的启动方式,保持原行为。 + */ +import path from "node:path"; + +export const LAUNCHD_LABEL = "com.forge-hub"; + +export function launchdPlistPath(home: string): string { + return path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`); +} + +export function chooseHubStarter(params: { + platform: string; + plistExists: boolean; +}): "launchd" | "spawn" { + return params.platform === "darwin" && params.plistExists ? "launchd" : "spawn"; +}