fix(update): scope Windows gateway lifecycle to current install
This commit is contained in:
@@ -992,7 +992,9 @@ def install(
|
||||
raise RuntimeError(f"Windows gateway install failed: {detail}")
|
||||
|
||||
|
||||
def _live_gateway_pids(all_profiles: bool = False, home: Path | None = None) -> list[int]:
|
||||
def _live_gateway_pids(
|
||||
all_profiles: bool = False, home: Path | None = None, pid_filter=None
|
||||
) -> list[int]:
|
||||
"""Live gateway PIDs for the readiness poll. ``home`` scopes the probe to ONE profile's identity
|
||||
files (a still-running sibling must not vouch for a per-profile spawn, #110959); otherwise the
|
||||
process-table discovery for the active profile or the whole fleet."""
|
||||
@@ -1000,14 +1002,16 @@ def _live_gateway_pids(all_profiles: bool = False, home: Path | None = None) ->
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
pid = get_running_pid(home / "gateway.pid", cleanup_stale=False)
|
||||
return [pid] if pid else []
|
||||
from hermes_cli.gateway import find_gateway_pids
|
||||
|
||||
return list(find_gateway_pids(all_profiles=all_profiles))
|
||||
pids = [pid] if pid else []
|
||||
else:
|
||||
from hermes_cli.gateway import find_gateway_pids
|
||||
pids = list(find_gateway_pids(all_profiles=all_profiles))
|
||||
return list(pid_filter(pids)) if pid_filter is not None else pids
|
||||
|
||||
|
||||
def _confirm_gateway_stable(
|
||||
initial_pids: list[int], confirm_s: float, interval_s: float, all_profiles: bool = False, home: Path | None = None,
|
||||
initial_pids: list[int], confirm_s: float, interval_s: float, all_profiles: bool = False,
|
||||
home: Path | None = None, pid_filter=None,
|
||||
) -> list[int]:
|
||||
"""Re-check a freshly detected gateway for ``confirm_s`` seconds: one process-table hit proves
|
||||
the child was *created*, not that it survived startup (or a parent Job Object teardown).
|
||||
@@ -1024,7 +1028,7 @@ def _confirm_gateway_stable(
|
||||
confirm_deadline = time.monotonic() + confirm_s
|
||||
while time.monotonic() < confirm_deadline:
|
||||
time.sleep(interval_s)
|
||||
pids = _live_gateway_pids(all_profiles, home)
|
||||
pids = _live_gateway_pids(all_profiles, home, pid_filter)
|
||||
if not pids:
|
||||
return []
|
||||
return pids
|
||||
@@ -1032,15 +1036,17 @@ def _confirm_gateway_stable(
|
||||
|
||||
def _wait_for_gateway_ready(
|
||||
timeout_s: float = 6.0, interval_s: float = 0.4, confirm_s: float = 2.0, all_profiles: bool = False,
|
||||
home: Path | None = None,
|
||||
home: Path | None = None, pid_filter=None,
|
||||
) -> list[int]:
|
||||
"""Poll for a live gateway for up to ``timeout_s``; a first hit is provisional until the gateway
|
||||
stays visible for ``confirm_s`` more seconds (a child that dies right after spawn earns no ✓)."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
pids = _live_gateway_pids(all_profiles, home)
|
||||
pids = _live_gateway_pids(all_profiles, home, pid_filter)
|
||||
if pids:
|
||||
confirmed = _confirm_gateway_stable(pids, confirm_s, interval_s, all_profiles=all_profiles, home=home)
|
||||
confirmed = _confirm_gateway_stable(
|
||||
pids, confirm_s, interval_s, all_profiles=all_profiles, home=home, pid_filter=pid_filter
|
||||
)
|
||||
if confirmed:
|
||||
return confirmed
|
||||
continue # died during confirmation — keep polling until deadline
|
||||
|
||||
@@ -833,6 +833,102 @@ def _pause_windows_gateway_services(service_gateways, token: dict, profiles: dic
|
||||
raise RuntimeError(detail) from exc
|
||||
|
||||
|
||||
def _gateway_process_install_match(pid: int, project_root: Path, home_root: Path | None) -> bool | None:
|
||||
"""True/False for proven current/foreign ownership; None when the process cannot prove either."""
|
||||
psutil = _psutil()
|
||||
if psutil is None:
|
||||
return None
|
||||
try:
|
||||
proc = psutil.Process(int(pid))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _under(raw, root: Path | None) -> bool:
|
||||
if not raw or root is None:
|
||||
return False
|
||||
try:
|
||||
Path(raw).resolve().relative_to(root.resolve())
|
||||
return True
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return False
|
||||
|
||||
home_match = None
|
||||
try:
|
||||
raw_home = (proc.environ() or {}).get("HERMES_HOME")
|
||||
if raw_home and Path(raw_home).is_absolute():
|
||||
home_match = _under(raw_home, home_root)
|
||||
if home_match:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if _under(proc.exe(), project_root):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
argv = proc.cmdline() or []
|
||||
if argv and _under(argv[0], project_root):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False if home_match is False else None
|
||||
|
||||
|
||||
def _classify_current_install_gateway_pids(pids, *, known_owned=()) -> tuple[list[int], list[int]]:
|
||||
"""Return (owned, unknown); candidates proven foreign are intentionally omitted."""
|
||||
candidates: list[int] = []
|
||||
for value in pids:
|
||||
try:
|
||||
pid = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if pid > 0 and pid not in candidates:
|
||||
candidates.append(pid)
|
||||
if not candidates:
|
||||
return [], []
|
||||
|
||||
candidate_set = set(candidates)
|
||||
owned = {int(pid) for pid in known_owned if int(pid) in candidate_set}
|
||||
from hermes_cli.update_cmd import _m
|
||||
project_root = Path(_m().PROJECT_ROOT)
|
||||
|
||||
try:
|
||||
from hermes_cli.process_identity import ledger_entries
|
||||
for entry in ledger_entries(project_root=project_root, verified_only=True):
|
||||
pid = entry.get("pid")
|
||||
if entry.get("purpose") == "gateway" and isinstance(pid, int) and pid in candidate_set:
|
||||
owned.add(pid)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read current-install gateway ledger: %s", exc)
|
||||
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
home_root = Path(get_default_hermes_root())
|
||||
except Exception:
|
||||
home_root = None
|
||||
|
||||
unknown: set[int] = set()
|
||||
for pid in candidates:
|
||||
if pid in owned:
|
||||
continue
|
||||
match = _gateway_process_install_match(pid, project_root, home_root)
|
||||
if match is True:
|
||||
owned.add(pid)
|
||||
elif match is None:
|
||||
unknown.add(pid)
|
||||
return (
|
||||
[pid for pid in candidates if pid in owned],
|
||||
[pid for pid in candidates if pid in unknown],
|
||||
)
|
||||
|
||||
|
||||
def _current_install_gateway_pids(pids, *, known_owned=()) -> list[int]:
|
||||
"""Owned half of the install-scoped gateway classification, suitable for mutation/readiness."""
|
||||
return _classify_current_install_gateway_pids(pids, known_owned=known_owned)[0]
|
||||
|
||||
|
||||
def _discover_windows_gateways():
|
||||
"""``(profile_processes, service_gateways, service_gateway_pids, running_pids)`` for the pause; any indeterminate probe aborts."""
|
||||
from hermes_cli.gateway import find_gateway_pids, find_profile_gateway_processes, find_windows_gateway_services
|
||||
@@ -843,9 +939,12 @@ def _discover_windows_gateways():
|
||||
service_gateways = find_windows_gateway_services(profile_processes=profile_process_list)
|
||||
service_gateway_pids = {int(service.gateway_pid) for service in service_gateways}
|
||||
with _abort_on_error("Could not discover Windows gateway PIDs before update"):
|
||||
running_pids = list(dict.fromkeys(
|
||||
machine_pids = list(dict.fromkeys(
|
||||
[*find_gateway_pids(all_profiles=True), *sorted(profile_processes), *sorted(service_gateway_pids)]
|
||||
))
|
||||
running_pids = _current_install_gateway_pids(
|
||||
machine_pids, known_owned=set(profile_processes) | service_gateway_pids
|
||||
)
|
||||
return profile_processes, service_gateways, service_gateway_pids, running_pids
|
||||
|
||||
|
||||
@@ -1037,7 +1136,16 @@ def _cold_start_windows_gateway_after_update(token: dict | None = None) -> bool:
|
||||
from hermes_cli import gateway_windows
|
||||
from hermes_cli.gateway import find_gateway_pids
|
||||
with _abort_on_error("Could not re-check gateway liveness before cold-start"):
|
||||
if list(find_gateway_pids(all_profiles=True)):
|
||||
owned_pids, unknown_pids = _classify_current_install_gateway_pids(
|
||||
find_gateway_pids(all_profiles=True)
|
||||
)
|
||||
if owned_pids:
|
||||
return True
|
||||
if unknown_pids:
|
||||
logger.debug(
|
||||
"Skipping Windows gateway cold-start: ownership is indeterminate for PID(s) %s",
|
||||
", ".join(map(str, unknown_pids)),
|
||||
)
|
||||
return True
|
||||
token = token or {}
|
||||
generation = token.get("attested_generation")
|
||||
@@ -1252,7 +1360,9 @@ def _verify_relaunched_gateways_alive(token: dict, profiles: dict, unmapped: lis
|
||||
from gateway.status import _pid_exists
|
||||
from hermes_cli import gateway_windows
|
||||
timeout_s = _relaunch_verify_timeout_s(profiles, unmapped, _pid_exists)
|
||||
ready_pids = gateway_windows._wait_for_gateway_ready(timeout_s=timeout_s, all_profiles=True)
|
||||
ready_pids = gateway_windows._wait_for_gateway_ready(
|
||||
timeout_s=timeout_s, all_profiles=True, pid_filter=_current_install_gateway_pids
|
||||
)
|
||||
if not ready_pids:
|
||||
token["profiles"] = dict(profiles)
|
||||
token["unmapped"] = list(unmapped)
|
||||
|
||||
176
tests/hermes_cli/test_update_windows_cross_install_gateway.py
Normal file
176
tests/hermes_cli/test_update_windows_cross_install_gateway.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Windows update gateway ownership is install-scoped, not machine-scoped (#124659)."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from hermes_cli import gateway as gateway_mod
|
||||
from hermes_cli import gateway_windows
|
||||
from hermes_cli import main as cli_main
|
||||
import hermes_cli.main_install_repair as main_install_repair
|
||||
from hermes_cli import process_identity
|
||||
from hermes_cli import update_cmd
|
||||
from hermes_cli import update_cmd_windows
|
||||
|
||||
|
||||
def test_current_install_filter_keeps_ledger_owned_unmapped_gateway(monkeypatch, tmp_path):
|
||||
root = tmp_path / "checkout"
|
||||
home = tmp_path / "home"
|
||||
root.mkdir()
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(
|
||||
process_identity, "ledger_entries", lambda **_kwargs: [{"pid": 101, "purpose": "gateway"}]
|
||||
)
|
||||
monkeypatch.setattr(update_cmd_windows, "_psutil", lambda: None)
|
||||
|
||||
assert update_cmd_windows._current_install_gateway_pids([101, 202]) == [101]
|
||||
|
||||
|
||||
def test_legacy_home_proves_owned_unmapped_gateway(monkeypatch, tmp_path):
|
||||
root = tmp_path / "checkout"
|
||||
home = tmp_path / "hermes"
|
||||
foreign = tmp_path / "foreign"
|
||||
for path in (root, home, foreign):
|
||||
path.mkdir()
|
||||
profile_home = home / "profiles" / "work"
|
||||
profile_home.mkdir(parents=True)
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(process_identity, "ledger_entries", lambda **_kwargs: [])
|
||||
|
||||
class Proc:
|
||||
def __init__(self, pid):
|
||||
self.pid = pid
|
||||
|
||||
def environ(self):
|
||||
return {"HERMES_HOME": str(profile_home if self.pid == 101 else foreign)}
|
||||
|
||||
def exe(self):
|
||||
return str(foreign / "python.exe")
|
||||
|
||||
def cmdline(self):
|
||||
return [str(foreign / "python.exe")]
|
||||
|
||||
monkeypatch.setattr(update_cmd_windows, "_psutil", lambda: SimpleNamespace(Process=Proc))
|
||||
|
||||
owned, unknown = update_cmd_windows._classify_current_install_gateway_pids([101, 202])
|
||||
assert owned == [101]
|
||||
assert unknown == []
|
||||
|
||||
|
||||
def test_cwd_alone_is_not_destructive_ownership(monkeypatch, tmp_path):
|
||||
root = tmp_path / "checkout"
|
||||
home = tmp_path / "hermes"
|
||||
foreign = tmp_path / "foreign"
|
||||
for path in (root, home, foreign):
|
||||
path.mkdir()
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(process_identity, "ledger_entries", lambda **_kwargs: [])
|
||||
|
||||
proc = SimpleNamespace(
|
||||
environ=lambda: {},
|
||||
exe=lambda: str(foreign / "python.exe"),
|
||||
cmdline=lambda: [str(foreign / "python.exe")],
|
||||
cwd=lambda: str(root),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_windows, "_psutil", lambda: SimpleNamespace(Process=lambda _pid: proc)
|
||||
)
|
||||
|
||||
owned, unknown = update_cmd_windows._classify_current_install_gateway_pids([202])
|
||||
assert owned == []
|
||||
assert unknown == [202]
|
||||
|
||||
|
||||
def test_discovery_keeps_owned_unmapped_and_excludes_foreign(monkeypatch):
|
||||
monkeypatch.setattr(gateway_mod, "find_profile_gateway_processes", lambda **_kwargs: [])
|
||||
monkeypatch.setattr(gateway_mod, "find_windows_gateway_services", lambda **_kwargs: [])
|
||||
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_kwargs: [101, 202])
|
||||
monkeypatch.setattr(
|
||||
update_cmd_windows,
|
||||
"_current_install_gateway_pids",
|
||||
lambda pids, **_kwargs: [pid for pid in pids if pid == 101],
|
||||
)
|
||||
|
||||
profiles, services, service_pids, running = update_cmd_windows._discover_windows_gateways()
|
||||
|
||||
assert profiles == {}
|
||||
assert services == []
|
||||
assert service_pids == set()
|
||||
assert running == [101]
|
||||
|
||||
|
||||
def test_foreign_gateway_does_not_suppress_current_install_cold_start(monkeypatch):
|
||||
monkeypatch.setattr(cli_main, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(main_install_repair, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_kwargs: [202])
|
||||
monkeypatch.setattr(
|
||||
update_cmd_windows,
|
||||
"_classify_current_install_gateway_pids",
|
||||
lambda _pids, **_kwargs: ([], []),
|
||||
)
|
||||
monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: False)
|
||||
|
||||
spawned = []
|
||||
monkeypatch.setattr(gateway_windows, "_spawn_detached", lambda: spawned.append(True) or 4242)
|
||||
monkeypatch.setattr(gateway_windows, "_wait_for_gateway_ready", lambda *a, **k: [4242])
|
||||
monkeypatch.setattr(gateway_windows, "_write_start_attestation", lambda *a, **k: None)
|
||||
|
||||
assert update_cmd_windows._cold_start_windows_gateway_after_update({"attested_generation": None})
|
||||
assert spawned == [True]
|
||||
|
||||
|
||||
def test_unknown_gateway_conservatively_suppresses_cold_start(monkeypatch):
|
||||
monkeypatch.setattr(cli_main, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(main_install_repair, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_kwargs: [303])
|
||||
monkeypatch.setattr(
|
||||
update_cmd_windows,
|
||||
"_classify_current_install_gateway_pids",
|
||||
lambda _pids, **_kwargs: ([], [303]),
|
||||
)
|
||||
monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
gateway_windows,
|
||||
"_spawn_detached",
|
||||
lambda: (_ for _ in ()).throw(AssertionError("must not spawn with unknown ownership")),
|
||||
)
|
||||
|
||||
assert update_cmd_windows._cold_start_windows_gateway_after_update(
|
||||
{"attested_generation": None}
|
||||
)
|
||||
|
||||
|
||||
def test_readiness_filter_waits_past_foreign_gateway(monkeypatch):
|
||||
snapshots = iter(([202], [202, 4242]))
|
||||
monkeypatch.setattr(
|
||||
gateway_mod, "find_gateway_pids", lambda **_kwargs: next(snapshots, [202, 4242])
|
||||
)
|
||||
monkeypatch.setattr(gateway_windows.time, "sleep", lambda _seconds: None)
|
||||
|
||||
ready = gateway_windows._wait_for_gateway_ready(
|
||||
timeout_s=1.0,
|
||||
confirm_s=0,
|
||||
all_profiles=True,
|
||||
pid_filter=lambda pids: [pid for pid in pids if pid == 4242],
|
||||
)
|
||||
|
||||
assert ready == [4242]
|
||||
|
||||
|
||||
def test_post_relaunch_verification_uses_install_filter(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def wait_for_ready(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return [4242]
|
||||
|
||||
monkeypatch.setattr(gateway_windows, "_wait_for_gateway_ready", wait_for_ready)
|
||||
monkeypatch.setattr(gateway_windows, "_write_start_attestation", lambda *a, **k: None)
|
||||
monkeypatch.setattr(update_cmd_windows, "_relaunch_verify_timeout_s", lambda *_args: 1.0)
|
||||
|
||||
update_cmd_windows._verify_relaunched_gateways_alive({}, {}, [])
|
||||
|
||||
assert captured["all_profiles"] is True
|
||||
assert captured["pid_filter"] is update_cmd_windows._current_install_gateway_pids
|
||||
Reference in New Issue
Block a user