diff --git a/Agent.md b/Agent.md index eb6a3cb..ca18173 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` (825) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (834) — 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/bin/stop-emrg.cmd b/bin/stop-emrg.cmd index 2e1f4c8..981e3b6 100644 --- a/bin/stop-emrg.cmd +++ b/bin/stop-emrg.cmd @@ -11,6 +11,21 @@ echo [stop-emrg] ============ begin ============ echo [stop-emrg] EMRG_DIR=%EMRG_DIR% echo [stop-emrg] INSTALL=%INSTALL% +echo [0] call emrg stop (all processes, best-effort)... +if exist "%INSTALL%\bin\emrg.cmd" goto :stop_all_installed +where emrg >nul 2>&1 +if errorlevel 1 ( + echo [0] emrg command not found -- continue (first-time install) + goto :step1 +) +call emrg stop +echo [0] emrg stop done (exit=%errorlevel%) +goto :step1 +:stop_all_installed +call "%INSTALL%\bin\emrg.cmd" stop +echo [0] emrg stop done (exit=%errorlevel%) +:step1 + echo [1] check GUI (EMRG.exe)... taskkill /IM EMRG.exe >nul 2>&1 if not errorlevel 1 ( diff --git a/emrg/__main__.py b/emrg/__main__.py index 8c3c458..b14678a 100644 --- a/emrg/__main__.py +++ b/emrg/__main__.py @@ -5,6 +5,7 @@ emrg server Run daemon in foreground emrg server stop Stop the running daemon emrg server restart Restart the daemon + emrg stop Stop ALL running emrg processes (daemon, TUI, GUI) emrg update git pull + reinstall from source """ @@ -15,6 +16,7 @@ import json import logging import os +import re import signal import subprocess import sys @@ -68,6 +70,15 @@ def _build_parser() -> argparse.ArgumentParser: ) # (no action = foreground run, handled in main()) + # emrg stop + sub.add_parser( + "stop", + help="Stop ALL running emrg processes (daemon, TUI, GUI)", + description="Stop every running emrg process: the daemon, TUI clients and " + "the GUI app. Graceful stop first, force-kill stragglers. Used by the " + "Windows installer pre-stop (stop-emrg.cmd step [0]).", + ) + # emrg update sub.add_parser( "update", @@ -109,6 +120,8 @@ def main() -> None: _run_daemon() elif parsed.command == "rant": _send_rant(" ".join(parsed.message), project=parsed.project) + elif parsed.command == "stop": + _stop_all() elif parsed.command == "update": _run_update() else: @@ -193,6 +206,141 @@ async def _get_pid(): print("daemon not running.") +# ── Stop everything (`emrg stop`) ────────────────────────────── + +_EMRG_CLIENT_RE = re.compile(r"-m\s+emrg(\.server)?(\s|$)") + + +def _match_emrg_client(cmd: str) -> bool: + """True if a process command line belongs to an emrg process (TUI/daemon/GUI). + + Matches: + - `python -m emrg` (TUI client) + - `python -m emrg.server` (daemon; protocol/pid stop may have missed it) + - `/Applications/EMRG.app/...` (macOS GUI) + Does NOT match lookalikes like `-m emrg.serverless` or `-m emrgx`. + """ + if "EMRG.app" in cmd: + return True + return bool(_EMRG_CLIENT_RE.search(cmd)) + + +def _scan_emrg_client_pids(ps_output: str, own_pid: int) -> list[int]: + """Parse `ps -axww -o pid=,command=` output → pids of emrg processes. + + `own_pid` is excluded so `emrg stop` (itself `python -m emrg stop`) never + kills the CLI that is running it. + """ + pids: list[int] = [] + for line in ps_output.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split(None, 1) + if len(parts) != 2: + continue + try: + pid = int(parts[0]) + except ValueError: + continue + if pid == own_pid: + continue + if _match_emrg_client(parts[1]): + pids.append(pid) + return pids + + +def _stop_pids(pids: list[int]) -> list[int]: + """Graceful SIGTERM → short grace → SIGKILL. Returns pids that survived.""" + for pid in pids: + try: + os.kill(pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + pass + alive: list[int] = [] + for _ in range(20): # ~3s grace window + alive = [] + for pid in pids: + try: + os.kill(pid, 0) + alive.append(pid) + except (ProcessLookupError, PermissionError): + pass + if not alive: + break + time.sleep(0.15) + for pid in alive: + try: + os.kill(pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + return alive + + +def _stop_posix_clients() -> None: + """Kill TUI/GUI emrg client processes on POSIX (ps scan + SIGTERM/SIGKILL).""" + try: + out = subprocess.run( + ["ps", "-axww", "-o", "pid=,command="], + capture_output=True, text=True, timeout=10, + **win32_no_window_kwargs(), + ).stdout + except (OSError, subprocess.SubprocessError, TimeoutError): + print("emrg stop: could not scan processes (ps unavailable).") + return + pids = _scan_emrg_client_pids(out, os.getpid()) + if not pids: + print("emrg stop: no other emrg client processes found.") + return + print(f"emrg stop: found {len(pids)} client process(es), stopping ...") + survivors = _stop_pids(pids) + if survivors: + print(f"emrg stop: WARNING {len(survivors)} process(es) survived SIGKILL: {survivors}") + else: + print("emrg stop: client processes stopped.") + + +def _stop_windows_clients() -> None: + """Kill GUI (EMRG.exe) + TUI (python -m emrg, excluding daemon) on Windows. + + Mirrors bin/stop-emrg.cmd steps [1]/[2]: graceful GUI stop then unconditional + /F fallback (host 2026-08-10T01:27:07Z lesson), TUI via PowerShell command + line filter (wmic-free, Win11 24H2+ safe). + """ + kw = win32_no_window_kwargs() + # GUI: graceful first, then unconditional force (no survivor gate) + subprocess.run(["taskkill", "/IM", "EMRG.exe"], capture_output=True, **kw) + time.sleep(0.5) + subprocess.run(["taskkill", "/F", "/IM", "EMRG.exe"], capture_output=True, **kw) + # TUI: python.exe running `-m emrg` but NOT `emrg.server` (daemon) + ps_cmd = ( + "Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | " + "Where-Object { $_.CommandLine -match '-m emrg' -and " + "$_.CommandLine -notmatch 'emrg\\.server' } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }" + ) + subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, **kw, + ) + + +def _stop_all() -> None: + """Stop every running emrg process: daemon, TUI, GUI. + + Graceful stop first, force-kill stragglers — the CLI counterpart of + bin/stop-emrg.cmd (host request 2026-08-15: `emrg stop` must check all + open emrg TUI/GUI/server processes and stop them all). + """ + print("emrg stop: stopping daemon ...") + _stop_daemon() + if sys.platform == "win32": + _stop_windows_clients() + else: + _stop_posix_clients() + print("emrg stop: done.") + + def _restart_daemon() -> None: """Stop and restart the daemon.""" print("restarting daemon ...") diff --git a/tests/test_installer_stop.py b/tests/test_installer_stop.py index fcde1de..fda80f4 100644 --- a/tests/test_installer_stop.py +++ b/tests/test_installer_stop.py @@ -83,6 +83,29 @@ def test_stop_emrg_cmd_covers_gui_tui_daemon_and_inline_step4(): assert "Get-CimInstance" in verify_block +def test_stop_emrg_cmd_step0_calls_emrg_stop(): + """Host request 2026-08-15 (session s_260815_0844): stop-emrg.cmd must call + `emrg stop` at the very beginning (step [0]) — it stops daemon+TUI+GUI in one + shot. First-time install may not have the `emrg` command on PATH → skip + without error and continue with the rest of the script.""" + content = (REPO_ROOT / "bin" / "stop-emrg.cmd").read_text(encoding="utf-8") + # step [0] runs before step [1] (GUI taskkill) + step0_idx = content.index("echo [0] call emrg stop") + gui_idx = content.index("taskkill /IM EMRG.exe") + assert step0_idx < gui_idx + # preferred: installed launcher path (upgrade case — PATH may lack install\bin) + assert 'call "%INSTALL%\\bin\\emrg.cmd" stop' in content + # fallback: `emrg` on PATH; missing → continue without error (first-time install) + assert "where emrg >nul 2>&1" in content + assert "emrg command not found -- continue" in content + # both paths fall through to :step1 (never to :verify — step 4 must still run) + assert content.index("goto :step1") < gui_idx + # no %VAR% inside the new paren block (v1 parse-time expansion lesson) + block = content[step0_idx:gui_idx] + assert "%VAR%" not in block + assert "%errorlevel%" in block # echo-only use, never gates on it + + def test_stop_git_merged_single_file(): # rant 2026-08-12T14:00:05 验收:bin/ 下无 stop-git.ps1;grep stop-git 仅历史注释 assert not (REPO_ROOT / "bin" / "stop-git.ps1").exists() diff --git a/tests/test_main.py b/tests/test_main.py index 18ff175..fb2f896 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,9 @@ -"""Tests for emrg/__main__.py argument parsing.""" -from emrg.__main__ import _build_parser +"""Tests for emrg/__main__.py argument parsing and `emrg stop` process matching.""" +from emrg.__main__ import ( + _build_parser, + _match_emrg_client, + _scan_emrg_client_pids, +) class TestBuildParser: @@ -57,3 +61,56 @@ def test_rant_with_long_project_flag(self): assert args.command == "rant" assert args.project == "emrg" assert args.message == ["hello"] + + def test_stop_command(self): + parser = _build_parser() + args = parser.parse_args(["stop"]) + assert args.command == "stop" + + +class TestMatchEmrgClient: + """`emrg stop` process matching — positive and negative states.""" + + def test_matches_tui(self): + assert _match_emrg_client("python -m emrg") + assert _match_emrg_client("python -m emrg --init-auto-evolve") + + def test_matches_daemon(self): + assert _match_emrg_client("python -m emrg.server") + assert _match_emrg_client("pythonw -m emrg.server") + + def test_matches_macos_gui(self): + assert _match_emrg_client("/Applications/EMRG.app/Contents/MacOS/EMRG") + assert _match_emrg_client("EMRG.app/Contents/MacOS/EMRG --no-sandbox") + + def test_does_not_match_lookalikes(self): + # module-like but different module: must not match + assert not _match_emrg_client("python -m emrg.serverless") + assert not _match_emrg_client("python -m emrgx") + # unrelated processes + assert not _match_emrg_client("git fetch origin master") + assert not _match_emrg_client("python -m pytest tests/") + assert not _match_emrg_client("EMRGX.app/Contents/MacOS/EMRGX") + # `-m emrg` as substring of a longer flag (e.g. -X something) must not match + assert not _match_emrg_client("python -X dev main.py -m emrgistry") + + +class TestScanEmrgClientPids: + def test_parses_and_excludes_own_pid(self): + ps_out = ( + " 100 /usr/bin/python -m emrg\n" + " 200 /usr/bin/python -m emrg.server\n" + " 300 /usr/bin/python -m pytest\n" + " 400 /Applications/EMRG.app/Contents/MacOS/EMRG\n" + ) + pids = _scan_emrg_client_pids(ps_out, own_pid=200) + # 100 (TUI) + 400 (GUI) matched; 200 excluded as own pid; 300 unrelated + assert pids == [100, 400] + + def test_empty_and_malformed_lines(self): + ps_out = " 100 /usr/bin/python -m emrg\n\n \nnot-a-pid /bin/ls\n" + pids = _scan_emrg_client_pids(ps_out, own_pid=9999) + assert pids == [100] + + def test_no_matches(self): + assert _scan_emrg_client_pids(" 1 /sbin/launchd\n 2 /usr/libexec/foo\n", own_pid=9999) == []