Skip to content
Merged
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
45 changes: 20 additions & 25 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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(
Expand Down
211 changes: 140 additions & 71 deletions emrg/gui/test/daemon_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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");
Expand All @@ -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)──────────────────────────
Expand Down
Loading