From 04554d95a7cf609ccf7cda346c412c8d1f359e0c Mon Sep 17 00:00:00 2001 From: argszero Date: Sun, 9 Aug 2026 17:05:30 +0800 Subject: [PATCH 1/2] =?UTF-8?q?emrg:=20TUI=20daemon=20spawn=20throttle=20?= =?UTF-8?q?=E2=80=94=20complete=20the=20anti-storm=20fix=20(rant=202026-08?= =?UTF-8?q?-09T13:16:36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRs #592 (CREATE_NO_WINDOW + GUI spawn throttle + scheduler backoff) and #593 (port-file self-heal + G43 PID guard) contain the Windows v0.2.15 storm fix, but the TUI client had the same storm pattern the GUI had: app.py _reconnect() loops every 1s calling ensure_connected() → with a down daemon each iteration calls start_daemon() → spawns a NEW daemon process every second. On Windows each spawn was a cmd-window source (pre-#592); even windowless it is process churn + PID-lock exit races post-#592. This completes rant acceptance item ① ("启动 GUI/TUI 后零 cmd 窗口弹出"): - daemon_manager.start_daemon: spawn throttle — max 3 attempts per connect lifecycle, then raise with a clear 'run emrg server manually' message instead of spawning forever; counter resets on successful connect in ensure_connected (mirrors GUI daemon_client.js MAX_SPAWN_ATTEMPTS). - app.py _reconnect: on throttle, surface a one-time system message + status hint so the host knows to start the daemon manually (recovery path intact: host starts daemon → is_running True → connect succeeds → counter resets). Tests: +2 (start_daemon throttles after 3 attempts, no 4th spawn; counter resets on success). 641→643 py, doc counts synced (#511). --- Agent.md | 2 +- README.cn.md | 2 +- README.md | 2 +- emrg/client/app.py | 10 +++++++ emrg/client/daemon_manager.py | 24 +++++++++++++-- tests/test_daemon_manager.py | 56 +++++++++++++++++++++++++++++++++++ 6 files changed, 91 insertions(+), 5 deletions(-) diff --git a/Agent.md b/Agent.md index 67e761d6..4bb7fff9 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,7 @@ 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"` +Python: `uv run pytest tests/ -v` (643) — 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` 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..34420377 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 # 跑测试(当前 643 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 diff --git a/README.md b/README.md index 724528e9..72ffa189 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 643 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/client/app.py b/emrg/client/app.py index b7511716..0b90f7b6 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -300,6 +300,9 @@ async def _reconnect(): # close stale connection try: await conn.close() except Exception: pass + # Rant 2026-08-09T13:16:36 ⑤: spawn 节流命中后提示宿主手动启动 + # (否则每 1s 静默重试 spawn 一台新 daemon,Windows 上即弹窗风暴)。 + _throttle_warned = False while True: try: await asyncio.sleep(1) @@ -310,6 +313,13 @@ async def _reconnect(): status.update(center=server_id or "emrg") term.render() return + except RuntimeError as e: + if "failed to start after" in str(e) and not _throttle_warned: + _throttle_warned = True + chat.add("system", f"⚠ {e}") + status.update(center="daemon down — run 'emrg server'") + term.render() + continue except Exception: continue diff --git a/emrg/client/daemon_manager.py b/emrg/client/daemon_manager.py index 6bf72b4f..912ad534 100644 --- a/emrg/client/daemon_manager.py +++ b/emrg/client/daemon_manager.py @@ -69,9 +69,25 @@ def is_running() -> bool: return is_server_running_sync() +# Rant 2026-08-09T13:16:36 ⑤(防风暴总闸):daemon 启动失败时不得无限重拉—— +# TUI app.py _reconnect 循环每 1s 调 ensure_connected → start_daemon 会每 1s +# spawn 一个新 daemon 进程(Windows 上每个 spawn 都是 cmd 窗口来源)。单个 +# "连接生命周期"内最多 _MAX_SPAWN_ATTEMPTS 次 spawn,超限抛错提示宿主手动 +# `emrg server`;成功连接后归零。 +_MAX_SPAWN_ATTEMPTS = 3 +_spawn_attempts = 0 + + async def start_daemon() -> subprocess.Popen: """Start emrgd in the background and wait until it accepts connections.""" - logger.info("starting emrgd daemon...") + global _spawn_attempts + if _spawn_attempts >= _MAX_SPAWN_ATTEMPTS: + raise RuntimeError( + f"daemon failed to start after {_MAX_SPAWN_ATTEMPTS} attempts — " + "please run 'emrg server' manually and check emrgd.log" + ) + _spawn_attempts += 1 + logger.info("starting emrgd daemon (attempt %d/%d)...", _spawn_attempts, _MAX_SPAWN_ATTEMPTS) cleanup_server() proc = await asyncio.create_subprocess_exec( sys.executable, "-m", "emrg.server", @@ -183,11 +199,15 @@ async def ensure_connected() -> "DaemonConnection": 内部改名:check_and_restart_if_stale / is_running / start_daemon。 """ + global _spawn_attempts await check_and_restart_if_stale() if not is_running(): cleanup_server() await start_daemon() - return DaemonConnection(await connect_to_server()) + conn = DaemonConnection(await connect_to_server()) + # 连接生命周期成功 → spawn 节流计数归零(对照 GUI daemon_client.js auth_ok) + _spawn_attempts = 0 + return conn # ── 协议客户端封装 ───────────────────────────────────────────────────── diff --git a/tests/test_daemon_manager.py b/tests/test_daemon_manager.py index f54f3b59..b335cdee 100644 --- a/tests/test_daemon_manager.py +++ b/tests/test_daemon_manager.py @@ -24,6 +24,14 @@ from emrg.client import daemon_manager +@pytest.fixture(autouse=True) +def _reset_spawn_attempts(): + """每个测试前重置模块级 spawn 节流计数(跨测试状态不泄漏)。""" + daemon_manager._spawn_attempts = 0 + yield + daemon_manager._spawn_attempts = 0 + + class FakeWS: """Minimal websockets-like fake: send/recv/close.""" @@ -233,6 +241,54 @@ async def _run(): asyncio.run(_run()) +# ── Spawn throttle (rant 2026-08-09T13:16:36 ⑤) ───────────── +# TUI _reconnect 循环每 1s 调 ensure_connected → start_daemon 会每 1s spawn 一台 +# 新 daemon(Windows 每个 spawn 都是 cmd 窗口来源)。单个连接生命周期内最多 +# _MAX_SPAWN_ATTEMPTS 次,超限抛节流错误;成功连接后归零。 + +class TestSpawnThrottle: + @patch("emrg.client.daemon_manager.is_running", return_value=False) + @patch("emrg.client.daemon_manager.cleanup_server") + @patch("emrg.client.daemon_manager.asyncio.create_subprocess_exec", + new_callable=AsyncMock) + def test_start_daemon_throttles_after_max_attempts(self, mock_spawn, + mock_cleanup, mock_is_running): + proc = MagicMock(pid=1234) + mock_spawn.return_value = proc + + async def _run(): + # 前 3 次:真正 spawn(is_running 恒 False → 超时抛错) + for _ in range(3): + with pytest.raises(RuntimeError, match="failed to start"): + await daemon_manager.start_daemon() + assert mock_spawn.await_count == 3 + # 第 4 次:不再 spawn,直接抛节流错误(提示手动 emrg server) + with pytest.raises(RuntimeError, match="after 3 attempts"): + await daemon_manager.start_daemon() + assert mock_spawn.await_count == 3, "超过上限后不得再 spawn(防弹窗/重试风暴)" + assert daemon_manager._spawn_attempts == 3 + + asyncio.run(_run()) + + @patch("emrg.client.daemon_manager.is_running", return_value=False) + @patch("emrg.client.daemon_manager.check_and_restart_if_stale", + new_callable=AsyncMock) + @patch("emrg.client.daemon_manager.start_daemon", new_callable=AsyncMock) + @patch("emrg.client.daemon_manager.cleanup_server") + @patch("emrg.client.daemon_manager.connect_to_server", new_callable=AsyncMock) + def test_spawn_attempts_reset_on_success(self, mock_connect, mock_cleanup, + mock_start, mock_check, mock_running): + daemon_manager._spawn_attempts = 2 # 模拟已有失败 + ws = FakeWS([json.dumps({"type": "auth_ok"})]) + mock_connect.return_value = ws + + async def _run(): + await daemon_manager.ensure_connected() + assert daemon_manager._spawn_attempts == 0, "成功连接后节流计数必须归零" + + asyncio.run(_run()) + + # ── DaemonConnection ───────────────────────────────────────── class TestDaemonConnection: From 765822ef7cc4a96517eab0a03ed95b670d011e24 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sun, 9 Aug 2026 17:33:15 +0800 Subject: [PATCH 2/2] emrg: sync doc test counts to 652 (post-#593 merge + #594 tests) --- Agent.md | 2 +- README.cn.md | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Agent.md b/Agent.md index 39e6b5d1..6db0d4c8 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,7 @@ 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` (650) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (652) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (101: 27 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 9276262d..9e13d922 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 # 跑测试(当前 650 项) +uv run pytest tests/ -v # 跑测试(当前 652 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 diff --git a/README.md b/README.md index 30722182..87cf8f0d 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 650 items) +uv run pytest tests/ -v # run tests (currently 652 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI