c6a71294b6
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
150 lines
5.5 KiB
Python
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 == []
|