From 6e5ecdc314fd6716a52f8a1736a47d38da967364 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 17 Aug 2026 11:37:54 +0800 Subject: [PATCH 1/2] emrg: converge Windows stop logic into emrg stop (Python), delete stop-emrg.cmd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host rant 2026-08-17T10:32:27: '不要 stop-emrg.cmd 了,所有动作都在 emrg stop 命令里完成' — Windows upgrade installs kept failing (had to restart the OS) because stop logic was split across cmd+PowerShell (stop-emrg.cmd) and Python (emrg stop), with emrg stop missing key actions. - New emrg/_stop_all.py (pure stdlib, no emrg imports): ws protocol shutdown -> ~/.emrg/emrgd.pid fallback -> SIGTERM/taskkill /F, GUI EMRG.exe graceful + unconditional /F, TUI CIM filter (python.exe|pythonw.exe -m emrg, not emrg.server), bundled-git install\git\ prefix kill (Windows), verify residual scan + exit 1 with named residual list. Runs standalone with the runtime python (Inno extracts the single file to {tmp}). - emrg/__main__.py: 'emrg stop' delegates to emrg._stop_all.stop_all() and sys.exit() propagates the code; matcher helpers delegate to _stop_all (single source of truth). - Delete bin/stop-emrg.cmd; build-runtime.sh ships bin/stop_all.py instead (CRLF loop now covers only emrg.cmd/emrgd.cmd). - make-installer.sh: [Files] dontcopy stop_all.py; PrepareToInstall probes {app}\bin\python-dist\python.exe (fallback python3.13.exe, R90 layout), runs it via cmd /c with > log 2>&1 (R125 log display preserved), clean install skips; abort message updated to 'emrg stop exit code'. - test.yml iscc compile gate stub: stop_all.py. - Tests: rewrite tests/test_installer_stop.py (stop-emrg.cmd deleted wiring), add tests/test_stop_all.py (pure-stdlib, matching, exit-code semantics). pytest 834 -> 851, Agent.md synced. --- .github/workflows/test.yml | 2 +- Agent.md | 2 +- bin/stop-emrg.cmd | 106 -------- emrg/__main__.py | 142 ++--------- emrg/_stop_all.py | 470 +++++++++++++++++++++++++++++++++++ packaging/build-runtime.sh | 9 +- packaging/make-installer.sh | 61 +++-- tests/test_installer_stop.py | 278 ++++++++------------- tests/test_stop_all.py | 176 +++++++++++++ 9 files changed, 814 insertions(+), 432 deletions(-) delete mode 100644 bin/stop-emrg.cmd create mode 100644 emrg/_stop_all.py create mode 100644 tests/test_stop_all.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0943adee..a39e6544 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,7 +67,7 @@ jobs: set -euo pipefail STAGE="$(mktemp -d)" mkdir -p "$STAGE/payload/bin" "$STAGE/dist/artifacts" - touch "$STAGE/payload/bin/stop-emrg.cmd" + touch "$STAGE/payload/bin/stop_all.py" # icon.ico 是 gen-assets 产物(未入库)——生成最小合法 .ico 供 iscc 编译期 # SetupIconFile 检查;{app}(={%USERPROFILE}\.emrg\install)引用的文件 # (UninstallDisplayIcon/[Icons]/[UninstallRun])也需存在。 diff --git a/Agent.md b/Agent.md index ca181732..2a19dba0 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` (834) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (851) — 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 deleted file mode 100644 index 981e3b6f..00000000 --- a/bin/stop-emrg.cmd +++ /dev/null @@ -1,106 +0,0 @@ -@echo off -chcp 65001 >nul -REM stop-emrg.cmd -- fixed v2: no nested-parenthesis %VAR% expansion bugs -REM (v1 failed: "10 was unexpected" + "no emrg.cmd" -- %VAR% inside ( ) blocks -REM expands at parse time. Use labels + !VAR! delayed expansion instead.) -setlocal enabledelayedexpansion -set "EMRG_DIR=%USERPROFILE%\.emrg" -set "INSTALL=%EMRG_DIR%\install" -set "EXIT_CODE=0" -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 ( - echo [1] graceful found, wait 5s... - ping -n 6 127.0.0.1 >nul 2>&1 -) -echo [1] force-kill GUI (taskkill /F)... -taskkill /F /IM EMRG.exe >nul 2>&1 -echo [1] GUI done (exit=%errorlevel%) - -echo [2] check TUI (python -m emrg)... -where wmic >nul 2>&1 -if not errorlevel 1 goto :tui_wmic -echo [2] kill TUI via PowerShell... -powershell -NoProfile -Command "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 }" >nul 2>&1 -echo [2] PowerShell done (exit=%errorlevel%) -goto :tui_done -:tui_wmic -echo [2] kill TUI via wmic... -wmic process where "name='python.exe' and commandline like '%%-m emrg%%' and commandline not like '%%emrg.server%%'" call terminate >nul 2>&1 -echo [2] wmic done (exit=%errorlevel%) -:tui_done - -echo [3] check daemon... -if exist "%INSTALL%\bin\emrg.cmd" goto :daemon_stop -echo [3] no emrg.cmd at %INSTALL%\bin -- skip protocol stop -goto :daemon_pid -:daemon_stop -echo [3] call emrg server stop... -call "%INSTALL%\bin\emrg.cmd" server stop -echo [3] emrg server stop done (exit=%errorlevel%) -:daemon_pid -if exist "%EMRG_DIR%\emrgd.pid" goto :daemon_pid_wait -echo [3] no emrgd.pid (daemon not running) -- skip, continue to step 4 -goto :step4 -:daemon_pid_wait -set /a TRIES=0 -:wait_pid -if not exist "%EMRG_DIR%\emrgd.pid" goto :pid_gone -set /a TRIES+=1 -if !TRIES! geq 10 goto :kill_pid -ping -n 2 127.0.0.1 >nul 2>&1 -goto :wait_pid -:kill_pid -set "DPID=" -for /f "usebackq delims=" %%p in ("%EMRG_DIR%\emrgd.pid") do set "DPID=%%p" -if defined DPID ( - echo [3] force-kill daemon PID !DPID!... - taskkill /F /PID !DPID! >nul 2>&1 - echo [3] daemon kill done (exit=%errorlevel%) -) -:pid_gone -echo [3] daemon pid gone - -:step4 -echo [4] check+kill bundled git (install\git)... -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $prefix=\"$env:USERPROFILE\.emrg\install\git\*\"; Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -like $prefix -and $_.Name -in @('git.exe','ssh.exe','plink.exe','bash.exe') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; Start-Sleep -Milliseconds 300; Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -like $prefix } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; Start-Sleep -Milliseconds 300; $left = @(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -like $prefix }); if ($left.Count -gt 0) { $left | ForEach-Object { Write-Host (\"still running: {0} (pid {1})\" -f $_.Name, $_.ProcessId) }; exit 1 }; exit 0" -echo [4] git kill done (exit=%errorlevel%) -if errorlevel 1 set "EXIT_CODE=1" - -:verify -echo [verify] check residual GUI... -tasklist /FI "IMAGENAME eq EMRG.exe" 2>nul | findstr /i "EMRG.exe" >nul && set "EXIT_CODE=1" -echo [verify] GUI residual (EXIT_CODE=%EXIT_CODE%) -if exist "%EMRG_DIR%\emrgd.pid" ( - set "DPID=" - for /f "usebackq delims=" %%p in ("%EMRG_DIR%\emrgd.pid") do set "DPID=%%p" - if defined DPID ( - tasklist /FI "PID eq !DPID!" 2>nul | findstr /i "!DPID!" >nul && set "EXIT_CODE=1" - ) -) -echo [verify] check residual git... -powershell -NoProfile -Command "$p = Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -like \"$env:USERPROFILE\.emrg\install\git\*\" }; if ($p) { $p | ForEach-Object { Write-Host (\" [verify] residual: {0} (pid {1})\" -f $_.Name, $_.ProcessId) }; exit 1 }" -echo [verify] git residual (exit=%errorlevel%) -if errorlevel 1 set "EXIT_CODE=1" - -echo [stop-emrg] ============ end (EXIT_CODE=%EXIT_CODE%) ============ -endlocal & exit /b %EXIT_CODE% diff --git a/emrg/__main__.py b/emrg/__main__.py index b14678a3..5919adb9 100644 --- a/emrg/__main__.py +++ b/emrg/__main__.py @@ -16,7 +16,6 @@ import json import logging import os -import re import signal import subprocess import sys @@ -75,8 +74,9 @@ def _build_parser() -> argparse.ArgumentParser: "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]).", + "the GUI app. Graceful stop first, force-kill stragglers. Also kills the " + "bundled git tree on Windows and exits non-zero when residual processes " + "remain (used by the Windows installer pre-stop).", ) # emrg update @@ -121,7 +121,9 @@ def main() -> None: elif parsed.command == "rant": _send_rant(" ".join(parsed.message), project=parsed.project) elif parsed.command == "stop": - _stop_all() + # Windows installer pre-stop depends on the non-zero exit code when + # residual processes remain (emrg/_stop_all.py owns the full logic). + sys.exit(_stop_all()) elif parsed.command == "update": _run_update() else: @@ -208,137 +210,37 @@ async def _get_pid(): # ── 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`. + Delegates to ``emrg._stop_all.match_cmdline`` — the single source of truth + shared with the standalone installer script (bin/stop_all.py). """ - if "EMRG.app" in cmd: - return True - return bool(_EMRG_CLIENT_RE.search(cmd)) + from emrg._stop_all import match_cmdline + return match_cmdline(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. + kills the CLI that is running it. Delegates to ``emrg._stop_all.scan_pids``. """ - 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 + from emrg._stop_all import scan_pids + return scan_pids(ps_output, own_pid) -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() -> int: + """Stop every running emrg process: daemon, TUI, GUI (+ bundled git on + Windows) and verify. Returns 0 on clean stop, 1 when residual processes + remain — the Windows installer aborts on the non-zero exit. -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). + All logic lives in ``emrg/_stop_all.py`` (pure stdlib) so the installer + can also run it standalone with the runtime's Python; this function is + the ``emrg stop`` CLI entry point that propagates the exit code. """ - print("emrg stop: stopping daemon ...") - _stop_daemon() - if sys.platform == "win32": - _stop_windows_clients() - else: - _stop_posix_clients() - print("emrg stop: done.") + from emrg._stop_all import stop_all + return stop_all() def _restart_daemon() -> None: diff --git a/emrg/_stop_all.py b/emrg/_stop_all.py new file mode 100644 index 00000000..a9456a57 --- /dev/null +++ b/emrg/_stop_all.py @@ -0,0 +1,470 @@ +"""Stop every running EMRG process — pure standard library (no emrg imports). + +This module converges the old ``bin/stop-emrg.cmd`` logic into one Python +implementation (host rant 2026-08-17T10:32:27 — "不要 stop-emrg.cmd 了, +所有动作都在 emrg stop 命令里完成"). It is the single source of truth for +stopping the daemon / TUI / GUI / bundled git before a Windows installer +overwrites ``~/.emrg/install`` (the pythonw daemon holds file locks that +Inno's CloseApplications cannot see). + +It runs in three contexts, which is why it must stay pure-stdlib: + +1. ``emrg stop`` → ``emrg.__main__._stop_all`` delegates to :func:`stop_all` + and propagates the exit code. +2. ``python -m emrg._stop_all`` (module mode, same code path). +3. Standalone script: the Inno installer extracts this single file to + ``{tmp}`` and executes it with the *runtime's* Python + (``{app}\\bin\\python-dist\\python.exe``), where no emrg package / + third-party modules are importable (``sys.path[0]`` is ``{tmp}``). + +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 +- 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) +""" + +from __future__ import annotations + +import base64 +import json +import os +import re +import secrets +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path + +_EMRG_CLIENT_RE = re.compile(r"-m\s+emrg(\.server)?(\s|$)") +_APPIMAGE_RE = re.compile(r"EMRG-[\w.\-]*AppImage(\s|$)") + + +def is_win() -> bool: + """True on Windows (incl. Git Bash launched python).""" + return sys.platform == "win32" + + +def config_dir() -> Path: + """The EMRG runtime directory (~/.emrg), matching emrg.config.config_dir.""" + return Path(os.path.expanduser("~")) / ".emrg" + + +def _no_window() -> dict: + """subprocess kwargs that suppress console windows on Windows (#592).""" + if is_win(): + return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)} + return {} + + +# ── Process matching ──────────────────────────────────────────── + +def match_cmdline(cmd: str) -> bool: + """True if a command line belongs to an emrg process. + + Matches ``-m emrg`` / ``-m emrg.server`` (TUI + daemon), ``EMRG.app`` + (macOS GUI) and ``EMRG-*.AppImage`` (Linux AppImage). Does NOT match + lookalikes such as ``-m emrg.serverless`` or ``-m emrgx``. + """ + if "EMRG.app" in cmd: + return True + if _APPIMAGE_RE.search(cmd): + return True + return bool(_EMRG_CLIENT_RE.search(cmd)) + + +def scan_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 a running ``emrg stop`` never kills itself. + """ + 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_cmdline(parts[1]): + pids.append(pid) + return pids + + +def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except (ProcessLookupError, PermissionError): + return False + except OSError: + 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( + ["taskkill", "/F", "/PID", str(pid)], + capture_output=True, + ** _no_window(), + ) + + +def _kill_pid_posix(pid: int, grace: float = 3.0) -> None: + """SIGTERM → short grace → SIGKILL on POSIX.""" + try: + os.kill(pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + return + deadline = time.monotonic() + grace + while time.monotonic() < deadline: + if not _pid_alive(pid): + return + time.sleep(0.15) + try: + os.kill(pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + + +# ── Minimal WebSocket client (RFC 6455, stdlib only) ──────────── + +def _ws_recv_exact(sock: socket.socket, n: int) -> bytes: + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise OSError("ws: connection closed") + buf += chunk + return buf + + +def _ws_send_text(sock: socket.socket, text: str) -> None: + payload = text.encode("utf-8") + mask = secrets.token_bytes(4) + header = bytearray([0x81]) # FIN + text frame + ln = len(payload) + if ln < 126: + header.append(0x80 | ln) + elif ln < 65536: + header.append(0x80 | 126) + header.extend(ln.to_bytes(2, "big")) + else: + header.append(0x80 | 127) + header.extend(ln.to_bytes(8, "big")) + header.extend(mask) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + sock.sendall(bytes(header) + masked) + + +def _ws_recv_text(sock: socket.socket, timeout: float) -> str | None: + sock.settimeout(timeout) + b1, b2 = _ws_recv_exact(sock, 2) + opcode = b1 & 0x0F + ln = b2 & 0x7F + if ln == 126: + ln = int.from_bytes(_ws_recv_exact(sock, 2), "big") + elif ln == 127: + ln = int.from_bytes(_ws_recv_exact(sock, 8), "big") + mask = _ws_recv_exact(sock, 4) if (b2 & 0x80) else None + payload = _ws_recv_exact(sock, ln) + if mask: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + if opcode != 0x1: # not a text frame + return None + return payload.decode("utf-8", "replace") + + +def ws_graceful_shutdown(port: int, token: str, timeout: float = 3.0) -> bool: + """Send the daemon a graceful ``shutdown`` over a minimal WS connection. + + Mirrors emrg.connect.connect_to_server + _send_shutdown but with only the + standard library (this module must run standalone inside the installer). + Returns True when the daemon acked ``shutdown_ack``. + """ + key = base64.b64encode(secrets.token_bytes(16)).decode() + try: + sock = socket.create_connection(("127.0.0.1", port), timeout=timeout) + except OSError: + return False + try: + sock.sendall( + ( + f"GET / HTTP/1.1\r\n" + f"Host: 127.0.0.1:{port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ).encode("ascii") + ) + resp = b"" + while b"\r\n\r\n" not in resp: + chunk = sock.recv(4096) + if not chunk: + return False + resp += chunk + if not resp.startswith(b"HTTP/1.1 101"): + return False + # auth handshake (first frame) → auth_ok + _ws_send_text(sock, json.dumps({"type": "auth", "token": token})) + ack = _ws_recv_text(sock, timeout) + if not ack: + return False + try: + if json.loads(ack).get("type") != "auth_ok": + return False + except json.JSONDecodeError: + return False + # shutdown → shutdown_ack + _ws_send_text(sock, json.dumps({"type": "shutdown"})) + ack = _ws_recv_text(sock, timeout) + if not ack: + return False + try: + return json.loads(ack).get("type") == "shutdown_ack" + except json.JSONDecodeError: + return False + except OSError: + return False + finally: + try: + sock.close() + except OSError: + pass + + +# ── Individual stop steps ─────────────────────────────────────── + +def stop_daemon() -> None: + """Stop the daemon: ws shutdown → pid file → SIGTERM/taskkill /F → poll. + + Also removes ``~/.emrg/emrgd.port`` 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). + """ + port_path = config_dir() / "emrgd.port" + try: + port_tok = port_path.read_text(encoding="utf-8").split() + if len(port_tok) == 2: + if ws_graceful_shutdown(int(port_tok[0]), port_tok[1]): + # wait for the daemon to exit + remove its pid file + for _ in range(20): + pid = _read_pid_file() + if pid is None or not _pid_alive(pid): + break + time.sleep(0.15) + except (OSError, ValueError): + pass # port file missing/corrupt → fall through to pid path + + pid = _read_pid_file() + if pid is not None and _pid_alive(pid): + if is_win(): + _kill_pid_windows(pid) + else: + _kill_pid_posix(pid) + # poll up to 3s for it to disappear + for _ in range(20): + if not _pid_alive(pid): + break + time.sleep(0.15) + + # 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). + daemon_gone = pid is None or not _pid_alive(pid) + if daemon_gone: + try: + port_path.unlink() + except OSError: + pass + + +def _ps_output() -> str | None: + try: + return subprocess.run( + ["ps", "-axww", "-o", "pid=,command="], + capture_output=True, text=True, timeout=10, + **_no_window(), + ).stdout + except (OSError, subprocess.SubprocessError, TimeoutError): + return None + + +def _stop_scan_pids(own_pid: int) -> list[int]: + out = _ps_output() + if out is None: + return [] + return scan_pids(out, own_pid) + + +def stop_gui() -> None: + """Stop the GUI app: Windows EMRG.exe (graceful then unconditional /F); + POSIX ps-scan for EMRG.app / EMRG-*.AppImage (SIGTERM → SIGKILL).""" + kw = _no_window() + if is_win(): + # graceful first, then unconditional /F (host 2026-08-10T01:27:07Z: + # long-lived GUI sessions ignore WM_CLOSE — /F must not be gated) + 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) + return + pids = [p for p in _stop_scan_pids(os.getpid()) + if p != os.getpid()] + for pid in pids: + _kill_pid_posix(pid) + + +def stop_tui() -> None: + """Stop TUI clients: Windows CIM filter (python.exe|pythonw.exe running + ``-m emrg`` but NOT ``emrg.server``); POSIX ps-scan.""" + if is_win(): + ps_cmd = ( + "Get-CimInstance Win32_Process | " + "Where-Object { $_.Name -match '^python(\\.exe|w\\.exe)?$' -and " + "$_.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, **_no_window(), + ) + return + pids = [p for p in _stop_scan_pids(os.getpid()) + if p != os.getpid()] + for pid in pids: + _kill_pid_posix(pid) + + +def stop_bundled_git() -> None: + """Windows only: kill processes under ``install\\git\\`` (git/ssh/plink/ + bash first, then fallback prefix full-kill) — port of stop-emrg.cmd step 4. + Never touches system Git (outside the prefix).""" + if not is_win(): + return + ps_cmd = ( + "$ErrorActionPreference='SilentlyContinue'; " + "$prefix=\"$env:USERPROFILE\\.emrg\\install\\git\\*\"; " + "Get-CimInstance Win32_Process | " + "Where-Object { $_.ExecutablePath -like $prefix -and " + "$_.Name -in @('git.exe','ssh.exe','plink.exe','bash.exe') } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force " + "-ErrorAction SilentlyContinue }; " + "Start-Sleep -Milliseconds 300; " + "Get-CimInstance Win32_Process | " + "Where-Object { $_.ExecutablePath -like $prefix } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force " + "-ErrorAction SilentlyContinue }; " + "Start-Sleep -Milliseconds 300" + ) + subprocess.run( + ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-Command", ps_cmd], + capture_output=True, **_no_window(), + ) + + +# ── Verify + exit code ────────────────────────────────────────── + +def _verify_windows() -> list[str]: + residuals: list[str] = [] + # GUI residual + try: + out = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq EMRG.exe"], + capture_output=True, text=True, timeout=10, **_no_window(), + ).stdout + for m in re.finditer(r"EMRG\.exe\s+(\d+)", out): + residuals.append(f"EMRG.exe (pid {m.group(1)})") + except (OSError, subprocess.SubprocessError, TimeoutError): + pass + # daemon residual (emrgd.pid still alive) + pid = _read_pid_file() + if pid is not None and _pid_alive(pid): + residuals.append(f"daemon (pid {pid})") + # bundled-git residual + try: + out = subprocess.run( + ["powershell", "-NoProfile", "-Command", + "$p = Get-CimInstance Win32_Process | Where-Object { " + "$_.ExecutablePath -like \"$env:USERPROFILE\\.emrg\\install\\git\\*\" }; " + "if ($p) { $p | ForEach-Object { Write-Output " + "(\"{0} (pid {1})\" -f $_.Name, $_.ProcessId) } }"], + capture_output=True, text=True, timeout=10, **_no_window(), + ).stdout + for line in out.splitlines(): + line = line.strip() + if line: + residuals.append(f"bundled-git {line}") + except (OSError, subprocess.SubprocessError, TimeoutError): + pass + return residuals + + +def _verify_posix() -> list[str]: + return [f"emrg process (pid {pid})" for pid in _stop_scan_pids(os.getpid())] + + +def verify() -> list[str]: + """Scan for residual emrg processes. Returns a list of human-readable + ``"name (pid N)"`` entries (empty = clean).""" + return _verify_windows() if is_win() else _verify_posix() + + +# ── Orchestration ─────────────────────────────────────────────── + +def stop_all() -> int: + """Run every stop step, then verify. Returns 0 (clean) or 1 (residuals).""" + print("emrg stop: stopping daemon ...") + stop_daemon() + print("emrg stop: stopping GUI ...") + stop_gui() + print("emrg stop: stopping TUI clients ...") + stop_tui() + if is_win(): + print("emrg stop: stopping bundled git under install\\git ...") + stop_bundled_git() + residuals = verify() + if residuals: + print("emrg stop: WARNING residual process(es) still running:") + for r in residuals: + print(f" - {r}") + return 1 + print("emrg stop: all emrg processes stopped.") + return 0 + + +def main() -> None: + code = stop_all() + sys.exit(code) + + +if __name__ == "__main__": + main() diff --git a/packaging/build-runtime.sh b/packaging/build-runtime.sh index a2940bd9..897ec017 100755 --- a/packaging/build-runtime.sh +++ b/packaging/build-runtime.sh @@ -6,6 +6,7 @@ # bin/python 软链 → ../python-dist/bin/python3.13(R82 相对软链) # bin/python3 软链(同规则) # bin/emrg bin/emrgd bin/emrg.cmd bin/emrgd.cmd bin/emrg-uninstall 启动/卸载脚本 +# bin/stop_all.py Windows 安装器预停止单文件(纯标准库,emrg/_stop_all.py 副本) # source/ emrg 源码(只读,排除 gui node_modules) # lib/ pip --target 依赖(全量含传递依赖,R3 禁 --no-deps) # assets/ LICENSE version.txt @@ -68,14 +69,18 @@ cp -R "$PY_ROOT/." "$DIST/bin/python-dist/" cp "$ROOT/bin/emrgd" emrgd cp "$ROOT/bin/emrg.cmd" emrg.cmd 2>/dev/null || true cp "$ROOT/bin/emrgd.cmd" emrgd.cmd 2>/dev/null || true - cp "$ROOT/bin/stop-emrg.cmd" stop-emrg.cmd 2>/dev/null || true + # R130: rant 2026-08-17T10:32:27 — stop-emrg.cmd 删除,Windows 安装器预停止收敛到 + # Python。emrg/_stop_all.py 是纯标准库单文件(不 import emrg),复制到 bin/ 顶层 + # 供 Inno [Code] ExtractTemporaryFile 提取后用 runtime python 直接执行。 + cp "$ROOT/emrg/_stop_all.py" stop_all.py cp "$ROOT/bin/emrg-uninstall" emrg-uninstall chmod +x emrg emrgd emrg-uninstall # R126: rant 2026-08-13T09:44:32 — 打包强制 CRLF,不依赖 .gitattributes 在 CI # 检出是否生效(git blob 是 LF;工作区 CRLF 靠 checkout 转换,CI 打包若拿到 # LF → cmd.exe 整文件串行拼接 → 安装器 "stop-emrg.cmd exit code 1")。 # 拷贝后统一为纯 CRLF(幂等:先归 LF 再转 CRLF),并断言纯 CRLF 才继续。 - for f in emrg.cmd emrgd.cmd stop-emrg.cmd; do + # stop_all.py 是 .py 文件(python 解析,无 cmd.exe 换行问题),不需 CRLF。 + for f in emrg.cmd emrgd.cmd; do [ -f "$f" ] || continue python3 - "$f" <<'PY' import sys diff --git a/packaging/make-installer.sh b/packaging/make-installer.sh index ec9fbe31..28ad5bbc 100755 --- a/packaging/make-installer.sh +++ b/packaging/make-installer.sh @@ -239,7 +239,7 @@ ChangesEnvironment=yes DisableProgramGroupPage=yes ; R125: CloseApplications=no — Inno Restart Manager (CloseApplications=yes default) 会误报 ; 任何占用 install 目录文件的非 EMRG 进程(sh/vim/explorer/Defender)弹 "unable to automatically -; close all applications" 选择框,且 Try again 反复失败;EMRG 进程关闭由 R124 stop-emrg.cmd 精确负责 +; close all applications" 选择框,且 Try again 反复失败;EMRG 进程关闭由 R130 stop_all.py 精确负责 CloseApplications=no OutputDir=$DIST_WIN/artifacts OutputBaseFilename=EMRG-$VERSION-windows-x64 @@ -249,11 +249,12 @@ Compression=lzma2 SolidCompression=yes [Files] Source: "$STAGE_WIN/payload\\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion -; R124: dontcopy — 供 [Code] PrepareToInstall 在覆盖文件前 ExtractTemporaryFile 取出并 -; 运行 bin\stop-emrg.cmd(升级安装前优雅关闭 GUI/TUI/daemon,rant 2026-08-10T08:50:44: -; pythonw daemon 锁文件导致卡在"停止已有进程")。正常安装时该文件仍由上方通配符 -; 装入 {app}\bin\stop-emrg.cmd。 -Source: "$STAGE_WIN/payload\\bin\\stop-emrg.cmd"; DestDir: "{tmp}"; Flags: dontcopy +; R130: dontcopy — 供 [Code] PrepareToInstall 在覆盖文件前 ExtractTemporaryFile 取出 +; bin\stop_all.py 并用 runtime python 执行(升级安装前优雅关闭 GUI/TUI/daemon/bundled +; git,rant 2026-08-17T10:32:27:停止逻辑全部收敛到 Python,stop-emrg.cmd 已删除)。 +; stop_all.py 是纯标准库单文件(emrg/_stop_all.py 的副本),不依赖 emrg 包可导入。 +; 正常安装时该文件仍由上方通配符装入 {app}\bin\stop_all.py。 +Source: "$STAGE_WIN/payload\\bin\\stop_all.py"; DestDir: "{tmp}"; Flags: dontcopy [Icons] Name: "{userprograms}\\EMRG"; Filename: "{app}\\emrg-gui\\EMRG\\EMRG.exe"; IconFilename: "{app}\\emrg-gui\\EMRG\\EMRG.exe" [UninstallRun] @@ -370,35 +371,43 @@ begin RemoveBinDirFromPath; end; -// R124: 升级安装前优雅关闭运行中的 EMRG 进程(rant 2026-08-10T08:50:44)—— +// R130: 升级安装前优雅关闭运行中的 EMRG 进程(rant 2026-08-10T08:50:44 起, +// 2026-08-17T10:32:27 收敛:stop-emrg.cmd 删除,全部停止逻辑进 emrg/_stop_all.py)—— // Inno CloseApplications 看不到无窗口的 pythonw daemon(emrgd.cmd → pythonw.exe // -m emrg.server 常驻锁文件),覆盖 ~/.emrg\install 时卡在"停止已有进程"。 -// PrepareToInstall 在安装开始前运行 bin\stop-emrg.cmd:taskkill EMRG.exe -// 优雅→/F 兜底、wmic/PowerShell 命令行过滤 TUI、emrg server stop 协议关闭 -// daemon + emrgd.pid 轮询兜底、step 4 内联 PowerShell 连坐强杀 bundled git -// (宿主 2026-08-11T19:47:44 拍板覆盖 #689:sh/vim 锁 install\git\usr\bin\ -// msys-2.0.dll 导致 Inno DeleteFile code 5 时一并强杀,安装成功优先;只碰 -// install\git\ 前缀,系统 Git 不受影响;rant 2026-08-12T14:00:05 合并为 -// 单文件——stop-git.ps1 已删除,逻辑内联进 stop-emrg.cmd step 4)。 -// 干净安装(无旧 install)脚本自行跳过。返回非空字符串 = 中止安装 -// 并显示该消息(宁可中止也不卡死)。 -// {cmd} = cmd.exe(Inno 预定义常量,批处理文件须经 cmd 启动)。 +// PrepareToInstall 在安装开始前用 runtime 的 python 运行 bin\stop_all.py: +// ws 协议关闭 daemon → emrgd.pid 兜底 → taskkill /F、taskkill EMRG.exe 优雅→/F、 +// CIM 命令行过滤 TUI、install\git\ 前缀连坐强杀 bundled git、verify 残留检查; +// 有残留 exit 1(脚本打印残留清单)→ 中止安装。干净安装(无旧 install)直接跳过。 +// {app} 是旧版安装目录——不能依赖旧版 emrg 命令(可能无 stop 子命令),所以用 +// 单文件脚本 + runtime python({app}\bin\python-dist\python.exe,R90 布局, +// 与 DLL 同目录;回退 python3.13.exe,与 emrg.cmd 探测链一致)。 +// 返回非空字符串 = 中止安装并显示该消息(宁可中止也不卡死)。 +// 重定向必须经 {cmd}(cmd.exe)——CreateProcess 不解释 > 重定向,直接 Exec +// python.exe 会把 ">" "log" 2>&1 当脚本参数静默吞掉(R125 日志展示失效)。 function PrepareToInstall(var NeedsRestart: Boolean): String; var ResultCode: Integer; + PythonExe: string; StopScript: string; LogFile: string; LogText: AnsiString; begin Result := ''; - ExtractTemporaryFile('stop-emrg.cmd'); - StopScript := ExpandConstant('{tmp}\\stop-emrg.cmd'); - // R125: rant 2026-08-13T09:24:37 — stop-emrg.cmd 输出重定向到日志,失败时 - // 直接展示日志内容(列出杀不掉的进程),宿主不再需要手动跑诊断。 + // 干净安装:{app} 尚不存在或旧版无 python-dist 布局 → 无进程需停止 + PythonExe := ExpandConstant('{app}\bin\python-dist\python.exe'); + if not FileExists(PythonExe) then + PythonExe := ExpandConstant('{app}\bin\python-dist\python3.13.exe'); + if not FileExists(PythonExe) then + Exit; + ExtractTemporaryFile('stop_all.py'); + StopScript := ExpandConstant('{tmp}\stop_all.py'); + // R125: rant 2026-08-13T09:24:37 — 输出重定向到日志,失败时直接展示日志内容 + // (列出杀不掉的进程),宿主不再需要手动跑诊断。 // cmd 引号嵌套:外层 /c "...",内层脚本路径用双引号包裹,重定向在外、 // 仍在内层引号外(SW_HIDE 隐藏窗口后 stdout/stderr 经 > log 2>&1 落盘)。 - LogFile := ExpandConstant('{tmp}\\stop-emrg.log'); - if Exec(ExpandConstant('{cmd}'), '/c ""' + StopScript + '" > "' + LogFile + '" 2>&1"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + LogFile := ExpandConstant('{tmp}\stop_all.log'); + if Exec(ExpandConstant('{cmd}'), '/c ""' + PythonExe + '" "' + StopScript + '" > "' + LogFile + '" 2>&1"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then begin if ResultCode <> 0 then begin @@ -416,13 +425,13 @@ begin if Length(LogText) > 2000 then LogText := Copy(LogText, 1, 2000); if LogText <> '' then - Result := 'EMRG could not stop all running processes (stop-emrg.cmd exit code ' + IntToStr(ResultCode) + '). Details from stop-emrg.cmd:' + #13#10 + #13#10 + LogText + #13#10 + #13#10 + 'Please close EMRG (GUI/TUI) and retry the install, or restart the computer and retry (a helper process such as the bundled Git may still hold a file lock).' + Result := 'EMRG could not stop all running processes (emrg stop exit code ' + IntToStr(ResultCode) + '). Details from the stop script:' + #13#10 + #13#10 + LogText + #13#10 + #13#10 + 'Please close EMRG (GUI/TUI) and retry the install, or restart the computer and retry (a helper process such as the bundled Git may still hold a file lock).' else - Result := 'EMRG could not stop all running processes (stop-emrg.cmd exit code ' + IntToStr(ResultCode) + '). Please close EMRG (GUI/TUI) and retry the install, or restart the computer and retry (a helper process such as the bundled Git may still hold a file lock).'; + Result := 'EMRG could not stop all running processes (emrg stop exit code ' + IntToStr(ResultCode) + '). Please close EMRG (GUI/TUI) and retry the install, or restart the computer and retry (a helper process such as the bundled Git may still hold a file lock).'; end; end else - Result := 'EMRG could not run the process-stop helper (stop-emrg.cmd). Please close EMRG (GUI/TUI) and retry the install, or restart the computer and retry.'; + Result := 'EMRG could not run the process-stop script (stop_all.py). Please close EMRG (GUI/TUI) and retry the install, or restart the computer and retry.'; end; EOF # Windows 路径转义(iscc 需要 Windows 路径,但在 bash/msys 下用当前路径) diff --git a/tests/test_installer_stop.py b/tests/test_installer_stop.py index fda80f44..08c0e271 100644 --- a/tests/test_installer_stop.py +++ b/tests/test_installer_stop.py @@ -1,20 +1,20 @@ -"""Windows 安装器预停止接线回归测试(rant 2026-08-10T08:50:44 + 宿主 2026-08-11T19:47:44 连坐强杀拍板 -+ rant 2026-08-12T14:00:05 合并单文件)。 +"""Windows 安装器预停止接线回归测试(rant 2026-08-10T08:50:44 → 2026-08-17T10:32:27 收敛)。 安装器(Inno Setup,make-installer.sh 生成 emrg.iss)覆盖 ~/.emrg/install 前必须 先优雅关闭 GUI/TUI/daemon/bundled-git,否则无窗口的 pythonw daemon 独占锁文件 → 卡在 -"停止已有进程"(宿主只能重启系统)。本测试纯文本断言(不执行 iscc/cmd —— -macOS/CI 无 Windows),钉死接线: - 1. bin/stop-emrg.cmd 单文件覆盖全流程:GUI 优雅关闭+/F 兜底、TUI 命令行过滤、 - daemon 协议关闭 + emrgd.pid 轮询兜底(顺序 GUI → daemon)、step 4 内联 - PowerShell 连坐强杀 bundled git(stop-git.ps1 已删除合并,rant 2026-08-12T14:00:05) - 2. bin/stop-git.ps1 不存在;内联逻辑:install\\git\\ 前缀收集 → 先杀 git 树 → - 连坐强杀全部残留(sh/vim 一并杀)→ 每次枚举最新 Get-CimInstance → 无残留 - exit 0 / 有残留 exit 1(truthful failure,#701) - 3. bin/emrgd.cmd 含 stop 分支(复用 `emrg server stop`) - 4. make-installer.sh 的 .iss 模板含 [Files] dontcopy(stop-emrg.cmd)+ [Code] - PrepareToInstall 运行 {tmp} 提取版 - 5. build-runtime.sh 把 stop-emrg.cmd 复制进 runtime bin/ +"停止已有进程"(宿主只能重启系统)。宿主 2026-08-17 拍板:**停止逻辑全部收敛到 +Python**——bin/stop-emrg.cmd 删除,新增 emrg/_stop_all.py(纯标准库单文件), +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 + 3. emrg/__main__.py 的 stop 子命令 sys.exit(_stop_all())(退出码透传) + 4. make-installer.sh 的 .iss 模板:[Files] dontcopy(stop_all.py) + [Code] + PrepareToInstall 用 runtime python 运行 {tmp} 提取版 + 5. build-runtime.sh 把 emrg/_stop_all.py 复制进 runtime bin/stop_all.py + (stop-emrg.cmd 不再打包;CRLF 转换循环只剩 emrg.cmd emrgd.cmd) """ from pathlib import Path @@ -22,192 +22,118 @@ REPO_ROOT = Path(__file__).resolve().parent.parent -def test_stop_emrg_cmd_covers_gui_tui_daemon_and_inline_step4(): - content = (REPO_ROOT / "bin" / "stop-emrg.cmd").read_text(encoding="utf-8") - # GUI:无 /F 优雅 WM_CLOSE 优先,/F 兜底 - assert "taskkill /IM EMRG.exe" in content - assert "taskkill /F /IM EMRG.exe" in content - assert content.index("taskkill /IM EMRG.exe") < content.index("taskkill /F /IM EMRG.exe") - # 宿主 2026-08-10T01:27:07Z 实测:长活 ~15h GUI 会话 WM_CLOSE 在 5s 窗内未退出, - # 两次整跑未终止、直接 taskkill /F 才终止 → /F 必须是无条件兜底(不 gate 在 - # survivor 检查上)。判别:ping 等待之后、/F 之前不得再有 findstr 判定。 - ping_idx = content.index("ping -n 6 127.0.0.1") - f_idx = content.index("taskkill /F /IM EMRG.exe") - assert ping_idx < f_idx - assert "findstr" not in content[ping_idx:f_idx] - # TUI:命令行过滤(wmic LIKE 通配符须 %% 转义) - assert "wmic" in content and "commandline like" in content - assert "powershell" in content # wmic 缺失(Win11 24H2+)时的回退 - # daemon:协议关闭 + pid 轮询兜底(用实际执行行而非注释里的字面量) - assert 'call "%INSTALL%\\bin\\emrg.cmd" server stop' in content - assert "emrgd.pid" in content - # 顺序:GUI 在 daemon 之前(GUI 不能复活 daemon) - daemon_line = content.index('call "%INSTALL%\\bin\\emrg.cmd" server stop') - assert content.index("taskkill /IM EMRG.exe") < daemon_line - # step 4(rant 2026-08-12T14:00:05 合并单文件):内联 PowerShell 连坐强杀。 - # 判别:-Command 内联(无 -File 独立脚本),\" 转义与 TUI/verify 段同模式。 - step4_idx = content.index("\n:step4\n") # v2 label(宿主 2026-08-13 真机验证版:标签结构替代注释头) - verify_idx = content.index("\n:verify\n") # 标签定义处(\n 前缀排除前面 goto :verify 行) - assert daemon_line < step4_idx < verify_idx - assert "-ExecutionPolicy Bypass -Command" in content - assert "-ExecutionPolicy Bypass -File" not in content # 独立脚本已删除 - assert "GITSTOP" not in content # %~1/{tmp} 传递机制随 stop-git.ps1 一并移除 - # pass 1:先杀 git 树(git/ssh/plink/bash) - assert "-in @('git.exe','ssh.exe','plink.exe','bash.exe')" in content - # 前缀过滤:只碰 %USERPROFILE%\.emrg\install\git\*(系统 Git 永不命中); - # cmd 内联转义形态(\" 包裹 PS 字符串字面量) - assert r'\"$env:USERPROFILE\.emrg\install\git\*\"' in content - # 存活检查基于最新枚举(Get-CimInstance 每次重新查询,无旧快照) - assert content.count("Get-CimInstance Win32_Process") >= 4 # pass1 + pass2 + $left + verify - # truthful failure:幸存进程点名输出 + exit 1(rant 2026-08-12T12:30:41 语义) - assert r'Write-Host (\"still running: {0} (pid {1})\" -f $_.Name, $_.ProcessId)' in content - # step 4 失败必须传导到退出码(EXIT_CODE 初始化在 setlocal 之后、verify 不再清零) - assert 'set "EXIT_CODE=0"' in content - assert 'if errorlevel 1 set "EXIT_CODE=1"' in content - assert content.index('set "EXIT_CODE=0"') < step4_idx - # 干净安装安全:无旧 install 目录时跳过 - assert 'set "INSTALL=%EMRG_DIR%\\install"' in content - # 括号块内 pid 判定必须用延迟展开(!DPID!)——%DPID% 在块解析时展开, - # set "DPID=" 后取到旧值/空值 → if defined 恒假 → daemon 存活误报干净 - # (rant 2026-08-10T08:50:44,cmd.exe 经典括号块展开坑) - assert "setlocal enabledelayedexpansion" in content - # 从标签定义处截取校验块 - verify_block = content[content.index("\n:verify\n"):] - assert "PID eq !DPID!" in verify_block - # 非延迟展开 %DPID% 不得出现在括号块内(块解析时展开=恒旧值) - assert "%DPID%" not in verify_block - # :verify 的 bundled-git 存活判定:无差别前缀检查(连坐语义——连坐杀后仍存活才中止) - assert "exit 1" in verify_block - assert 'if errorlevel 1 set "EXIT_CODE=1"' in verify_block - assert r'$env:USERPROFILE\.emrg\install\git\*' in verify_block - 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 仅历史注释 +def _read(rel: str) -> str: + return (REPO_ROOT / rel).read_text(encoding="utf-8") + + +def test_stop_emrg_cmd_deleted(): + # 宿主 2026-08-17T10:32:27:不要 stop-emrg.cmd 了 + assert not (REPO_ROOT / "bin" / "stop-emrg.cmd").exists() assert not (REPO_ROOT / "bin" / "stop-git.ps1").exists() - cmd = (REPO_ROOT / "bin" / "stop-emrg.cmd").read_text(encoding="utf-8") - step4 = cmd[cmd.index("\n:step4\n"):cmd.index("\n:verify\n")] - # pass 2:连坐强杀——前缀内全部残留(sh/vim 一并杀,宿主 2026-08-11T19:47:44 拍板) - assert "Start-Sleep -Milliseconds 300" in step4 - # 每次枚举重新 Get-CimInstance(不用旧快照)——pass1/pass2/$left 共 3 次 - assert step4.count("Get-CimInstance Win32_Process") == 3 - # 退出码语义:无残留(或本来无进程)→ 0;仍有存活 → 1 - assert "exit 1" in step4 and "exit 0" in step4 - # 打包链路无功能引用:make-installer.sh / build-runtime.sh 不再处理 stop-git.ps1 - mi = (REPO_ROOT / "packaging" / "make-installer.sh").read_text(encoding="utf-8") - assert '"$STAGE_WIN/payload\\\\bin\\\\stop-git.ps1"' not in mi - assert "ExtractTemporaryFile('stop-git.ps1')" not in mi - assert "GitStopScript" not in mi - br = (REPO_ROOT / "packaging" / "build-runtime.sh").read_text(encoding="utf-8") - assert 'cp "$ROOT/bin/stop-git.ps1"' not in br - - -def test_stop_emrg_v2_step4_always_runs(): - """R127 / rant 2026-08-13T09:56:47 + 10:00:33 — host-verified v2 (EXIT_CODE=0): - step 4 (kill bundled git) must ALWAYS run before :verify. The old - `if not exist emrgd.pid goto :verify` skipped step 4 when the daemon was - down → orphaned evolution-spawned git/sh/vim processes locked install\\git - → verify reported them → exit 1 → installer aborted.""" - content = (REPO_ROOT / "bin" / "stop-emrg.cmd").read_text(encoding="utf-8") - # step 4 unconditional: daemon section falls through via goto :step4, never to :verify - daemon_end = content.index("goto :step4") - verify_idx = content.index("\n:verify\n") - step4_idx = content.index("\n:step4\n") - assert daemon_end < step4_idx < verify_idx - # no direct daemon→verify jump exists (only the :verify label, no early goto :verify) - # (v1 had `if not exist "%EMRG_DIR%\\emrgd.pid" goto :verify` — must be gone) - assert 'goto :verify' not in content - # paren-block %VAR% parse-time expansion fixed: !TRIES! delayed expansion for the loop guard - assert "if !TRIES! geq 10 goto :kill_pid" in content - # label structure replaces nested parens for TUI (wmic/PowerShell) + daemon stop - assert ":tui_wmic" in content and ":tui_done" in content - assert ":daemon_stop" in content and ":daemon_pid" in content - # host diagnostics preserved (每步 echo [N] check/kill/result) - assert "echo [4] check+kill bundled git" in content - assert "echo [verify] git residual" in content + + +def test_stop_all_py_covers_daemon_gui_tui_git_verify(): + content = _read("emrg/_stop_all.py") + # daemon:ws 协议关闭 + emrgd.pid 兜底(taskkill /F) + assert "ws_graceful_shutdown" in content + assert "emrgd.pid" 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 + assert '"taskkill", "/F", "/IM", "EMRG.exe"' in content + assert content.index('"/IM", "EMRG.exe"') < content.index('"/F", "/IM", "EMRG.exe"') + # TUI:CIM 命令行过滤 python.exe|pythonw.exe,排除 emrg.server + assert r"python(\\.exe|w\\.exe)?" in content + assert r"-notmatch 'emrg\\.server'" in content + # bundled git:install\\git\\ 前缀(系统 Git 永不命中) + assert r'\"$env:USERPROFILE\\.emrg\\install\\git\\*\"' in content + assert "stop_bundled_git" in content + # verify + 非零退出码(残留清单点名进程名+pid) + assert "def verify" in content + assert "return 1" in content + assert "WARNING residual process(es) still running" in content + # 纯标准库:import 行不得引用 emrg 包(安装器独立运行) + import_lines = [ln for ln in content.splitlines() + if ln.startswith(("import ", "from "))] + assert import_lines, "no import lines found" + assert all(not ln.startswith(("from emrg", "import emrg")) for ln in import_lines) + assert "__name__ == \"__main__\"" in content + + +def test_main_delegates_stop_to_stop_all(): + content = _read("emrg/__main__.py") + # stop 子命令帮助文案不再引用 stop-emrg.cmd + assert "stop-emrg.cmd" not in content + # main() 对 stop 分支 sys.exit(_stop_all());_stop_all 委托 emrg._stop_all + assert "sys.exit(_stop_all())" in content + assert "from emrg._stop_all import stop_all" in content def test_emrgd_cmd_has_stop_branch(): - content = (REPO_ROOT / "bin" / "emrgd.cmd").read_text(encoding="utf-8") + content = _read("bin/emrgd.cmd") assert 'if /I not "%~1"=="stop" goto :start' in content assert "-m emrg server stop" in content assert "exit /b %errorlevel%" in content def test_make_installer_iss_has_prepare_to_install(): - content = (REPO_ROOT / "packaging" / "make-installer.sh").read_text(encoding="utf-8") + content = _read("packaging/make-installer.sh") + # 无任何功能引用 stop-emrg.cmd(历史注释里的"删除"说明除外) + assert "payload\\\\bin\\\\stop-emrg.cmd" not in content + assert "ExtractTemporaryFile('stop-emrg.cmd')" not in content + assert "stop-emrg.log" not in content + # dontcopy 只提取/执行 stop_all.py assert "dontcopy" in content - assert "stop-emrg.cmd" in content + assert "payload\\\\bin\\\\stop_all.py" in content assert "PrepareToInstall" in content - assert "ExtractTemporaryFile('stop-emrg.cmd')" in content - # 单文件:只提取/执行 stop-emrg.cmd(stop-git.ps1 已删除) + assert "ExtractTemporaryFile('stop_all.py')" in content assert "ExtractTemporaryFile('stop-git.ps1')" not in content - # R125: rant 2026-08-13T09:24:37 — 输出重定向到 {tmp}\stop-emrg.log(2>&1), - # 失败时 LoadStringFromFile 读日志展示杀不掉的进程,不再让宿主手动跑诊断 - assert '/c ""\' + StopScript + \'" > "\' + LogFile + \'" 2>&1"' in content + # runtime python 探测链(R90 布局,与 emrg.cmd 一致);干净安装跳过 + assert "python-dist\\python.exe" in content + assert "python-dist\\python3.13.exe" in content + assert "if not FileExists(PythonExe) then" in content + # R125: rant 2026-08-13T09:24:37 — 输出重定向到 {tmp}\stop_all.log(2>&1), + # 失败时 LoadStringFromFile 读日志展示杀不掉的进程 + assert "stop_all.log" in content + assert '''" > "' + LogFile + '" 2>&1"''' in content + assert content.count("2>&1") >= 1 # ⚡ LoadStringFromFile 的 Inno Pascal Script 签名是 2 参数 out-param 形式 - # `(const FileName: String; var S: AnsiString): Boolean`(6.7.1 → 7.x 一致, - # issrc Shared.ScriptFunc.pas)——单参数字符串返回形式不存在,iscc 编译报 - # "Invalid number of parameters"(v0.2.30 Build Release 31661378619 实际失败, - # Test CI 不编译 .iss 未拦住)。正反两态钉死正确调用形态。 assert "LoadStringFromFile(LogFile, LogText)" in content # 正:out-param 形式 assert ":= LoadStringFromFile(LogFile)" not in content # 反:1 参数形式不存在 - # ⚡ 2 参形式第 2 参是 var S: AnsiString——LogText 必须声明 AnsiString(Inno 6 - # 的 string=UnicodeString,传 string 变量 → iscc "Type mismatch",门禁实测拦截)。 assert "LogText: AnsiString;" in content # 正:AnsiString 变量 - assert "LogText: string;" not in content # 反:UnicodeString 不匹配 var AnsiString + assert "LogText: string;" not in content # 反:UnicodeString 不匹配 assert "Length(LogText) > 2000" in content - assert "Details from stop-emrg.cmd:" in content - assert "SW_HIDE" in content # 批处理执行不弹控制台窗口(#592 纪律) - # rant 2026-08-11T17:56:25:中止消息含重启兜底引导(杀不掉时宿主可重启后重试) + assert "Details from the stop script:" in content + assert "SW_HIDE" in content # 不弹控制台窗口(#592 纪律) + # 中止消息含重启兜底引导 assert "restart the computer" in content -def test_build_runtime_copies_stop_scripts(): - content = (REPO_ROOT / "packaging" / "build-runtime.sh").read_text(encoding="utf-8") - assert 'cp "$ROOT/bin/stop-emrg.cmd" stop-emrg.cmd' in content - assert 'cp "$ROOT/bin/stop-git.ps1"' not in content - - -def test_build_runtime_forces_crlf(): - """R126 / rant 2026-08-13T09:44:32 — build-runtime.sh must force pure CRLF - on the packaged *.cmd files (git blob is LF; .gitattributes checkout - conversion may not apply in CI → LF .cmd misparsed by cmd.exe → installer - "exit code 1").""" - content = (REPO_ROOT / "packaging" / "build-runtime.sh").read_text(encoding="utf-8") - # Normalization loop covers all three Windows launchers - assert "for f in emrg.cmd emrgd.cmd stop-emrg.cmd" in content - # Pure-CRLF assertion gates the build (LF-only → build fails) +def test_build_runtime_copies_stop_all_not_stop_emrg(): + content = _read("packaging/build-runtime.sh") + # 复制 emrg/_stop_all.py → bin/stop_all.py(不再复制 stop-emrg.cmd) + assert 'cp "$ROOT/emrg/_stop_all.py" stop_all.py' in content + assert 'cp "$ROOT/bin/stop-emrg.cmd"' not in content + # R126 CRLF 转换循环只剩两个 .cmd 启动器 + assert "for f in emrg.cmd emrgd.cmd" in content + assert "stop-emrg.cmd" not in content.split("for f in emrg.cmd emrgd.cmd")[1] + # Pure-CRLF assertion gate keeps guarding the build assert "b.count(b'\\r\\n') == b.count(b'\\n')" in content def test_build_release_workflow_verifies_crlf(): - """R126 companion — build-release.yml must assert packaged *.cmd are pure - CRLF after the Build runtime step (CI red on LF).""" - wf = (REPO_ROOT / ".github" / "workflows" / "build-release.yml").read_text(encoding="utf-8") + wf = _read(".github/workflows/build-release.yml") assert "Verify runtime *.cmd are pure CRLF" in wf assert "bare-LF" in wf + + +def test_test_workflow_iscc_stub_has_stop_all(): + wf = _read(".github/workflows/test.yml") + assert 'touch "$STAGE/payload/bin/stop_all.py"' in wf + assert "stop-emrg.cmd" not in wf + + +def test_agent_md_no_stop_emrg_cmd_refs(): + """No stale stop-emrg.cmd references in docs.""" + for rel in ("README.md", "README.cn.md", "Agent.md"): + assert "stop-emrg.cmd" not in _read(rel), rel diff --git a/tests/test_stop_all.py b/tests/test_stop_all.py new file mode 100644 index 00000000..c1d3f14c --- /dev/null +++ b/tests/test_stop_all.py @@ -0,0 +1,176 @@ +"""Unit tests for emrg/_stop_all.py — the pure-stdlib process-stopper. + +Windows installer pre-stop converged into Python (host rant 2026-08-17T10:32:27: +"不要 stop-emrg.cmd 了,所有动作都在 emrg stop 命令里完成"). This module must +stay importable WITHOUT the emrg package (the Inno installer extracts the single +file to {tmp} and runs it with the runtime's Python), so it may only use the +standard library. Tests here run on POSIX (CI) and exercise the pure logic + +orchestration; the Windows-only branches are covered by the textual wiring tests +in test_installer_stop.py + the real installer runs on Windows hosts. +""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +import pytest + +from emrg import _stop_all +from emrg._stop_all import ( + _read_pid_file, + _verify_posix, + match_cmdline, + scan_pids, + stop_all, + ws_graceful_shutdown, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class TestPureStdlib: + def test_no_nonstdlib_imports(self): + """The module must import only stdlib — the installer runs it standalone + with the runtime python where no third-party packages are importable.""" + src = (REPO_ROOT / "emrg" / "_stop_all.py").read_text(encoding="utf-8") + tree = ast.parse(src) + allowed = { + "base64", "json", "os", "re", "secrets", "signal", "socket", + "subprocess", "sys", "time", "pathlib", "ast", "pytest", "annotations", + "__future__", + } + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + top = alias.name.split(".")[0] + assert top in allowed, f"non-stdlib import: {alias.name}" + elif isinstance(node, ast.ImportFrom): + assert node.module is None or node.module.split(".")[0] in allowed, \ + f"non-stdlib from-import: {node.module}" + + def test_has_main_guard(self): + src = (REPO_ROOT / "emrg" / "_stop_all.py").read_text(encoding="utf-8") + assert '__name__ == "__main__"' in src + + +class TestMatchCmdline: + def test_matches_tui_and_daemon(self): + assert match_cmdline("python -m emrg") + assert match_cmdline("python -m emrg --init-auto-evolve") + assert match_cmdline("python -m emrg.server") + assert match_cmdline("pythonw -m emrg.server") + + def test_matches_gui(self): + assert match_cmdline("/Applications/EMRG.app/Contents/MacOS/EMRG") + assert match_cmdline("EMRG-0.2.41-x86_64.AppImage --no-sandbox") + assert match_cmdline("/opt/EMRG-0.2.41-arm64.AppImage") + + def test_does_not_match_lookalikes(self): + assert not match_cmdline("python -m emrg.serverless") + assert not match_cmdline("python -m emrgx") + assert not match_cmdline("git fetch origin master") + assert not match_cmdline("python -m pytest tests/") + assert not match_cmdline("EMRGX.app/Contents/MacOS/EMRGX") + assert not match_cmdline("python -X dev main.py -m emrgistry") + + +class TestScanPids: + 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" + " 500 /opt/EMRG-0.2.41-x86_64.AppImage\n" + ) + pids = scan_pids(ps_out, own_pid=200) + assert pids == [100, 400, 500] + + def test_empty_and_malformed_lines(self): + ps_out = " 100 /usr/bin/python -m emrg\n\n \nnot-a-pid /bin/ls\n" + assert scan_pids(ps_out, own_pid=9999) == [100] + + 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 TestWsGracefulShutdown: + def test_connection_refused_returns_false(self): + # port 1 on loopback: nothing listens → ConnectionRefused → False fast + assert ws_graceful_shutdown(1, "token", timeout=1.0) is False + + def test_unreachable_port_returns_false(self, monkeypatch): + def _boom(*a, **k): + raise OSError("network unreachable") + + monkeypatch.setattr(_stop_all.socket, "create_connection", _boom) + assert ws_graceful_shutdown(12345, "token") is False + + +class TestVerifyPosix: + def test_no_residuals(self, monkeypatch): + monkeypatch.setattr(_stop_all, "_stop_scan_pids", lambda own: []) + assert _verify_posix() == [] + + def test_residuals_named(self, monkeypatch): + monkeypatch.setattr(_stop_all, "_stop_scan_pids", lambda own: [42, 43]) + out = _verify_posix() + assert any("pid 42" in r for r in out) + assert any("pid 43" in r for r in out) + + +class TestStopAllExitCode: + """exit code semantics: 0 = clean, 1 = residual processes remain + (the installer aborts on non-zero and shows the residual list).""" + + def _patch_steps(self, monkeypatch, residuals): + monkeypatch.setattr(_stop_all, "stop_daemon", lambda: None) + monkeypatch.setattr(_stop_all, "stop_gui", lambda: None) + monkeypatch.setattr(_stop_all, "stop_tui", lambda: None) + monkeypatch.setattr(_stop_all, "stop_bundled_git", lambda: None) + monkeypatch.setattr(_stop_all, "verify", lambda: residuals) + + def test_clean_returns_0(self, monkeypatch, capsys): + self._patch_steps(monkeypatch, residuals=[]) + assert stop_all() == 0 + out = capsys.readouterr().out + assert "all emrg processes stopped" in out + + def test_residual_returns_1_and_lists_them(self, monkeypatch, capsys): + self._patch_steps(monkeypatch, residuals=["EMRG.exe (pid 1234)", "daemon (pid 99)"]) + assert stop_all() == 1 + out = capsys.readouterr().out + assert "WARNING residual process(es) still running" in out + assert "EMRG.exe (pid 1234)" in out + assert "daemon (pid 99)" in out + + def test_main_exits_with_code(self, monkeypatch): + monkeypatch.setattr(_stop_all, "stop_all", lambda: 1) + with pytest.raises(SystemExit) as exc: + _stop_all.main() + assert exc.value.code == 1 + + +class TestMainDelegatesToStopAll: + def test_emrg_stop_cli_exits_nonzero(self): + """`emrg stop` must sys.exit with the stop_all() code (installer gate).""" + import emrg.__main__ as m + + assert hasattr(m, "_stop_all") + src = Path(m.__file__).read_text(encoding="utf-8") + assert "sys.exit(_stop_all())" in src + assert "from emrg._stop_all import stop_all" in src From 463f8fb4a7df3a85215e3c3b34baa56adfceb145 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 17 Aug 2026 11:55:39 +0800 Subject: [PATCH 2/2] emrg: exclude invoking PID from Windows TUI stop filter; restore 10s daemon grace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pm25coder #811 review findings: 1. A user-invoked `emrg stop` runs as `python.exe -m emrg stop`, which matches the TUI CIM filter (`-m emrg` and not `emrg.server`) → Stop-Process -Force killed the CLI itself at the TUI step, so stop_bundled_git + verify never ran and the exit-code contract was lost. Fix: pass the invoking PID into the PowerShell filter and exclude it ($_.ProcessId -ne ), mirroring the POSIX branch's own_pid guard. The installer path is unaffected (stop_all.py runs as a plain script). 2. Daemon grace window restored 3s → 10s (60 x 0.15s) to match the host-verified stop-emrg.cmd v2 semantics — a busy daemon mid-tool-loop needs the full window before the taskkill /F fallback. Wiring test extended to pin the ProcessId exclusion (textual). --- emrg/_stop_all.py | 21 +++++++++++++++------ tests/test_installer_stop.py | 4 ++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/emrg/_stop_all.py b/emrg/_stop_all.py index a9456a57..98589e79 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -275,7 +275,9 @@ def stop_daemon() -> None: if len(port_tok) == 2: if ws_graceful_shutdown(int(port_tok[0]), port_tok[1]): # wait for the daemon to exit + remove its pid file - for _ in range(20): + # (~10s grace: old stop-emrg.cmd v2 polled emrgd.pid 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): break @@ -289,8 +291,8 @@ def stop_daemon() -> None: _kill_pid_windows(pid) else: _kill_pid_posix(pid) - # poll up to 3s for it to disappear - for _ in range(20): + # poll up to 10s for it to disappear (matches old v2 grace window) + for _ in range(60): if not _pid_alive(pid): break time.sleep(0.15) @@ -343,15 +345,22 @@ def stop_gui() -> None: def stop_tui() -> None: """Stop TUI clients: Windows CIM filter (python.exe|pythonw.exe running - ``-m emrg`` but NOT ``emrg.server``); POSIX ps-scan.""" + ``-m emrg`` but NOT ``emrg.server``); POSIX ps-scan. + + On Windows the invoking PID (a user-run ``emrg stop`` = ``python.exe + -m emrg stop``) matches the ``-m emrg`` filter and would kill the CLI + itself before ``stop_bundled_git`` + ``verify`` run — exclude it + (same contract as the POSIX branch's ``own_pid`` exclusion).""" if is_win(): + own = os.getpid() ps_cmd = ( "Get-CimInstance Win32_Process | " - "Where-Object { $_.Name -match '^python(\\.exe|w\\.exe)?$' -and " + "Where-Object { $_.ProcessId -ne {own} -and " + "$_.Name -match '^python(\\.exe|w\\.exe)?$' -and " "$_.CommandLine -match '-m emrg' -and " "$_.CommandLine -notmatch 'emrg\\.server' } | " "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }" - ) + ).format(own=own) subprocess.run( ["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True, **_no_window(), diff --git a/tests/test_installer_stop.py b/tests/test_installer_stop.py index 08c0e271..bab58053 100644 --- a/tests/test_installer_stop.py +++ b/tests/test_installer_stop.py @@ -45,6 +45,10 @@ def test_stop_all_py_covers_daemon_gui_tui_git_verify(): # TUI:CIM 命令行过滤 python.exe|pythonw.exe,排除 emrg.server assert r"python(\\.exe|w\\.exe)?" in content assert r"-notmatch 'emrg\\.server'" in content + # 调用方自身 PID 排除(`emrg stop` CLI 本身匹配 -m emrg 过滤,会自杀于 + # stop_bundled_git + verify 之前 — pm25coder #811 review finding 1) + assert r"$_.ProcessId -ne {own}" in content + assert ".format(own=own)" in content # bundled git:install\\git\\ 前缀(系统 Git 永不命中) assert r'\"$env:USERPROFILE\\.emrg\\install\\git\\*\"' in content assert "stop_bundled_git" in content