diff --git a/Agent.md b/Agent.md index ef077853..9bad68d5 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` (961) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (967) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 19 conn-manager + 22 app-commands + 130 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/_stop_all.py b/emrg/_stop_all.py index b65106b1..c369bf4b 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -782,6 +782,113 @@ def _kill_tree_windows(pid: int) -> None: pass +def _escalate_kill_windows(pid: int) -> str: + """Escalation kill for a lock-holder that survived the stop phase (rant + 2026-08-18T21:24:48 #2c): ``taskkill /F /T`` first → ancestor chain + (parent tree) kill → ``Stop-Process -Force`` fallback. Returns a one-line + outcome for the per-file disposition log.""" + log: list[str] = [] + try: + r = subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, text=True, timeout=10, **_no_window(), + ) + log.append(f"taskkill /F /T rc={getattr(r, 'returncode', '?')}") + if getattr(r, "returncode", 1) == 0 and not _pid_alive(pid): + return "; ".join(log) + " => killed" + except (OSError, subprocess.SubprocessError, TimeoutError) as e: + log.append(f"taskkill err={type(e).__name__}") + # Ancestor chain (parent tree): walk ProcessId → ParentProcessId via CIM + # and kill each ancestor with its own tree (the holder may be a child + # whose parent keeps it / the lock alive). + try: + ps = ( + "powershell -NoProfile -Command " + '"$p=Get-CimInstance Win32_Process -Filter \\"ProcessId=' + str(pid) + '\\"; ' + '$chain=@(); while($p -and $p.ParentProcessId -and $p.ParentProcessId -ne 0){' + '$par=Get-CimInstance Win32_Process -Filter ("ProcessId="+$p.ParentProcessId); ' + 'if(-not $par){break}; $chain+=$par; $p=$par}; ' + '$chain | ForEach-Object { Write-Output ("{0} {1}" -f $_.ProcessId,$_.Name) }"' + ) + out = subprocess.run(ps, capture_output=True, text=True, timeout=15, **_no_window()).stdout + for line in out.splitlines(): + parts = line.split() + if not parts or not parts[0].strip().isdigit(): + continue + apid, aname = int(parts[0]), " ".join(parts[1:]) + r = subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(apid)], + capture_output=True, text=True, timeout=10, **_no_window(), + ) + log.append(f"parent {apid} ({aname}) rc={getattr(r, 'returncode', '?')}") + except (OSError, subprocess.SubprocessError, TimeoutError) as e: + log.append(f"parent-tree err={type(e).__name__}") + if not _pid_alive(pid): + return "; ".join(log) + " => killed" + # Final fallback: Stop-Process -Force (CIM/WMIC-class stop). + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-Command", + f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue"], + capture_output=True, text=True, timeout=10, **_no_window(), + ) + log.append(f"Stop-Process rc={getattr(r, 'returncode', '?')}") + if not _pid_alive(pid): + return "; ".join(log) + " => killed" + except (OSError, subprocess.SubprocessError, TimeoutError) as e: + log.append(f"Stop-Process err={type(e).__name__}") + return "; ".join(log) + " => SURVIVED" + + +def _escalate_locked_files( + locked: list[str], + mh_holders: list[tuple[int, str, str, int, list[str], str]], + root: str, +) -> None: + """Final escalation + per-file disposition log (rant 2026-08-18T21:24:48 + #2c/#4): for each still-locked file, attribute holders from the three data + sources (module-holder enumeration / Restart Manager / self), kill + external holders with escalation, and log the full chain — file path / + holder PIDs / attribution source / action / result. Never raises; any + survivors are logged as advisory and the install continues (the installer's + own overwrite is the final arbiter).""" + if not is_win() or not locked or not root: + return + try: + root_n = os.path.normpath(root).replace("\\", "/").rstrip("/") + holders_by_rel: dict[str, list[tuple[str, int, str]]] = {} + for pid, name, _exe, _parent, files, tag in mh_holders: + for f in files: + rel = os.path.normpath(f).replace("\\", "/") + if root_n and rel.startswith(root_n + "/"): + rel = rel[len(root_n) + 1:] + src = "self/excluded" if tag == "excluded" else "module-holder" + holders_by_rel.setdefault(rel, []).append((src, pid, name or "")) + rm_owners = _windows_lock_owners(kill=False) + print("emrg stop: escalation — locks surviving the stop phase:") + for p in locked: + rel = os.path.normpath(p).replace("\\", "/") + if root_n and rel.startswith(root_n + "/"): + rel = rel[len(root_n) + 1:] + holders = list(holders_by_rel.get(rel, [])) + for o_pid, o_name, _cmd in rm_owners: # RM reports actual owners + if not any(h[1] == o_pid for h in holders): + holders.append(("rm", o_pid, o_name)) + chain = ", ".join(f"{src}:{pid}:{name}" for src, pid, name in holders) or "none found" + actions: list[str] = [] + for src, pid, name in holders: + if src == "self/excluded": + actions.append(f"self pid {pid} — released on stop_all exit") + continue + actions.append(f"pid {pid} ({name}): " + _escalate_kill_windows(pid)) + print( + f"emrg stop: locked {rel} | holders [{chain}] | " + + (" | ".join(actions) if actions else "no external holder") + ) + except Exception as e: # best-effort — never break the stop flow + print(f"emrg stop: ERROR escalation: {e} ({type(e).__name__})") + + # Any EXTERNAL (non-self/ancestor) module holder was found and killed — # evidence for the self-lock final guard: when lock-probe still reports # locked files but no external holder exists, the lock is stop_all's own @@ -1048,7 +1155,13 @@ def _classify_locked_files( residuals aborts a perfectly fine install. Returns ``(self_held, residual)`` install-relative paths: - - self_held: locked file attributed ONLY to excluded (self/ancestor) holders + - self_held: locked file attributed ONLY to excluded (self/ancestor) holders, + or (rant 2026-08-18T21:24:48 #3) a file under ``python-dist\\`` with no + external target holder — stop_all always runs from + install\\python-dist\\python.exe, whose interpreter + lazily-loaded + stdlib modules (select/_ctypes/_hashlib/_socket, ...) hold DLL locks + that module-holder enumeration does NOT list (v0.2.49: 12 locked vs 5 + enumerated modules); such locks release when stop_all exits. - residual: locked file with an external (``target``) holder, or one that cannot be attributed to any known holder (conservative — could be a plain non-DLL lock held by an external process that loaded no module). @@ -1062,10 +1175,28 @@ def _norm(p: str) -> str: return p.replace("\\", "/") root_n = _norm(root) + def _to_rel(p: str) -> str: + n = _norm(p) + if n.startswith(root_n + "/"): + # Normalize again: on Windows os.path.relpath() returns + # backslash-separated results, but the locked-file lookup keys are + # forward-slash — an un-normalized key would miss (external target + # holder misclassified as self-held on Windows). + return _norm(os.path.relpath(n, root_n)) + return n # already install-relative (PS Substring / fixture form) + tag_by_rel: dict[str, set[str]] = {} for _pid, _name, _exe, _parent, files, tag in mh_holders: for f in files: - tag_by_rel.setdefault(_norm(f), set()).add(tag) + # Key by the SAME rel path the locked-file lookup uses below — + # full-path keys never matched the rel lookup, so holder tags + # (esp. ``target``) were lost and every python-dist file was + # mis-attributed as self-held (test_pydist_external_target_is_residual). + # Holder files may already be install-relative: os.path.relpath() + # against root would mangle those on POSIX (CWD prefix), so only + # convert genuine absolute paths. + rel_f = _to_rel(f) + tag_by_rel.setdefault(rel_f, set()).add(tag) self_held: list[str] = [] residual: list[str] = [] for p in locked: @@ -1073,6 +1204,16 @@ def _norm(p: str) -> str: tags = tag_by_rel.get(rel, set()) if tags == {"excluded"}: self_held.append(rel) + elif "/python-dist/" in "/" + rel and "target" not in tags: + # Rant 2026-08-18T21:24:48 #3 — self-held relaxation: stop_all + # itself runs from install\python-dist\python.exe; its runtime + + # lazily-loaded stdlib modules hold python-dist DLL locks that the + # module-holder enumeration does not cover (v0.2.49: 12 locked vs + # 5 enumerated modules → 7 falsely "unattributable" → old + # self-held check misjudged them as residual and aborted a fine + # install). python-dist\ + no external target holder ⇒ self-held: + # released when stop_all exits, installer continues. + self_held.append(rel) else: # No tags (unattributable) OR has an external target holder. residual.append(rel) @@ -1151,20 +1292,37 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]: probe_items = [] if _lock_probe_error: probe_items.append(f"lock-probe failed (error: {_lock_probe_error})") - # Self-held attribution (rant 2026-08-18T18:57:09): when stop_all runs - # from install\python-dist\python.exe, that interpreter MUST load its own - # python-dist DLLs (python313.dll, select.pyd, ...) — those image-section - # locks are held by the stop_all process itself (module-holder tag - # ``excluded`` = self + ancestor chain) and are RELEASED when stop_all + # Self-held attribution (rant 2026-08-18T18:57:09 + 21:24:48 #3): when + # stop_all runs from install\python-dist\python.exe, that interpreter MUST + # load its own python-dist DLLs (python313.dll, select.pyd, ...) — those + # image-section locks are held by the stop_all process itself (module-holder + # tag ``excluded`` = self + ancestor chain) and are RELEASED when stop_all # exits, before the installer starts overwriting (make-installer.sh uses # ewWaitUntilTerminated). They are NOT residuals — counting them aborts - # the install while nothing is actually wrong. A locked file is residual - # only when an EXTERNAL holder (module-holder ``target`` / RM owner) - # exists or the lock cannot be attributed to any known holder (conservative). + # the install while nothing is actually wrong. Since 21:24:48 the same + # holds for ANY locked file under python-dist\ with no external target + # holder (lazily-loaded stdlib modules enumeration misses). self_held, residual_locked = _classify_locked_files( locked, mh_holders, _install_root() if is_win() else "" ) - probe_items.extend(f"locked file (createfile-probe): {p}" for p in residual_locked) + # Final escalation (rant 2026-08-18T21:24:48 #2c): locks that survive the + # stop phase get a hard re-kill of their external holders (taskkill /F /T → + # parent tree → Stop-Process), then the probe + classification run again. + # Only TRULY unkillable locks are logged — the install CONTINUES and the + # installer's own overwrite is the final arbiter (21:24:48 #2c/#5). + if residual_locked and is_win(): + _escalate_locked_files(locked, mh_holders, _install_root()) + locked = check_install_writable() + if not _lock_probe_error: + _self2, residual_locked = _classify_locked_files( + locked, mh_holders, _install_root() + ) + self_held = sorted(set(self_held + _self2)) + # Advisory (non-aborting) after escalation — detailed chain already logged + # per file by _escalate_locked_files; exit stays 0 unless EMRG process + # residuals exist (rant 2026-08-18T21:24:48). + for p in residual_locked: + probe_items.append(f"locked file (advisory, install continues): {p}") if self_held: print( f"emrg stop: WARNING {len(self_held)} file(s) locked by stop_all " @@ -1348,6 +1506,18 @@ def _step_plan() -> list[tuple[str, object]]: ] +def _is_lock_residual(r: str) -> bool: + """True when a verify residual is lock-related (external lock holder or + locked file) rather than an EMRG process residual (rant 2026-08-18T21:24:48 + #2c/#5: lock residuals are advisory after escalation — install continues; + process residuals still abort).""" + return r.startswith(( + "locked file", + "install-module holder", + "file-lock owner", + )) + + def stop_all() -> int: """Run every stop step, then verify. Returns 0 (clean) or 1 (residuals). @@ -1436,9 +1606,27 @@ def stop_all() -> int: f"stop_all exits; installer continues" ) residuals = verify() - if residuals: + # Lock-related residuals are ADVISORY after escalation (rant + # 2026-08-18T21:24:48 #2c/#5): an unkillable external lock holder is + # logged in detail and the install CONTINUES — the installer's own + # overwrite is the final arbiter ("若仍有杀不掉的锁:日志详细记录但安装 + # 继续,最终成功与否以实际覆盖为准"). Only EMRG process residuals + # (GUI / daemon / python-emrg / bundled-git) and probe failures abort. + lock_res = [r for r in residuals if _is_lock_residual(r)] + proc_res = [r for r in residuals if not _is_lock_residual(r)] + # Advisory only when the install ACTUALLY continues: a process residual + # below returns exit 1, so the "install continues" message would be a lie. + if lock_res and not proc_res: + print( + f"emrg stop: WARNING {len(lock_res)} lock-related residual(s) " + f"after escalation — install continues, overwrite is the final " + f"arbiter (rant 21:24:48):" + ) + for r in lock_res: + print(f" - {r}") + if proc_res: print("emrg stop: WARNING residual process(es) still running:") - for r in residuals: + for r in proc_res: print(f" - {r}") if is_win(): try: @@ -1446,7 +1634,7 @@ def stop_all() -> int: except Exception: pass print( - f"emrg stop: exit code 1 ({len(residuals)} residual) " + f"emrg stop: exit code 1 ({len(proc_res)} residual) " f"({time.monotonic() - t0:.1f}s)" ) return 1 diff --git a/tests/test_stop_all.py b/tests/test_stop_all.py index d7deb1fc..064ac17b 100644 --- a/tests/test_stop_all.py +++ b/tests/test_stop_all.py @@ -746,9 +746,10 @@ def test_verify_categories_include_lock_probe(self, monkeypatch): 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).""" + """RM killed owners but locks linger → re-probe + retry up to 2×; + lock residuals after escalation are ADVISORY — the install continues + and the overwrite is the final arbiter (rant 2026-08-18T21:24:48 #2c/#5, + superseding the pre-21:24:48 abort-on-lock behavior).""" calls: list[str] = [] def rec(name): @@ -769,14 +770,90 @@ def _fn(*a, **k): _stop_all, "verify", lambda: ["locked file (installer overwrite would fail): C:\\locked.pyd"], ) - assert _stop_all.stop_all() == 1 + # lock residual only → exit 0 (advisory), not 1 + assert _stop_all.stop_all() == 0 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 + # advisory, install continues + assert "lock-related residual" in out + assert "install continues" in out + assert "exit code 0 (clean)" in out + + def test_stop_all_process_residual_still_aborts(self, monkeypatch, capsys): + """Only EMRG PROCESS residuals (daemon/gui/python/git) abort; lock + residuals do not (rant 2026-08-18T21:24:48 #2c/#5).""" + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr(_stop_all.time, "sleep", lambda *a, **k: None) + monkeypatch.setattr(_stop_all, "check_install_writable", lambda: []) + monkeypatch.setattr( + _stop_all, "verify", + lambda: ["daemon (pid 1234)", "file-lock owner (pid 9400, python.exe)"], + ) + assert _stop_all.stop_all() == 1 + out = capsys.readouterr().out assert "exit code 1 (1 residual)" in out + assert "daemon (pid 1234)" in out + assert "lock-related residual" not in out + + +class TestClassifyLockedFilesRelaxation: + """python-dist self-held relaxation (rant 2026-08-18T21:24:48 #3): a + locked file under python-dist\\ with no external target holder is + self-held — stop_all runs from python-dist and lazily-loaded stdlib + modules hold DLL locks the enumeration misses.""" + + def _root(self): + return r"C:\Users\me\.emrg\install" + + def _locked(self, rel): + return [rf"C:\Users\me\.emrg\install\{rel}"] + + def test_pydist_unattributable_is_self_held(self): + # select.pyd locked, enumeration lists NO holder for it → self-held + self_held, residual = _stop_all._classify_locked_files( + self._locked(r"python-dist\select.pyd"), [], self._root() + ) + assert self_held == ["python-dist/select.pyd"] + assert residual == [] + + def test_pydist_excluded_holder_is_self_held(self): + # python313.dll locked, holder = self (tag excluded) → self-held + mh = [(11572, "python.exe", r"C:\...\python-dist\python.exe", 0, + [r"C:\Users\me\.emrg\install\python-dist\python313.dll"], "excluded")] + self_held, residual = _stop_all._classify_locked_files( + self._locked(r"python-dist\python313.dll"), mh, self._root() + ) + assert self_held == ["python-dist/python313.dll"] + assert residual == [] + + def test_pydist_external_target_is_residual(self): + # python-dist DLL held by an EXTERNAL target → residual (strict) + mh = [(9280, "python.exe", r"C:\...\browser_harness\python.exe", 0, + [r"C:\Users\me\.emrg\install\python-dist\_socket.pyd"], "target")] + self_held, residual = _stop_all._classify_locked_files( + self._locked(r"python-dist\_socket.pyd"), mh, self._root() + ) + assert self_held == [] + assert residual == ["python-dist/_socket.pyd"] + + def test_lib_unattributable_is_residual(self): + # lib\ lock with no holder → residual (relaxation is python-dist only) + self_held, residual = _stop_all._classify_locked_files( + self._locked(r"lib\websockets\speedups.cp313-win_amd64.pyd"), [], self._root() + ) + assert self_held == [] + assert residual == ["lib/websockets/speedups.cp313-win_amd64.pyd"] + + def test_is_lock_residual_prefixes(self): + assert _stop_all._is_lock_residual("locked file (advisory): x") + assert _stop_all._is_lock_residual("install-module holder (pid 1, x)") + assert _stop_all._is_lock_residual("file-lock owner (pid 1, x)") + assert not _stop_all._is_lock_residual("daemon (pid 1)") + assert not _stop_all._is_lock_residual("EMRG.exe (pid 1)") + assert not _stop_all._is_lock_residual("lock-probe failed (error: x)") class TestMainDelegatesToStopAll: