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@@ -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 路径不受影响)
Expand Down
48 changes: 47 additions & 1 deletion emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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:
Expand DownExpand Up@@ -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).
Expand DownExpand Up@@ -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(
Expand Down
27 changes: 27 additions & 0 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
101 changes: 101 additions & 0 deletions tests/test_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)."""
Expand Down
Loading