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
1 change: 1 addition & 0 deletions .github/workflows/windows-venv-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ jobs:
tests/hermes_cli/test_venv_holder_windows_live.py \
tests/hermes_cli/test_taskkill_identity_windows_live.py \
tests/hermes_cli/test_git_trampoline_windows_live.py \
"tests/hermes_cli/test_managed_uv.py::TestWindowsRuntimeSelfLock" \
-o addopts= -v -p no:cacheprovider

- name: Run Telegram CLOSE-WAIT reconnect live E2E (#87057)
Expand Down
98 changes: 98 additions & 0 deletions hermes_cli/managed_uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,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 _uv_version_string(uv_bin: str) -> str:
"""Return ``uv --version`` output, or ``""`` when it cannot be read."""
try:
Expand Down Expand Up @@ -1276,6 +1346,34 @@ 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")
print(
" Sessions stay protected meanwhile: Hermes keeps databases "
"out of WAL mode on this SQLite build."
)
return RuntimeRepairResult(
"skipped",
self_detail,
sqlite_before=current.sqlite_version_string,
)

runtime_root = root / _RUNTIME_DIR_NAME
lock = _acquire_repair_lock(runtime_root)
if lock is None:
Expand Down
145 changes: 145 additions & 0 deletions tests/hermes_cli/test_managed_uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -1354,3 +1354,148 @@ def _no_reload(module): # pragma: no cover - must not run
expected = Path("/opt/hermes/venv/Scripts/python.exe") \
if sys.platform == "win32" else Path("/opt/hermes/venv/bin/python")
assert _venv_python(Path("/opt/hermes/venv")) == expected



class TestWindowsRuntimeSelfLock:
"""The repair pre-flight must see the ONE holder the generic scan hides:
the updater itself (#93032).

A CLI ``hermes update`` runs from the venv's own python, and
``_detect_venv_python_processes`` excludes the calling process and its
ancestors on purpose (correct for the dependency-sync path). For the
whole-venv park rename that exemption is fatal on Windows: a directory
containing an executable mapped by a running process cannot be renamed,
so the cutover retries burn out against a lock that cannot be released
while the updater lives. The repair must detect the self-lock and defer
with honest guidance instead of provisioning a candidate for a doomed
rename.
"""

def _checkout(self, tmp_path):
root, live, sentinel = _make_runtime_install(tmp_path)
# Windows-layout interpreter so sys.executable can point inside the
# live venv on any host (the detector only string-compares paths).
scripts_python = live / "Scripts" / "python.exe"
scripts_python.parent.mkdir(parents=True, exist_ok=True)
scripts_python.write_text("live interpreter", encoding="utf-8")
return root, live, sentinel, scripts_python

def test_self_lock_defers_repair_before_provisioning(
self, tmp_path, monkeypatch, capsys
):
"""Regression for #93032: pre-fix, the repair walks straight into the
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

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(sys, "executable", str(scripts_python))

with patch(
"hermes_cli.managed_uv._windows_runtime_holders",
return_value=(False, ""),
), \
patch(
"hermes_cli.managed_uv.probe_sqlite_runtime",
return_value=current,
), \
patch(
"hermes_cli.managed_uv._install_safe_python_generation"
) as mock_install:
result = repair_vulnerable_runtime("uv", project_root=root)

assert result.status == "skipped"
assert "live venv" in result.detail
assert mock_install.call_count == 0, (
"a self-locked updater must not provision a candidate it can "
"never cut over"
)
assert sentinel.read_text(encoding="utf-8") == "live"
assert not (root / ".hermes-runtime").exists()

out = capsys.readouterr().out
assert "SQLite runtime repair deferred" in out
assert "will retry" not in out, (
"the structural self-lock must not promise that retrying helps"
)
assert "outside" in out, "the deferral must point at an escape hatch"

def test_non_self_locked_repair_proceeds(self, tmp_path, monkeypatch):
"""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

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(
sys, "executable", str(tmp_path / "outside" / "python.exe")
)

with patch(
"hermes_cli.managed_uv._windows_runtime_holders",
return_value=(False, ""),
), \
patch(
"hermes_cli.managed_uv.probe_sqlite_runtime",
return_value=current,
), \
patch(
"hermes_cli.managed_uv._install_safe_python_generation",
return_value=None,
) as mock_install:
result = repair_vulnerable_runtime("uv", project_root=root)

assert result.status == "failed"
assert "provision" in result.detail
mock_install.assert_called_once()
assert sentinel.read_text(encoding="utf-8") == "live"

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

root, live, sentinel, scripts_python = self._checkout(tmp_path)
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Linux")
monkeypatch.setattr(sys, "executable", str(scripts_python))

locked, detail = managed_uv._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

root, live, sentinel, scripts_python = self._checkout(tmp_path)
monkeypatch.setattr(managed_uv.platform, "system", lambda: "Windows")
monkeypatch.setattr(
sys, "executable", str(tmp_path / "outside" / "python.exe")
)

class _FakeProc:
def __init__(self, pid, exe):
self.pid = pid
self._exe = exe

def exe(self):
return self._exe

fake_psutil = SimpleNamespace(
Process=lambda: SimpleNamespace(
parents=lambda: [_FakeProc(999, str(scripts_python))],
),
)
with patch.dict(sys.modules, {"psutil": fake_psutil}):
locked, detail = managed_uv._windows_runtime_self_lock(live)

assert locked
assert "999" in detail
Loading