diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index 936a0a7..acf3237 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -24,7 +24,6 @@ const WebSocket = require("ws"); // emrgd.token 已是唯一规范位置,回退冗余)。 const TOKEN_FILE = () => path.join(os.homedir(), ".emrg", "emrgd.token"); const EMRGD_LOG = () => path.join(os.homedir(), ".emrg", "emrgd.log"); -const HOME_PID_FILE = () => path.join(os.homedir(), ".emrg", "emrgd.pid"); // Fixed daemon port (rant 2026-08-19T08:05:21 + 2026-08-20T14:32:52): the // daemon always listens on this constant — keep in sync with emrg/connect.py // EMRGD_PORT and emrg/_stop_all.py _EMRGD_PORT. The token file no longer @@ -221,24 +220,19 @@ class DaemonClient { throw new Error(`emrgd failed to start within timeout${this._readLogTail()}`); } - // Rant 2026-08-09T13:16:36 G43 加固:daemon 进程是否存活(emrgd.pid 探测)。 - // 存活 → ws 连接失败视为瞬时(daemon 重启/启动中),保留 port 文件交给退避重试; - // 死亡 → 允许 G43 删文件重拉。 - // 18:47:37:pid 文件在规范 ~/.emrg;16:03:31 后固定读该位置。 - _daemonProcessAlive() { - const pidFiles = [HOME_PID_FILE()]; - for (const pidFile of pidFiles) { - try { - const pid = Number(String(fs.readFileSync(pidFile, "utf8")).trim()); - if (!Number.isInteger(pid) || pid <= 0) return false; - process.kill(pid, 0); // 信号 0 = 仅探测存在性 - return true; - } catch (err) { - if (err && err.code === "EPERM") return true; // 进程存在但权限不同(Windows) - // ESRCH(不存在)/ ENOENT(无 pid 文件)→ 试下一个候选 - } - } - return false; + // Rant 2026-08-21T15:26:42:daemon 存活判断改用固定端口 TCP 探测—— + // emrgd.pid 不再作为存活依据(可缺失/stale:stop_all 清理、崩溃、外部删除), + // 固定端口才是 ground truth(rant 2026-08-19T08:05:21,connect.py + // is_server_running_sync 同语义)。端口通 = daemon 活着 = 绝不删 token; + // 端口不通才允许 stale-token 删除+重拉路径。pid 文件降级为纯诊断 + // (daemon 自己写/删,其他代码不再读它判断存活)。 + _daemonProcessAlive(timeoutMs = 1000) { + return new Promise((resolve) => { + const sock = net.connect({ host: "127.0.0.1", port: EMRGD_PORT, timeout: timeoutMs }); + sock.once("connect", () => { sock.destroy(); resolve(true); }); + sock.once("error", () => { sock.destroy(); resolve(false); }); + sock.once("timeout", () => { sock.destroy(); resolve(false); }); + }); } _findDaemonExecutable() { @@ -376,15 +370,16 @@ class DaemonClient { await this._awaitOpen(); } catch (e) { // G43 加固(rant 2026-08-09T13:16:36 根因):token 文件存在但连不上时, - // 先查 emrgd.pid —— daemon 进程还活着就【绝不删 token 文件】。旧 G43 直接 - // unlink 会把健康 daemon 的 token 文件删掉 → 僵尸态(daemon 活着、scheduler - // 永远 cannot connect、PID 锁挡住新 spawn)。只有 daemon 真死了才删+重拉。 - if (this._daemonProcessAlive()) { + // 先探测固定端口(rant 2026-08-21T15:26:42:TCP 探活,不再读 emrgd.pid)—— + // daemon 还活着就【绝不删 token 文件】。旧 G43 直接 unlink 会把健康 + // daemon 的 token 文件删掉 → 僵尸态(daemon 活着、scheduler 永远 + // cannot connect、PID 锁挡住新 spawn)。只有 daemon 真死了才删+重拉。 + if (await this._daemonProcessAlive()) { this.logger.warn( - `[gui] ws connect failed: ${e.message} — daemon pid alive, keeping token file (transient)` + `[gui] ws connect failed: ${e.message} — daemon port alive, keeping token file (transient)` ); try { this.ws.close(); } catch { /* ignore */ } - throw new Error(`daemon unreachable (pid alive): ${e.message}`); + throw new Error(`daemon unreachable (port alive): ${e.message}`); } if (skipStart) { this.logger.warn( diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index d94b89a..ba7db38 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -279,21 +279,33 @@ test("P2 skipStart: stale port + daemon 已死 → 抛错不重拉(不删文 // 预写 token 文件(连接用常量端口,必然失败场景由 ws error 模拟) fs.writeFileSync(TOKEN_FILE(), "seekrit-token"); // 127.0.0.1:1 拒绝连接 const client = new DaemonClient(); - // daemon 进程已死(无 pid 文件)→ 旧路径会删文件重拉;skipStart 必须拒绝 - let spawnCalls = 0; - client.startDaemon = async function () { - spawnCalls += 1; - throw new Error("startDaemon must not be called with skipStart"); + // 端口探测 mock:端口不通 = daemon 死(rant 15:26:42 固定端口为准) + const origConnect = net.connect; + net.connect = (opts) => { + const sock = new (require("node:events").EventEmitter)(); + sock.destroy = () => {}; + setTimeout(() => sock.emit("error", new Error("ECONNREFUSED")), 1); + return sock; }; - const p = client.ensureConnected({ skipStart: true }); - await waitForWs(); - const firstWs = currentMockWs; - firstWs.emit("error", new Error("connect ECONNREFUSED")); - await assert.rejects(p, /daemon unreachable \(skipStart\)/); - assert.strictEqual(spawnCalls, 0, "startDaemon must never be called"); - // token 文件保留(connManager 重启恢复依赖它判断 daemon 状态) - assert.ok(fs.existsSync(TOKEN_FILE()), "port file must be kept"); - assert.strictEqual(client.connected, false); + try { + // daemon 已死(端口不通)→ 旧路径会删文件重拉;skipStart 必须拒绝 + let spawnCalls = 0; + client.startDaemon = async function () { + spawnCalls += 1; + throw new Error("startDaemon must not be called with skipStart"); + }; + const p = client.ensureConnected({ skipStart: true }); + await waitForWs(); + const firstWs = currentMockWs; + firstWs.emit("error", new Error("connect ECONNREFUSED")); + await assert.rejects(p, /daemon unreachable \(skipStart\)/); + assert.strictEqual(spawnCalls, 0, "startDaemon must never be called"); + // token 文件保留(connManager 重启恢复依赖它判断 daemon 状态) + assert.ok(fs.existsSync(TOKEN_FILE()), "port file must be kept"); + assert.strictEqual(client.connected, false); + } finally { + net.connect = origConnect; + } }); test("Phase4: _findDaemonExecutable 打包模式定位捆绑 emrgd(POSIX)", async () => { @@ -350,74 +362,119 @@ test("Phase4: isPackaged startDaemon 走捆绑 emrgd 分支(非 python -m)", test("G43 stale port: 连接失败(port 文件存在但拒绝)→ 删文件重拉", async () => { const client = new DaemonClient(); - let respawned = false; - client.startDaemon = async function () { - respawned = true; - fs.writeFileSync(TOKEN_FILE(), "seekrit-token"); + // 端口探测 mock:端口不通 = daemon 死(rant 15:26:42 固定端口为准) + const origConnect = net.connect; + net.connect = (opts) => { + const sock = new (require("node:events").EventEmitter)(); + sock.destroy = () => {}; + setTimeout(() => sock.emit("error", new Error("ECONNREFUSED")), 1); + return sock; }; - const p = client.ensureConnected(); - await waitForWs(); - const firstWs = currentMockWs; - firstWs.emit("error", new Error("connect ECONNREFUSED")); - // 重拉后创建第二个 ws → open → auth → auth_ok - await waitForWs(() => currentMockWs !== firstWs); - assert.ok(respawned, "startDaemon should respawn after stale port"); - assert.strictEqual(fs.existsSync(TOKEN_FILE()), true); - assert.strictEqual(currentMockWs.url, "ws://127.0.0.1:" + EMRGD_PORT); - currentMockWs.emit("open"); - await waitForAuthSent(currentMockWs); - currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "auth_ok" }))); - await p; - assert.strictEqual(client.connected, true); + try { + let respawned = false; + client.startDaemon = async function () { + respawned = true; + fs.writeFileSync(TOKEN_FILE(), "seekrit-token"); + }; + const p = client.ensureConnected(); + await waitForWs(); + const firstWs = currentMockWs; + firstWs.emit("error", new Error("connect ECONNREFUSED")); + // 重拉后创建第二个 ws → open → auth → auth_ok + await waitForWs(() => currentMockWs !== firstWs); + assert.ok(respawned, "startDaemon should respawn after stale port"); + assert.strictEqual(fs.existsSync(TOKEN_FILE()), true); + assert.strictEqual(currentMockWs.url, "ws://127.0.0.1:" + EMRGD_PORT); + currentMockWs.emit("open"); + await waitForAuthSent(currentMockWs); + currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "auth_ok" }))); + await p; + assert.strictEqual(client.connected, true); + } finally { + net.connect = origConnect; + } }); -test("rant 13:16:36 G43 加固:daemon 进程活着 → ws 失败不删 port 文件、不重拉", async () => { +test("rant 15:26:42 加固:daemon 端口活着 → ws 失败不删 token 文件、不重拉", async () => { const client = new DaemonClient(); - // 写入 emrgd.pid(当前进程 = 活着) - fs.writeFileSync(path.join(tmpHome, ".emrg", "emrgd.pid"), String(process.pid)); - const tokenFile = TOKEN_FILE(); - fs.writeFileSync(tokenFile, "seekrit-token"); - assert.strictEqual(client._daemonProcessAlive(), true, "pid alive → true"); + // mock net.connect:端口通 = daemon 活着(固定端口 ground truth,不再读 emrgd.pid) + const origConnect = net.connect; + net.connect = (opts) => { + const sock = new (require("node:events").EventEmitter)(); + sock.destroy = () => {}; + setTimeout(() => sock.emit("connect"), 1); + return sock; + }; + try { + const tokenFile = TOKEN_FILE(); + fs.writeFileSync(tokenFile, "seekrit-token"); + assert.strictEqual(await client._daemonProcessAlive(), true, "端口通 → true"); - let respawned = false; - client.startDaemon = async function () { respawned = true; }; - const p = client.ensureConnected(); - await waitForWs(); - const firstWs = currentMockWs; - firstWs.emit("error", new Error("connect ECONNREFUSED")); - // 守卫路径:不删文件、不重拉,直接抛"daemon unreachable (pid alive)" - await assert.rejects(p, /daemon unreachable \(pid alive\)/); - assert.strictEqual(fs.existsSync(tokenFile), true, "token 文件必须保留(daemon 还活着)"); - assert.strictEqual(respawned, false, "pid 活着 → 不重拉 daemon(防风暴)"); + let respawned = false; + client.startDaemon = async function () { respawned = true; }; + const p = client.ensureConnected(); + await waitForWs(); + const firstWs = currentMockWs; + firstWs.emit("error", new Error("connect ECONNREFUSED")); + // 守卫路径:不删文件、不重拉,直接抛"daemon unreachable (port alive)" + await assert.rejects(p, /daemon unreachable \(port alive\)/); + assert.strictEqual(fs.existsSync(tokenFile), true, "token 文件必须保留(daemon 还活着)"); + assert.strictEqual(respawned, false, "端口活着 → 不重拉 daemon(防风暴)"); + } finally { + net.connect = origConnect; + } }); -test("rant 13:16:36 G43 加固:daemon 真死了(pid 不存在)→ 仍删文件重拉", async () => { +test("rant 15:26:42 加固:daemon 真死了(端口不通)→ 仍删文件重拉", async () => { const client = new DaemonClient(); - // pid 文件指向不存在的进程 → 视为死 daemon - fs.writeFileSync(path.join(tmpHome, ".emrg", "emrgd.pid"), "999999"); - assert.strictEqual(client._daemonProcessAlive(), false, "pid 不存在 → false"); - - let respawned = false; - client.startDaemon = async function () { - respawned = true; - fs.writeFileSync(TOKEN_FILE(), "seekrit-token"); + // mock net.connect:端口不通 = daemon 死了(pid 文件存在与否都不再影响判断) + const origConnect = net.connect; + net.connect = (opts) => { + const sock = new (require("node:events").EventEmitter)(); + sock.destroy = () => {}; + setTimeout(() => sock.emit("error", new Error("ECONNREFUSED")), 1); + return sock; }; - const p = client.ensureConnected(); - await waitForWs(); - const firstWs = currentMockWs; - firstWs.emit("error", new Error("connect ECONNREFUSED")); - await waitForWs(() => currentMockWs !== firstWs); - assert.ok(respawned, "真死 → 重拉 daemon"); - currentMockWs.emit("open"); - await waitForAuthSent(currentMockWs); - currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "auth_ok" }))); - await p; - assert.strictEqual(client.connected, true); + try { + assert.strictEqual(await client._daemonProcessAlive(), false, "端口不通 → false"); + + let respawned = false; + client.startDaemon = async function () { + respawned = true; + fs.writeFileSync(TOKEN_FILE(), "seekrit-token"); + }; + const p = client.ensureConnected(); + await waitForWs(); + const firstWs = currentMockWs; + firstWs.emit("error", new Error("connect ECONNREFUSED")); + await waitForWs(() => currentMockWs !== firstWs); + assert.ok(respawned, "真死 → 重拉 daemon"); + currentMockWs.emit("open"); + await waitForAuthSent(currentMockWs); + currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "auth_ok" }))); + await p; + assert.strictEqual(client.connected, true); + } finally { + net.connect = origConnect; + } }); -test("rant 13:16:36 G43 加固:无 pid 文件 → 视为死 daemon(删文件重拉)", async () => { +test("rant 15:26:42:emrgd.pid 缺失/存在都不再是存活依据——固定端口为准", async () => { const client = new DaemonClient(); - assert.strictEqual(client._daemonProcessAlive(), false, "无 pid 文件 → false"); + const origConnect = net.connect; + net.connect = (opts) => { + const sock = new (require("node:events").EventEmitter)(); + sock.destroy = () => {}; + setTimeout(() => sock.emit("error", new Error("ECONNREFUSED")), 1); + return sock; + }; + try { + // 即使 emrgd.pid 存在(指向当前活进程),端口不通 → 仍判死(pid 文件不参与判断) + fs.writeFileSync(path.join(tmpHome, ".emrg", "emrgd.pid"), String(process.pid)); + assert.strictEqual(await client._daemonProcessAlive(), false, "端口不通 → false(pid 存在也无效)"); + } finally { + net.connect = origConnect; + } }); test("rant 13:16:36 ⑤ spawn 节流:超 MAX_SPAWN_ATTEMPTS 后不再拉起 daemon", async () => { @@ -874,10 +931,19 @@ test("16:03:31: token 固定读规范 ~/.emrg → 存在则不 spawn 直接连 }); test("16:03:31: stale token + spawn 节流失败 → probe 诚实失败,抛原始错误(不假装复用)", async () => { - // 宿主场景:token 文件存在(stale,无 daemon 监听)。ws 失败 → pid 死 → + // 宿主场景:token 文件存在(stale,无 daemon 监听)。ws 失败 → 端口探测死 → // 删 stale token → spawn 节流抛错 → probe 无 token 可复用 → 诚实抛原始错误 // (projectDir 概念删除后 token 只有一个规范位置,probe 复用需 token 存在)。 const client = new DaemonClient(); + // 端口探测 mock:端口不通 = daemon 死(rant 15:26:42 固定端口为准,不再读 emrgd.pid) + const origConnect = net.connect; + net.connect = (opts) => { + const sock = new (require("node:events").EventEmitter)(); + sock.destroy = () => {}; + setTimeout(() => sock.emit("error", new Error("ECONNREFUSED")), 1); + return sock; + }; + try { // spawn 命中节流(正是宿主看到的假错误 "after 3 attempts") client.startDaemon = async function () { throw new Error("daemon failed to start after 3 attempts — please start it manually"); @@ -897,6 +963,9 @@ test("16:03:31: stale token + spawn 节流失败 → probe 诚实失败,抛原 assert.match(probeLine, /daemon_alive\(ping\)=/); assert.match(probeLine, /spawn_result=failed\(daemon failed to start after 3 attempts/); assert.ok(logs.some((l) => l.includes("no existing daemon reachable, giving up")), "诚实放弃日志"); + } finally { + net.connect = origConnect; + } }); // ── P2 自有流锁(G65 每连接独立;rant 15:07:19)──────────────────────────