diff --git a/Agent.md b/Agent.md index f6fd557..648bf7e 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.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (995) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1001) — 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 + 128 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 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 3f783e3..02d9e4c 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -23,10 +23,10 @@ ``/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 → 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 +- daemon: ws protocol ``shutdown`` → fixed-port TCP probe wait → + SIGTERM / ``taskkill /F /PID`` → cmdline-scan fallback + (``-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 @@ -76,6 +76,10 @@ _EMRGD_PORT = 56031 _EMRG_CLIENT_RE = re.compile(r"-m\s+emrg(\.server)?(\s|$)") +# daemon-only cmdline identity (``-m emrg.server`` — rant 2026-08-21T16:45:06: +# the fixed port is the daemon ground truth, cmdline scan is the kill fallback; +# TUI clients use ``-m emrg`` and are stopped earlier in the chain). +_EMRG_SERVER_RE = re.compile(r"-m\s+emrg\.server(\s|$)") _APPIMAGE_RE = re.compile(r"EMRG-[\w.\-]*AppImage(\s|$)") @@ -183,16 +187,6 @@ def _pid_alive(pid: int) -> bool: return False -def _read_pid_file() -> int | None: - """Read ~/.emrg/emrgd.pid → int pid, or None if missing/invalid.""" - try: - raw = (config_dir() / "emrgd.pid").read_text(encoding="utf-8").strip() - pid = int(raw) - return pid if pid > 0 else None - except (OSError, ValueError): - return None - - def _kill_pid_windows(pid: int) -> None: """Force-kill a pid on Windows (taskkill /F — TerminateProcess).""" subprocess.run( @@ -219,28 +213,32 @@ def _kill_pid_posix(pid: int, grace: float = 3.0) -> None: pass -def _scan_windows_python_emrg(own_pid: int) -> list[int]: +def _scan_windows_python_emrg(own_pid: int, server_only: bool = False) -> list[int]: """Scan python.exe/pythonw.exe whose command line matches ``-m emrg`` / ``-m emrg.server`` (TUI + daemon), excluding ``own_pid``. + ``server_only=True`` restricts the match to ``-m emrg.server`` (the daemon + only — rant 2026-08-21T16:45:06: stop_daemon's kill fallback targets the + daemon, TUI clients are stopped earlier in the chain). + 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. + 2026-08-17T17:03:38): 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 [] + cmd_re = r"-m emrg\.server" if server_only else r"-m emrg" # 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 '{name_re}' -and " - "$_.CommandLine -match '-m emrg' }} | " + "$_.CommandLine -match '{cmd_re}' }} | " "ForEach-Object {{ Write-Output $_.ProcessId }}" - ).format(own=own_pid, name_re=_WIN_PY_NAME_RE) + ).format(own=own_pid, name_re=_WIN_PY_NAME_RE, cmd_re=cmd_re) try: out = subprocess.run( ["powershell", "-NoProfile", "-Command", ps_cmd], @@ -251,6 +249,46 @@ def _scan_windows_python_emrg(own_pid: int) -> list[int]: return [int(p) for p in out.split() if p.strip().isdigit()] +def _daemon_scan_pids(own_pid: int) -> list[int]: + """Cmdline-scan pids of the daemon only (``-m emrg.server``) — the kill + fallback for stop_daemon (rant 2026-08-21T16:45:06). POSIX ps-scan filtered + to the daemon regex; Windows reuses the CIM scan with server_only.""" + if is_win(): + return _scan_windows_python_emrg(own_pid, server_only=True) + out = _ps_output() + if out is None: + return [] + pids: list[int] = [] + for line in out.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 _EMRG_SERVER_RE.search(parts[1]): + pids.append(pid) + return pids + + +def _port_is_open(port: int, timeout: float = 0.3) -> bool: + """Fixed-port TCP probe — port open = a live daemon owns it (ground truth, + rant 2026-08-19T08:05:21). Mirrors emrg.connect.is_server_running_sync + semantics without importing the emrg package (standalone in installer).""" + try: + sock = socket.create_connection(("127.0.0.1", port), timeout=timeout) + sock.close() + return True + except OSError: + return False + + # ── Minimal WebSocket client (RFC 6455, stdlib only) ──────────── def _ws_recv_exact(sock: socket.socket, n: int) -> bytes: @@ -362,55 +400,52 @@ def ws_graceful_shutdown(port: int, token: str, timeout: float = 3.0) -> bool: # ── Individual stop steps ─────────────────────────────────────── def stop_daemon() -> None: - """Stop the daemon: ws shutdown → pid file → SIGTERM/taskkill /F → poll. - - Also removes ``~/.emrg/emrgd.token`` once the daemon pid is confirmed dead - (the daemon itself removes it on graceful shutdown; a force-killed daemon - cannot, so we clean it up — the next daemon start re-asserts both files). + """Stop the daemon: ws shutdown → fixed-port wait → cmdline-scan kill + fallback (``-m emrg.server``) → token cleanup (rant 2026-08-21T16:45:06: + the fixed port is the ground truth; the emrgd.pid file is gone, so liveness + is judged by the port and the kill target is found by command line). + + Also removes ``~/.emrg/emrgd.token`` once the daemon port is confirmed + closed (the daemon itself removes it on graceful shutdown; a force-killed + daemon cannot, so we clean it up — the next daemon start re-asserts it). """ token_path = config_dir() / "emrgd.token" # Fixed-port shutdown (rant 2026-08-19T08:05:21): the daemon always # listens on _EMRGD_PORT; the token file only supplies the auth token # (single line, rant 2026-08-20T14:32:52). If the file is missing/stale, - # fall through to the pid + cmdline paths. + # fall through to the cmdline-scan path. try: token = token_path.read_text(encoding="utf-8").strip() except (OSError, ValueError): token = "" if token and ws_graceful_shutdown(_EMRGD_PORT, token): - # wait for the daemon to exit + remove its pid file - # (~10s grace: old stop-emrg.cmd v2 polled emrgd.pid up to - # 10s; a busy daemon mid-tool-loop needs the full window) + # wait for the daemon to exit — the port closing is the ground truth + # (~10s grace: old stop-emrg.cmd v2 polled up to 10s; a busy daemon + # mid-tool-loop needs the full window) for _ in range(60): - pid = _read_pid_file() - if pid is None or not _pid_alive(pid): + if not _port_is_open(_EMRGD_PORT): break time.sleep(0.15) - pid = _read_pid_file() - if pid is not None and _pid_alive(pid): + # Fallback: the daemon survived the ws path (or no ws token) → kill by + # command-line identity (``-m emrg.server``), the only reliable marker on + # Windows (rant 2026-08-17T17:03:38). TUI clients were already stopped + # earlier in the chain, so the daemon-only scan is safe. + for pid in _daemon_scan_pids(os.getpid()): if is_win(): _kill_pid_windows(pid) else: _kill_pid_posix(pid) # poll up to 10s for it to disappear (matches old v2 grace window) for _ in range(60): - if not _pid_alive(pid): + if not _port_is_open(_EMRGD_PORT): 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) - # Token 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). - daemon_gone = pid is None or not _pid_alive(pid) - if daemon_gone: + # force-killed daemon cannot, so remove it once the port is confirmed + # closed (the next daemon start re-asserts it). + if not _port_is_open(_EMRGD_PORT): try: token_path.unlink() except OSError: @@ -1283,16 +1318,16 @@ def _verify_windows_categories(skip_gui: bool = False) -> list[tuple[str, list[s pass cats.append(("GUI", gui)) - # daemon residual (emrgd.pid still alive) + # daemon residual (fixed-port TCP probe — rant 2026-08-21T16:45:06: the + # port is the daemon ground truth; emrgd.pid is gone) daemon: list[str] = [] - pid = _read_pid_file() - if pid is not None and _pid_alive(pid): - daemon.append(f"daemon (pid {pid})") + if _port_is_open(_EMRGD_PORT): + daemon.append(f"daemon (port {_EMRGD_PORT} open)") cats.append(("daemon", daemon)) # 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) + # port-probe blind spot: a live daemon on a different port / a TUI client + # the installer would otherwise miss) py = [f"python emrg process (pid {p})" for p in _scan_windows_python_emrg(os.getpid())] cats.append(("cmdline-scan", py)) diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index acf3237..067ab66 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -220,12 +220,11 @@ class DaemonClient { throw new Error(`emrgd failed to start within timeout${this._readLogTail()}`); } - // Rant 2026-08-21T15:26:42:daemon 存活判断改用固定端口 TCP 探测—— - // emrgd.pid 不再作为存活依据(可缺失/stale:stop_all 清理、崩溃、外部删除), + // Rant 2026-08-21T15:26:42:daemon 存活判断用固定端口 TCP 探测—— // 固定端口才是 ground truth(rant 2026-08-19T08:05:21,connect.py - // is_server_running_sync 同语义)。端口通 = daemon 活着 = 绝不删 token; - // 端口不通才允许 stale-token 删除+重拉路径。pid 文件降级为纯诊断 - // (daemon 自己写/删,其他代码不再读它判断存活)。 + // is_server_running_sync 同语义;rant 2026-08-21T16:45:06 后 emrgd.pid 已彻底 + // 移除)。端口通 = daemon 活着 = 绝不删 token;端口不通才允许 stale-token + // 删除+重拉路径。 _daemonProcessAlive(timeoutMs = 1000) { return new Promise((resolve) => { const sock = net.connect({ host: "127.0.0.1", port: EMRGD_PORT, timeout: timeoutMs }); @@ -370,10 +369,11 @@ class DaemonClient { await this._awaitOpen(); } catch (e) { // G43 加固(rant 2026-08-09T13:16:36 根因):token 文件存在但连不上时, - // 先探测固定端口(rant 2026-08-21T15:26:42:TCP 探活,不再读 emrgd.pid)—— - // daemon 还活着就【绝不删 token 文件】。旧 G43 直接 unlink 会把健康 - // daemon 的 token 文件删掉 → 僵尸态(daemon 活着、scheduler 永远 - // cannot connect、PID 锁挡住新 spawn)。只有 daemon 真死了才删+重拉。 + // 先探测固定端口(rant 2026-08-21T15:26:42:TCP 探活;rant 16:45:06 后 + // emrgd.pid 已彻底移除)——daemon 还活着就【绝不删 token 文件】。旧 G43 + // 直接 unlink 会把健康 daemon 的 token 文件删掉 → 僵尸态(daemon 活着、 + // scheduler 永远 cannot connect、PID 锁挡住新 spawn)。只有 daemon 真死 + // 了才删+重拉。 if (await this._daemonProcessAlive()) { this.logger.warn( `[gui] ws connect failed: ${e.message} — daemon port alive, keeping token file (transient)` diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index e4dabac..53d593d 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -333,18 +333,6 @@ async def serve(self) -> None: self._stop_reason = "bind_exit" return - # ── PID file: diagnostics only (rant 08-05:21 — no longer an - # admission gate). Written AFTER the fixed-port bind succeeded, so only - # the process that actually owns the port writes it; stop_all and - # diagnostics may still read it. - runtime_dir = config_dir() - pid_file = runtime_dir / "emrgd.pid" - try: - pid_file.write_text(str(os.getpid()), encoding="utf-8") - logger.debug("pid file written (diagnostic only): %s (pid=%d)", pid_file, os.getpid()) - except OSError: - logger.warning("could not write diagnostic pid file %s", pid_file, exc_info=True) - self._server = await serve( self._handle_client, sock=sock, @@ -409,9 +397,9 @@ async def serve(self) -> None: self._stop_reason = "crash" logger.error("daemon serve crashed — cleanup started", exc_info=True) finally: - await self._shutdown_all(pid_file) + await self._shutdown_all() - async def _shutdown_all(self, pid_file: Path) -> None: + async def _shutdown_all(self) -> None: """Best-effort teardown with per-step logging (rant 2026-08-19T14:02:37). Every daemon stop path funnels through here: shutdown message, @@ -474,16 +462,6 @@ async def _shutdown_all(self, pid_file: Path) -> None: steps.append(("removed port file", True)) except Exception: steps.append(("removed port file", False)) - # 5. PID file (diagnostic) - try: - if pid_file.exists() and pid_file.read_text(encoding="utf-8").strip() == str(os.getpid()): - pid_file.unlink() - logger.debug("pid file removed: %s", pid_file) - steps.append(("removed pid file", True)) - else: - steps.append(("removed pid file (absent)", True)) - except OSError: - steps.append(("removed pid file", False)) uptime = max(0, int((datetime.now() - self.start_time).total_seconds())) all_ok = all(ok for _, ok in steps) diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 5d49dac..cc1228b 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -1461,8 +1461,9 @@ def test_serve_refuses_duplicate_when_fixed_port_bound(tmp_path): The fixed-port bind (EADDRINUSE) is the ONLY single-instance admission (rant 2026-08-19T08:05:21): kernel-level resource exclusivity — no PID - file to forge/delete, no race window. A refused instance must not claim - the pid file nor reach the websockets bind. + file to forge/delete, no race window (emrgd.pid removed entirely per + rant 2026-08-21T16:45:06). A refused instance must not reach the + websockets bind. """ import errno as _errno from unittest.mock import AsyncMock, patch @@ -1481,12 +1482,11 @@ def _deny(port): assert server._running is False, "duplicate daemon must not keep running" mock_serve.assert_not_awaited(), "must not bind when the fixed port is taken" - assert not (tmp_path / "emrgd.pid").exists(), "refused instance must not claim the pid file" def test_serve_proceeds_when_fixed_port_free(tmp_path): - """Negative path: fixed port free → bind passes → pid diagnostic written - and the websockets serve is reached with the pre-bound socket.""" + """Negative path: fixed port free → bind passes → the websockets serve is + reached with the pre-bound socket (no pid file is written anymore).""" import asyncio from unittest.mock import AsyncMock, MagicMock, patch @@ -1503,8 +1503,7 @@ def test_serve_proceeds_when_fixed_port_free(tmp_path): assert "abort after admission" in str(e), f"unexpected abort: {e}" else: raise AssertionError("expected the websockets serve abort (admission passed)") - assert (tmp_path / "emrgd.pid").exists(), "bind success must write the diagnostic pid file" - assert (tmp_path / "emrgd.pid").read_text(encoding="utf-8").strip() == str(os.getpid()) + assert not (tmp_path / "emrgd.pid").exists(), "no pid file may be written (rant 2026-08-21T16:45:06)" def test_serve_timewait_retry_recovers_bind(tmp_path): @@ -1538,12 +1537,12 @@ def _flaky(port): else: raise AssertionError("expected the websockets serve abort (retry recovered)") assert bind_calls["n"] == 2, f"expected 2 bind attempts, got {bind_calls['n']}" - assert (tmp_path / "emrgd.pid").exists(), "recovered bind must write the diagnostic pid file" + assert not (tmp_path / "emrgd.pid").exists(), "no pid file may be written (rant 2026-08-21T16:45:06)" def test_serve_timewait_retry_exhausted(tmp_path): """EADDRINUSE with no listener that never clears → serve() gives up - gracefully (no pid claim, no websockets bind).""" + gracefully (no websockets bind; no pid file exists anymore).""" import asyncio import errno as _errno from unittest.mock import AsyncMock, patch @@ -1639,11 +1638,9 @@ def test_shutdown_all_logs_reason_and_cleanup_steps(tmp_path, caplog): import logging server = _make_shutdown_server(tmp_path) - pid_file = tmp_path / "emrgd.pid" - pid_file.write_text(str(os.getpid()), encoding="utf-8") caplog.set_level(logging.INFO, logger="emrg.server.daemon") - asyncio.run(server._shutdown_all(pid_file)) + asyncio.run(server._shutdown_all()) text = caplog.text assert "daemon stopping (reason=cancel, handlers=0) — cleaning up" in text @@ -1653,10 +1650,8 @@ def test_shutdown_all_logs_reason_and_cleanup_steps(tmp_path, caplog): assert "stopped scheduler" in text assert "closed llm client" in text assert "removed port file" in text - assert "removed pid file" in text assert "daemon stopped (reason=cancel, uptime=" in text assert "all_ok=True" in text - assert not pid_file.exists() # our own pid → unlinked def test_shutdown_all_handles_failing_cleanup(tmp_path, caplog): @@ -1668,7 +1663,7 @@ def test_shutdown_all_handles_failing_cleanup(tmp_path, caplog): server.llm.close = AsyncMock(side_effect=RuntimeError("boom")) caplog.set_level(logging.INFO, logger="emrg.server.daemon") - asyncio.run(server._shutdown_all(tmp_path / "missing.pid")) + asyncio.run(server._shutdown_all()) text = caplog.text assert "daemon stopping (reason=cancel" in text @@ -1685,7 +1680,7 @@ def test_shutdown_all_reason_crash_and_sigint(tmp_path, caplog): server = _make_shutdown_server(tmp_path) server._stop_reason = reason caplog.set_level(logging.INFO, logger="emrg.server.daemon") - asyncio.run(server._shutdown_all(tmp_path / "missing.pid")) + asyncio.run(server._shutdown_all()) assert f"daemon stopping (reason={reason}" in caplog.text assert f"daemon stopped (reason={reason}" in caplog.text caplog.clear() diff --git a/tests/test_installer_stop.py b/tests/test_installer_stop.py index 436b614..eacd29c 100644 --- a/tests/test_installer_stop.py +++ b/tests/test_installer_stop.py @@ -7,9 +7,11 @@ emrg stop 与 Inno PrepareToInstall 共用同一实现。本测试纯文本断言(不执行 iscc/cmd/python —— macOS/CI 无 Windows),钉死接线: 1. bin/stop-emrg.cmd 已删除;bin/stop-git.ps1 仍不存在 - 2. emrg/_stop_all.py 覆盖全流程:ws 协议关闭 → emrgd.pid 兜底 → taskkill /F、 - GUI 优雅关闭+/F 兜底、TUI CIM 命令行过滤(python.exe|pythonw.exe)、 - install\\git\\ 前缀连坐强杀 bundled git、verify 残留检查 + exit 1 + 2. emrg/_stop_all.py 覆盖全流程:ws 协议关闭 → 固定端口探测 → cmdline 扫描 + (-m emrg.server)→ SIGTERM/taskkill /F、GUI 优雅关闭+/F 兜底、TUI CIM + 命令行过滤(python.exe|pythonw.exe)、install\\git\\ 前缀连坐强杀 bundled git、 + verify 残留检查 + exit 1(rant 2026-08-21T16:45:06:emrgd.pid 已彻底移除, + daemon 存活 ground truth = 固定端口 56031) 3. emrg/__main__.py 的 stop 子命令 sys.exit(_stop_all())(退出码透传) 4. make-installer.sh 的 .iss 模板:[Files] dontcopy(stop_all.py) + [Code] PrepareToInstall 用 runtime python 运行 {tmp} 提取版 @@ -37,9 +39,11 @@ def test_stop_emrg_cmd_deleted(): def test_stop_all_py_covers_daemon_gui_tui_git_verify(): content = _read("emrg/_stop_all.py") - # daemon:ws 协议关闭 + emrgd.pid 兜底(taskkill /F) + # daemon:ws 协议关闭 + 固定端口探测等待 + cmdline 扫描兜底(SIGTERM/taskkill /F) assert "ws_graceful_shutdown" in content - assert "emrgd.pid" in content + assert "_port_is_open" in content + assert "_daemon_scan_pids" in content + assert "_EMRG_SERVER_RE" in content assert '["taskkill", "/F", "/PID", str(pid)]' in content # GUI:优雅 taskkill /IM EMRG.exe 先于无条件 /F(宿主 01:27:07Z 教训) assert '"taskkill", "/IM", "EMRG.exe"' in content @@ -70,13 +74,12 @@ def test_stop_all_py_covers_daemon_gui_tui_git_verify(): def test_stop_all_py_cmdline_scan_fallback(): - """rant 2026-08-17T17:03:38 — DeleteFile code 5: pid 文件盲区兜底。 + """rant 2026-08-17T17:03:38 + 2026-08-21T16:45:06 — daemon kill 用 cmdline 兜底。 - 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 步都接入。 + emrgd.pid 已彻底移除(rant 16:45:06):daemon 存活 ground truth = 固定端口 + (_port_is_open),kill 目标 = cmdline 扫描(_daemon_scan_pids 匹配 + -m emrg.server)。stop_tui() 刻意排除 emrg.server;verify() 的 daemon + 残留 = 端口探测 + python 进程 cmdline 扫描,两者都接入。 """ content = _read("emrg/_stop_all.py") # 扫描辅助:python 解释器镜像(_WIN_PY_NAME_RE 宽松匹配版本化启动器) @@ -87,13 +90,22 @@ def test_stop_all_py_cmdline_scan_fallback(): assert r'^python.*\.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 兜底 + # 默认扫描绝不能排除 emrg.server(stop_tui 的盲区——daemon 由默认扫描兜住); + # server_only 变体才收窄到 -m emrg\.server(stop_daemon kill 目标) + assert r"-notmatch 'emrg\\.server'" not in content.split("def _scan_windows_python_emrg")[0] + assert r'cmd_re = r"-m emrg\.server" if server_only else r"-m emrg"' in content + # 固定端口探测 helper(daemon 存活 ground truth) + assert "def _port_is_open" in content + assert "def _daemon_scan_pids" in content + assert "_EMRG_SERVER_RE" in content + # stop_daemon() 走 ws 关闭 → 端口等待 → 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 "_port_is_open(_EMRGD_PORT)" in daemon_src + assert "_daemon_scan_pids(os.getpid())" in daemon_src assert "_kill_pid_windows(pid)" in daemon_src - # verify() 增加 python emrg 进程残留检查(不依赖 pid 文件) + # verify() daemon 残留 = 端口探测 + python emrg 进程 cmdline 检查 verify_src = content.split("def _verify_windows")[1].split("def _verify_posix")[0] + assert "_port_is_open(_EMRGD_PORT)" in verify_src assert "_scan_windows_python_emrg(os.getpid())" in verify_src assert 'f"python emrg process (pid {p})"' in verify_src diff --git a/tests/test_stop_all.py b/tests/test_stop_all.py index 06b85bd..1c435ec 100644 --- a/tests/test_stop_all.py +++ b/tests/test_stop_all.py @@ -20,7 +20,8 @@ from emrg import _stop_all from emrg._stop_all import ( _caller_context, - _read_pid_file, + _daemon_scan_pids, + _port_is_open, _verify_posix, match_cmdline, scan_pids, @@ -97,16 +98,73 @@ def test_no_matches(self): assert scan_pids(" 1 /sbin/launchd\n 2 /usr/libexec/foo\n", own_pid=9999) == [] -class TestPidFile: - def test_read_pid_file(self, tmp_path, monkeypatch): - monkeypatch.setattr(_stop_all, "config_dir", lambda: tmp_path) - assert _read_pid_file() is None # missing - (tmp_path / "emrgd.pid").write_text("1234\n", encoding="utf-8") - assert _read_pid_file() == 1234 - (tmp_path / "emrgd.pid").write_text("abc\n", encoding="utf-8") - assert _read_pid_file() is None # invalid - (tmp_path / "emrgd.pid").write_text("-5\n", encoding="utf-8") - assert _read_pid_file() is None # non-positive +class TestPortIsOpen: + """_port_is_open — fixed-port TCP probe (rant 2026-08-21T16:45:06: the + port is the daemon ground truth; emrgd.pid is gone).""" + + def test_open_when_connection_succeeds(self, monkeypatch): + def _fake_create_connection(addr, timeout=0.3): + sock = type("S", (), {"close": lambda self: None})() + return sock + + monkeypatch.setattr(_stop_all.socket, "create_connection", _fake_create_connection) + assert _port_is_open(56031) is True + + def test_closed_when_refused(self, monkeypatch): + def _refused(addr, timeout=0.3): + raise ConnectionRefusedError("refused") + + monkeypatch.setattr(_stop_all.socket, "create_connection", _refused) + assert _port_is_open(56031) is False + + def test_closed_on_oserror(self, monkeypatch): + def _boom(addr, timeout=0.3): + raise OSError("network unreachable") + + monkeypatch.setattr(_stop_all.socket, "create_connection", _boom) + assert _port_is_open(56031) is False + + +class TestDaemonScanPids: + """_daemon_scan_pids — cmdline identity for the daemon only + (``-m emrg.server``, rant 2026-08-21T16:45:06: stop_daemon kill fallback).""" + + def test_posix_matches_only_daemon(self, monkeypatch): + ps_out = ( + " 100 /usr/bin/python -m emrg.server\n" + " 200 /usr/bin/python -m emrg\n" # TUI — NOT matched + " 300 /usr/bin/python -m pytest\n" + " 400 /usr/bin/python -m emrg.server --init-auto-evolve\n" + ) + monkeypatch.setattr(_stop_all, "is_win", lambda: False) + monkeypatch.setattr(_stop_all, "_ps_output", lambda: ps_out) + assert _daemon_scan_pids(own_pid=9999) == [100, 400] + + def test_posix_excludes_own_pid(self, monkeypatch): + ps_out = " 999 /usr/bin/python -m emrg.server\n" + monkeypatch.setattr(_stop_all, "is_win", lambda: False) + monkeypatch.setattr(_stop_all, "_ps_output", lambda: ps_out) + assert _daemon_scan_pids(own_pid=999) == [] + + def test_windows_delegates_server_only(self, monkeypatch): + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + captured = {} + monkeypatch.setattr( + _stop_all, "_scan_windows_python_emrg", + lambda own, server_only=False: captured.update(own=own, server_only=server_only) or [555], + ) + assert _daemon_scan_pids(own_pid=42) == [555] + assert captured == {"own": 42, "server_only": True} + + def test_windows_scan_server_only_filter(self, monkeypatch): + """The CIM CommandLine filter must target emrg.server only.""" + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr( + _stop_all.subprocess, "run", + lambda cmd, **kw: type("CP", (), {"stdout": "111\n222\n"}), + ) + pids = _stop_all._scan_windows_python_emrg(1, server_only=True) + assert pids == [111, 222] class TestCallerContext: @@ -316,7 +374,7 @@ class TestVerifyWindowsPythonResidual: 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, "_port_is_open", lambda port, timeout=0.3: False) monkeypatch.setattr(_stop_all, "_scan_windows_python_emrg", lambda own: [555]) monkeypatch.setattr( _stop_all.subprocess, "run", @@ -327,7 +385,7 @@ def test_reports_python_emrg_residual(self, monkeypatch): 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, "_port_is_open", lambda port, timeout=0.3: False) monkeypatch.setattr(_stop_all, "_scan_windows_python_emrg", lambda own: []) monkeypatch.setattr( _stop_all.subprocess, "run", @@ -534,7 +592,7 @@ def test_stop_lock_owners_logs_diag(self, monkeypatch, capsys): def test_verify_windows_logs_rm_diag(self, monkeypatch, capsys): monkeypatch.setattr(_stop_all, "is_win", lambda: True) - monkeypatch.setattr(_stop_all, "_read_pid_file", lambda: None) + monkeypatch.setattr(_stop_all, "_port_is_open", lambda port, timeout=0.3: False) monkeypatch.setattr(_stop_all, "_scan_windows_python_emrg", lambda own: []) monkeypatch.setattr( _stop_all.subprocess, "run", @@ -566,7 +624,7 @@ def test_ps_template_renders_without_valueerror(self, monkeypatch): def test_verify_reports_lock_owner_residual(self, monkeypatch): monkeypatch.setattr(_stop_all, "is_win", lambda: True) - monkeypatch.setattr(_stop_all, "_read_pid_file", lambda: None) + monkeypatch.setattr(_stop_all, "_port_is_open", lambda port, timeout=0.3: False) monkeypatch.setattr(_stop_all, "_scan_windows_python_emrg", lambda own: []) monkeypatch.setattr( _stop_all, "_windows_lock_owners", @@ -650,7 +708,7 @@ def boom(root): def test_verify_categories_include_lock_probe(self, monkeypatch): monkeypatch.setattr(_stop_all, "is_win", lambda: True) - monkeypatch.setattr(_stop_all, "_read_pid_file", lambda: None) + monkeypatch.setattr(_stop_all, "_port_is_open", lambda port, timeout=0.3: False) monkeypatch.setattr(_stop_all, "_scan_windows_python_emrg", lambda own: []) monkeypatch.setattr(_stop_all, "_lock_owner_ps", lambda kill: "") monkeypatch.setattr( @@ -877,7 +935,7 @@ def test_verify_surfaces_probe_error_as_residual(self, monkeypatch, tmp_path): root = tmp_path / ".emrg" / "install" root.mkdir(parents=True) (root / "a.txt").write_text("x", encoding="utf-8") - monkeypatch.setattr(_stop_all, "_read_pid_file", lambda: None) + monkeypatch.setattr(_stop_all, "_port_is_open", lambda port, timeout=0.3: False) monkeypatch.setattr(_stop_all, "_scan_windows_python_emrg", lambda own: []) monkeypatch.setattr(_stop_all, "_lock_owner_ps", lambda kill: "") monkeypatch.setattr( @@ -905,7 +963,7 @@ def test_summary_reuses_cache_no_second_scan(self, monkeypatch, tmp_path, capsys scan ONCE — the previous code ran it twice (~2s+ wasted, duplicated rm-scan log lines).""" monkeypatch.setattr(_stop_all, "is_win", lambda: True) - monkeypatch.setattr(_stop_all, "_read_pid_file", lambda: None) + monkeypatch.setattr(_stop_all, "_port_is_open", lambda port, timeout=0.3: False) monkeypatch.setattr(_stop_all, "_scan_windows_python_emrg", lambda own: []) monkeypatch.setattr(_stop_all.os.path, "expanduser", lambda _: str(tmp_path)) calls = {"n": 0}