"""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 == []