diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 98874824c196..a25f42f49384 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -73,6 +73,61 @@ import os import sys + +def _primary_command_early(argv: "list[str] | None" = None) -> str | None: + """Return the first non-option command without importing argparse/config.""" + if argv is None: + argv = sys.argv[1:] + + value_flags = { + "-p", "--profile", + "-z", "--oneshot", + "-m", "--model", + "--provider", + "-t", "--toolsets", + "-r", "--resume", + "-s", "--skills", + "--usage-file", + } + optional_value_flags = {"-c", "--continue"} + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "--": + return argv[i + 1] if i + 1 < len(argv) else None + if arg.startswith("--profile="): + i += 1 + continue + if "=" not in arg and arg in value_flags and i + 1 < len(argv): + i += 2 + continue + if ( + "=" not in arg + and arg in optional_value_flags + and i + 1 < len(argv) + and not argv[i + 1].startswith("-") + ): + i += 2 + continue + if arg.startswith("-"): + i += 1 + continue + return arg + return None + + +def _windows_update_import_minimal() -> bool: + """True while Windows `hermes update` must avoid target-venv imports. + + Windows keeps native extensions (`*.pyd`) mapped while their importing + process is alive. The update command mutates the same venv it runs from, so + importing PyYAML/config before `uv pip install -e .[all]` can lock + `yaml/_yaml*.pyd` and strand the install half-updated. Keep startup on this + path dependency-light until the dependency sync has completed. + """ + return sys.platform == "win32" and _primary_command_early() == "update" + + # ── Startup fast-path bootstrap ───────────────────────────────────────── # Two lines of inline path math so ``python hermes_cli/main.py`` (script # mode — sys.path[0] is hermes_cli/, not the repo root) can import the @@ -326,6 +381,8 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool: """ if argv is None: argv = sys.argv[1:] + if sys.platform == "win32" and _primary_command_early(argv) == "update": + return False if "--cli" in argv: return False if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv: @@ -691,10 +748,12 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None: # Load .env from ~/.hermes/.env first, then project root as dev fallback. # User-managed env files should override stale shell exports on restart. -from hermes_cli.config import get_hermes_home -from hermes_cli.env_loader import load_hermes_dotenv +from hermes_constants import get_hermes_home -load_hermes_dotenv(project_env=PROJECT_ROOT / ".env") +if not _windows_update_import_minimal(): + from hermes_cli.env_loader import load_hermes_dotenv + + load_hermes_dotenv(project_env=PROJECT_ROOT / ".env") # Bridge security.redact_secrets from config.yaml → HERMES_REDACT_SECRETS env # var BEFORE hermes_logging imports agent.redact (which snapshots the flag at @@ -707,6 +766,8 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None: # `load_config()` was doing a full deep-merge for one boolean lookup). _FORCE_IPV4_EARLY = False try: + if _windows_update_import_minimal(): + raise RuntimeError("skip config yaml during Windows update pre-sync") # Reuse read_raw_config()'s (mtime, size)-keyed cache instead of a bespoke # yaml.load — the SAME parse then serves hermes_logging's # _read_logging_config and any later raw reads in this process, collapsing @@ -745,6 +806,8 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None: # Dashboard entrypoints bootstrap with GUI mode so gui.log is always present # during GUI testing, including pre-dispatch startup failures. try: + if _windows_update_import_minimal(): + raise RuntimeError("skip logging setup during Windows update pre-sync") from hermes_logging import setup_logging as _setup_logging _setup_logging( @@ -779,29 +842,30 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None: # Provider model-selection wizard flows extracted to hermes_cli/model_setup_flows.py # (god-file decomposition Phase 2). Re-imported here so select_provider_and_model and # existing test monkeypatches (hermes_cli.main._model_flow_*) keep resolving unchanged. -from hermes_cli.model_setup_flows import ( - _prompt_auth_credentials_choice, - _model_flow_openrouter, - _model_flow_nous, - _model_flow_openai_codex, - _model_flow_xai_oauth, - _model_flow_qwen_oauth, - _model_flow_minimax_oauth, - _model_flow_custom, - _model_flow_azure_foundry, - _model_flow_named_custom, - _model_flow_copilot, - _model_flow_copilot_acp, - _model_flow_kimi, - _model_flow_stepfun, - _model_flow_bedrock_api_key, - _model_flow_bedrock, - _model_flow_vertex, - _model_flow_api_key_provider, - _model_flow_anthropic, - _model_flow_moa, - _model_flow_ai_gateway, -) +if not _windows_update_import_minimal(): + from hermes_cli.model_setup_flows import ( + _prompt_auth_credentials_choice, + _model_flow_openrouter, + _model_flow_nous, + _model_flow_openai_codex, + _model_flow_xai_oauth, + _model_flow_qwen_oauth, + _model_flow_minimax_oauth, + _model_flow_custom, + _model_flow_azure_foundry, + _model_flow_named_custom, + _model_flow_copilot, + _model_flow_copilot_acp, + _model_flow_kimi, + _model_flow_stepfun, + _model_flow_bedrock_api_key, + _model_flow_bedrock, + _model_flow_vertex, + _model_flow_api_key_provider, + _model_flow_anthropic, + _model_flow_moa, + _model_flow_ai_gateway, + ) logger = logging.getLogger(__name__) @@ -5062,7 +5126,9 @@ def _clear_bytecode_cache(root: Path) -> int: _npm_lockfile_changed, _npm_manifest_paths, _npm_manifests_digest, + _gateway_run_argv_minimal, _pause_windows_gateways_for_update, + _pause_windows_gateways_for_update_minimal, _print_curator_first_run_notice, _print_curator_recent_run_notice, _print_fts_optimize_available_notice, @@ -9068,32 +9134,36 @@ def cmd_update(args): runs the update, then restores stdio on the way out (even on ``sys.exit`` or unhandled exceptions). """ - from hermes_cli.config import ( - detect_install_method, - format_docker_update_message, - is_managed, - managed_error, - recommended_update_command_for_method, + minimal_windows_git_update = ( + _windows_update_import_minimal() and (PROJECT_ROOT / ".git").exists() ) + if not minimal_windows_git_update: + from hermes_cli.config import ( + detect_install_method, + format_docker_update_message, + is_managed, + managed_error, + recommended_update_command_for_method, + ) - if is_managed(): - managed_error("update Hermes Agent") - return + if is_managed(): + managed_error("update Hermes Agent") + return - # Docker users can't ``git pull`` — the image excludes ``.git`` from - # the build context. Bail with a friendly explanation pointing at - # ``docker pull`` BEFORE any of the apply-path / check-path branches - # below get a chance to error out with misleading "Not a git - # repository" text. See format_docker_update_message() for the full - # rationale and tag-pinning / config-persistence notes. - install_method = detect_install_method(PROJECT_ROOT) - if install_method == "docker": - print(format_docker_update_message()) - sys.exit(1) + # Docker users can't ``git pull`` — the image excludes ``.git`` from + # the build context. Bail with a friendly explanation pointing at + # ``docker pull`` BEFORE any of the apply-path / check-path branches + # below get a chance to error out with misleading "Not a git + # repository" text. See format_docker_update_message() for the full + # rationale and tag-pinning / config-persistence notes. + install_method = detect_install_method(PROJECT_ROOT) + if install_method == "docker": + print(format_docker_update_message()) + sys.exit(1) - if install_method in {"nix", "nixos"}: - print(recommended_update_command_for_method(install_method)) - sys.exit(1) + if install_method in {"nix", "nixos"}: + print(recommended_update_command_for_method(install_method)) + sys.exit(1) if getattr(args, "check", False): # --check honors --branch so the "any new commits?" answer matches diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 95982c0f28ee..3cb6f9b4a66c 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -36,8 +36,7 @@ from pathlib import Path from typing import Optional -from hermes_cli.config import get_hermes_home -from hermes_constants import venv_python_path +from hermes_constants import get_hermes_home, venv_python_path logger = logging.getLogger(__name__) @@ -2508,15 +2507,17 @@ def _resolve_pre_update_backup_mode(args) -> str: if getattr(args, "backup", False): return "full" - try: - from hermes_cli.config import load_config + cfg = {} + if not _m()._windows_update_import_minimal(): + try: + from hermes_cli.config import load_config - cfg = load_config() - except Exception as exc: - logging.getLogger(__name__).debug( - "Could not load config for pre-update backup: %s", exc - ) - cfg = {} + cfg = load_config() + except Exception as exc: + logging.getLogger(__name__).debug( + "Could not load config for pre-update backup: %s", exc + ) + cfg = {} updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {} raw = updates_cfg.get("pre_update_backup", "quick") @@ -2797,8 +2798,10 @@ def _venv_core_imports_healthy() -> tuple[bool, str]: return True, "" # Core web/serve imports plus their newest transitive deps. Import (not - # just metadata) — a package can have intact dist-info but a missing - # module after an interrupted uninstall/install cycle. + # just metadata) because a package can have intact dist-info but a missing + # module after an interrupted uninstall/install cycle. PyYAML needs an + # attribute-level probe too: a failed Windows replace can leave `yaml` as an + # empty namespace package with no __file__, __version__, or SafeDumper. check = ( "import importlib\n" "mods = ['fastapi', 'uvicorn', 'pydantic', 'openai', 'yaml']\n" @@ -2806,6 +2809,16 @@ def _venv_core_imports_healthy() -> tuple[bool, str]: "for m in mods:\n" " try: importlib.import_module(m)\n" " except Exception as e: missing.append(f'{m}: {e}')\n" + "try:\n" + " import yaml\n" + " if not getattr(yaml, '__file__', None):\n" + " missing.append('yaml: missing __file__')\n" + " if not getattr(yaml, '__version__', None):\n" + " missing.append('yaml: missing __version__')\n" + " if not hasattr(yaml, 'SafeDumper'):\n" + " missing.append('yaml: missing SafeDumper')\n" + "except Exception as e:\n" + " missing.append(f'yaml: {e}')\n" "print('\\n'.join(missing))\n" ) try: @@ -3055,6 +3068,100 @@ def _leftover_pausable_gateway_pids( return pids +def _gateway_run_argv_minimal(argv: list[str]) -> bool: + """Recognize ``hermes ... gateway run`` without gateway/config imports.""" + tokens = [str(value).strip("\"'").replace("\\", "/").lower() for value in argv] + joined = " ".join(tokens) + if not ( + "hermes_cli.main" in joined + or "hermes_cli/main.py" in joined + or any(token.rsplit("/", 1)[-1] in {"hermes", "hermes.exe"} for token in tokens) + ): + return False + + filtered: list[str] = [] + skip_next = False + for token in tokens: + if skip_next: + skip_next = False + continue + if token in {"-p", "--profile"}: + skip_next = True + continue + if token.startswith("-p=") or token.startswith("--profile="): + continue + filtered.append(token) + for index, token in enumerate(filtered): + if token == "gateway": + return index + 1 == len(filtered) or filtered[index + 1] == "run" + return False + + +def _pause_windows_gateways_for_update_minimal() -> dict | None: + """Stop active gateways without importing PyYAML/config-dependent code.""" + try: + import psutil + except Exception: + return None + + gateways: list[dict] = [] + for pid, _name, _command in _m()._detect_venv_python_processes(): + try: + process = psutil.Process(int(pid)) + argv = list(process.cmdline() or []) + except Exception: + continue + if not _gateway_run_argv_minimal(argv): + continue + try: + parent_pid = int(process.ppid()) + except Exception: + parent_pid = 0 + gateways.append( + {"pid": int(pid), "ppid": parent_pid, "argv": argv, "process": process} + ) + + # A Windows venv may expose both its launcher stub and child interpreter. + # Keep the child so one gateway is stopped and later restarted only once. + parents = {entry["ppid"] for entry in gateways} + gateways = [entry for entry in gateways if entry["pid"] not in parents] + if not gateways: + return None + + print("→ Stopping Windows gateway process(es) before updating Hermes...") + from hermes_cli._subprocess_compat import windows_hide_flags + + stopped = [] + for entry in gateways: + try: + result = subprocess.run( + ["taskkill", "/PID", str(entry["pid"]), "/T", "/F"], + capture_output=True, + text=True, + timeout=10, + creationflags=windows_hide_flags(), + ) + if result.returncode == 0: + stopped.append(entry) + except (FileNotFoundError, OSError, subprocess.TimeoutExpired): + try: + entry["process"].kill() + stopped.append(entry) + except Exception: + pass + + if stopped: + print(f" → Paused {len(stopped)} Windows gateway process(es)") + return { + "resume_needed": True, + "profiles": {}, + "unmapped_pids": [entry["pid"] for entry in stopped], + "unmapped": [ + {"pid": entry["pid"], "argv": entry["argv"]} for entry in stopped + ], + } if stopped else None + + def _pause_windows_gateways_for_update() -> dict | None: """Stop running Windows gateways before mutating the checkout or venv. @@ -3065,6 +3172,8 @@ def _pause_windows_gateways_for_update() -> dict | None: """ if not _m()._is_windows(): return None + if _m()._windows_update_import_minimal(): + return _m()._pause_windows_gateways_for_update_minimal() try: from gateway.status import terminate_pid @@ -3573,7 +3682,7 @@ def _cmd_update_impl(args, gateway_mode: bool): or not (sys.stdin.isatty() and sys.stdout.isatty()) ) discard_local_changes = False - if _non_interactive_update: + if _non_interactive_update and not _m()._windows_update_import_minimal(): try: from hermes_cli.config import load_config diff --git a/tests/hermes_cli/test_update_import_minimal.py b/tests/hermes_cli/test_update_import_minimal.py new file mode 100644 index 000000000000..a5973da71a1e --- /dev/null +++ b/tests/hermes_cli/test_update_import_minimal.py @@ -0,0 +1,135 @@ +"""Regression tests for the Windows update import boundary. + +`hermes update` mutates the same venv it runs from. On Windows, importing +native-extension packages before dependency sync can keep their `.pyd` files +locked and leave the install half-updated. The update import path therefore +must stay PyYAML-free until the install step has completed. +""" + +from __future__ import annotations + +import builtins +import os +import subprocess +import sys +import textwrap +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from hermes_cli import main as cli_main + + +def test_minimal_pause_stops_active_gateway_without_yaml_import(monkeypatch): + class FakeProcess: + def __init__(self, pid): + self.pid = pid + + def cmdline(self): + return ["pythonw.exe", "-m", "hermes_cli.main", "gateway", "run"] + + def ppid(self): + return 1 + + def kill(self): + raise AssertionError("taskkill should handle the gateway") + + fake_psutil = SimpleNamespace(Process=FakeProcess) + monkeypatch.setitem(sys.modules, "psutil", fake_psutil) + monkeypatch.setattr( + cli_main, + "_detect_venv_python_processes", + lambda: [ + (101, "pythonw.exe", "pythonw.exe -m hermes_cli.main gateway run") + ], + ) + calls = [] + monkeypatch.setattr( + cli_main.subprocess, + "run", + lambda argv, **kwargs: calls.append(argv) + or SimpleNamespace(returncode=0), + ) + + real_import = builtins.__import__ + + def block_yaml(name, *args, **kwargs): + if name == "yaml" or name.startswith("yaml."): + raise AssertionError(f"unexpected yaml import: {name}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", block_yaml) + token = cli_main._pause_windows_gateways_for_update_minimal() + + assert calls == [["taskkill", "/PID", "101", "/T", "/F"]] + assert token == { + "resume_needed": True, + "profiles": {}, + "unmapped_pids": [101], + "unmapped": [ + { + "pid": 101, + "argv": [ + "pythonw.exe", + "-m", + "hermes_cli.main", + "gateway", + "run", + ], + } + ], + } + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows file-lock regression") +def test_windows_update_import_does_not_import_yaml(): + repo = Path(__file__).resolve().parents[2] + code = r""" +import importlib.abc +import sys +from types import SimpleNamespace + +sys.argv = ["hermes", "update", "--yes"] + + +class BlockYaml(importlib.abc.MetaPathFinder): + seen = 0 + + def find_spec(self, fullname, path=None, target=None): + if fullname == "yaml" or fullname.startswith("yaml."): + self.seen += 1 + raise AssertionError(f"unexpected yaml import: {fullname}") + return None + + +blocker = BlockYaml() +sys.meta_path.insert(0, blocker) + +import hermes_cli.main as main + +assert main._windows_update_import_minimal() +assert blocker.seen == 0 + +# The real pause seam must remain dependency-light on this path too. +main._detect_venv_python_processes = lambda: [] +assert main._pause_windows_gateways_for_update() is None +assert blocker.seen == 0 + +main._install_hangup_protection = lambda gateway_mode=False: {} +main._finalize_update_output = lambda state: None +main._cmd_update_impl = lambda args, gateway_mode: None +main.cmd_update(SimpleNamespace(check=False, gateway=False)) +assert blocker.seen == 0 +""" + env = os.environ.copy() + env["PYTHONPATH"] = str(repo) + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(code)], + cwd=repo, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr or result.stdout diff --git a/tests/hermes_cli/test_update_venv_health.py b/tests/hermes_cli/test_update_venv_health.py index ef583aef671d..b55a599754a8 100644 --- a/tests/hermes_cli/test_update_venv_health.py +++ b/tests/hermes_cli/test_update_venv_health.py @@ -31,9 +31,37 @@ # --------------------------------------------------------------------------- - - -def _fake_venv_python(tmp_path, *, windows: bool = False): +def test_venv_health_reports_healthy_when_no_venv(tmp_path): + """No venv python in a DEV checkout → nothing to probe → healthy.""" + with patch.object(cli_main, "PROJECT_ROOT", tmp_path): + healthy, detail = cli_main._venv_core_imports_healthy() + assert healthy is True + assert detail == "" + + +def test_venv_health_missing_venv_unhealthy_on_managed_install(tmp_path): + """On a managed install (bootstrap marker) the venv IS the install — + its absence must be reported unhealthy so the repair lane runs instead + of 'Already up to date!'.""" + (tmp_path / ".hermes-bootstrap-complete").write_text("done") + with patch.object(cli_main, "PROJECT_ROOT", tmp_path): + healthy, detail = cli_main._venv_core_imports_healthy() + assert healthy is False + assert "venv python missing" in detail + + +def test_venv_health_missing_venv_unhealthy_with_interrupted_marker(tmp_path): + """An interrupted-update breadcrumb also flips missing-venv to unhealthy.""" + (tmp_path / ".update-incomplete").write_text("started=1\npid=1\n") + with patch.object(cli_main, "PROJECT_ROOT", tmp_path): + healthy, detail = cli_main._venv_core_imports_healthy() + assert healthy is False + assert "venv python missing" in detail + + +def _fake_venv_python(tmp_path, *, windows: bool | None = None): + if windows is None: + windows = cli_main._is_windows() bin_dir = tmp_path / "venv" / ("Scripts" if windows else "bin") bin_dir.mkdir(parents=True) py = bin_dir / ("python.exe" if windows else "python") @@ -41,6 +69,79 @@ def _fake_venv_python(tmp_path, *, windows: bool = False): return py +def test_venv_health_reports_missing_imports(tmp_path): + """Probe output lines are surfaced as the unhealthy detail.""" + _fake_venv_python(tmp_path) + + fake = SimpleNamespace( + returncode=0, + stdout="fastapi: No module named 'annotated_doc'\n", + stderr="", + ) + with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object( + cli_main.subprocess, "run", return_value=fake + ): + healthy, detail = cli_main._venv_core_imports_healthy() + + assert healthy is False + assert "annotated_doc" in detail + + +def test_venv_health_reports_broken_pyyaml_namespace(tmp_path): + """An importable-but-empty PyYAML namespace package is unhealthy.""" + _fake_venv_python(tmp_path) + + fake = SimpleNamespace( + returncode=0, + stdout=( + "yaml: missing __file__\n" + "yaml: missing __version__\n" + "yaml: missing SafeDumper\n" + ), + stderr="", + ) + with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object( + cli_main.subprocess, "run", return_value=fake + ): + healthy, detail = cli_main._venv_core_imports_healthy() + + assert healthy is False + assert "missing __file__" in detail + assert "missing SafeDumper" in detail + + +def test_venv_health_healthy_when_probe_clean(tmp_path): + _fake_venv_python(tmp_path) + fake = SimpleNamespace(returncode=0, stdout="", stderr="") + with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object( + cli_main.subprocess, "run", return_value=fake + ): + healthy, detail = cli_main._venv_core_imports_healthy() + assert healthy is True + + +def test_venv_health_broken_interpreter_is_unhealthy(tmp_path): + """Nonzero exit with no module list = interpreter itself is broken.""" + _fake_venv_python(tmp_path) + fake = SimpleNamespace(returncode=1, stdout="", stderr="Fatal Python error: init failed\n") + with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object( + cli_main.subprocess, "run", return_value=fake + ): + healthy, detail = cli_main._venv_core_imports_healthy() + assert healthy is False + assert "Fatal Python error" in detail + + +def test_venv_health_probe_failure_reports_healthy(tmp_path): + """A probe that can't run must NOT force needless reinstalls.""" + _fake_venv_python(tmp_path) + with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object( + cli_main.subprocess, + "run", + side_effect=subprocess.TimeoutExpired(cmd="python", timeout=60), + ): + healthy, _detail = cli_main._venv_core_imports_healthy() + assert healthy is True # --------------------------------------------------------------------------- @@ -60,6 +161,34 @@ def _proc(pid: int, exe: str, name: str, cmdline: list[str] | None = None, cwd: return proc +def test_detect_venv_python_off_windows_is_empty(): + with patch.object(cli_main, "_is_windows", return_value=False): + assert cli_main._detect_venv_python_processes() == [] + + +@patch.object(cli_main, "_is_windows", return_value=True) +def test_detect_venv_python_finds_backend(_winp, tmp_path): + venv_py = str(tmp_path / "venv" / "Scripts" / "python.exe") + other_py = "C:\\Python311\\python.exe" + + me = MagicMock() + me.parents.return_value = [] + fake_psutil = types.SimpleNamespace( + process_iter=lambda attrs: iter( + [ + _proc(101, venv_py, "python.exe", ["python.exe", "-m", "hermes_cli.main", "serve"]), + _proc(102, other_py, "python.exe", ["python.exe", "somescript.py"]), + ] + ), + Process=lambda *a, **k: me, + ) + with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict( + sys.modules, {"psutil": fake_psutil} + ): + matches = cli_main._detect_venv_python_processes() + + assert [m[0] for m in matches] == [101] + assert "serve" in matches[0][2] @patch.object(cli_main, "_is_windows", return_value=True) @@ -86,6 +215,89 @@ def test_detect_venv_python_excludes_self_and_ancestors(_winp, tmp_path): assert cli_main._detect_venv_python_processes() == [] +@patch.object(cli_main, "_is_windows", return_value=True) +def test_detect_venv_python_no_psutil_is_empty(_winp, tmp_path): + with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict( + sys.modules, {"psutil": None} + ): + assert cli_main._detect_venv_python_processes() == [] + + +def test_format_venv_holders_message_flags_desktop_backend(tmp_path): + matches = [ + (101, "python.exe", "python.exe -m hermes_cli.main serve --host 127.0.0.1"), + (102, "pythonw.exe", "pythonw.exe -m hermes_cli.main gateway run"), + ] + msg = cli_main._format_venv_python_holders_message(matches) + assert "101" in msg + assert "desktop app" in msg.lower() + assert "gateway" in msg + assert "hermes update" in msg + assert "--force-venv" in msg + + +@patch.object(cli_main, "_is_windows", return_value=True) +def test_detect_venv_python_catches_outside_venv_trampoline(_winp, tmp_path): + """uv/base-interpreter trampoline: exe OUTSIDE the venv, but the cmdline + clearly runs Hermes from this install → must still be flagged as a holder + (it imports from the venv and holds its .pyd files).""" + base_py = "C:\\Python311\\python.exe" + venv_path = str(tmp_path / "venv" / "Scripts" / "python.exe") + + me = MagicMock() + me.parents.return_value = [] + fake_psutil = types.SimpleNamespace( + process_iter=lambda attrs: iter( + [ + # cmdline references the venv path directly + _proc(201, base_py, "python.exe", [base_py, venv_path, "-m", "x"]), + # `-m hermes_cli.main serve` with the install root as cwd + _proc( + 202, + base_py, + "python.exe", + [base_py, "-m", "hermes_cli.main", "serve"], + cwd=str(tmp_path), + ), + # unrelated base-interpreter python → NOT a holder + _proc(203, base_py, "python.exe", [base_py, "somescript.py"], cwd="C:\\other"), + ] + ), + Process=lambda *a, **k: me, + ) + with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict( + sys.modules, {"psutil": fake_psutil} + ): + matches = cli_main._detect_venv_python_processes() + + assert sorted(m[0] for m in matches) == [201, 202] + + +@patch.object(cli_main, "_is_windows", return_value=True) +def test_detect_venv_hermes_cli_cmdline_outside_install_not_matched(_winp, tmp_path): + """A hermes_cli.main process belonging to a DIFFERENT install (neither + install root in cmdline nor cwd under it) must not be flagged.""" + base_py = "C:\\Python311\\python.exe" + me = MagicMock() + me.parents.return_value = [] + fake_psutil = types.SimpleNamespace( + process_iter=lambda attrs: iter( + [ + _proc( + 301, + base_py, + "python.exe", + [base_py, "-m", "hermes_cli.main", "serve"], + cwd="C:\\other-install", + ), + ] + ), + Process=lambda *a, **k: me, + ) + with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict( + sys.modules, {"psutil": fake_psutil} + ): + assert cli_main._detect_venv_python_processes() == [] # --------------------------------------------------------------------------- @@ -157,3 +369,62 @@ def __truediv__(self, _other): def test_venv_holder_guard_force_semantics(force, force_venv, expected, capsys): result = _run_update_until_guard(_update_args(force=force, force_venv=force_venv)) assert result == expected, capsys.readouterr().out + + +def test_minimal_windows_update_pauses_gateway_before_holder_guard(): + """An active gateway must be gone before the venv-holder guard runs.""" + gateway_running = True + events = [] + token = { + "resume_needed": True, + "profiles": {}, + "unmapped_pids": [101], + "unmapped": [], + } + + class _PastGuard(Exception): + pass + + class _RootSentinel: + def __truediv__(self, _other): + raise _PastGuard + + def pause(): + nonlocal gateway_running + events.append("pause") + gateway_running = False + return token + + def holders(): + events.append("holders") + if gateway_running: + return [ + ( + 101, + "pythonw.exe", + "pythonw.exe -m hermes_cli.main gateway run", + ) + ] + return [] + + with patch.object(cli_main, "_is_windows", return_value=True), patch.object( + cli_main, "_windows_update_import_minimal", 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", side_effect=pause + ), patch.object( + cli_main, "_detect_venv_python_processes", side_effect=holders + ), patch.object( + cli_main, "_resume_windows_gateways_after_update" + ), patch.object( + cli_main, "PROJECT_ROOT", _RootSentinel() + ), patch( + "atexit.register" + ): + with pytest.raises(_PastGuard): + cli_main._cmd_update_impl(_update_args(), gateway_mode=False) + + assert events == ["pause", "holders"]