Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
});
});
57 changes: 54 additions & 3 deletions cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,29 @@ function installCmd(): void {
`);
}

/**
* 轮询 predicate 直到为真或超时,返回是否在超时前成立。
*
* 存在的理由:`launchctl bootout` / `bootstrap` 的退出码只说明命令被接受了,
* 不说明进程真的退出或真的起来了。把"发了命令"和"状态真的变了"分开,
* 否则就会出现"报告已重启、实际没换进程"。
*/
export async function waitUntil(
predicate: () => boolean | Promise<boolean>,
timeoutMs: number,
intervalMs = 250,
): Promise<boolean> {
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<void> {
log("🔄 Forge Hub sync\n");

// 1. Re-stage package snapshot from current source
Expand Down Expand Up @@ -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<boolean> => {
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 <PID> && 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");
}
}

Expand Down Expand Up @@ -751,7 +802,7 @@ if (import.meta.main) {
installCmd();
break;
case "sync":
syncCmd();
await syncCmd();
break;
case "uninstall":
uninstallCmd();
Expand Down
40 changes: 34 additions & 6 deletions hub-client/hub-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -320,12 +323,37 @@ async function ensureHubRunning(): Promise<HubProbeResult> {
// 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++) {
Expand Down
22 changes: 22 additions & 0 deletions hub-client/hub-starter.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
25 changes: 25 additions & 0 deletions hub-client/hub-starter.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Loading