Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (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 路径不受影响)
Expand Down
161 changes: 161 additions & 0 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<int> res = new List<int>();
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 ``PID<TAB>name<TAB>cmdline`` 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]:
Expand All@@ -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(
Expand DownExpand Up@@ -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:")
Expand Down
38 changes: 38 additions & 0 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
109 changes: 108 additions & 1 deletion tests/test_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -183,18 +183,25 @@ 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

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)
Expand DownExpand Up@@ -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)."""
Expand Down
Loading