diff --git a/Agent.md b/Agent.md index de91871d..60e5ba94 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,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` (869) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (871) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + 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/emrg/_stop_all.py b/emrg/_stop_all.py index 02e999e6..e8a0ab81 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -19,16 +19,23 @@ Steps (mirroring the old stop-emrg.cmd flow, all best-effort): -- daemon: ws protocol ``shutdown`` → ``~/.emrg/emrgd.pid`` → SIGTERM / - ``taskkill /F /PID`` → 3s poll; port file removed once dead - GUI: Windows ``taskkill /IM EMRG.exe`` graceful → unconditional ``/F``; POSIX ps-scan (``EMRG.app`` / ``EMRG-*.AppImage``) - TUI: Windows CIM filter ``python.exe|pythonw.exe -m emrg`` (not ``emrg.server``); POSIX ps-scan +- daemon: ws protocol ``shutdown`` → ``~/.emrg/emrgd.pid`` → SIGTERM / + ``taskkill /F /PID`` → 3s poll; port file removed once dead - bundled git: Windows ``install\\git\\`` prefix kill (git/ssh/plink/bash + fallback prefix full-kill — port of stop-emrg.cmd step 4) - verify: residual scan; any survivor → ``exit 1`` with a named list (installer aborts and shows the log, R125 semantics) + +Order is deliberate: **clients (GUI/TUI) first, daemon LAST** (host rant +2026-08-17T14:15:33). Both GUI and TUI auto-spawn the daemon when they +detect it missing — stopping the daemon first while clients are alive makes +them immediately re-spawn it, so the stop "stops nothing" and the installer +still hits locked files. With the daemon last, no client remains to bring +it back, and verify() sees the true final state. """ from __future__ import annotations @@ -453,13 +460,19 @@ def verify() -> list[str]: # ── Orchestration ─────────────────────────────────────────────── def stop_all() -> int: - """Run every stop step, then verify. Returns 0 (clean) or 1 (residuals).""" - print("emrg stop: stopping daemon ...") - stop_daemon() + """Run every stop step, then verify. Returns 0 (clean) or 1 (residuals). + + Clients (GUI/TUI) are stopped FIRST and the daemon LAST (rant + 2026-08-17T14:15:33): both clients auto-spawn the daemon when it + disappears, so stopping the daemon first would let a live client + immediately bring it back — leaving locked files for the installer. + """ print("emrg stop: stopping GUI ...") stop_gui() print("emrg stop: stopping TUI clients ...") stop_tui() + print("emrg stop: stopping daemon ...") + stop_daemon() if is_win(): print("emrg stop: stopping bundled git under install\\git ...") stop_bundled_git() diff --git a/tests/test_stop_all.py b/tests/test_stop_all.py index 0fcd4153..f6bdbe33 100644 --- a/tests/test_stop_all.py +++ b/tests/test_stop_all.py @@ -165,6 +165,44 @@ def test_main_exits_with_code(self, monkeypatch): assert exc.value.code == 1 +class TestStopAllOrder: + """stop_all() must stop clients FIRST and the daemon LAST (rant + 2026-08-17T14:15:33): GUI/TUI auto-spawn the daemon when they notice it + missing, so stopping the daemon first would let a live client re-spawn + it — the installer then still hits locked files.""" + + def _record_order(self, monkeypatch): + order: list[str] = [] + + def _rec(name: str): + def _fn(*a, **k): + order.append(name) + return _fn + + monkeypatch.setattr(_stop_all, "stop_gui", _rec("stop_gui")) + monkeypatch.setattr(_stop_all, "stop_tui", _rec("stop_tui")) + monkeypatch.setattr(_stop_all, "stop_daemon", _rec("stop_daemon")) + monkeypatch.setattr(_stop_all, "stop_bundled_git", _rec("stop_bundled_git")) + monkeypatch.setattr(_stop_all, "verify", lambda: []) + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + return order + + def test_clients_before_daemon(self, monkeypatch): + order = self._record_order(monkeypatch) + assert stop_all() == 0 + assert order == ["stop_gui", "stop_tui", "stop_daemon", "stop_bundled_git"] + # daemon MUST come after both clients — a client alive when the + # daemon dies would re-spawn it (auto-spawn mechanisms in GUI/TUI) + assert order.index("stop_daemon") > order.index("stop_gui") + assert order.index("stop_daemon") > order.index("stop_tui") + + def test_posix_skips_bundled_git(self, monkeypatch): + order = self._record_order(monkeypatch) + monkeypatch.setattr(_stop_all, "is_win", lambda: False) + assert stop_all() == 0 + assert order == ["stop_gui", "stop_tui", "stop_daemon"] + + class TestStopTuiPsTemplate: """Windows stop_tui() must render its PowerShell template without raising.