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).
183 lines
6.3 KiB
Python
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 == []
|