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
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 即失败

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
10 changes: 10 additions & 0 deletions emrg/client/app.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand All@@ -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

Expand Down
24 changes: 22 additions & 2 deletions emrg/client/daemon_manager.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand DownExpand Up@@ -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


# ── 协议客户端封装 ─────────────────────────────────────────────────────
Expand Down
56 changes: 56 additions & 0 deletions tests/test_daemon_manager.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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."""

Expand DownExpand Up@@ -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:
Expand Down
Loading