Files
hermes-agent/tests/hermes_cli/test_update_concurrent_quarantine.py
teknium1 e692d61d19 test: restore update self-lock, quarantine and repair guards dropped by #120071
Restored (OS fakes replaced by windows_only/macos_only markers):
- test_update_shim_self_lock.py: venv/.venv scripts dir lookup (#79542, all OSes); Windows shim detection (argv0, zipapp, ancestor chain, foreign install ignored), re-exec hand-off under venv python without recursion or on spawn failure, dependency-sync guard (shim-only vs native lock).
- test_update_concurrent_quarantine.py: concurrent-parent detection robust to one bad hop and excludes launcher/self (#29341, #34795); quarantine success/unbreakable lock; Windows gateway pause/resume, service rollback and retryable resume failures, abort on indeterminate discovery; venv launcher ancestor detection; concurrent gate gateway-only skip vs non-gateway abort; refusal to tree-kill the gateway ancestor (#98814).
- test_update_apply_shallow_count.py (recreated): real _prepare_checkout_for_update on full vs depth-1 clones — shallow behind-count recovered via compare API, unknown offline, never bogus (#53479, #86257).
- test_lazy_refresh_venv_repair.py: venv repair reinstalls lazy/tool snapshots through an isolated uv env (#83914).
- test_verify_console_scripts.py (recreated, windows_only): missing hermes.exe triggers reinstall; still-missing shim reported, no reinstall when all present (#52931).
- test_macos_fda_guidance.py (recreated): doctor/setup Full Disk Access guidance granted/denied/indeterminate, silent off macOS (#52010).
2026-09-23 10:34:54 -07:00

183 lines
6.3 KiB
Python

"""Windows gateway service invariants and launcher identity after PM cutover."""
from __future__ import annotations
import sys
import types
from types import SimpleNamespace
import pytest
from hermes_cli import main as cli_main
def test_restore_windows_gateway_service_waits_out_stop_pending(monkeypatch):
import hermes_cli.update_cmd as update_cmd
import hermes_cli.update_cmd_windows as update_cmd_windows
statuses = iter(["stop_pending", "stopped"])
service = SimpleNamespace(status=lambda: next(statuses))
fake_psutil = SimpleNamespace(win_service_get=lambda _name: service)
restarted = []
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
monkeypatch.setattr(update_cmd._time, "sleep", lambda _seconds: None)
monkeypatch.setattr(
update_cmd,
"_start_windows_gateway_service",
lambda name: restarted.append(name),
)
monkeypatch.setattr(
update_cmd_windows,
"_start_windows_gateway_service",
lambda name: restarted.append(name),
)
update_cmd._restore_windows_gateway_service("HermesGateway")
assert restarted == ["HermesGateway"]
def test_stop_windows_gateway_service_waits_for_original_descendants(
monkeypatch,
):
"""SCM STOPPED is insufficient while the original process identity lives."""
import hermes_cli.update_cmd as update_cmd
service = SimpleNamespace(status=lambda: "stopped")
fake_psutil = SimpleNamespace(
win_service_get=lambda _name: service,
Process=lambda pid: SimpleNamespace(create_time=lambda: 12.5),
)
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
monkeypatch.setattr(
update_cmd.subprocess,
"run",
lambda *_a, **_k: SimpleNamespace(returncode=0, stdout="", stderr=""),
)
with pytest.raises(RuntimeError, match="process tree"):
update_cmd._stop_windows_gateway_service(
"HermesGateway",
expected_processes=((123, 12.5),),
timeout=0,
)
def _fake_psutil_tree(tree, venv_exe, worker_exe, dead=None):
"""Build a psutil stand-in where ``tree`` maps worker pid -> parent pid.
Parents whose pid is even are venv-side (``venv_exe``); odd parents are
unrelated ancestors (``worker_exe``) that must NOT be returned. Pids in
``dead`` (a live reference — later additions count) are uninspectable:
construction raises, exactly like psutil.NoSuchProcess for an exited
process.
"""
dead_set = dead if dead is not None else set()
class FakeProc:
def __init__(self, pid):
self.pid = pid
if pid in dead_set:
raise ValueError(f"process {pid} has exited")
if pid not in tree and pid not in tree.values():
raise ValueError(f"no such pid {pid}")
def parent(self):
ppid = tree.get(self.pid)
return FakeProc(ppid) if ppid else None
def parents(self):
return []
def exe(self):
# Parents of workers are the launchers under test.
return venv_exe if self.pid % 2 == 0 else worker_exe
mod = types.SimpleNamespace(Process=FakeProc)
return mod
@pytest.mark.platforms("windows")
def test_pause_stops_launcher_after_worker_drain(
monkeypatch,
tmp_path,
):
"""Capture the launcher identity while its worker is still inspectable."""
import hermes_cli.gateway as gateway_mod
import gateway.status as status_mod
# The install venv is whatever hermes_constants.project_venv_dir resolves for the checkout (a
# CI checkout has no venv/ and the test interpreter lives elsewhere); pin it to the fixture layout.
monkeypatch.setattr("hermes_constants.project_venv_dir", lambda root: cli_main.PROJECT_ROOT / "venv")
venv_exe = str(cli_main.PROJECT_ROOT / "venv" / "Scripts" / "python.exe")
worker_exe = r"C:\Users\x\AppData\Roaming\uv\python\cpython-3.11\python.exe"
profile_home = tmp_path / "profiles" / "default"
profile_home.mkdir(parents=True)
# The PID file records the WORKER (even-numbered parent 400 is its launcher).
worker_pid, launcher_pid = 500, 400
profile_proc = SimpleNamespace(
profile="default", path=profile_home, pid=worker_pid
)
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_k: [worker_pid])
monkeypatch.setattr(
gateway_mod, "find_windows_gateway_services", lambda **_k: []
)
monkeypatch.setattr(
gateway_mod, "find_profile_gateway_processes", lambda **_k: [profile_proc]
)
monkeypatch.setattr(gateway_mod, "_get_restart_drain_timeout", lambda: 0.1)
# Graceful drain succeeds: the worker exits, leaving zero survivors — and
# an exited worker is UNINSPECTABLE afterwards, exactly like the real
# process table. Resolving the launcher after this point is impossible,
# so the pause must snapshot launcher ancestors before draining. This is
# precisely the case that used to leave the launcher alive and abort.
drained_dead: set[int] = set()
def _drain_marks_workers_dead(pids, *, timeout):
drained_dead.update(int(p) for p in pids)
return set()
monkeypatch.setattr(
cli_main,
"_wait_for_windows_update_gateway_exit",
_drain_marks_workers_dead,
)
fake = _fake_psutil_tree(
{worker_pid: launcher_pid}, venv_exe, worker_exe, dead=drained_dead
)
monkeypatch.setitem(sys.modules, "psutil", fake)
terminated = []
monkeypatch.setattr(
status_mod,
"terminate_pid",
lambda pid, force=False, **kwargs: terminated.append(int(pid)),
)
cli_main._pause_windows_gateways_for_update()
assert terminated == [launcher_pid]
def test_stop_service_refuses_pid_reuse_before_sc_stop(monkeypatch):
import hermes_cli.update_cmd as update_cmd
fake_psutil = SimpleNamespace(
win_service_get=lambda _name: SimpleNamespace(
status=lambda: "running", pid=lambda: 11
),
Process=lambda _pid: SimpleNamespace(create_time=lambda: 99.0),
)
calls = []
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
monkeypatch.setattr(update_cmd.subprocess, "run", lambda *_a, **_k: calls.append(True))
with pytest.raises(RuntimeError, match="identity changed"):
update_cmd._stop_windows_gateway_service(
"HermesGateway", expected_service_identity=(11, 11.0)
)
assert calls == []