From 00a27f4f54baeea022ade0ba0f6e50affcfd4fda Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 17 Aug 2026 18:09:04 +0800 Subject: [PATCH] emrg: stop_all Restart Manager lock-owner kill (generic DeleteFile code 5 fix) --- Agent.md | 2 +- emrg/_stop_all.py | 161 +++++++++++++++++++++++++++++++++++ tests/test_installer_stop.py | 38 +++++++++ tests/test_stop_all.py | 109 +++++++++++++++++++++++- 4 files changed, 308 insertions(+), 2 deletions(-) diff --git a/Agent.md b/Agent.md index 1d0393a7..5394e31e 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` (885) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (893) — 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 45cb8082..f78b6157 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -451,6 +451,160 @@ def stop_bundled_git() -> None: ) +# ── Restart Manager lock-owner scan (rant 2026-08-17T17:55:42) ───── +# +# Generic DeleteFile-code-5 fix. The 0.2.43 install failure was traced (via a +# verified find_lock_owner.ps1) to an EXTERNAL process — the browser-harness +# daemon, a standalone uv CPython under AppData\Roaming\uv\tools — locking +# files under install\. emrgd.pid/emrgd.port were empty and no `-m emrg` +# process existed, so the EMRG cmdline scan (17:03:38) could never see it. +# Restart Manager (rstrtmgr.dll) reports the ACTUAL owners of locked files, +# which covers both EMRG and foreign processes. The template is fully static +# (no str.format on the Python side) so the literal PowerShell/C# braces need +# no ``{{ }}`` escaping — the ``& { ... }`` wrapper only receives the kill +# flag as a positional argument. + +_LOCK_OWNER_PS = r""" +$ErrorActionPreference = 'SilentlyContinue' +$kill = ($args[0] -eq $true) +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Collections.Generic; +public static class RM { + [DllImport("rstrtmgr.dll", CharSet=CharSet.Unicode)] static extern int RmStartSession(out uint h, int f, string k); + [DllImport("rstrtmgr.dll", CharSet=CharSet.Unicode)] static extern int RmRegisterResources(uint h, uint n, string[] r, uint a, IntPtr p, uint b, IntPtr q); + [DllImport("rstrtmgr.dll")] static extern int RmGetList(uint h, out uint n, ref uint m, [In,Out] RM_PROCESS_INFO[] i, ref uint r); + [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[] Who(string[] files) { + uint h; + if (RmStartSession(out h, 0, Guid.NewGuid().ToString()) != 0) return new int[0]; + try { + const int BATCH = 500; + 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); + } + uint n = 0, m = 0, reason = 0; + RM_PROCESS_INFO[] infos = null; + int rc; + do { + rc = RmGetList(h, out n, ref m, infos, ref reason); + if (rc == 234) infos = new RM_PROCESS_INFO[n]; + } while (rc == 234); + List res = new List(); + if (rc == 0) { + for (uint i = 0; i < Math.Min(n, m); i++) res.Add(infos[i].Process.dwProcessId); + } + return res.ToArray(); + } finally { + RmEndSession(h); + } + } +} +'@ +$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 }) +$owners = New-Object 'System.Collections.Generic.HashSet[int]' +if ($files.Length -gt 0) { + foreach ($p in [RM]::Who([string[]]$files)) { [void]$owners.Add($p) } +} +# 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 +# installer setup.exe — NEVER kill it (rant 2026-08-17T17:55:42 safety). +$exclude = New-Object 'System.Collections.Generic.HashSet[int]' +$cur = [int]$PID +for ($g = 0; $g -lt 64 -and $cur -gt 0; $g++) { + [void]$exclude.Add($cur) + $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$cur" -ErrorAction SilentlyContinue + if (-not $proc) { break } + $cur = [int]$proc.ParentProcessId +} +$targets = @($owners | Where-Object { -not $exclude.Contains($_) }) +$killedHint = $false +foreach ($pid in $targets) { + $p = Get-CimInstance Win32_Process -Filter "ProcessId=$pid" -ErrorAction SilentlyContinue + $name = '' + $cmd = '' + if ($p) { + $name = [string]$p.Name + if ($p.CommandLine) { $cmd = [string]$p.CommandLine } + } + if ($cmd.Length -gt 150) { $cmd = $cmd.Substring(0, 150) } + if ($kill) { + Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue + Write-Output ("killed file-lock owner: PID {0} {1} | {2}" -f $pid, $name, $cmd) + if ($cmd -match 'browser[-_]?harness') { $killedHint = $true } + } else { + Write-Output ("{0}`t{1}`t{2}" -f $pid, $name, $cmd) + } +} +if ($kill -and $killedHint) { + Write-Output 'hint: browser-harness daemon stopped - restart it after the installer completes' +} +""" + + +def _lock_owner_ps(kill: bool) -> str: + """Run the Restart Manager lock-owner scan under ``install\\`` (Windows only). + + ``kill=True`` stops every non-EMRG/ancestor owner (stop_lock_owners); + ``kill=False`` emits ``PIDnamecmdline`` lines for the verify + step. Returns "" on POSIX or when PowerShell/RM is unavailable (best-effort, + like every other stop step). The script is fully static — no str.format() — + so the literal PowerShell/C# braces need no ``{{ }}`` escaping. + """ + if not is_win(): + return "" + ps_cmd = "& { " + _LOCK_OWNER_PS + " } " + ("$true" if kill else "$false") + try: + return subprocess.run( + ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-Command", ps_cmd], + capture_output=True, text=True, timeout=60, **_no_window(), + ).stdout + except (OSError, subprocess.SubprocessError, TimeoutError): + return "" + + +def _windows_lock_owners(kill: bool) -> list[tuple[int, str, str]]: + """Parse ``_lock_owner_ps`` output → ``[(pid, name, cmdline_150), ...]``.""" + owners: list[tuple[int, str, str]] = [] + for line in _lock_owner_ps(kill).splitlines(): + parts = line.split("\t") + if not parts or not parts[0].strip().isdigit(): + continue + pid = int(parts[0]) + name = parts[1] if len(parts) > 1 else "" + cmd = parts[2] if len(parts) > 2 else "" + owners.append((pid, name, cmd)) + return owners + + +def stop_lock_owners() -> None: + """Windows only: stop every process holding a lock on files under install\\. + + The generic DeleteFile-code-5 fix (rant 2026-08-17T17:55:42): Restart + Manager finds ANY owner — including non-EMRG processes (e.g. the + browser-harness daemon) that the cmdline scan can never see. Self + the + ancestor chain (the running python + Inno setup.exe) are excluded. Prints + PID/name/cmdline of every stopped process. Best-effort: RM unavailable → + silently skipped, verify() surfaces any survivor. + """ + if not is_win(): + return + for line in _lock_owner_ps(kill=True).splitlines(): + line = line.strip() + if line: + print(f"emrg stop: {line}") + + # ── Verify + exit code ────────────────────────────────────────── def _verify_windows() -> list[str]: @@ -474,6 +628,11 @@ def _verify_windows() -> list[str]: # 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})") + # 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'})") # bundled-git residual try: out = subprocess.run( @@ -522,6 +681,8 @@ def stop_all() -> int: 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() residuals = verify() if residuals: print("emrg stop: WARNING residual process(es) still running:") diff --git a/tests/test_installer_stop.py b/tests/test_installer_stop.py index 678d7b26..5b274579 100644 --- a/tests/test_installer_stop.py +++ b/tests/test_installer_stop.py @@ -91,6 +91,44 @@ def test_stop_all_py_cmdline_scan_fallback(): assert 'residuals.append(f"python emrg process (pid {pid})")' in verify_src +def test_stop_all_py_restart_manager_lock_owners(): + """rant 2026-08-17T17:55:42 — DeleteFile code 5 通用解(Restart Manager)。 + + 0.2.43 安装实测根因:占用 install\\ 下文件的是【外来进程】(browser-harness + daemon,独立 uv CPython,AppData\\Roaming\\uv\\tools),emrgd.pid/emrgd.port + 全空、无任何 -m emrg 进程 → 命令行扫描永远找不到。修复 = Restart Manager + (rstrtmgr.dll)扫 install\\ 全部文件收集占用者 → 排除自身+祖先进程链 + (stop_all 由 install\\python-dist\\python.exe 执行,自身加载 install\\python313.dll; + 祖先含 Inno setup.exe 绝不能杀)→ Stop-Process -Force,打印 PID/名称/命令行 + (截断 150);verify 用同一扫描复查残留 → exit 1。 + """ + content = _read("emrg/_stop_all.py") + # 扫描+击杀步骤与辅助 + assert "def stop_lock_owners" in content + assert "def _lock_owner_ps" in content + assert "def _windows_lock_owners" in content + # Restart Manager API + 批注册 + ERROR_MORE_DATA(234) 重试 + assert "rstrtmgr.dll" in content + assert "RmRegisterResources" in content + assert "RmGetList" in content + assert "234" in content + # 不硬编码用户名 + 祖先链排除 + 命令行截断 150 + 击杀 + browser-harness 提示 + assert "$env:USERPROFILE" in content + assert "ParentProcessId" in content + 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()") + # 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 "file-lock owner" in verify_src + + def test_main_delegates_stop_to_stop_all(): content = _read("emrg/__main__.py") # stop 子命令帮助文案不再引用 stop-emrg.cmd diff --git a/tests/test_stop_all.py b/tests/test_stop_all.py index 829dfee0..fbfbae13 100644 --- a/tests/test_stop_all.py +++ b/tests/test_stop_all.py @@ -183,6 +183,7 @@ def _fn(*a, **k): monkeypatch.setattr(_stop_all, "stop_tui", _rec("stop_tui")) monkeypatch.setattr(_stop_all, "stop_daemon", _rec("stop_daemon")) 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, "is_win", lambda: True) return order @@ -190,11 +191,17 @@ def _fn(*a, **k): def test_clients_before_daemon(self, monkeypatch): order = self._record_order(monkeypatch) assert stop_all() == 0 - assert order == ["stop_gui", "stop_tui", "stop_daemon", "stop_bundled_git"] + assert order == [ + "stop_gui", "stop_tui", "stop_daemon", "stop_bundled_git", + "stop_lock_owners", + ] # daemon MUST come after both clients — a client alive when the # daemon dies would re-spawn it (auto-spawn mechanisms in GUI/TUI) assert order.index("stop_daemon") > order.index("stop_gui") assert order.index("stop_daemon") > order.index("stop_tui") + # Restart Manager lock-owner kill must run AFTER bundled-git and + # BEFORE verify (it is the last cleanup before the residual check) + assert order.index("stop_lock_owners") > order.index("stop_bundled_git") def test_posix_skips_bundled_git(self, monkeypatch): order = self._record_order(monkeypatch) @@ -338,6 +345,106 @@ def test_no_python_residual_when_clean(self, monkeypatch): assert _stop_all._verify_windows() == [] +class TestLockOwners: + """stop_lock_owners — Restart Manager generic file-lock fix + (rant 2026-08-17T17:55:42: 0.2.43 DeleteFile code 5 root cause = an + EXTERNAL browser-harness daemon locking install\\ files; emrgd.pid/port + empty and no -m emrg process, so the cmdline scan could never see it).""" + + def test_posix_noop(self, monkeypatch): + monkeypatch.setattr(_stop_all, "is_win", lambda: False) + called: list = [] + monkeypatch.setattr( + _stop_all, "_lock_owner_ps", lambda kill: called.append(kill) or "x" + ) + _stop_all.stop_lock_owners() + assert called == [] + + def test_parses_tab_separated_output(self, monkeypatch): + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr( + _stop_all, "_lock_owner_ps", + lambda kill: ( + "9400\tpython.exe\tC:\\...\\browser_harness\\Scripts\\python.exe -m browser_harness.daemon\n" + "not-a-line\n" + "555\tpythonw.exe\tsome -m emrg.server cmdline\n" + ), + ) + owners = _stop_all._windows_lock_owners(kill=False) + assert owners == [ + (9400, "python.exe", + "C:\\...\\browser_harness\\Scripts\\python.exe -m browser_harness.daemon"), + (555, "pythonw.exe", "some -m emrg.server cmdline"), + ] + + def test_subprocess_failure_returns_empty(self, monkeypatch): + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + + def boom(*a, **k): + raise OSError("no powershell") + + monkeypatch.setattr(_stop_all.subprocess, "run", boom) + assert _stop_all._lock_owner_ps(kill=False) == "" + assert _stop_all._windows_lock_owners(kill=False) == [] + + def test_kill_flag_rendered(self, monkeypatch): + calls: list = [] + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr( + _stop_all.subprocess, "run", + lambda cmd, **kw: calls.append(cmd) or type("CP", (), {"stdout": ""}), + ) + _stop_all._lock_owner_ps(kill=True) + ps = calls[0][-1] + assert ps.startswith("& {") + assert ps.rstrip().endswith("$true") + _stop_all._lock_owner_ps(kill=False) + assert calls[1][-1].rstrip().endswith("$false") + + def test_ps_template_contains_rm_key_elements(self): + ps = _stop_all._LOCK_OWNER_PS + assert "rstrtmgr.dll" in ps # Restart Manager API + assert "RmRegisterResources" in ps + assert "RmGetList" in ps + assert "234" in ps # ERROR_MORE_DATA retry + assert "BATCH" in ps and "500" in ps # batch registration (perf) + assert "$env:USERPROFILE" in ps # no hardcoded user + assert "ParentProcessId" in ps # ancestor-chain exclusion + assert "Substring(0, 150)" in ps # cmdline truncation + assert "Stop-Process" in ps # kill + assert "browser" in ps # browser-harness hint + + 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.""" + calls: list = [] + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr( + _stop_all.subprocess, "run", + lambda cmd, **kw: calls.append(cmd) or type("CP", (), {"stdout": ""}), + ) + _stop_all._lock_owner_ps(kill=False) # must not raise ValueError + ps = calls[0][-1] + assert "Where-Object { " in ps # literal PowerShell braces intact + assert "{0}" in ps # -f format placeholders intact + + def test_verify_reports_lock_owner_residual(self, monkeypatch): + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr(_stop_all, "_read_pid_file", lambda: None) + monkeypatch.setattr(_stop_all, "_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")], + ) + monkeypatch.setattr( + _stop_all.subprocess, "run", + lambda cmd, **kw: type("CP", (), {"stdout": ""}), + ) + out = _stop_all._verify_windows() + assert any("file-lock owner (pid 9400, python.exe)" in r for r in out) + + class TestMainDelegatesToStopAll: def test_emrg_stop_cli_exits_nonzero(self): """`emrg stop` must sys.exit with the stop_all() code (installer gate)."""