From d7f30850305a6518903630bcb09a05a433a67c22 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 17 Aug 2026 17:09:00 +0800 Subject: [PATCH] emrg: stop_all cmdline-scan fallback for missed python daemon (Windows installer DeleteFile code 5) --- Agent.md | 2 +- emrg/_stop_all.py | 48 ++++++++++++++++- tests/test_installer_stop.py | 27 ++++++++++ tests/test_stop_all.py | 101 +++++++++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 2 deletions(-) diff --git a/Agent.md b/Agent.md index c3714148..1d0393a7 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` (876) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (885) — 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 e8a0ab81..45cb8082 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -24,7 +24,9 @@ - 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 + ``taskkill /F /PID`` → 3s poll → cmdline-scan fallback + (missing/stale pid file → kill any ``python*.exe -m emrg(.server)``, + rant 2026-08-17T17:03:38); 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 @@ -159,6 +161,38 @@ def _kill_pid_posix(pid: int, grace: float = 3.0) -> None: pass +def _scan_windows_python_emrg(own_pid: int) -> list[int]: + """Scan python.exe/pythonw.exe whose command line matches ``-m emrg`` / + ``-m emrg.server`` (TUI + daemon), excluding ``own_pid``. + + Command line is the only reliable identity on Windows (rant + 2026-08-17T17:03:38): the daemon's ``emrgd.pid`` can be missing/stale/ + mismatched (GUI spawn, crash restart, external unlink — #593 family), so a + live daemon would otherwise survive the pid-file path and keep locking the + ``websockets`` C extensions under ``install\\`` — the installer then fails + with ``DeleteFile failed; code 5`` while verify() reports clean. + """ + if not is_win(): + return [] + # Literal PowerShell script-block braces must be escaped as {{ }} — same + # contract as stop_tui() (str.format() would raise on unescaped braces). + ps_cmd = ( + "Get-CimInstance Win32_Process | " + "Where-Object {{ $_.ProcessId -ne {own} -and " + "$_.Name -match '^python(\\.exe|w\\.exe)?$' -and " + "$_.CommandLine -match '-m emrg' }} | " + "ForEach-Object {{ Write-Output $_.ProcessId }}" + ).format(own=own_pid) + try: + out = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, text=True, timeout=10, **_no_window(), + ).stdout + except (OSError, subprocess.SubprocessError, TimeoutError): + return [] + return [int(p) for p in out.split() if p.strip().isdigit()] + + # ── Minimal WebSocket client (RFC 6455, stdlib only) ──────────── def _ws_recv_exact(sock: socket.socket, n: int) -> bytes: @@ -304,6 +338,13 @@ def stop_daemon() -> None: break time.sleep(0.15) + # Fallback: pid file missing/stale/mismatched → the live daemon (or any + # TUI client stop_tui could not reach) would otherwise survive and keep + # locking files under install\. Scan the command line — the only reliable + # identity on Windows (rant 2026-08-17T17:03:38; returns [] on POSIX). + for pid in _scan_windows_python_emrg(os.getpid()): + _kill_pid_windows(pid) + # Port file cleanup: the daemon removes it on graceful shutdown; a # force-killed daemon cannot, so remove it once the pid is confirmed gone # (the next daemon start re-asserts both files). @@ -428,6 +469,11 @@ def _verify_windows() -> list[str]: pid = _read_pid_file() if pid is not None and _pid_alive(pid): residuals.append(f"daemon (pid {pid})") + # python emrg process residual (TUI/daemon by command line — covers the + # pid-file blind spot: a live daemon with a missing/stale pid file would + # otherwise pass verify and the installer would overwrite locked files) + for pid in _scan_windows_python_emrg(os.getpid()): + residuals.append(f"python emrg process (pid {pid})") # bundled-git residual try: out = subprocess.run( diff --git a/tests/test_installer_stop.py b/tests/test_installer_stop.py index bab58053..678d7b26 100644 --- a/tests/test_installer_stop.py +++ b/tests/test_installer_stop.py @@ -64,6 +64,33 @@ def test_stop_all_py_covers_daemon_gui_tui_git_verify(): assert "__name__ == \"__main__\"" in content +def test_stop_all_py_cmdline_scan_fallback(): + """rant 2026-08-17T17:03:38 — DeleteFile code 5: pid 文件盲区兜底。 + + stop_daemon() 只杀 emrgd.pid 里的 pid(文件丢失/过时/不匹配 → 实际活着的 + pythonw daemon 漏杀,锁住 websockets C 扩展),stop_tui() 刻意排除 + emrg.server,verify() 不扫 python 进程 → 漏杀时 exit 0 → Inno 继续覆盖。 + 修复 = Windows 侧按命令行扫描 python.exe|pythonw.exe -m emrg(.server) + 兜底(cmdline 是唯一可靠身份),daemon 步与 verify 步都接入。 + """ + content = _read("emrg/_stop_all.py") + # 扫描辅助:python.exe|pythonw.exe + CommandLine 匹配 -m emrg(含 emrg.server), + # 排除自身;不排除 emrg.server(那是 stop_tui 的盲区) + assert "def _scan_windows_python_emrg" in content + assert r"python(\\.exe|w\\.exe)?" in content + assert r"-match '-m emrg'" in content + assert "Write-Output $_.ProcessId" in content + assert "emrg\\.server" not in content # 绝不能 -notmatch emrg.server + # stop_daemon() 在 pid 路径后追加 cmdline 兜底 + daemon_src = content.split("def stop_daemon")[1].split("def stop_gui")[0] + assert "_scan_windows_python_emrg(os.getpid())" in daemon_src + assert "_kill_pid_windows(pid)" in daemon_src + # verify() 增加 python emrg 进程残留检查(不依赖 pid 文件) + verify_src = content.split("def _verify_windows")[1].split("def _verify_posix")[0] + assert "_scan_windows_python_emrg(os.getpid())" in verify_src + assert 'residuals.append(f"python emrg process (pid {pid})")' in verify_src + + def test_main_delegates_stop_to_stop_all(): content = _read("emrg/__main__.py") # stop 子命令帮助文案不再引用 stop-emrg.cmd diff --git a/tests/test_stop_all.py b/tests/test_stop_all.py index f6bdbe33..829dfee0 100644 --- a/tests/test_stop_all.py +++ b/tests/test_stop_all.py @@ -237,6 +237,107 @@ def test_stop_tui_renders_ps_template_win(self, monkeypatch): assert "ForEach-Object { Stop-Process" in ps +class TestScanWindowsPythonEmrg: + """_scan_windows_python_emrg — the cmdline fallback (rant 2026-08-17T17:03:38). + + Windows installer hit DeleteFile code 5 because a live python process + holding websockets' C extension was missed: stop_daemon() only trusted the + pid file, stop_tui() deliberately excluded emrg.server, and verify() never + scanned python processes. The command line is the only reliable identity. + """ + + def test_parses_pids_from_cim_output(self, monkeypatch): + calls: list = [] + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + + def fake_run(cmd, **kw): + calls.append(cmd) + return type("CP", (), {"stdout": " 101\n 202\nnot-a-pid\n\n"})() + + monkeypatch.setattr(_stop_all.subprocess, "run", fake_run) + assert _stop_all._scan_windows_python_emrg(9999) == [101, 202] + ps = calls[0][-1] + # matches `-m emrg` AND `-m emrg.server` (daemon), excludes own pid + assert "-match '-m emrg'" in ps + assert "-ne 9999" in ps + assert "Write-Output $_.ProcessId" in ps + # must NOT exclude emrg.server (that was stop_tui's blind spot) + assert "emrg\\.server" not in ps + + def test_renders_template_without_valueerror(self, monkeypatch): + import os + + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr( + _stop_all.subprocess, "run", + lambda cmd, **kw: type("CP", (), {"stdout": ""}), + ) + assert _stop_all._scan_windows_python_emrg(os.getpid()) == [] + + def test_posix_returns_empty(self, monkeypatch): + monkeypatch.setattr(_stop_all, "is_win", lambda: False) + assert _stop_all._scan_windows_python_emrg(1) == [] + + def test_subprocess_failure_returns_empty(self, monkeypatch): + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + + def boom(*a, **k): + raise OSError("powershell unavailable") + + monkeypatch.setattr(_stop_all.subprocess, "run", boom) + assert _stop_all._scan_windows_python_emrg(1) == [] + + +class TestStopDaemonCmdlineFallback: + """stop_daemon() must kill a live daemon even when emrgd.pid is missing + (rant 2026-08-17T17:03:38 acceptance #1: delete the pid file but keep the + pythonw -m emrg.server process alive → cmdline scan still kills it).""" + + def test_kills_python_emrg_when_pid_file_missing(self, monkeypatch, tmp_path): + monkeypatch.setattr(_stop_all, "config_dir", lambda: tmp_path) + killed: list[int] = [] + monkeypatch.setattr(_stop_all, "_kill_pid_windows", lambda pid: killed.append(pid)) + monkeypatch.setattr(_stop_all, "_scan_windows_python_emrg", lambda own: [777]) + monkeypatch.setattr(_stop_all, "_pid_alive", lambda pid: False) + _stop_all.stop_daemon() + assert killed == [777] + + def test_no_pids_no_kill(self, monkeypatch, tmp_path): + monkeypatch.setattr(_stop_all, "config_dir", lambda: tmp_path) + killed: list[int] = [] + monkeypatch.setattr(_stop_all, "_kill_pid_windows", lambda pid: killed.append(pid)) + monkeypatch.setattr(_stop_all, "_scan_windows_python_emrg", lambda own: []) + monkeypatch.setattr(_stop_all, "_pid_alive", lambda pid: False) + _stop_all.stop_daemon() + assert killed == [] + + +class TestVerifyWindowsPythonResidual: + """verify() must report a live python emrg process even without a pid file + (rant 2026-08-17T17:03:38 acceptance #2: residual → named list + exit 1).""" + + def test_reports_python_emrg_residual(self, monkeypatch): + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr(_stop_all, "_read_pid_file", lambda: None) + monkeypatch.setattr(_stop_all, "_scan_windows_python_emrg", lambda own: [555]) + monkeypatch.setattr( + _stop_all.subprocess, "run", + lambda cmd, **kw: type("CP", (), {"stdout": ""}), + ) + out = _stop_all._verify_windows() + assert any("python emrg process (pid 555)" in r for r in out) + + def test_no_python_residual_when_clean(self, monkeypatch): + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr(_stop_all, "_read_pid_file", lambda: None) + monkeypatch.setattr(_stop_all, "_scan_windows_python_emrg", lambda own: []) + monkeypatch.setattr( + _stop_all.subprocess, "run", + lambda cmd, **kw: type("CP", (), {"stdout": ""}), + ) + assert _stop_all._verify_windows() == [] + + class TestMainDelegatesToStopAll: def test_emrg_stop_cli_exits_nonzero(self): """`emrg stop` must sys.exit with the stop_all() code (installer gate)."""