diff --git a/Agent.md b/Agent.md index 5394e31e..338f993c 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` (893) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (914) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/_stop_all.py b/emrg/_stop_all.py index f78b6157..60c39bfd 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -45,6 +45,7 @@ import base64 import json import os +import platform import re import secrets import signal @@ -54,6 +55,10 @@ import time from pathlib import Path +# Build stamp printed at the start of every run so the operator can tell at a +# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31). +_STOP_ALL_STAMP = "built 2026-08-17 (rm-deadloop-fix + lock-probe)" + _EMRG_CLIENT_RE = re.compile(r"-m\s+emrg(\.server)?(\s|$)") _APPIMAGE_RE = re.compile(r"EMRG-[\w.\-]*AppImage(\s|$)") @@ -478,27 +483,45 @@ def stop_bundled_git() -> None: [DllImport("rstrtmgr.dll")] static extern int RmEndSession(uint h); [StructLayout(LayoutKind.Sequential)] struct RM_UNIQUE_PROCESS { public int dwProcessId; public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime; } [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct RM_PROCESS_INFO { public RM_UNIQUE_PROCESS Process; [MarshalAs(UnmanagedType.ByValTStr, SizeConst=256)] public string strAppName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst=64)] public string strServiceShortName; public int ApplicationType; public uint AppStatus; public uint TSSessionId; public bool bRestartable; } + public static int LastRegFail = 0; public static int[] Who(string[] files) { uint h; if (RmStartSession(out h, 0, Guid.NewGuid().ToString()) != 0) return new int[0]; try { const int BATCH = 500; + int regFail = 0; for (int i = 0; i < files.Length; i += BATCH) { int cnt = Math.Min(BATCH, files.Length - i); string[] batch = new string[cnt]; Array.Copy(files, i, batch, 0, cnt); - RmRegisterResources(h, (uint)cnt, batch, 0, IntPtr.Zero, 0, IntPtr.Zero); + // Check the return value — a failed batch must be visible, never + // silently ignored (rant 2026-08-17T21:04:32). + if (RmRegisterResources(h, (uint)cnt, batch, 0, IntPtr.Zero, 0, IntPtr.Zero) != 0) regFail++; } - uint n = 0, m = 0, reason = 0; - RM_PROCESS_INFO[] infos = null; - int rc; - do { + LastRegFail = regFail; + uint n = 0, reason = 0; + int rc = 0; + const int MAX_ATTEMPTS = 3; + // RmGetList's pdwProcCount (m) is IN/OUT: input = buffer capacity, + // output = number of entries written. The old code passed m=0 forever, + // so every call returned ERROR_MORE_DATA(234) -> infinite loop -> zero + // owners reported -> installer still hit DeleteFile code 5. Fix: + // preallocate 50 entries (m=50), on 234 resize to n and retry, hard + // capped at MAX_ATTEMPTS so an abnormal API can NEVER dead-loop + // (rant 2026-08-17T21:04:32). + uint m = 50; + RM_PROCESS_INFO[] infos = new RM_PROCESS_INFO[50]; + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { rc = RmGetList(h, out n, ref m, infos, ref reason); - if (rc == 234) infos = new RM_PROCESS_INFO[n]; - } while (rc == 234); + if (rc != 234) break; + m = n; + infos = new RM_PROCESS_INFO[n]; + } List res = new List(); if (rc == 0) { - for (uint i = 0; i < Math.Min(n, m); i++) res.Add(infos[i].Process.dwProcessId); + uint count = Math.Min(n, m); + if (count > (uint)infos.Length) count = (uint)infos.Length; + for (uint i = 0; i < count; i++) res.Add(infos[i].Process.dwProcessId); } return res.ToArray(); } finally { @@ -510,10 +533,12 @@ def stop_bundled_git() -> None: $root = Join-Path $env:USERPROFILE '.emrg\install' if (-not (Test-Path $root)) { exit 0 } $files = @(Get-ChildItem $root -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }) +$sw = [System.Diagnostics.Stopwatch]::StartNew() $owners = New-Object 'System.Collections.Generic.HashSet[int]' if ($files.Length -gt 0) { foreach ($p in [RM]::Who([string[]]$files)) { [void]$owners.Add($p) } } +$sw.Stop() # Exclude self + the full ancestor chain: stop_all runs from # install\python-dist\python.exe, which itself loads install\python313.dll # etc. and would be reported as an owner; the chain also contains the Inno @@ -548,6 +573,9 @@ def stop_bundled_git() -> None: if ($kill -and $killedHint) { Write-Output 'hint: browser-harness daemon stopped - restart it after the installer completes' } +# Structured diagnostics so the Python side can log files/owners/elapsed and +# RmRegisterResources failures — no more silent idle scans (rant 2026-08-17T21:04:32). +Write-Output ("rm-diag`t{0}`t{1}`t{2}`t{3}" -f $files.Length, $owners.Count, $sw.ElapsedMilliseconds, [RM]::LastRegFail) """ @@ -573,10 +601,55 @@ def _lock_owner_ps(kill: bool) -> str: return "" -def _windows_lock_owners(kill: bool) -> list[tuple[int, str, str]]: - """Parse ``_lock_owner_ps`` output → ``[(pid, name, cmdline_150), ...]``.""" +def _lock_owner_diag(stdout: str) -> dict | None: + """Parse the ``rm-diag`` line emitted by ``_LOCK_OWNER_PS``. + + Shape: ``rm-diagfilesownerselapsed_msreg_fail``. + Returns a dict or None when absent/unparseable (e.g. RM unavailable). + """ + for line in stdout.splitlines(): + parts = line.split("\t") + if parts and parts[0] == "rm-diag" and len(parts) >= 5: + try: + return { + "files": int(parts[1]), + "owners": int(parts[2]), + "elapsed_ms": int(parts[3]), + "reg_fail": int(parts[4]), + } + except ValueError: + return None + return None + + +def _print_rm_diag(stdout: str) -> None: + """Log the Restart Manager scan summary (files scanned / owners found / + elapsed / registration failures) so a scan can never be silently idle + (rant 2026-08-17T21:04:32).""" + d = _lock_owner_diag(stdout) + if not d: + return + print( + f"emrg stop: rm-scan files={d['files']} owners={d['owners']} " + f"elapsed={d['elapsed_ms']}ms reg_fail={d['reg_fail']}" + ) + if d["reg_fail"]: + print( + f"emrg stop: WARNING {d['reg_fail']} resource-batch registration(s) " + "failed - some file-lock owners may be missed" + ) + + +def _windows_lock_owners(kill: bool, stdout: str | None = None) -> list[tuple[int, str, str]]: + """Parse ``_lock_owner_ps`` output → ``[(pid, name, cmdline_150), ...]``. + + ``stdout`` may be supplied by the caller (avoids a second PowerShell + invocation when the diag line is needed too); None → run the scan. + """ + if stdout is None: + stdout = _lock_owner_ps(kill) owners: list[tuple[int, str, str]] = [] - for line in _lock_owner_ps(kill).splitlines(): + for line in stdout.splitlines(): parts = line.split("\t") if not parts or not parts[0].strip().isdigit(): continue @@ -587,6 +660,75 @@ def _windows_lock_owners(kill: bool) -> list[tuple[int, str, str]]: return owners +# ── Independent lock probe (rant 2026-08-17T21:06:05) ───────────── +# The RM scan and verify previously shared the same _windows_lock_owners +# function — when the detector broke (RmGetList dead-loop → empty result), +# verify went blind too and the installer overwrote locked files. This probe +# simulates the installer's overwrite directly (exclusive open = DeleteFile +# would fail) and is INDEPENDENT of Restart Manager, so a broken RM can never +# silently pass verify. + +def _iter_install_files(root: str) -> list[str]: + """All files under ``root`` (``~/.emrg/install``) — deterministic order.""" + files: list[str] = [] + for dirpath, _dirnames, filenames in os.walk(root): + for fn in filenames: + files.append(os.path.join(dirpath, fn)) + return files + + +def _win_exclusive_open(path: str) -> None: + """Open an existing file with ``dwShareMode=0`` (FileShare.None) — the exact + semantic the Inno installer needs to overwrite/delete it. Raises OSError + when another process holds the file (DeleteFile code 5 would occur).""" + import ctypes + + GENERIC_READ = 0x80000000 + OPEN_EXISTING = 3 + FILE_SHARE_NONE = 0 + kernel32 = ctypes.windll.kernel32 + h = kernel32.CreateFileW(path, GENERIC_READ, FILE_SHARE_NONE, None, + OPEN_EXISTING, 0, None) + if h == 0 or h == -1: + raise OSError(f"CreateFileW failed for {path} (file is locked)") + kernel32.CloseHandle(h) + + +def _check_locked_files(root: str, try_open=None) -> list[str]: + """Return files under ``root`` that cannot be opened exclusively. + + ``try_open`` is injectable so the traversal/collection logic is testable on + POSIX (default: Windows FileShare.None via :func:`_win_exclusive_open`). + """ + if try_open is None: + try_open = _win_exclusive_open + locked: list[str] = [] + for path in _iter_install_files(root): + try: + try_open(path) + except OSError: + locked.append(path) + return locked + + +def check_install_writable() -> list[str]: + """Windows: probe ``install\\`` for files locked against overwrite. + + Independent of Restart Manager — the installer's DeleteFile would fail on + every returned path. Returns [] when the probe is unavailable (POSIX, no + install dir, or probe error) — best-effort like every other stop step. + """ + if not is_win(): + return [] + root = os.path.join(os.path.expanduser("~"), ".emrg", "install") + if not os.path.isdir(root): + return [] + try: + return _check_locked_files(root) + except Exception: + return [] + + def stop_lock_owners() -> None: """Windows only: stop every process holding a lock on files under install\\. @@ -599,41 +741,66 @@ def stop_lock_owners() -> None: """ if not is_win(): return - for line in _lock_owner_ps(kill=True).splitlines(): + stdout = _lock_owner_ps(kill=True) + for line in stdout.splitlines(): line = line.strip() if line: print(f"emrg stop: {line}") + _print_rm_diag(stdout) # ── Verify + exit code ────────────────────────────────────────── -def _verify_windows() -> list[str]: - residuals: list[str] = [] +def _verify_windows_categories() -> list[tuple[str, list[str]]]: + """Windows residual scan, one ``(category, residual_strings)`` entry per + check — so the operator can see each check's result instead of guessing + (rant 2026-08-17T21:06:31 #3).""" + cats: list[tuple[str, list[str]]] = [] + # GUI residual + gui: list[str] = [] 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)})") + gui.append(f"EMRG.exe (pid {m.group(1)})") except (OSError, subprocess.SubprocessError, TimeoutError): pass + cats.append(("GUI", gui)) + # daemon residual (emrgd.pid still alive) + daemon: list[str] = [] pid = _read_pid_file() if pid is not None and _pid_alive(pid): - residuals.append(f"daemon (pid {pid})") + daemon.append(f"daemon (pid {pid})") + 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) - for pid in _scan_windows_python_emrg(os.getpid()): - residuals.append(f"python emrg process (pid {pid})") + py = [f"python emrg process (pid {p})" for p in _scan_windows_python_emrg(os.getpid())] + cats.append(("cmdline-scan", py)) + # file-lock owners under install\ (Restart Manager — generic code-5 fix: # covers ANY process holding locked files, incl. non-EMRG ones such as the # browser-harness daemon; self + ancestor chain excluded; rant 2026-08-17T17:55:42) - for pid, name, _cmd in _windows_lock_owners(kill=False): - residuals.append(f"file-lock owner (pid {pid}, {name or 'unknown'})") + rm_out = _lock_owner_ps(kill=False) + rm = [ + f"file-lock owner (pid {o_pid}, {name or 'unknown'})" + for o_pid, name, _cmd in _windows_lock_owners(kill=False, stdout=rm_out) + ] + cats.append(("RM re-scan", rm)) + _print_rm_diag(rm_out) + + # install-writability probe — INDEPENDENT of Restart Manager so a broken + # detector can never blind verify (rant 2026-08-17T21:06:05) + locked = check_install_writable() + cats.append(("lock-probe", [f"locked file (installer overwrite would fail): {p}" for p in locked])) + # bundled-git residual + bg: list[str] = [] try: out = subprocess.run( ["powershell", "-NoProfile", "-Command", @@ -646,9 +813,25 @@ def _verify_windows() -> list[str]: for line in out.splitlines(): line = line.strip() if line: - residuals.append(f"bundled-git {line}") + bg.append(f"bundled-git {line}") except (OSError, subprocess.SubprocessError, TimeoutError): pass + cats.append(("bundled-git", bg)) + return cats + + +def _verify_windows_summary() -> str: + """One-line per-category verify summary, e.g. + ``GUI 0 / daemon 0 / cmdline-scan 0 / RM re-scan 0 / lock-probe 0 locked / + bundled-git 0`` (rant 2026-08-17T21:06:31 #3).""" + cats = _verify_windows_categories() + return " / ".join(f"{name} {len(items)}" for name, items in cats) + + +def _verify_windows() -> list[str]: + residuals: list[str] = [] + for _name, items in _verify_windows_categories(): + residuals.extend(items) return residuals @@ -664,32 +847,95 @@ def verify() -> list[str]: # ── Orchestration ─────────────────────────────────────────────── -def stop_all() -> int: - """Run every stop step, then verify. Returns 0 (clean) or 1 (residuals). - - Clients (GUI/TUI) are stopped FIRST and the daemon LAST (rant +def _step_plan() -> list[tuple[str, object]]: + """Ordered stop steps. Clients (GUI/TUI) FIRST, daemon LAST (rant 2026-08-17T14:15:33): both clients auto-spawn the daemon when it disappears, so stopping the daemon first would let a live client immediately bring it back — leaving locked files for the installer. + Bundled git + RM lock-owner kill are Windows-only.""" + if is_win(): + return [ + ("GUI", stop_gui), + ("TUI", stop_tui), + ("daemon", stop_daemon), + ("bundled git", stop_bundled_git), + ("file-lock owners", stop_lock_owners), + ] + return [ + ("GUI", stop_gui), + ("TUI", stop_tui), + ("daemon", stop_daemon), + ] + + +def stop_all() -> int: + """Run every stop step, then verify. Returns 0 (clean) or 1 (residuals). + + Logging follows the standard from rant 2026-08-17T21:06:31: header with + build stamp / python / platform / pid, ``[N/T] step -> result (elapsed)`` + per step, per-category verify summary, exit-code line with total elapsed, + and NO silent failures (every step is wrapped so an exception still shows + ``ERROR : `` and the run continues to the final exit code). """ - print("emrg stop: stopping GUI ...") - stop_gui() - print("emrg stop: stopping TUI clients ...") - stop_tui() - print("emrg stop: stopping daemon ...") - stop_daemon() + t0 = time.monotonic() + print( + f"emrg stop: stop_all.py {_STOP_ALL_STAMP} | " + f"python {platform.python_version()} {platform.system()}-{platform.machine()} " + f"| pid {os.getpid()}" + ) + steps = _step_plan() + for i, (name, fn) in enumerate(steps, 1): + s = time.monotonic() + try: + fn() + print(f"emrg stop: [{i}/{len(steps)}] {name} -> done ({time.monotonic() - s:.1f}s)") + except Exception as e: # never silent (rant 2026-08-17T21:06:31 #4) + print( + f"emrg stop: ERROR [{i}/{len(steps)}] {name}: {e} " + f"({type(e).__name__}) ({time.monotonic() - s:.1f}s)" + ) + # Kill retry: RM may have killed owners but locks can linger — re-probe + # with the INDEPENDENT writability check and retry (Try-again semantics, + # rant 2026-08-17T21:06:05 #3); anything still locked flows into verify → + # installer aborts with a named list instead of a code-5 dialog. if is_win(): - print("emrg stop: stopping bundled git under install\\git ...") - stop_bundled_git() - print("emrg stop: stopping file-lock owners under install\\ (Restart Manager) ...") - stop_lock_owners() + for attempt in range(1, 3): + locked = check_install_writable() + if not locked: + break + print( + f"emrg stop: {len(locked)} file(s) still locked after kill " + f"(retry {attempt}/2) ..." + ) + s = time.monotonic() + try: + stop_lock_owners() + except Exception as e: + print(f"emrg stop: ERROR retry {attempt}/2: {e} ({type(e).__name__})") + time.sleep(0.3) + print(f"emrg stop: retry {attempt}/2 done ({time.monotonic() - s:.1f}s)") residuals = verify() if residuals: print("emrg stop: WARNING residual process(es) still running:") for r in residuals: print(f" - {r}") + if is_win(): + try: + print("emrg stop: verify: " + _verify_windows_summary() + " -> RESIDUAL") + except Exception: + pass + print( + f"emrg stop: exit code 1 ({len(residuals)} residual) " + f"({time.monotonic() - t0:.1f}s)" + ) return 1 + if is_win(): + try: + print("emrg stop: verify: " + _verify_windows_summary() + " -> CLEAN") + except Exception: + pass print("emrg stop: all emrg processes stopped.") + print(f"emrg stop: exit code 0 (clean) ({time.monotonic() - t0:.1f}s)") return 0 diff --git a/tests/test_installer_stop.py b/tests/test_installer_stop.py index 5b274579..d5931c08 100644 --- a/tests/test_installer_stop.py +++ b/tests/test_installer_stop.py @@ -88,7 +88,7 @@ def test_stop_all_py_cmdline_scan_fallback(): # verify() 增加 python emrg 进程残留检查(不依赖 pid 文件) verify_src = content.split("def _verify_windows")[1].split("def _verify_posix")[0] assert "_scan_windows_python_emrg(os.getpid())" in verify_src - assert 'residuals.append(f"python emrg process (pid {pid})")' in verify_src + assert 'f"python emrg process (pid {p})"' in verify_src def test_stop_all_py_restart_manager_lock_owners(): @@ -118,15 +118,20 @@ def test_stop_all_py_restart_manager_lock_owners(): assert "Substring(0, 150)" in content assert "Stop-Process" in content assert "browser" in content - # stop_all() 顺序:bundled git 之后、verify 之前 - stop_all_src = content.split("def stop_all")[1] - assert "stop_bundled_git()" in stop_all_src - assert "stop_lock_owners()" in stop_all_src - assert stop_all_src.index("stop_bundled_git()") < stop_all_src.index("stop_lock_owners()") + # stop_all() 顺序:bundled git 之后、verify 之前(步骤计划表 _step_plan, + # rant 2026-08-17T21:06:31 日志规范重构后 stop_all 从计划表驱动) + step_src = content.split("def _step_plan")[1].split("def stop_all")[0] + assert "stop_bundled_git" in step_src + assert "stop_lock_owners" in step_src + assert step_src.index("stop_bundled_git") < step_src.index("stop_lock_owners") + assert '"GUI", stop_gui' in step_src + assert '"daemon", stop_daemon' in step_src # verify 接入:残留 file-lock owner → 点名 + exit 1(R125 中止语义不变) verify_src = content.split("def _verify_windows")[1].split("def _verify_posix")[0] - assert "_windows_lock_owners(kill=False)" in verify_src + assert "_windows_lock_owners(kill=False, stdout=rm_out)" in verify_src assert "file-lock owner" in verify_src + # rant 2026-08-17T21:04:32:verify 也要打印 RM 扫描摘要(防静默空转) + assert "_print_rm_diag(rm_out)" in verify_src def test_main_delegates_stop_to_stop_all(): diff --git a/tests/test_stop_all.py b/tests/test_stop_all.py index fbfbae13..e6d11664 100644 --- a/tests/test_stop_all.py +++ b/tests/test_stop_all.py @@ -38,8 +38,8 @@ def test_no_nonstdlib_imports(self): tree = ast.parse(src) allowed = { "base64", "json", "os", "re", "secrets", "signal", "socket", - "subprocess", "sys", "time", "pathlib", "ast", "pytest", "annotations", - "__future__", + "subprocess", "sys", "time", "pathlib", "platform", "ctypes", "ast", + "pytest", "annotations", "__future__", } for node in ast.walk(tree): if isinstance(node, ast.Import): @@ -143,12 +143,17 @@ def _patch_steps(self, monkeypatch, residuals): monkeypatch.setattr(_stop_all, "stop_tui", lambda: None) monkeypatch.setattr(_stop_all, "stop_bundled_git", lambda: None) monkeypatch.setattr(_stop_all, "verify", lambda: residuals) + monkeypatch.setattr(_stop_all, "check_install_writable", lambda: []) + monkeypatch.setattr(_stop_all.time, "sleep", lambda *a, **k: None) 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 + assert "exit code 0 (clean)" in out + assert "stop_all.py built" in out # header (rant 21:06:31 #1) + assert "[1/" in out and "-> done" in out # per-step [N/T] (rant #2) def test_residual_returns_1_and_lists_them(self, monkeypatch, capsys): self._patch_steps(monkeypatch, residuals=["EMRG.exe (pid 1234)", "daemon (pid 99)"]) @@ -157,6 +162,20 @@ def test_residual_returns_1_and_lists_them(self, monkeypatch, capsys): assert "WARNING residual process(es) still running" in out assert "EMRG.exe (pid 1234)" in out assert "daemon (pid 99)" in out + assert "exit code 1 (2 residual)" in out + + def test_step_exception_not_silent(self, monkeypatch, capsys): + """A crashing step must print ERROR and still reach the final + exit code (rant 2026-08-17T21:06:31 #4 — never silent).""" + self._patch_steps(monkeypatch, residuals=[]) + + def boom(): + raise RuntimeError("taskkill failed") + + monkeypatch.setattr(_stop_all, "stop_gui", boom) + assert stop_all() == 0 + out = capsys.readouterr().out + assert "ERROR [1/" in out and "GUI" in out and "taskkill failed" in out def test_main_exits_with_code(self, monkeypatch): monkeypatch.setattr(_stop_all, "stop_all", lambda: 1) @@ -185,6 +204,8 @@ def _fn(*a, **k): monkeypatch.setattr(_stop_all, "stop_bundled_git", _rec("stop_bundled_git")) monkeypatch.setattr(_stop_all, "stop_lock_owners", _rec("stop_lock_owners")) monkeypatch.setattr(_stop_all, "verify", lambda: []) + monkeypatch.setattr(_stop_all, "check_install_writable", lambda: []) + monkeypatch.setattr(_stop_all.time, "sleep", lambda *a, **k: None) monkeypatch.setattr(_stop_all, "is_win", lambda: True) return order @@ -414,6 +435,143 @@ def test_ps_template_contains_rm_key_elements(self): assert "Stop-Process" in ps # kill assert "browser" in ps # browser-harness hint + def test_ps_template_rm_get_list_cannot_deadloop(self): + """Rant 2026-08-17T21:04:32 — the old C# loop passed m=0 forever + (RmGetList's pdwProcCount is in/out: input=capacity, output=written) + → every call returned ERROR_MORE_DATA(234) → infinite loop → zero + owners killed → installer still hit DeleteFile code 5.""" + ps = _stop_all._LOCK_OWNER_PS + # Preallocated capacity on the first call (not 0) + assert "uint m = 50;" in ps + assert "new RM_PROCESS_INFO[50]" in ps + # Resize to n on 234, m passed as array capacity (ref in/out) + assert "m = n;" in ps + assert "new RM_PROCESS_INFO[n]" in ps + # Hard loop cap — an abnormal API can never spin forever + assert "MAX_ATTEMPTS" in ps and "attempt < MAX_ATTEMPTS" in ps + assert "const int MAX_ATTEMPTS = 3;" in ps + # No unbounded do/while(rc == 234) construct remains + assert "while (rc == 234);" not in ps + # Result capped by buffer length as well as n/m + assert "Math.Min(n, m)" in ps + assert "infos.Length" in ps + # RmRegisterResources failure is checked, not silent + assert "RmRegisterResources(h, (uint)cnt, batch, 0, IntPtr.Zero, 0, IntPtr.Zero) != 0" in ps + assert "LastRegFail" in ps + # Structured diagnostics line (files/owners/elapsed/reg_fail) + assert "rm-diag" in ps + assert "$sw.ElapsedMilliseconds" in ps + + @pytest.mark.parametrize( + "scenario", + [ + # (needed_seq, expected_rc, expected_count) + # no owners at all + ([0], 0, 0), + # few owners fit in the initial 50-slot buffer + ([3], 0, 3), + # exactly the buffer capacity + ([50], 0, 50), + # one resize: 120 owners > 50 → 234 → resize to 120 → success + ([120], 0, 120), + # two resizes then success: 60→234(resize 60), 80→234(resize 80), + # then 40 fits → rc=0, count 40 + ([60, 80, 40], 0, 40), + # monotonic growth past capacity: 3 attempts all 234 → hard cap + # terminates with rc=234 (no owners), NEVER dead-loops + ([60, 80, 120], 234, 0), + # pathological: API always returns 234 → cap terminates, empty result + ([234, 234, 234, 234], 234, 0), + ], + ) + def test_rm_get_list_loop_logic(self, scenario): + """Pure-Python model of the fixed C# RmGetList loop (rant + 2026-08-17T21:04:32) — parameterized over real RM behaviors. The loop + must terminate in EVERY case (never dead-loop) and return the right + owner count.""" + needed_seq, expected_rc, expected_count = scenario + MAX_ATTEMPTS = 3 + calls = 0 + + def get_list(infos_cap): + """Mirror rstrtmgr.dll: m (input capacity) vs n (needed). + rc=234 (ERROR_MORE_DATA) when needed > capacity; else rc=0 with + m = written count = min(capacity, needed). After the sequence is + exhausted the needed count stabilizes at its last value.""" + nonlocal calls + idx = min(calls, len(needed_seq) - 1) + calls += 1 + needed = needed_seq[idx] + if needed == 234: # pathological: API never converges + return 234, needed, 0 + if needed > infos_cap: + return 234, needed, 0 + return 0, needed, min(infos_cap, needed) + + n, reason = 0, 0 + rc = 0 + m = 50 + infos_len = 50 + attempts = 0 + for attempt in range(MAX_ATTEMPTS): + attempts += 1 + rc, n, m = get_list(infos_len) + if rc != 234: + break + infos_len = n + m = n + count = min(n, m) if rc == 0 else 0 + if count > infos_len: + count = infos_len + assert rc == expected_rc + assert count == expected_count + assert attempts <= MAX_ATTEMPTS # hard cap — never dead-loops + + def test_lock_owner_diag_parsed(self): + stdout = ( + "9400\tpython.exe\tC:\\...\\browser_harness\\Scripts\\python.exe -m browser_harness.daemon\n" + "rm-diag\t1234\t2\t1500\t0\n" + ) + assert _stop_all._lock_owner_diag(stdout) == { + "files": 1234, "owners": 2, "elapsed_ms": 1500, "reg_fail": 0, + } + # no diag line / malformed → None + assert _stop_all._lock_owner_diag("9400\tpython.exe\tx\n") is None + assert _stop_all._lock_owner_diag("rm-diag\tabc\t2\t3\t0\n") is None + assert _stop_all._lock_owner_diag("") is None + + def test_stop_lock_owners_logs_diag(self, monkeypatch, capsys): + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr( + _stop_all, "_lock_owner_ps", lambda kill: ( + "killed file-lock owner: PID 9400 python.exe | C:\\...\\browser_harness\n" + "rm-diag\t1234\t1\t900\t1\n" + ), + ) + _stop_all.stop_lock_owners() + out = capsys.readouterr().out + assert "killed file-lock owner: PID 9400 python.exe" in out + assert "rm-scan files=1234 owners=1 elapsed=900ms reg_fail=1" in out + assert "WARNING 1 resource-batch registration(s) failed" in out + + 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, "_scan_windows_python_emrg", lambda own: []) + monkeypatch.setattr( + _stop_all.subprocess, "run", + lambda cmd, **kw: type("CP", (), {"stdout": ""}), + ) + monkeypatch.setattr( + _stop_all, "_lock_owner_ps", lambda kill: ( + "9400\tpython.exe\tC:\\...\\browser_harness\\Scripts\\python.exe -m browser_harness.daemon\n" + "rm-diag\t1234\t1\t900\t0\n" + ), + ) + out = _stop_all._verify_windows() + assert any("file-lock owner (pid 9400, python.exe)" in r for r in out) + assert "rm-scan files=1234 owners=1 elapsed=900ms reg_fail=0" in capsys.readouterr().out + def test_ps_template_renders_without_valueerror(self, monkeypatch): """The template must render without raising — braces are literal (no str.format), so the {{ }} escaping contract does not apply here.""" @@ -434,8 +592,8 @@ def test_verify_reports_lock_owner_residual(self, monkeypatch): monkeypatch.setattr(_stop_all, "_scan_windows_python_emrg", lambda own: []) monkeypatch.setattr( _stop_all, "_windows_lock_owners", - lambda kill: [(9400, "python.exe", - "C:\\...\\browser_harness\\Scripts\\python.exe -m browser_harness.daemon")], + lambda kill, stdout=None: [(9400, "python.exe", + "C:\\...\\browser_harness\\Scripts\\python.exe -m browser_harness.daemon")], ) monkeypatch.setattr( _stop_all.subprocess, "run", @@ -445,6 +603,129 @@ def test_verify_reports_lock_owner_residual(self, monkeypatch): assert any("file-lock owner (pid 9400, python.exe)" in r for r in out) +class TestIndependentLockProbe: + """check_install_writable — INDEPENDENT of Restart Manager (rant + 2026-08-17T21:06:05): simulates the installer's overwrite with an + exclusive open, so a broken RM detector can never blind verify.""" + + def _mk_root(self, tmp_path, files=("a.txt", "sub/b.txt")): + root = tmp_path / "install" + for f in files: + p = root / f + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("x", encoding="utf-8") + return str(root) + + def test_check_locked_files_collects_unopenable(self, tmp_path): + root = self._mk_root(tmp_path) + opened = set() + + def try_open(path): + opened.add(path) + if path.endswith("b.txt"): + raise OSError("locked") + + assert _stop_all._check_locked_files(root, try_open=try_open) == [ + str(tmp_path / "install" / "sub" / "b.txt") + ] + assert opened == { + str(tmp_path / "install" / "a.txt"), + str(tmp_path / "install" / "sub" / "b.txt"), + } + + def test_check_locked_files_clean(self, tmp_path): + root = self._mk_root(tmp_path) + assert _stop_all._check_locked_files(root, try_open=lambda p: None) == [] + + def test_check_locked_files_empty_root(self, tmp_path): + root = tmp_path / "empty-install" + root.mkdir() + assert _stop_all._check_locked_files(str(root), try_open=lambda p: None) == [] + + def test_check_install_writable_posix_noop(self, monkeypatch): + monkeypatch.setattr(_stop_all, "is_win", lambda: False) + assert _stop_all.check_install_writable() == [] + + def test_check_install_writable_no_install_dir(self, monkeypatch, tmp_path): + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr(_stop_all.os.path, "expanduser", lambda _: str(tmp_path)) + assert _stop_all.check_install_writable() == [] + + def test_check_install_writable_returns_locked(self, monkeypatch, tmp_path): + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr(_stop_all.os.path, "expanduser", lambda _: str(tmp_path)) + emrg_root = str(tmp_path / ".emrg" / "install") + self._mk_root(tmp_path / ".emrg", files=("a.txt",)) # creates ~/.emrg/install + monkeypatch.setattr(_stop_all, "_check_locked_files", lambda r: [emrg_root + "/a.txt"]) + assert _stop_all.check_install_writable() == [emrg_root + "/a.txt"] + + def test_check_install_writable_probe_error_returns_empty(self, monkeypatch, tmp_path): + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr(_stop_all.os.path, "expanduser", lambda _: str(tmp_path)) + self._mk_root(tmp_path) + + def boom(root): + raise RuntimeError("ctypes unavailable") + + monkeypatch.setattr(_stop_all, "_check_locked_files", boom) + assert _stop_all.check_install_writable() == [] # best-effort, never raise + + 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, "_scan_windows_python_emrg", lambda own: []) + monkeypatch.setattr(_stop_all, "_lock_owner_ps", lambda kill: "") + monkeypatch.setattr( + _stop_all.subprocess, "run", + lambda cmd, **kw: type("CP", (), {"stdout": ""}), + ) + monkeypatch.setattr( + _stop_all, "check_install_writable", + lambda: [r"C:\\Users\\me\\.emrg\\install\\lib\\websockets\\speedups.cp313-win_amd64.pyd"], + ) + cats = _stop_all._verify_windows_categories() + names = [n for n, _ in cats] + assert names == ["GUI", "daemon", "cmdline-scan", "RM re-scan", "lock-probe", "bundled-git"] + locked = dict(cats)["lock-probe"] + assert any("locked file" in r and "speedups" in r for r in locked) + summary = _stop_all._verify_windows_summary() + assert "lock-probe 1" in summary + assert "GUI 0" in summary + + def test_stop_all_retries_lock_kill(self, monkeypatch, capsys): + """RM killed owners but locks linger → re-probe + retry up to 2×, + then verify still surfaces the locked file → exit 1 (rant 21:06:05 #3: + installer aborts with a named list instead of a code-5 dialog).""" + calls: list[str] = [] + + def rec(name): + def _fn(*a, **k): + calls.append(name) + return _fn + + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr(_stop_all, "stop_gui", rec("gui")) + monkeypatch.setattr(_stop_all, "stop_tui", rec("tui")) + monkeypatch.setattr(_stop_all, "stop_daemon", rec("daemon")) + monkeypatch.setattr(_stop_all, "stop_bundled_git", rec("bundled_git")) + monkeypatch.setattr(_stop_all, "stop_lock_owners", rec("lock_owners")) + monkeypatch.setattr(_stop_all.time, "sleep", lambda *a, **k: None) + locked = iter([[r"C:\\locked.pyd"], [r"C:\\locked.pyd"], []]) + monkeypatch.setattr(_stop_all, "check_install_writable", lambda: next(locked)) + monkeypatch.setattr( + _stop_all, "verify", + lambda: ["locked file (installer overwrite would fail): C:\\locked.pyd"], + ) + assert _stop_all.stop_all() == 1 + out = capsys.readouterr().out + # initial kill + 2 retries + assert calls.count("lock_owners") == 3 + assert "still locked after kill (retry 1/2)" in out + assert "retry 2/2" in out + assert "locked file (installer overwrite would fail): C:\\locked.pyd" in out + assert "exit code 1 (1 residual)" in out + + class TestMainDelegatesToStopAll: def test_emrg_stop_cli_exits_nonzero(self): """`emrg stop` must sys.exit with the stop_all() code (installer gate)."""