Files
hermes-agent/tests/hermes_cli/test_update_self_lock.py
T
Halldrix c6a71294b6 fix(update): detect updater self-lock on Windows + repair venvs whose base interpreter is uv-managed
Two gaps left every Windows git-checkout install unable to recover from
the exact failure state #83569 reports:

1. Self-lock detection. _detect_venv_python_processes() always excludes
   the calling process by design — a CLI hermes update IS the venv python.
   An updater that had already imported a native venv extension (the
   canonical one being cryptography.hazmat.bindings._rust, mapped while
   hermes_cli.main resolved external secret sources) passed every
   preflight and then died mid-sync with os error 5 when uv tried to
   rewrite the mapped .pyd, stranding the venv half-updated. A new
   preflight now refuses the sync before touching the checkout, writes
   the update-incomplete marker so the next fresh launch completes the
   install, and exits 2. Verified on a live Windows 11 host: after
   importing hermes_cli.main, tasklist /m _rust.pyd shows the .pyd mapped
   in the caller, and a peer process cannot open it read-write
   (Permission denied) — while a rename succeeds, matching how uv/pip
   actually fail (truncate+write, not rename).

2. Early-recovery install path. _early_recovery._run_repair_install used
   sys.executable -m pip unconditionally. Windows git checkouts install
   on a uv-managed base interpreter (python-build-standalone), whose
   EXTERNALLY-MANAGED marker makes plain pip abort with
   externally-managed-environment — the repair no-oped and the venv
   stayed broken. The repair now detects the PEP 668 marker, prefers
   uv pip install with VIRTUAL_ENV pointed at the project venv, and
   falls back to pip --break-system-packages when no uv binary exists.

Both fixes ship with subprocess/unit regressions (sabotage-verified):
the new tests fail on pre-fix code and pass with it. Complements #77517,
which keeps the updater from importing cryptography in the first place;
this PR is the defence-in-depth when any future path loads it anyway.

Fixes #83569
2026-08-14 22:03:56 -07:00

150 lines
5.5 KiB
Python

"""Regression coverage for the updater self-lock preflight (#83569).
``_detect_venv_python_processes`` excludes the calling process and its
ancestors by design — a CLI ``hermes update`` IS the venv python. Before
this guard, an updater that had already imported a native venv extension
(e.g. ``cryptography.hazmat.bindings._rust``, mapped the moment
``hermes_cli.main`` resolved external secret sources) sailed through every
preflight and then died mid-sync with ``os error 5`` when ``uv`` tried to
replace the mapped ``.pyd``, stranding the venv half-updated:
- ``cryptography-48.0.1.dist-info`` left without ``RECORD`` (uninstall
finished, reinstall could not replace ``_rust.pyd``)
- ``.update-incomplete`` marker written but no actionable guidance
- every retry (git path + ZIP fallback) failing identically
The self-lock preflight refuses the update BEFORE touching the checkout,
writes the update-incomplete marker so the next fresh launch completes the
install, and exits 2 like the other preflight refusals.
"""
from __future__ import annotations
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import hermes_cli.main as cli_main
# ---------------------------------------------------------------------------
# _detect_self_loaded_native_modules
# ---------------------------------------------------------------------------
@patch.object(cli_main, "_is_windows", return_value=False)
def test_self_lock_detection_is_noop_off_windows(_winp):
with patch.dict(sys.modules, {"cryptography.hazmat.bindings._rust": MagicMock()}):
assert cli_main._detect_self_loaded_native_modules() == []
@patch.object(cli_main, "_is_windows", return_value=True)
def test_self_lock_detection_flags_loaded_rust_module(_winp):
with patch.dict(sys.modules, {"cryptography.hazmat.bindings._rust": MagicMock()}):
assert cli_main._detect_self_loaded_native_modules() == [
"cryptography (_rust.pyd)"
]
@patch.object(cli_main, "_is_windows", return_value=True)
def test_self_lock_detection_clean_when_rust_not_loaded(_winp):
# The lazy-import startup path (#73381, #83569) must NOT trip the guard:
# no cryptography module in sys.modules → no lock → update proceeds.
sys.modules.pop("cryptography.hazmat.bindings._rust", None)
assert cli_main._detect_self_loaded_native_modules() == []
# ---------------------------------------------------------------------------
# Preflight wiring inside _cmd_update_impl
# ---------------------------------------------------------------------------
def _update_args(**overrides):
defaults = dict(
gateway=False,
check=False,
no_backup=True,
backup=False,
yes=True,
branch=None,
force=False,
force_venv=False,
)
defaults.update(overrides)
return SimpleNamespace(**defaults)
def _run_update_until_sync(args, *, self_locked: list[str]):
"""Drive _cmd_update_impl to just past the self-lock preflight.
Everything before the preflight is stubbed. The first statement AFTER it
is ``git_dir = PROJECT_ROOT / ".git"`` — a PROJECT_ROOT sentinel whose
``__truediv__`` raises marks 'preflight passed'.
"""
class _PastPreflight(Exception):
pass
class _RootSentinel:
def __truediv__(self, _other):
raise _PastPreflight
marker_writes = []
with patch.object(cli_main, "_is_windows", return_value=True), patch.object(
cli_main, "_venv_scripts_dir", return_value=None
), patch.object(cli_main, "_run_pre_update_backup"), patch.object(
cli_main, "_pause_windows_gateways_for_update", return_value=None
), patch.object(
cli_main, "_resume_windows_gateways_after_update"
), patch.object(
cli_main, "_detect_venv_python_processes", return_value=[]
), patch.object(
cli_main, "_detect_self_loaded_native_modules", return_value=self_locked
), patch.object(
cli_main,
"_write_update_incomplete_marker",
side_effect=lambda: marker_writes.append("written"),
), patch.object(
cli_main, "PROJECT_ROOT", _RootSentinel()
):
try:
cli_main._cmd_update_impl(args, gateway_mode=False)
except _PastPreflight:
return "past_preflight", marker_writes
except SystemExit as exc:
return f"exit_{exc.code}", marker_writes
return "returned", marker_writes
def test_self_lock_preflight_refuses_and_defers(capsys):
result, markers = _run_update_until_sync(
_update_args(), self_locked=["cryptography (_rust.pyd)"]
)
assert result == "exit_2"
# Deferral contract: marker dropped so the next fresh launch completes
# the install before anything imports the native extension.
assert markers == ["written"]
out = capsys.readouterr().out
assert "cryptography (_rust.pyd)" in out
assert "deferred" in out
def test_self_lock_preflight_not_bypassed_by_force_venv(capsys):
"""--force-venv escapes EXTERNAL holders; it cannot unmap an image from
the running process, so the self-lock refusal must stand."""
result, markers = _run_update_until_sync(
_update_args(force=True, force_venv=True),
self_locked=["cryptography (_rust.pyd)"],
)
assert result == "exit_2"
assert markers == ["written"]
def test_self_lock_preflight_passes_when_nothing_loaded():
result, markers = _run_update_until_sync(_update_args(), self_locked=[])
assert result == "past_preflight"
assert markers == []