diff --git a/hermes_cli/_early_recovery.py b/hermes_cli/_early_recovery.py index caba297a22..fcd514ee9e 100644 --- a/hermes_cli/_early_recovery.py +++ b/hermes_cli/_early_recovery.py @@ -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) diff --git a/hermes_cli/main_install_repair.py b/hermes_cli/main_install_repair.py index 6e1fd55353..e26d04d214 100644 --- a/hermes_cli/main_install_repair.py +++ b/hermes_cli/main_install_repair.py @@ -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: diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 2e8db85a47..0badfb34e9 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -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, diff --git a/hermes_cli/update_cmd_deps.py b/hermes_cli/update_cmd_deps.py index ff5c8c7273..a1f0864e65 100644 --- a/hermes_cli/update_cmd_deps.py +++ b/hermes_cli/update_cmd_deps.py @@ -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: diff --git a/tests/hermes_cli/test_early_recovery.py b/tests/hermes_cli/test_early_recovery.py index d8bac329be..4991c7ed5f 100644 --- a/tests/hermes_cli/test_early_recovery.py +++ b/tests/hermes_cli/test_early_recovery.py @@ -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 diff --git a/tests/hermes_cli/test_update_interrupted_recovery.py b/tests/hermes_cli/test_update_interrupted_recovery.py index c7a4fc9057..ee9e828918 100644 --- a/tests/hermes_cli/test_update_interrupted_recovery.py +++ b/tests/hermes_cli/test_update_interrupted_recovery.py @@ -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" diff --git a/tests/hermes_cli/test_update_self_lock.py b/tests/hermes_cli/test_update_self_lock.py index 95f0e18a74..265a1dde76 100644 --- a/tests/hermes_cli/test_update_self_lock.py +++ b/tests/hermes_cli/test_update_self_lock.py @@ -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") """ )