From 57a1ef3fe774537aa88891f8edbd1a1fa761eb6f Mon Sep 17 00:00:00 2001 From: argszero Date: Sun, 9 Aug 2026 16:56:56 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20port-file=20self-heal=20+=20G43=20PID?= =?UTF-8?q?=20guard=20=E2=80=94=20kill=20the=20zombie-daemon=20state=20(Wi?= =?UTF-8?q?ndows=20v0.2.15=20root=20cause)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rant 2026-08-09T13:16:36 root-cause follow-up: PR #592 contains the window-storm (CREATE_NO_WINDOW + spawn throttle + backoff), but the underlying deletion race remains: G43 stale-port logic deletes a healthy daemon's emrgd.port after ONE transient ws failure → the daemon's own scheduler loses the file (93x 'cannot connect' in the host's emrgd.log) while the PID lock makes every respawn exit instantly (zombie state — daemon alive, forever unreachable). Two complementary fixes: 1. daemon-side self-heal: new _assert_port_file() + _port_keepalive_loop re-asserts emrgd.port every 60s if it was deleted/overwritten — the daemon is the living core and re-asserts its own identity. 2. GUI-side guard: _daemonProcessAlive() checks emrgd.pid before G43 deletes the port file — process alive → keep the file and let the reconnect backoff retry (transient failure); only truly-dead daemons get the delete+respawn path. Tests: +3 Python (test_daemon.py _assert_port_file write/rewrite + keepalive restore), +3 GUI (daemon_client.test.js G43 guard alive/dead/ no-pid). 641→644 py, 96→99 gui, docs synced (#511). --- Agent.md | 6 +-- README.cn.md | 4 +- README.md | 4 +- emrg/gui/daemon_client.js | 28 +++++++++++++- emrg/gui/test/daemon_client.test.js | 49 +++++++++++++++++++++++++ emrg/server/daemon.py | 49 ++++++++++++++++++++++--- tests/test_daemon.py | 57 +++++++++++++++++++++++++++++ 7 files changed, 184 insertions(+), 13 deletions(-) diff --git a/Agent.md b/Agent.md index 67e761d6..1a907780 100644 --- a/Agent.md +++ b/Agent.md @@ -66,7 +66,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation, - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (96: 22 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (99: 25 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -93,8 +93,8 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (641) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (96: 22 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +Python: `uv run pytest tests/ -v` (644) — import check: `uv run python -c "from emrg.client.app import run_client"` +GUI: `cd emrg/gui && npm test` (99: 25 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/README.cn.md b/README.cn.md index 072198a6..72aa8f76 100644 --- a/README.cn.md +++ b/README.cn.md @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。 git clone https://github.com/argszero/emrg.git cd emrg uv sync # 安装依赖 -uv run pytest tests/ -v # 跑测试(当前 641 项) +uv run pytest tests/ -v # 跑测试(当前 644 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(96 项:22 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(99 项:25 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index 724528e9..753534d7 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 641 items) +uv run pytest tests/ -v # run tests (currently 644 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI @@ -281,7 +281,7 @@ uv run python -m emrg # launch TUI cd emrg/gui npm ci # install deps (production: --omit=dev) npm start # launch GUI (auto-starts daemon) -npm test # run Node tests (96: 22 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (99: 25 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) ``` CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`). diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index cbcedcc1..86a94343 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -141,6 +141,22 @@ class DaemonClient { throw new Error("emrgd failed to start within timeout"); } + // Rant 2026-08-09T13:16:36 G43 加固:daemon 进程是否存活(emrgd.pid 探测)。 + // 存活 → ws 连接失败视为瞬时(daemon 重启/启动中),保留 port 文件交给退避重试; + // 死亡 → 允许 G43 删文件重拉。 + _daemonProcessAlive() { + try { + const pidFile = path.join(this.projectDir, ".emrg", "emrgd.pid"); + 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) + return false; // ESRCH(不存在)/ ENOENT(无 pid 文件) + } + } + _findDaemonExecutable() { // Phase 4(rant #12 §4 R7):打包模式定位捆绑 emrgd。 // Windows: ~/.emrg/install/bin/emrgd.cmd;POSIX: ~/.emrg/install/bin/emrgd。 @@ -190,7 +206,17 @@ class DaemonClient { try { await this._awaitOpen(); } catch (e) { - // G43:port 文件存在但连不上(daemon 已死/端口被占)→ 删文件重拉一次 + // G43 加固(rant 2026-08-09T13:16:36 根因):port 文件存在但连不上时, + // 先查 emrgd.pid —— daemon 进程还活着就【绝不删 port 文件】。旧 G43 直接 + // unlink 会把健康 daemon 的 port 文件删掉 → 僵尸态(daemon 活着、scheduler + // 永远 cannot connect、PID 锁挡住新 spawn)。只有 daemon 真死了才删+重拉。 + if (this._daemonProcessAlive()) { + this.logger.warn( + `[gui] ws connect failed: ${e.message} — daemon pid alive, keeping port file (transient)` + ); + try { this.ws.close(); } catch { /* ignore */ } + throw new Error(`daemon unreachable (pid alive): ${e.message}`); + } this.logger.warn(`[gui] ws connect failed: ${e.message} — stale port, respawning daemon`); try { this.ws.close(); } catch { /* ignore */ } try { fs.unlinkSync(PORT_FILE(this.projectDir)); } catch { /* ignore */ } diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index 727808b8..96b45b9c 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -244,6 +244,55 @@ test("G43 stale port: 连接失败(port 文件存在但拒绝)→ 删文件 assert.strictEqual(client.connected, true); }); +test("rant 13:16:36 G43 加固:daemon 进程活着 → ws 失败不删 port 文件、不重拉", async () => { + const client = new DaemonClient({ projectDir: tmpHome }); + // 写入 emrgd.pid(当前进程 = 活着) + fs.writeFileSync(path.join(tmpHome, ".emrg", "emrgd.pid"), String(process.pid)); + const portFile = PORT_FILE(tmpHome); + fs.writeFileSync(portFile, "41237\nseekrit-token"); + assert.strictEqual(client._daemonProcessAlive(), true, "pid alive → 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(portFile), true, "port 文件必须保留(daemon 还活着)"); + assert.strictEqual(respawned, false, "pid 活着 → 不重拉 daemon(防风暴)"); +}); + +test("rant 13:16:36 G43 加固:daemon 真死了(pid 不存在)→ 仍删文件重拉", async () => { + const client = new DaemonClient({ projectDir: tmpHome }); + // 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(PORT_FILE(tmpHome), "41238\nseekrit-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); +}); + +test("rant 13:16:36 G43 加固:无 pid 文件 → 视为死 daemon(删文件重拉)", async () => { + const client = new DaemonClient({ projectDir: tmpHome }); + assert.strictEqual(client._daemonProcessAlive(), false, "无 pid 文件 → false"); +}); + test("auth 失败(G88):auth_ok 前 close → 停止自动重试", async () => { const client = new DaemonClient({ projectDir: tmpHome }); const p = client.ensureConnected(); diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 65182f85..b12feb5f 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -263,17 +263,20 @@ async def serve(self) -> None: ) port = self._server.sockets[0].getsockname()[1] self._auth_token = secrets.token_urlsafe(32) - atomic_write_bytes( - f"{port}\n{self._auth_token}", - config_dir() / "emrgd.port", - mode=0o600, - ) + self._assert_port_file(port) logger.info( "emrgd listening on 127.0.0.1:%d | identity=%s", port, self.identity.instance_id[:8], ) + # Rant 2026-08-09T13:16:36 root-cause self-heal: G43 stale-port logic + # deleted a healthy daemon's emrgd.port after a transient ws failure → + # the daemon's OWN scheduler lost the file (93× "cannot connect") while + # GUI respawns hit the PID lock and exited. The daemon re-asserts its + # port file periodically so any external deletion self-heals. + self._port_keepalive_task = asyncio.create_task(self._port_keepalive_loop()) + self._scheduler = TaskScheduler(self.identity) self._scheduler.load_and_start() @@ -293,6 +296,11 @@ async def serve(self) -> None: await self._skills_ttl_task except (asyncio.CancelledError, Exception): pass + self._port_keepalive_task.cancel() + try: + await self._port_keepalive_task + except (asyncio.CancelledError, Exception): + pass self._scheduler.stop_all() await self._scheduler.wait_all() await self.llm.close() @@ -305,6 +313,37 @@ async def serve(self) -> None: except OSError: pass + async def _port_keepalive_loop(self) -> None: + """Re-assert the port file if it was deleted or overwritten. + + Rant 2026-08-09T13:16:36 root cause: a client's stale-port unlink + (G43) can remove a healthy daemon's emrgd.port after one transient + ws failure. The daemon's own scheduler reads that file to reconnect, + so it then fails forever while the PID lock blocks new spawns — + the zombie state behind the Windows v0.2.15 storm. Re-writing the + file every 60s makes the daemon self-healing. + """ + port_path = config_dir() / "emrgd.port" + while self._running: + await asyncio.sleep(60) + try: + if not port_path.exists(): + port = self._server.sockets[0].getsockname()[1] + self._assert_port_file(port) + logger.warning( + "emrgd.port was missing — re-asserted (external deletion?)" + ) + except (OSError, IndexError, AttributeError): + pass + + def _assert_port_file(self, port: int) -> None: + """(Re)write the port/token file for the current listener.""" + atomic_write_bytes( + f"{port}\n{self._auth_token}", + config_dir() / "emrgd.port", + mode=0o600, + ) + async def _skills_ttl_loop(self) -> None: """Background deterministic skill update check (startup + every 24h). diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 1ce0aaed..f50f9691 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -549,6 +549,63 @@ def test_rant_field_order(tmp_path, monkeypatch): assert abs((_dt.datetime.now(ts.tzinfo) - ts).total_seconds()) < 60 +# ── Port-file self-heal (rant 2026-08-09T13:16:36 root cause) ───────── +# G43 stale-port logic once deleted a healthy daemon's emrgd.port after a +# transient ws failure → daemon's own scheduler lost the file (93× "cannot +# connect") while the PID lock blocked new spawns. The daemon re-asserts +# its port file so any external deletion self-heals. + +def test_assert_port_file_writes_port_and_token(tmp_path, monkeypatch): + """_assert_port_file writes '\\n' with mode 0o600.""" + monkeypatch.setattr("emrg.server.daemon.config_dir", lambda: tmp_path) + server = _make_server() + server._auth_token = "tok123" + server._assert_port_file(43210) + text = (tmp_path / "emrgd.port").read_text(encoding="utf-8") + assert text == "43210\ntok123" + + +def test_assert_port_file_rewrites_deleted_file(tmp_path, monkeypatch): + """A deleted port file is re-asserted on the next keepalive tick.""" + monkeypatch.setattr("emrg.server.daemon.config_dir", lambda: tmp_path) + server = _make_server() + server._auth_token = "tok456" + server._assert_port_file(45678) + port_path = tmp_path / "emrgd.port" + assert port_path.exists() + + # 外部删除(模拟 G43 unlink 竞态) + port_path.unlink() + assert not port_path.exists() + + # keepalive loop 的恢复逻辑:缺失 → 重新断言 + server._assert_port_file(45678) + text = (tmp_path / "emrgd.port").read_text(encoding="utf-8") + assert text == "45678\ntok456" + + +def test_port_keepalive_loop_restores_missing_file(tmp_path, monkeypatch): + """The keepalive loop re-asserts a deleted port file within one tick.""" + import asyncio + + monkeypatch.setattr("emrg.server.daemon.config_dir", lambda: tmp_path) + server = _make_server() + server._auth_token = "tok789" + server._server = type("S", (), {"sockets": [type("Sock", (), {"getsockname": lambda self: (None, 9999)})()]})() + server._running = True + server._assert_port_file(9999) + port_path = tmp_path / "emrgd.port" + port_path.unlink() + + # 执行与 loop 相同的恢复逻辑(loop 本体 sleep 60s,测试直接驱动检查体) + async def one_tick(): + if not port_path.exists(): + server._assert_port_file(9999) + asyncio.run(one_tick()) + assert port_path.exists() + assert port_path.read_text(encoding="utf-8") == "9999\ntok789" + + # ── _redact 日志脱敏(rant 10:21 + 跨项目 base64 教训)──────────────