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 diff --git a/emrg/client/app.py b/emrg/client/app.py index c469536f..f2aeea4d 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -310,6 +310,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) @@ -320,6 +323,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 4dd6579b..665313cf 100644 --- a/emrg/client/daemon_manager.py +++ b/emrg/client/daemon_manager.py @@ -70,9 +70,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", @@ -187,11 +203,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: