fix: drop restored upstream managed_uv — runtime_repair owns it, port self-lock

The upstream/main merge dragged hermes_cli/managed_uv.py back in via
rename detection, but our branch deliberately deleted it in the pm-store
rewrite (audit script: "this branch deliberately deleted
hermes_cli.managed_uv.*"). Upstream's managed_uv is the old name of our
runtime_repair.py plus the uv-acquisition half pm replaced — zero
production code in the merged tree imports it.

- Port upstream's _windows_runtime_self_lock (#93032) into runtime_repair:
  the Windows case where the updater itself runs from the live venv it
  must replace (structurally un-renamable; defer before provisioning).
- Wire the self-lock check into repair_vulnerable_runtime after the
  existing _windows_runtime_holders check.
- Re-point the 4 auto-merged upstream tests (test_managed_uv.py content)
  from hermes_cli.managed_uv to hermes_cli.runtime_repair; adapt calls to
  the keyword-only repair_vulnerable_runtime signature.
- Delete hermes_cli/managed_uv.py; fix the stale docstring reference in
  hermes_state.py.
This commit is contained in:
ethernet
2026-09-01 21:02:59 -04:00
parent c35a6d65c9
commit 63d25b0dae
4 changed files with 115 additions and 1573 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -861,6 +861,76 @@ def _windows_runtime_holders() -> tuple[bool, str]:
return False, ""
def _windows_runtime_self_lock(live: Path) -> tuple[bool, str]:
"""Detect the one holder the generic scan above is blind to: THIS process.
``_detect_venv_python_processes`` excludes the calling process and its
ancestors on purpose — a CLI ``hermes update`` itself runs from the
venv python — which is correct for the dependency-sync path, where only
a *loaded* ``.pyd`` image blocks the rewrite and a fresh child process
dodges it. For the whole-venv park rename that exemption is fatal:
Windows keeps the image of any executable a running process was started
from mapped until that process exits, so a directory containing the
updater's own ``python.exe`` (or a waiting ``hermes.exe`` launcher
ancestor) can never be renamed from inside the updater. The retry loop
in ``_cut_over_candidate`` cannot help against that — the lock is
structural, not transient (#93032).
No-op off Windows: POSIX renames work fine while this process maps
files from the renamed tree (open FDs and mmaps keep inodes alive).
"""
if platform.system() != "Windows":
return False, ""
try:
live_res = str(live.resolve()).lower().rstrip(os.sep) + os.sep
except OSError:
live_res = str(live).lower().rstrip(os.sep) + os.sep
def _under_live(path_value: str | None) -> bool:
if not path_value:
return False
try:
resolved = str(Path(path_value).resolve()).lower()
except (OSError, ValueError):
resolved = str(path_value).lower()
return resolved.startswith(live_res)
try:
exe = sys.executable
except Exception:
exe = None
if _under_live(exe):
return True, (
f"the updater itself runs from the live venv it must replace "
f"({exe}); Windows cannot rename a directory while a process "
"executes from inside it"
)
# Belt-and-braces: the venv\Scripts\hermes.exe launcher stays mapped
# while it waits for this child, so an ancestor started from the venv
# blocks the rename too.
try:
import psutil
try:
parents = psutil.Process().parents()
except Exception:
parents = []
for anc in parents:
try:
anc_exe = anc.exe()
except Exception:
continue
if _under_live(anc_exe):
return True, (
f"ancestor process PID {anc.pid} runs from the live venv "
f"({anc_exe}); Windows cannot rename a directory while a "
"process executes from inside it"
)
except Exception:
pass
return False, ""
def _default_live_venv(root: Path) -> Path:
"""Return the venv that runtime repair should target for *root*.
@@ -987,6 +1057,30 @@ def repair_vulnerable_runtime(
sqlite_before=current.sqlite_version_string,
)
self_locked, self_detail = _windows_runtime_self_lock(live)
if self_locked:
# Structural, not transient: this process maps the live venv's own
# executable, so the park rename fails the same way on every run and
# no number of retries converges. Defer BEFORE provisioning — a
# candidate staged for a cutover that can never run only leaks an
# incomplete generation (#93032).
print(f" ⚠ SQLite runtime repair deferred: {self_detail}.")
print(
" Retrying `hermes update` from inside this venv cannot help: "
"the mapped executable is released only when this process exits."
)
print(
" To complete the repair, run the updater from an interpreter "
"that lives outside this venv, e.g.:"
)
print(f" cd {root}")
print(" <system Python> -m hermes_cli.main update")
return RuntimeRepairResult(
"skipped",
self_detail,
sqlite_before=current.sqlite_version_string,
)
runtime_root = _runtime_dir(root)
lock = _acquire_repair_lock(runtime_root)
if lock is None:

View File

@@ -1788,7 +1788,7 @@ def _log_journal_mode_upgrade_once(db_label: str, previous_mode: str) -> None:
WARNING, not ERROR, and deliberately so. The reverse move is logged at
ERROR by ``_log_wal_fallback_once`` because dropping to DELETE is a real
loss of concurrency; this direction is normally the desirable one (see
``hermes_cli/managed_uv._default_live_venv``, which treats a database
``hermes_cli/runtime_repair._default_live_venv``, which treats a database
stuck on DELETE as a bug worth repairing on update). The problem is not
the change, it is that the change was invisible: an operator who chose
DELETE deliberately had no way to learn their choice had been overwritten,

View File

@@ -947,26 +947,26 @@ class TestWindowsRuntimeSelfLock:
doomed rename (provisioning + cutover) whenever the updater itself
maps the live venv; the park then fails with WinError 5 and the user
gets the misleading 'next update will retry' message forever."""
from hermes_cli import managed_uv
from hermes_cli.managed_uv import repair_vulnerable_runtime
from hermes_cli import runtime_repair as repair_mod
from hermes_cli.runtime_repair import repair_vulnerable_runtime
root, live, sentinel, scripts_python = self._checkout(tmp_path)
current = _runtime_info(scripts_python, (3, 50, 4))
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Windows")
monkeypatch.setattr(repair_mod.platform, "system", lambda: "Windows")
monkeypatch.setattr(sys, "executable", str(scripts_python))
with patch(
"hermes_cli.managed_uv._windows_runtime_holders",
"hermes_cli.runtime_repair._windows_runtime_holders",
return_value=(False, ""),
), \
patch(
"hermes_cli.managed_uv.probe_sqlite_runtime",
"hermes_cli.runtime_repair.probe_sqlite_runtime",
return_value=current,
), \
patch(
"hermes_cli.managed_uv._install_safe_python_generation"
"hermes_cli.runtime_repair._install_safe_python_generation"
) as mock_install:
result = repair_vulnerable_runtime("uv", project_root=root)
result = repair_vulnerable_runtime(project_root=root)
assert result.status == "skipped"
assert "live venv" in result.detail
@@ -988,29 +988,29 @@ class TestWindowsRuntimeSelfLock:
"""The guard must fail OPEN when the updater runs from outside the
venv — an always-firing deferral would recreate the never-converging
loop this fix removes (#86735 class)."""
from hermes_cli import managed_uv
from hermes_cli.managed_uv import repair_vulnerable_runtime
from hermes_cli import runtime_repair as repair_mod
from hermes_cli.runtime_repair import repair_vulnerable_runtime
root, live, sentinel, scripts_python = self._checkout(tmp_path)
current = _runtime_info(scripts_python, (3, 50, 4))
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Windows")
monkeypatch.setattr(repair_mod.platform, "system", lambda: "Windows")
monkeypatch.setattr(
sys, "executable", str(tmp_path / "outside" / "python.exe")
)
with patch(
"hermes_cli.managed_uv._windows_runtime_holders",
"hermes_cli.runtime_repair._windows_runtime_holders",
return_value=(False, ""),
), \
patch(
"hermes_cli.managed_uv.probe_sqlite_runtime",
"hermes_cli.runtime_repair.probe_sqlite_runtime",
return_value=current,
), \
patch(
"hermes_cli.managed_uv._install_safe_python_generation",
"hermes_cli.runtime_repair._install_safe_python_generation",
return_value=None,
) as mock_install:
result = repair_vulnerable_runtime("uv", project_root=root)
result = repair_vulnerable_runtime(project_root=root)
assert result.status == "failed"
assert "provision" in result.detail
@@ -1020,22 +1020,22 @@ class TestWindowsRuntimeSelfLock:
def test_self_lock_is_a_noop_off_windows(self, tmp_path, monkeypatch):
"""POSIX renames work while the updater maps the venv, so the guard
must stay Windows-only."""
from hermes_cli import managed_uv
from hermes_cli import runtime_repair as repair_mod
root, live, sentinel, scripts_python = self._checkout(tmp_path)
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Linux")
monkeypatch.setattr(repair_mod.platform, "system", lambda: "Linux")
monkeypatch.setattr(sys, "executable", str(scripts_python))
locked, detail = managed_uv._windows_runtime_self_lock(live)
locked, detail = repair_mod._windows_runtime_self_lock(live)
assert (locked, detail) == (False, "")
def test_venv_launcher_ancestor_is_a_self_lock(self, tmp_path, monkeypatch):
r"""The venv\Scripts\hermes.exe shim stays mapped while it waits for
this child — an ancestor running from the venv blocks the rename too."""
from hermes_cli import managed_uv
from hermes_cli import runtime_repair as repair_mod
root, live, sentinel, scripts_python = self._checkout(tmp_path)
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Windows")
monkeypatch.setattr(repair_mod.platform, "system", lambda: "Windows")
monkeypatch.setattr(
sys, "executable", str(tmp_path / "outside" / "python.exe")
)
@@ -1054,7 +1054,7 @@ class TestWindowsRuntimeSelfLock:
),
)
with patch.dict(sys.modules, {"psutil": fake_psutil}):
locked, detail = managed_uv._windows_runtime_self_lock(live)
locked, detail = repair_mod._windows_runtime_self_lock(live)
assert locked
assert "999" in detail