From 557d8ec9e02732d53179326582082b42bacd12c8 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 18 Aug 2026 09:54:46 +0800 Subject: [PATCH 1/2] emrg: stop_all observability gaps (owner detail + lock-probe fail-closed + single-scan + PYTHONPATH) --- Agent.md | 2 +- emrg/_stop_all.py | 167 ++++++++++++++++++++++++++++++++++++---- tests/test_stop_all.py | 170 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 324 insertions(+), 15 deletions(-) diff --git a/Agent.md b/Agent.md index e884ad24..1857c368 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` (916) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (925) — 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 d471dff5..988d8eed 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -57,7 +57,7 @@ # 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)" +_STOP_ALL_STAMP = "built 2026-08-18 (owner-detail + lock-probe-fail-closed + single-scan)" _EMRG_CLIENT_RE = re.compile(r"-m\s+emrg(\.server)?(\s|$)") _APPIMAGE_RE = re.compile(r"EMRG-[\w.\-]*AppImage(\s|$)") @@ -564,7 +564,12 @@ def stop_bundled_git() -> None: } $targets = @($owners | Where-Object { -not $exclude.Contains($_) }) $killedHint = $false -foreach ($pid in $targets) { +# Detail per OWNER (not just targets): each line carries a 4th column tagging +# whether the owner was excluded by the ancestor chain (self + Inno setup.exe) +# or is a real target — so the operator can see WHO the owners were and WHY +# nothing was killed (rant 2026-08-18T09:40:40: 3 owners found but all +# excluded → targets=0 with zero output = detector looked blind). +foreach ($pid in $owners) { $p = Get-CimInstance Win32_Process -Filter "ProcessId=$pid" -ErrorAction SilentlyContinue $name = '' $cmd = '' @@ -573,14 +578,20 @@ def stop_bundled_git() -> None: if ($p.CommandLine) { $cmd = [string]$p.CommandLine } } if ($cmd.Length -gt 150) { $cmd = $cmd.Substring(0, 150) } - if ($kill) { + if ($kill -and -not $exclude.Contains($pid)) { 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) + $tag = if ($exclude.Contains($pid)) { 'excluded' } else { 'target' } + Write-Output ("{0}`t{1}`t{2}`t{3}" -f $pid, $name, $cmd, $tag) } } +# The excluded ancestor chain (incl. self PID) — answers "who was skipped?". +Write-Output ("excluded-chain`t{0}" -f ($exclude -join ',')) +if ($kill -and $owners.Count -gt 0 -and $targets.Count -eq 0) { + Write-Output ("WARNING all {0} owner(s) excluded: {1}" -f $owners.Count, ($owners -join ',')) +} if ($kill -and $killedHint) { Write-Output 'hint: browser-harness daemon stopped - restart it after the installer completes' } @@ -656,6 +667,12 @@ def _windows_lock_owners(kill: bool, stdout: str | None = None) -> list[tuple[in ``stdout`` may be supplied by the caller (avoids a second PowerShell invocation when the diag line is needed too); None → run the scan. + + Since v0.2.45+ the owner lines carry a 4th ``excluded|target`` column + (rant 2026-08-18T09:40:40) — ancestors (self + Inno setup.exe) are + tagged ``excluded`` and are NOT returned here (verify must never list + the running stop process itself as a residual); the full detail with + tags stays visible in the raw log via stop_lock_owners. """ if stdout is None: stdout = _lock_owner_ps(kill) @@ -664,6 +681,9 @@ def _windows_lock_owners(kill: bool, stdout: str | None = None) -> list[tuple[in parts = line.split("\t") if not parts or not parts[0].strip().isdigit(): continue + tag = parts[3] if len(parts) > 3 else "target" + if tag == "excluded": + continue pid = int(parts[0]) name = parts[1] if len(parts) > 1 else "" cmd = parts[2] if len(parts) > 2 else "" @@ -698,9 +718,21 @@ def _win_exclusive_open(path: str) -> None: OPEN_EXISTING = 3 FILE_SHARE_NONE = 0 kernel32 = ctypes.windll.kernel32 + # 64-bit handle truncation fix (rant 2026-08-18T09:40:40): ctypes defaults + # the restype of a foreign function to c_int — a 64-bit HANDLE gets + # truncated, INVALID_HANDLE_VALUE(-1) becomes 0xFFFFFFFF and a valid + # handle can alias a failure → probe reports "0 locked" when files ARE + # locked. Pin the full signature explicitly. + kernel32.CreateFileW.restype = ctypes.c_void_p + kernel32.CreateFileW.argtypes = [ + ctypes.c_wchar_p, ctypes.c_uint32, ctypes.c_uint32, + ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_void_p, + ] + kernel32.CloseHandle.restype = ctypes.c_int + kernel32.CloseHandle.argtypes = [ctypes.c_void_p] h = kernel32.CreateFileW(path, GENERIC_READ, FILE_SHARE_NONE, None, OPEN_EXISTING, 0, None) - if h == 0 or h == -1: + if h == 0 or h == ctypes.c_void_p(-1).value: raise OSError(f"CreateFileW failed for {path} (file is locked)") kernel32.CloseHandle(h) @@ -722,22 +754,52 @@ def _check_locked_files(root: str, try_open=None) -> list[str]: return locked +# Last lock-probe failure (rant 2026-08-18T09:40:40): a probe exception is +# NO LONGER silently swallowed as "clean" — it is recorded here, printed as +# `lock-probe ERROR`, and surfaced as a verify residual so the installer +# aborts instead of overwriting locked files. Reset on every probe attempt. +_lock_probe_error: str | None = None + + 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. + install dir) — best-effort like every other stop step. On a PROBE ERROR + (exception) it also returns [] (never raises) but records the failure in + ``_lock_probe_error`` and prints ``lock-probe ERROR`` — verify() then + surfaces it as a residual and the installer aborts (fail-closed), instead + of the old silent ``except Exception: return []`` that reported + ``0 locked`` while files were actually locked (rant 2026-08-18T09:40:40). """ + global _lock_probe_error + _lock_probe_error = None if not is_win(): return [] root = os.path.join(os.path.expanduser("~"), ".emrg", "install") if not os.path.isdir(root): return [] + files = _iter_install_files(root) + t0 = time.monotonic() try: - return _check_locked_files(root) - except Exception: + locked = _check_locked_files(root) + except Exception as e: + elapsed = (time.monotonic() - t0) * 1000 + _lock_probe_error = f"{type(e).__name__}: {e}" + print( + f"emrg stop: lock-probe ERROR: {_lock_probe_error} " + f"(scanned {len(files)} files, {elapsed:.0f}ms) — FAIL CLOSED" + ) return [] + elapsed = (time.monotonic() - t0) * 1000 + # Scanned-file count / elapsed observability (rant 2026-08-18T09:40:40): + # "0 locked" is only trustworthy when the probe actually scanned files. + print( + f"emrg stop: lock-probe scanned {len(files)} files " + f"-> {len(locked)} locked ({elapsed:.0f}ms)" + ) + return locked def stop_lock_owners() -> None: @@ -762,10 +824,21 @@ def stop_lock_owners() -> None: # ── Verify + exit code ────────────────────────────────────────── +# Cache of the last _verify_windows_categories() result (rant +# 2026-08-18T09:40:40 #4): stop_all previously ran the FULL Windows verify +# TWICE per run — once via verify() and again via _verify_windows_summary() +# (two rm-scan PowerShell invocations, ~2s+ wasted, duplicated log lines). +# verify() always refreshes; _verify_windows_summary() reuses the freshest +# result when available. +_windows_cats_cache: list[tuple[str, list[str]]] | None = None + + 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).""" + (rant 2026-08-17T21:06:31 #3). Result is cached in ``_windows_cats_cache`` + so _verify_windows_summary() does not re-run the expensive scan.""" + global _windows_cats_cache cats: list[tuple[str, list[str]]] = [] # GUI residual @@ -806,9 +879,16 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]: _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) + # detector can never blind verify (rant 2026-08-17T21:06:05). A probe + # FAILURE is not "0 locked": it becomes a residual → installer aborts + # (rant 2026-08-18T09:40:40 fail-closed). + global _lock_probe_error + _lock_probe_error = None locked = check_install_writable() - cats.append(("lock-probe", [f"locked file (installer overwrite would fail): {p}" for p in locked])) + probe_items = [f"locked file (installer overwrite would fail): {p}" for p in locked] + if _lock_probe_error: + probe_items.append(f"lock-probe failed (error: {_lock_probe_error})") + cats.append(("lock-probe", probe_items)) # bundled-git residual bg: list[str] = [] @@ -828,14 +908,20 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]: except (OSError, subprocess.SubprocessError, TimeoutError): pass cats.append(("bundled-git", bg)) + _windows_cats_cache = cats 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() + bundled-git 0`` (rant 2026-08-17T21:06:31 #3). + + Reuses the freshest ``_verify_windows_categories()`` result when present + (single-scan, rant 2026-08-18T09:40:40 #4); falls back to a fresh scan + only when nothing has been cached yet. + """ + cats = _windows_cats_cache if _windows_cats_cache is not None else _verify_windows_categories() return " / ".join(f"{name} {len(items)}" for name, items in cats) @@ -858,6 +944,53 @@ def verify() -> list[str]: # ── Orchestration ─────────────────────────────────────────────── +def _pythonpath_env() -> str: + """User/Machine PYTHONPATH on Windows (registry), else the process env — + observability for the "an unrelated python imports from install\\lib and + locks C extensions" root-cause (rant 2026-08-18T09:40:40: browser-harness + uses its own uv python but loaded install\\lib\\websockets — PYTHONPATH + pollution would make ANY python process import from install\\lib).""" + if not is_win(): + p = os.environ.get("PYTHONPATH", "") + return f"PYTHONPATH={p or '(unset)'}" + try: + import winreg + + entries: list[str] = [] + for hive, key, label in ( + (winreg.HKEY_CURRENT_USER, r"Environment", "User"), + (winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", "Machine"), + ): + try: + with winreg.OpenKey(hive, key) as k: + val, _ = winreg.QueryValueEx(k, "PYTHONPATH") + entries.append(f"PYTHONPATH({label})={val}") + except OSError: + entries.append(f"PYTHONPATH({label})=(unset)") + proc = os.environ.get("PYTHONPATH") + if proc: + entries.append(f"PYTHONPATH(process)={proc}") + return " | ".join(entries) + except Exception as e: # registry read is best-effort + return f"PYTHONPATH(registry read failed: {type(e).__name__}: {e})" + + +def _pythonpath_install_warning(line: str) -> str | None: + """Warn when any PYTHONPATH references the install dir (C-extension lock + root cause, rant 2026-08-18T09:40:40). Pure function → unit-testable.""" + if not line or "PYTHONPATH" not in line or "(unset)" in line: + return None + lowered = line.lower() + for marker in (r".emrg\install", r"/.emrg/install", + "install\\lib", "install/lib"): + if marker in lowered: + return ("PYTHONPATH references ~/.emrg/install — any python " + "process may import from install\\lib and lock C " + "extensions; clear it before running the installer") + return None + + 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 @@ -894,6 +1027,14 @@ def stop_all() -> int: f"python {platform.python_version()} {platform.system()}-{platform.machine()} " f"| pid {os.getpid()}" ) + # User/Machine PYTHONPATH observability (rant 2026-08-18T09:40:40) — a + # polluted PYTHONPATH makes any python process import from install\lib + # and lock C extensions; surface it before any stop/kill logic. + _pp = _pythonpath_env() + print(f"emrg stop: {_pp}") + _pp_warn = _pythonpath_install_warning(_pp) + if _pp_warn: + print(f"emrg stop: WARNING {_pp_warn}") steps = _step_plan() for i, (name, fn) in enumerate(steps, 1): s = time.monotonic() diff --git a/tests/test_stop_all.py b/tests/test_stop_all.py index cc98344b..9c35826a 100644 --- a/tests/test_stop_all.py +++ b/tests/test_stop_all.py @@ -39,7 +39,7 @@ def test_no_nonstdlib_imports(self): allowed = { "base64", "json", "os", "re", "secrets", "signal", "socket", "subprocess", "sys", "time", "pathlib", "platform", "ctypes", "ast", - "pytest", "annotations", "__future__", + "pytest", "annotations", "__future__", "winreg", } for node in ast.walk(tree): if isinstance(node, ast.Import): @@ -785,3 +785,171 @@ def test_emrg_stop_cli_exits_nonzero(self): 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 + + +# ── Owner detail + excluded annotation (rant 2026-08-18T09:40:40) ── + +class TestLockOwnerDetailAnnotation: + def test_ps_template_has_owner_detail_annotation(self): + """kill=False owner lines carry a 4th excluded|target column; the + excluded ancestor chain + WARNING-all-excluded are emitted.""" + ps = _stop_all._LOCK_OWNER_PS + assert "{0}`t{1}`t{2}`t{3}" in ps # 4-col owner line + assert "'excluded'" in ps and "'target'" in ps # tag literals + assert "excluded-chain" in ps # chain dump (incl. self PID) + assert "WARNING all" in ps and "$targets.Count" in ps + assert "$exclude -join" in ps or "$exclude)" in ps + + def test_parser_filters_excluded_owners(self, monkeypatch): + """Owners tagged `excluded` (self + ancestor chain) must NOT surface + as verify residuals; `target` owners (and legacy 3-col lines) do.""" + 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\ttarget\n" + "555\tpythonw.exe\tsome -m emrg.server cmdline\texcluded\n" + "666\tsetup.exe\tC:\\...\\inno setup.exe\texcluded\n" + ), + ) + owners = _stop_all._windows_lock_owners(kill=False) + assert owners == [ + (9400, "python.exe", + "C:\\...\\browser_harness\\Scripts\\python.exe -m browser_harness.daemon"), + ] + + def test_parser_legacy_3col_lines_still_parsed(self, monkeypatch): + """Backward compatibility: pre-annotation 3-column lines default to + target (existing verify output must keep working).""" + 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\n", + ) + owners = _stop_all._windows_lock_owners(kill=False) + assert owners == [(9400, "python.exe", + "C:\\...\\browser_harness\\Scripts\\python.exe")] + + +# ── Lock-probe fail-closed (rant 2026-08-18T09:40:40) ──────────── + +class TestLockProbeFailClosed: + def test_probe_error_sets_global_and_prints_error(self, monkeypatch, tmp_path, capsys): + """A probe exception is NOT silently swallowed as 'clean': it records + _lock_probe_error + prints `lock-probe ERROR ... FAIL CLOSED`.""" + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr(_stop_all.os.path, "expanduser", lambda _: str(tmp_path)) + root = tmp_path / ".emrg" / "install" + root.mkdir(parents=True) + (root / "a.txt").write_text("x", encoding="utf-8") + + def boom(root_dir): + raise RuntimeError("ctypes unavailable") + + monkeypatch.setattr(_stop_all, "_check_locked_files", boom) + _stop_all._lock_probe_error = None + assert _stop_all.check_install_writable() == [] # never raises + assert _stop_all._lock_probe_error == "RuntimeError: ctypes unavailable" + out = capsys.readouterr().out + assert "lock-probe ERROR: RuntimeError: ctypes unavailable" in out + assert "FAIL CLOSED" in out + + def test_probe_prints_scanned_count(self, monkeypatch, tmp_path, capsys): + """Scan stats: `lock-probe scanned N files -> M locked (Xms)` — a + bare `0 locked` without a scan count is untrustworthy.""" + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr(_stop_all.os.path, "expanduser", lambda _: str(tmp_path)) + root = tmp_path / ".emrg" / "install" + (root / "sub").mkdir(parents=True) + (root / "a.txt").write_text("x", encoding="utf-8") + (root / "sub" / "b.txt").write_text("x", encoding="utf-8") + monkeypatch.setattr(_stop_all, "_check_locked_files", lambda root_dir: []) + _stop_all._lock_probe_error = None + assert _stop_all.check_install_writable() == [] + out = capsys.readouterr().out + assert "lock-probe scanned 2 files -> 0 locked" in out + + def test_verify_surfaces_probe_error_as_residual(self, monkeypatch, tmp_path): + """探测失败 ≠ 干净: a failed probe becomes a verify residual → exit 1 + (installer aborts instead of overwriting locked files).""" + monkeypatch.setattr(_stop_all, "is_win", lambda: True) + monkeypatch.setattr(_stop_all.os.path, "expanduser", lambda _: str(tmp_path)) + root = tmp_path / ".emrg" / "install" + root.mkdir(parents=True) + (root / "a.txt").write_text("x", encoding="utf-8") + 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": ""}), + ) + + def boom(root_dir): + raise OSError("probe exploded") + + monkeypatch.setattr(_stop_all, "_check_locked_files", boom) + _stop_all._windows_cats_cache = None + try: + out = _stop_all._verify_windows() + assert any("lock-probe failed (error: OSError: probe exploded)" in r for r in out) + finally: + _stop_all._windows_cats_cache = None + + +# ── Verify single-scan (rant 2026-08-18T09:40:40 #4) ───────────── + +class TestVerifySingleScan: + def test_summary_reuses_cache_no_second_scan(self, monkeypatch, tmp_path, capsys): + """verify() + _verify_windows_summary() must run the PowerShell RM + scan ONCE — the previous code ran it twice (~2s+ wasted, duplicated + rm-scan log lines).""" + 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.os.path, "expanduser", lambda _: str(tmp_path)) + calls = {"n": 0} + + def fake_ps(kill): + calls["n"] += 1 + return "rm-diag\t3\t0\t10\t0\n" + + monkeypatch.setattr(_stop_all, "_lock_owner_ps", fake_ps) + monkeypatch.setattr( + _stop_all.subprocess, "run", + lambda cmd, **kw: type("CP", (), {"stdout": ""}), + ) + _stop_all._windows_cats_cache = None + try: + assert _stop_all._verify_windows() == [] + assert calls["n"] == 1 + summary = _stop_all._verify_windows_summary() + assert "RM re-scan 0" in summary + assert calls["n"] == 1 # cached — no second PowerShell scan + # a fresh categories() call refreshes the cache + _stop_all._verify_windows_categories() + assert calls["n"] == 2 + finally: + _stop_all._windows_cats_cache = None + + +# ── PYTHONPATH observability (rant 2026-08-18T09:40:40) ────────── + +class TestPythonPathObservability: + def test_env_posix(self, monkeypatch): + monkeypatch.setattr(_stop_all, "is_win", lambda: False) + monkeypatch.setattr(_stop_all.os, "environ", {"PYTHONPATH": "C:/x"}) + assert _stop_all._pythonpath_env() == "PYTHONPATH=C:/x" + monkeypatch.setattr(_stop_all.os, "environ", {}) + assert _stop_all._pythonpath_env() == "PYTHONPATH=(unset)" + + def test_install_warning_positive_and_negative(self): + # install-dir references → warn (both Windows and POSIX separators) + assert _stop_all._pythonpath_install_warning( + r"PYTHONPATH(User)=C:\Users\me\.emrg\install\lib") is not None + assert _stop_all._pythonpath_install_warning( + "PYTHONPATH(process)=/home/me/.emrg/install") is not None + assert _stop_all._pythonpath_install_warning( + "PYTHONPATH(Machine)=C:\\python313;C:\\tools") is None + assert _stop_all._pythonpath_install_warning("PYTHONPATH(User)=(unset)") is None + assert _stop_all._pythonpath_install_warning("") is None From cec0c3f54d3aaaa4cc0a3d27da8c4157d126b52e Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 18 Aug 2026 10:18:13 +0800 Subject: [PATCH 2/2] emrg: tighten lock-probe handle guard + PYTHONPATH warning (pm25coder review nits, PR #832) --- emrg/_stop_all.py | 16 ++++++++++++---- tests/test_stop_all.py | 3 +++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/emrg/_stop_all.py b/emrg/_stop_all.py index 988d8eed..99eda2a9 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -732,7 +732,10 @@ def _win_exclusive_open(path: str) -> None: kernel32.CloseHandle.argtypes = [ctypes.c_void_p] h = kernel32.CreateFileW(path, GENERIC_READ, FILE_SHARE_NONE, None, OPEN_EXISTING, 0, None) - if h == 0 or h == ctypes.c_void_p(-1).value: + # With restype=c_void_p a NULL handle arrives as None (not 0) — cover both + # forms; INVALID_HANDLE_VALUE is c_void_p(-1).value (pm25coder review note, + # PR #832). A failed exclusive open = the installer's overwrite would fail. + if not h or h == ctypes.c_void_p(-1).value: raise OSError(f"CreateFileW failed for {path} (file is locked)") kernel32.CloseHandle(h) @@ -978,12 +981,17 @@ def _pythonpath_env() -> str: def _pythonpath_install_warning(line: str) -> str | None: """Warn when any PYTHONPATH references the install dir (C-extension lock - root cause, rant 2026-08-18T09:40:40). Pure function → unit-testable.""" + root cause, rant 2026-08-18T09:40:40). Pure function → unit-testable. + + Matches only the install dir anchored on ``~/.emrg/install`` (both path + separators) — bare ``install\\lib`` substrings are NOT matched, so an + unrelated ``C:\\python\\install\\lib`` never warns spuriously (pm25coder + review note, PR #832). + """ if not line or "PYTHONPATH" not in line or "(unset)" in line: return None lowered = line.lower() - for marker in (r".emrg\install", r"/.emrg/install", - "install\\lib", "install/lib"): + for marker in (r".emrg\install", r"/.emrg/install"): if marker in lowered: return ("PYTHONPATH references ~/.emrg/install — any python " "process may import from install\\lib and lock C " diff --git a/tests/test_stop_all.py b/tests/test_stop_all.py index 9c35826a..af874edf 100644 --- a/tests/test_stop_all.py +++ b/tests/test_stop_all.py @@ -953,3 +953,6 @@ def test_install_warning_positive_and_negative(self): "PYTHONPATH(Machine)=C:\\python313;C:\\tools") is None assert _stop_all._pythonpath_install_warning("PYTHONPATH(User)=(unset)") is None assert _stop_all._pythonpath_install_warning("") is None + # unrelated install\lib path must NOT warn (pm25coder review note, PR #832) + assert _stop_all._pythonpath_install_warning( + r"PYTHONPATH(User)=C:\python\install\lib") is None