fix(update): cap boot recovery retries and derive self-lock native modules
Two residual Windows reinstall-loop paths behind #81594: - The post-import recovery (_recover_core_update_marker_locked) had no attempt cap: once the early pass stood down after 3 failures (e.g. a .pyd mapped by a live Desktop backend or gateway), every launch re-ran the full .[all] reinstall from a process that already maps native venv extensions. It now shares the early pass's attempt budget stored in the marker body: failures bump it, and past the cap boots stop reinstalling and print the exact manual recovery commands (including clearing the marker). A single successful recovery still clears the marker. - The updater's self-lock detector only knew cryptography._rust and yaml._yaml; the report's locked file was _cffi_backend.pyd. The set is now derived from sys.modules (every loaded extension under site-packages, mapped to its distribution). An unpinned transitive such as cffi counts as rewritten when uv.lock resolves it elsewhere AND a base-dependency parent is moving to its pin, so the deferral is cleared by the sync it defers (no #86735 always-fire loop); anything else stays fail-open.
This commit is contained in:
@@ -732,9 +732,9 @@ def recover_if_needed(project_root: Path | None = None, argv: list[str] | None =
|
||||
# updater inside the marker-to-install window — never race it. A dead owner MUST be
|
||||
# recovered even when this launch is itself `hermes update`: CLI and Desktop retries keep
|
||||
# that argv, and skipping solely on argv recreates the self-lock loop.
|
||||
# Bounded retries: a persistently failing install must not hammer every launch, so attempts past the
|
||||
# ceiling are left for main.py's post-import recovery path (which can safely probe-import after this
|
||||
# process already holds whatever extensions it needs). See #83569.
|
||||
# Bounded retries: a persistently failing install must not hammer every launch. The budget
|
||||
# is shared with main.py's post-import recovery, which past it prints the manual command
|
||||
# instead of reinstalling. See #83569, #81594.
|
||||
if core_marker.exists():
|
||||
if _marker_owner_is_live(core_marker):
|
||||
return
|
||||
@@ -771,10 +771,12 @@ def recover_if_needed(project_root: Path | None = None, argv: list[str] | None =
|
||||
pass # Never block launch — the import of main.py will surface the truth.
|
||||
|
||||
|
||||
# Cap on automatic early-pass install retries: a persistently failing install (network down) must
|
||||
# not reinstall-hammer every launch. Past this the marker is left to main.py's post-import recovery,
|
||||
# which presents the manual command. The counter lives in the marker's JSON body.
|
||||
_EARLY_CORE_INSTALL_MAX_ATTEMPTS = 3
|
||||
# Cap on automatic core-install retries, shared by this early pass and main.py's post-import
|
||||
# recovery: a persistently failing install (network down, a .pyd another Hermes process maps) must
|
||||
# not reinstall-hammer every launch. Past it the post-import path presents the manual command. The
|
||||
# counter lives in the marker's JSON body; an update that reaches the dependency sync rewrites
|
||||
# the marker, re-arming it.
|
||||
_CORE_INSTALL_MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _claim_recovery_lock(root: Path) -> bool:
|
||||
@@ -822,10 +824,9 @@ def _complete_pending_core_install(root: Path, core_marker: Path) -> bool:
|
||||
|
||||
# Read attempts before claiming the lock so a persistently-failing install stops early.
|
||||
attempts = _read_marker_attempts(core_marker)
|
||||
if attempts >= _EARLY_CORE_INSTALL_MAX_ATTEMPTS:
|
||||
if attempts >= _CORE_INSTALL_MAX_ATTEMPTS:
|
||||
print("⚠ Pending interrupted-update install has already failed "
|
||||
f"{attempts} times in the early pass — leaving it for the "
|
||||
"post-import recovery path.", file=sys.stderr)
|
||||
f"{attempts} times — skipping the automatic retry.", file=sys.stderr)
|
||||
return False
|
||||
if not _claim_recovery_lock(root):
|
||||
return False
|
||||
@@ -837,9 +838,10 @@ def _complete_pending_core_install(root: Path, core_marker: Path) -> bool:
|
||||
except Exception as exc:
|
||||
new_attempts = ir.bump_marker_attempts(core_marker)
|
||||
print(f" ✗ Early interrupted-install completion failed (attempt "
|
||||
f"{new_attempts}/{_EARLY_CORE_INSTALL_MAX_ATTEMPTS}): {exc}", file=sys.stderr)
|
||||
print(" The next launch will retry; hermes will keep working from "
|
||||
"the current venv in the meantime.", file=sys.stderr)
|
||||
f"{new_attempts}/{_CORE_INSTALL_MAX_ATTEMPTS}): {exc}", file=sys.stderr)
|
||||
if new_attempts < _CORE_INSTALL_MAX_ATTEMPTS:
|
||||
print(" The next launch will retry; hermes will keep working from "
|
||||
"the current venv in the meantime.", file=sys.stderr)
|
||||
return False
|
||||
finally:
|
||||
_release_recovery_lock(root)
|
||||
|
||||
@@ -241,8 +241,22 @@ def _recover_core_update_marker_locked() -> None:
|
||||
Narrow lazy-refresh import probes are not proof that a generic interrupted core
|
||||
install finished — a missing dep outside that probe set would look healthy and
|
||||
clear the breadcrumb too early.
|
||||
|
||||
Shares the early pass's attempt budget (the marker's JSON body). Past it, this path
|
||||
used to reinstall on EVERY boot from a process that already maps native venv
|
||||
extensions — the reinstall loop of #81594; now it prints the manual recovery instead.
|
||||
"""
|
||||
from hermes_cli.main import PROJECT_ROOT
|
||||
marker = _update_marker_path()
|
||||
attempts = _early_recovery_mod._read_marker_attempts(marker)
|
||||
max_attempts = _early_recovery_mod._CORE_INSTALL_MAX_ATTEMPTS
|
||||
if attempts >= max_attempts:
|
||||
print(
|
||||
f"✗ Finishing an interrupted `hermes update` failed {attempts} times; automatic "
|
||||
"retries are paused so Hermes stops reinstalling on every launch.")
|
||||
for line in _manual_core_recovery_lines(PROJECT_ROOT, marker, windows=_is_windows()):
|
||||
print(line)
|
||||
return
|
||||
print(
|
||||
"⚠ A previous `hermes update` was interrupted mid-install — "
|
||||
"finishing dependency installation now...")
|
||||
@@ -259,9 +273,8 @@ def _recover_core_update_marker_locked() -> None:
|
||||
"then quarantined full reinstall (core marker stays until that "
|
||||
"succeeds)...")
|
||||
_repair_venv_via_import_probes(install_prefix, env=install_env)
|
||||
from hermes_cli import _install_repair as _ir
|
||||
try:
|
||||
from hermes_cli import _install_repair as _ir
|
||||
|
||||
# ensure_uv bootstraps uv itself when missing (the early pass's stdlib-only lookup
|
||||
# cannot), so a venv whose uv vanished mid-update still heals.
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
@@ -272,25 +285,41 @@ def _recover_core_update_marker_locked() -> None:
|
||||
_clear_update_incomplete_marker()
|
||||
print("✓ Dependency installation recovered — your install is healthy again.")
|
||||
except Exception as exc:
|
||||
# Leave the marker so the next launch retries; give the exact manual command.
|
||||
# Leave the marker so the next launch retries (within the shared budget); give the
|
||||
# exact manual command.
|
||||
attempts = _ir.bump_marker_attempts(marker)
|
||||
logger.debug("Interrupted-install recovery failed: %s", exc)
|
||||
print("✗ Could not auto-recover the interrupted install.")
|
||||
manual = (
|
||||
" Hermes is still running from the launcher that needs "
|
||||
"replacing. Close other Hermes windows, restart from a "
|
||||
"different terminal, then run:",
|
||||
f' cd /d "{PROJECT_ROOT}"',
|
||||
f' "{sys.executable}" -m pip install -e ".[all]"',
|
||||
) if self_locked else (
|
||||
" Recover manually with:",
|
||||
f" cd {PROJECT_ROOT}",
|
||||
f" {sys.executable} -m ensurepip --upgrade",
|
||||
f" {sys.executable} -m pip install -e '.[all]'",
|
||||
)
|
||||
for line in manual:
|
||||
retry = ("the next launch will retry" if attempts < max_attempts
|
||||
else "automatic retries are now paused")
|
||||
print(f"✗ Could not auto-recover the interrupted install "
|
||||
f"(attempt {attempts}/{max_attempts}; {retry}).")
|
||||
for line in _manual_core_recovery_lines(
|
||||
PROJECT_ROOT, marker, windows=self_locked or _is_windows()):
|
||||
print(line)
|
||||
|
||||
|
||||
def _manual_core_recovery_lines(root: Path, marker: Path, *, windows: bool) -> tuple[str, ...]:
|
||||
"""The exact commands that finish a pending core install by hand and clear its marker.
|
||||
|
||||
On Windows another Hermes process (Desktop backend, gateway, a second terminal) mapping a
|
||||
venv ``.pyd`` is what keeps the automatic install failing, so they must be closed first.
|
||||
"""
|
||||
if windows:
|
||||
return (
|
||||
" Close every Hermes window (Desktop app, gateways, other terminals), then from a "
|
||||
"new terminal run:",
|
||||
f' cd /d "{root}"',
|
||||
f' "{sys.executable}" -m ensurepip --upgrade',
|
||||
f' "{sys.executable}" -m pip install -e ".[all]"',
|
||||
f' del "{marker}"')
|
||||
return (
|
||||
" Recover manually with:",
|
||||
f" cd {shlex.quote(str(root))}",
|
||||
f" {sys.executable} -m ensurepip --upgrade",
|
||||
f" {sys.executable} -m pip install -e '.[all]'",
|
||||
f" rm -f {shlex.quote(str(marker))}")
|
||||
|
||||
|
||||
def _norm_exe_path(path) -> str:
|
||||
"""Case-folded resolved path, for comparing executables on Windows."""
|
||||
try:
|
||||
|
||||
@@ -74,7 +74,7 @@ from hermes_cli.update_cmd_config import ( # noqa: F401
|
||||
_LAST_SIBLING_SNAPSHOTS, _check_and_apply_config_migration, _migrate_sibling_profile_configs,
|
||||
_print_items, _run_config_check_fresh, _run_migrate_config_fresh)
|
||||
from hermes_cli.update_cmd_deps import ( # noqa: F401
|
||||
_INSTALL_DEFINING_FILES, _SELF_LOCKING_NATIVE_MODULES, _UPDATE_CRITICAL_MODULES,
|
||||
_INSTALL_DEFINING_FILES, _UPDATE_CRITICAL_MODULES,
|
||||
_abort_dependency_sync_if_self_locked, _capture_active_lazy_features,
|
||||
_capture_active_tool_dependencies, _critical_module_import_failures,
|
||||
_defer_update_for_self_lock, _dependency_sync_would_rewrite, _desktop_app_present,
|
||||
|
||||
@@ -880,38 +880,94 @@ def _venv_dependency_set_stale() -> tuple[bool, str]:
|
||||
return stale, f"installed hermes-agent {installed}, checkout is {expected}" if stale else ""
|
||||
|
||||
|
||||
# Native extensions that pin venv files once imported: if the updater holds one, Windows blocks
|
||||
# REPLACE on the mapped ``.pyd`` and the sync dies with ``os error 5``. PyYAML's ``_yaml`` is in
|
||||
# every CLI process, so the guard must be HONEST: fire only when the sync would actually REWRITE
|
||||
# the dist, and only AFTER the code swap so a deferral leaves new code with just the install
|
||||
# pending. Keys are ``sys.modules`` prefixes; values are ``(display name, PyPI dist)``.
|
||||
# If the updater process itself has any of these loaded, the dependency sync below cannot rewrite the
|
||||
# backing ``.pyd``/``.dll`` — Windows blocks REPLACE on a mapped image — and the update dies with ``os error
|
||||
# 5`` between uninstall and reinstall, stranding the venv half-updated (#83569). ``cryptography`` is the
|
||||
# canonical case: ``hermes_cli.main`` used to import it at startup while resolving external secret sources;
|
||||
# ``PyYAML``'s ``_yaml`` C extension is loaded by every CLI process (config parsing). Keep this guard as
|
||||
# defence-in-depth against future eager imports (new secret sources, plugins absorbed into core, refactors
|
||||
# of the startup order) — but the guard must be HONEST (#86735/#86780/#86781: a preflight that fired on
|
||||
# every run, before the fetch, re-bricked the exact flow it was meant to protect). Two honesty gates: 1. It
|
||||
# only fires when the dependency sync would actually REWRITE the loaded distribution
|
||||
# (``_dependency_sync_would_rewrite``): if the installed version already satisfies the on-disk pyproject
|
||||
# pins, uv/pip will not touch the mapped ``.pyd``, so there is no lock to trip. 2. It runs AFTER the code
|
||||
# swap (git pull / ZIP commit), immediately before the venv rewrite — so the on-disk pyproject is the NEW
|
||||
# one (gate 1 compares against the right target) and a deferral no longer strands the user on the old
|
||||
# checkout: the next launch's marker recovery completes the dependency install against the already-updated
|
||||
# pyproject.
|
||||
_SELF_LOCKING_NATIVE_MODULES: dict[str, tuple[str, str]] = {
|
||||
"cryptography.hazmat.bindings._rust": ("cryptography (_rust.pyd)", "cryptography"),
|
||||
"yaml._yaml": ("PyYAML (_yaml.pyd)", "pyyaml")}
|
||||
# A native extension the updater process has mapped pins its venv file: Windows blocks REPLACE on a
|
||||
# mapped ``.pyd`` and the sync dies with ``os error 5`` between uninstall and reinstall, stranding the
|
||||
# venv half-updated (#83569). Which extensions are mapped is DERIVED from ``sys.modules`` (every loaded
|
||||
# module whose file is an extension under site-packages), never hand-listed: the #81594 report's lock
|
||||
# was ``_cffi_backend.pyd``, which the old two-entry list missed. PyYAML's ``_yaml`` is in every CLI
|
||||
# process, so the guard must stay HONEST (#86735/#86780/#86781: a preflight that fired on every run
|
||||
# re-bricked the flow it protects): 1. it fires only when the sync would actually REWRITE the loaded
|
||||
# distribution (``_dependency_sync_would_rewrite``); 2. it runs AFTER the code swap, right before the
|
||||
# venv rewrite, so the on-disk pyproject/uv.lock are the NEW ones and a deferral leaves new code with
|
||||
# only the install pending for the next launch's marker recovery.
|
||||
def _loaded_native_extension_dists(
|
||||
modules, site_dirs, extension_suffixes, top_level_dists) -> dict[str, list[str]]:
|
||||
"""``{distribution: [extension file names]}`` for loaded modules backed by an extension file
|
||||
under one of *site_dirs*. Stdlib extensions (``DLLs``/``lib-dynload``) and pure-Python modules
|
||||
never match; a module whose top-level name maps to no distribution is skipped."""
|
||||
roots = [os.path.normcase(os.path.abspath(d)) for d in site_dirs]
|
||||
suffixes = tuple(extension_suffixes)
|
||||
loaded: dict[str, list[str]] = {}
|
||||
for name, module in list(modules.items()):
|
||||
path = getattr(module, "__file__", None)
|
||||
if not isinstance(path, str) or not path.endswith(suffixes):
|
||||
continue
|
||||
normalized = os.path.normcase(os.path.abspath(path))
|
||||
if not any(normalized.startswith(root + os.sep) for root in roots):
|
||||
continue
|
||||
for dist in top_level_dists.get(name.partition(".")[0], ()):
|
||||
files = loaded.setdefault(dist, [])
|
||||
if os.path.basename(path) not in files:
|
||||
files.append(os.path.basename(path))
|
||||
return loaded
|
||||
|
||||
|
||||
def _pyproject_pin_verdict(target: str, installed: str | None, req_strings: list[str]) -> bool | None:
|
||||
"""True: an applicable pin is unsatisfied (or the dist is missing); False: pinned and
|
||||
satisfied; None: *target* (canonical name) has no applicable pyproject requirement."""
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import Version
|
||||
saw_pin = False
|
||||
for req_str in req_strings:
|
||||
try:
|
||||
req = Requirement(req_str)
|
||||
except Exception:
|
||||
continue
|
||||
if canonicalize_name(req.name) != target:
|
||||
continue
|
||||
if req.marker is not None and not req.marker.evaluate():
|
||||
continue
|
||||
if installed is None or Version(installed) not in req.specifier:
|
||||
return True
|
||||
saw_pin = True
|
||||
return False if saw_pin else None
|
||||
|
||||
|
||||
def _transitive_sync_would_rewrite(target: str, installed: str, root: Path, base_reqs: list[str]) -> bool | None:
|
||||
"""An unpinned dist moves only when something requiring it moves. True when uv.lock resolves it
|
||||
to a version other than the installed one AND a lock parent is being moved by a BASE
|
||||
``dependencies`` pin. Only base pins count: the sync cannot skip them (a failing optional extra is
|
||||
skipped by the fallback ladder and would re-defer every update, #86735), so the sync this defers
|
||||
always clears the condition. None otherwise."""
|
||||
from importlib import metadata as _ilmd
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import Version
|
||||
packages = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8")).get("package") or []
|
||||
locked = {Version(p["version"]) for p in packages
|
||||
if canonicalize_name(p.get("name", "")) == target and p.get("version")}
|
||||
if not locked or Version(installed) in locked:
|
||||
return None
|
||||
for parent in packages:
|
||||
if not any(canonicalize_name(dep.get("name", "")) == target for dep in parent.get("dependencies") or ()):
|
||||
continue
|
||||
try:
|
||||
parent_installed = _ilmd.version(parent["name"])
|
||||
except Exception:
|
||||
parent_installed = None
|
||||
if _pyproject_pin_verdict(canonicalize_name(parent["name"]), parent_installed, base_reqs):
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def _dependency_sync_would_rewrite(dist_name: str) -> bool | None:
|
||||
"""Whether the ``.[all]`` install would replace *dist_name*'s files, judged against every
|
||||
applicable pin in on-disk ``pyproject.toml`` (base + extras). False: all pins satisfied;
|
||||
True: pin unsatisfied or dist missing; None: undeterminable. Never raises. Callers treat
|
||||
None as fail-OPEN — PyYAML is in every process, so deferring on uncertainty always fires.
|
||||
applicable pin in on-disk ``pyproject.toml`` (base + extras) and, for an unpinned transitive,
|
||||
its uv.lock parents. False: all pins satisfied; True: pin unsatisfied, dist missing, or moved
|
||||
by a rewritten parent; None: undeterminable. Never raises. Callers treat None as fail-OPEN —
|
||||
PyYAML is in every process, so deferring on uncertainty always fires.
|
||||
|
||||
See #86735.
|
||||
See #86735, #81594.
|
||||
"""
|
||||
from hermes_cli.update_cmd import _m
|
||||
try:
|
||||
@@ -920,34 +976,18 @@ def _dependency_sync_would_rewrite(dist_name: str) -> bool | None:
|
||||
except Exception:
|
||||
return True # not installed → the sync will definitely install it
|
||||
try:
|
||||
import tomllib
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import Version
|
||||
pyproject = _m().PROJECT_ROOT / "pyproject.toml"
|
||||
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
||||
project = data.get("project") or {}
|
||||
req_strings: list[str] = list(project.get("dependencies") or [])
|
||||
root = _m().PROJECT_ROOT
|
||||
project = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")).get("project") or {}
|
||||
base_reqs: list[str] = list(project.get("dependencies") or [])
|
||||
req_strings = list(base_reqs)
|
||||
for extra_reqs in (project.get("optional-dependencies") or {}).values():
|
||||
req_strings.extend(extra_reqs or [])
|
||||
|
||||
target = canonicalize_name(dist_name)
|
||||
installed_v = Version(installed)
|
||||
saw_pin = False
|
||||
for req_str in req_strings:
|
||||
try:
|
||||
req = Requirement(req_str)
|
||||
except Exception:
|
||||
continue
|
||||
if canonicalize_name(req.name) != target:
|
||||
continue
|
||||
if req.marker is not None and not req.marker.evaluate():
|
||||
continue
|
||||
saw_pin = True
|
||||
if installed_v not in req.specifier:
|
||||
return True
|
||||
# Not pinned in pyproject: the resolver may still move it as a transitive — unknown.
|
||||
return False if saw_pin else None
|
||||
verdict = _pyproject_pin_verdict(target, installed, req_strings)
|
||||
if verdict is not None:
|
||||
return verdict
|
||||
return _transitive_sync_would_rewrite(target, installed, root, base_reqs)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -965,12 +1005,20 @@ def _detect_self_loaded_native_modules() -> list[str]:
|
||||
from hermes_cli.update_cmd import _m
|
||||
if not _m()._is_windows():
|
||||
return []
|
||||
import sysconfig
|
||||
from importlib import machinery, metadata
|
||||
try:
|
||||
loaded = _loaded_native_extension_dists(
|
||||
sys.modules, {sysconfig.get_path("purelib"), sysconfig.get_path("platlib")},
|
||||
machinery.EXTENSION_SUFFIXES, metadata.packages_distributions())
|
||||
except Exception: # unreadable dist metadata: fail open, like an unknown rewrite below
|
||||
return []
|
||||
# Defer ONLY on a CONFIRMED rewrite; unknown fails OPEN (PyYAML is in every process, so
|
||||
# unknown-as-at-risk always fires). A missed deferral only yields the mid-sync os error 5
|
||||
# that marker recovery already handles — far less harmful than an update that never runs.
|
||||
return sorted({
|
||||
display for prefix, (display, dist) in _SELF_LOCKING_NATIVE_MODULES.items()
|
||||
if prefix in sys.modules and _m()._dependency_sync_would_rewrite(dist) is True})
|
||||
return sorted(
|
||||
f"{dist} ({', '.join(files)})" for dist, files in loaded.items()
|
||||
if _m()._dependency_sync_would_rewrite(dist) is True)
|
||||
|
||||
|
||||
def _abort_dependency_sync_if_self_locked(gateway_resume=None) -> None:
|
||||
|
||||
@@ -427,7 +427,7 @@ def test_core_marker_retry_ceiling_hands_off_to_late_recovery(
|
||||
root = _project(tmp_path)
|
||||
core_marker = root / ".update-incomplete"
|
||||
core_marker.write_text(
|
||||
f'{{"attempts": {er._EARLY_CORE_INSTALL_MAX_ATTEMPTS}}}', encoding="utf-8"
|
||||
f'{{"attempts": {er._CORE_INSTALL_MAX_ATTEMPTS}}}', encoding="utf-8"
|
||||
)
|
||||
|
||||
from hermes_cli import _install_repair as ir
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"""Tests for interrupted-install self-heal (the ``.update-incomplete`` marker).
|
||||
|
||||
Covers the breadcrumb lifecycle so a ``hermes update`` killed mid-install
|
||||
(Ctrl-C, terminal close, WSL OOM) leaves a marker the next launch can act on.
|
||||
(Ctrl-C, terminal close, WSL OOM) leaves a marker the next launch can act on,
|
||||
and the boot recovery that finishes it (#81594: bounded, never a reinstall per boot).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.main as m
|
||||
from hermes_cli import _early_recovery as er
|
||||
from hermes_cli import _install_repair as ir
|
||||
from hermes_cli import main_install_repair as mir
|
||||
|
||||
|
||||
def test_marker_round_trip(tmp_path, monkeypatch):
|
||||
@@ -23,3 +29,66 @@ def test_marker_round_trip(tmp_path, monkeypatch):
|
||||
|
||||
m._clear_update_incomplete_marker()
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pending_core_install(tmp_path, monkeypatch):
|
||||
"""A source checkout whose last ``hermes update`` (owner now dead) left the core install
|
||||
pending; ``installs`` records every reinstall the boot recovery starts."""
|
||||
(tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n', encoding="utf-8")
|
||||
marker = tmp_path / ".update-incomplete"
|
||||
marker.write_text("started=1\npid=999999\n", encoding="utf-8")
|
||||
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
|
||||
monkeypatch.setattr(er, "_marker_owner_is_live", lambda _marker: False)
|
||||
monkeypatch.setattr(mir, "_windows_running_hermes_launcher_locked", lambda: False)
|
||||
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda *a, **k: None)
|
||||
installs: list[str] = []
|
||||
outcome = {"fail": True}
|
||||
|
||||
def install(root):
|
||||
installs.append(str(root))
|
||||
if outcome["fail"]:
|
||||
raise RuntimeError("os error 5: _cffi_backend.pyd is in use")
|
||||
|
||||
monkeypatch.setattr(ir, "run_core_install", install)
|
||||
return tmp_path, marker, installs, outcome
|
||||
|
||||
|
||||
def _launch(root):
|
||||
"""One ``hermes`` boot: the pre-import early pass, then main()'s post-import recovery."""
|
||||
er.recover_if_needed(project_root=root, argv=[])
|
||||
m._recover_from_interrupted_install()
|
||||
|
||||
|
||||
def test_persistently_failing_install_stops_rerunning_every_boot(pending_core_install, capsys):
|
||||
"""#81594: once the early pass stood down, the post-import path reinstalled on every
|
||||
launch forever. Both paths share one attempt budget: after it, boots stop reinstalling
|
||||
and print the exact recovery command instead."""
|
||||
root, marker, installs, _ = pending_core_install
|
||||
for _ in range(4):
|
||||
_launch(root)
|
||||
spent = len(installs)
|
||||
capsys.readouterr()
|
||||
|
||||
for _ in range(4):
|
||||
_launch(root)
|
||||
|
||||
assert len(installs) == spent, "boots past the attempt budget must not reinstall"
|
||||
assert marker.exists()
|
||||
output = capsys.readouterr()
|
||||
shown = output.out + output.err
|
||||
assert "pip install -e" in shown
|
||||
assert str(marker) in shown, "the recovery command must say how to clear the marker"
|
||||
|
||||
|
||||
def test_post_import_recovery_counts_failures_and_clears_marker_on_success(pending_core_install):
|
||||
root, marker, installs, outcome = pending_core_install
|
||||
|
||||
m._recover_from_interrupted_install()
|
||||
assert marker.exists()
|
||||
assert er._read_marker_attempts(marker) == 1, "a late-path failure spends one attempt"
|
||||
|
||||
outcome["fail"] = False
|
||||
m._recover_from_interrupted_install()
|
||||
assert len(installs) == 2
|
||||
assert not marker.exists(), "a successful recovery clears the marker"
|
||||
|
||||
@@ -26,6 +26,7 @@ from __future__ import annotations
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -102,12 +103,79 @@ class TestDependencySyncWouldRewrite:
|
||||
):
|
||||
assert cli_main._dependency_sync_would_rewrite("cryptography") is None
|
||||
|
||||
def test_unpinned_transitive_moves_with_its_pinned_parent(self, tmp_path):
|
||||
"""#81594: ``_cffi_backend`` belongs to cffi, which no pyproject line pins; it moves
|
||||
only because cryptography's base pin moved and uv.lock resolves cffi elsewhere. Once the
|
||||
parent is at its pin the transitive stays put (fail-open, #86735) — even while an
|
||||
optional-extra parent (brotlicffi) is missing, which a skipped extra can leave forever."""
|
||||
(tmp_path / "uv.lock").write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
version = 1
|
||||
[[package]]
|
||||
name = "brotlicffi"
|
||||
version = "1.2.0.2"
|
||||
dependencies = [{ name = "cffi" }]
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "50.0.0"
|
||||
dependencies = [{ name = "cffi" }]
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
pyproject = textwrap.dedent(
|
||||
"""
|
||||
[project]
|
||||
name = "x"
|
||||
dependencies = ["cryptography==50.0.0"]
|
||||
[project.optional-dependencies]
|
||||
messaging = ["brotlicffi==1.2.0.2"]
|
||||
"""
|
||||
)
|
||||
for crypto, expected in (("49.0.0", True), ("50.0.0", None)):
|
||||
installed = {"cffi": "1.17.1", "cryptography": crypto}
|
||||
with self._with_pyproject(tmp_path, pyproject), patch(
|
||||
"importlib.metadata.version", side_effect=installed.__getitem__
|
||||
):
|
||||
assert cli_main._dependency_sync_would_rewrite("cffi") is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_self_loaded_native_modules — version-gated detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _native_module(site: Path, name: str, suffix: str) -> types.ModuleType:
|
||||
module = types.ModuleType(name)
|
||||
module.__file__ = str(site.joinpath(*name.split(".")).with_name(name.rsplit(".", 1)[-1] + suffix))
|
||||
return module
|
||||
|
||||
|
||||
def test_native_extension_scan_derives_every_loaded_venv_extension(tmp_path):
|
||||
"""#81594: the report's locked file was ``_cffi_backend.pyd``, which no hand list named.
|
||||
Every loaded extension under site-packages maps to its distribution; stdlib extensions
|
||||
and pure-Python modules never do."""
|
||||
from hermes_cli.update_cmd_deps import _loaded_native_extension_dists
|
||||
|
||||
site, stdlib = tmp_path / "site-packages", tmp_path / "DLLs"
|
||||
modules = {
|
||||
"_cffi_backend": _native_module(site, "_cffi_backend", ".pyd"),
|
||||
"yaml._yaml": _native_module(site, "yaml._yaml", ".pyd"),
|
||||
"yaml": _native_module(site, "yaml", ".py"),
|
||||
"_ssl": _native_module(stdlib, "_ssl", ".pyd"),
|
||||
"builtin_like": types.ModuleType("builtin_like"),
|
||||
}
|
||||
loaded = _loaded_native_extension_dists(
|
||||
modules, [site], (".pyd",),
|
||||
{"_cffi_backend": ["cffi"], "yaml": ["PyYAML"], "_ssl": ["nope"]})
|
||||
|
||||
assert loaded == {"cffi": ["_cffi_backend.pyd"], "PyYAML": ["_yaml.pyd"]}
|
||||
|
||||
|
||||
@pytest.mark.linux_only
|
||||
def test_self_lock_detection_is_noop_off_windows():
|
||||
with patch.dict(sys.modules, {"cryptography.hazmat.bindings._rust": MagicMock()}):
|
||||
@@ -116,10 +184,15 @@ def test_self_lock_detection_is_noop_off_windows():
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_loaded_module_with_pending_version_change_is_flagged():
|
||||
import sysconfig
|
||||
|
||||
site = Path(sysconfig.get_path("purelib"))
|
||||
with patch.dict(
|
||||
sys.modules, {"cryptography.hazmat.bindings._rust": MagicMock()}
|
||||
sys.modules, {"_cffi_backend": _native_module(site, "_cffi_backend", ".pyd")}
|
||||
), patch(
|
||||
"importlib.metadata.packages_distributions", return_value={"_cffi_backend": ["cffi"]}
|
||||
), patch.object(cli_main, "_dependency_sync_would_rewrite", return_value=True):
|
||||
assert "cryptography (_rust.pyd)" in cli_main._detect_self_loaded_native_modules()
|
||||
assert "cffi (_cffi_backend.pyd)" in cli_main._detect_self_loaded_native_modules()
|
||||
|
||||
|
||||
@pytest.mark.windows_only
|
||||
@@ -248,16 +321,14 @@ class TestUpdateEntrypointImportHygiene:
|
||||
"""
|
||||
import sys
|
||||
import hermes_cli.main
|
||||
from hermes_cli.update_cmd import _SELF_LOCKING_NATIVE_MODULES
|
||||
loaded = [
|
||||
p for p in _SELF_LOCKING_NATIVE_MODULES
|
||||
if p in sys.modules and not p.startswith("yaml")
|
||||
]
|
||||
# PyYAML is a base dep every CLI process needs (config parsing);
|
||||
# it is version-gated instead of import-gated. Everything else
|
||||
# in the registry must stay lazy.
|
||||
# it is version-gated instead of import-gated. The crypto stack
|
||||
# (cryptography's _rust.pyd and cffi's _cffi_backend.pyd) must stay lazy.
|
||||
loaded = [
|
||||
m for m in ("cryptography.hazmat.bindings._rust", "_cffi_backend")
|
||||
if m in sys.modules
|
||||
]
|
||||
assert not loaded, f"eagerly loaded self-locking modules: {loaded}"
|
||||
assert "cryptography.hazmat.bindings._rust" not in sys.modules
|
||||
print("OK")
|
||||
"""
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user