From 9188e708b314cccf75f243c440e5462d79d09861 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:57:25 -0700 Subject: [PATCH] fix(gateway): served_profiles bind to a verified gateway identity, not bare PID existence `live_default_gateway_pid()` trusted `gateway.pid` + `_pid_exists`, so a stale default record whose PID an unrelated process had recycled kept its old `served_profiles` authoritative: `hermes -p X gateway start` exited 78 and `status` said "running via multiplexer" for a gateway long gone (review of #108352, finding D). The salvaged #110167 fallback inherited the same bare check for the pid-file branch. One helper now answers "which live gateway owns this home?" for every reader: `gateway.status.live_gateway_pid_for_home` = scoped `get_running_pid` (pid file + runtime lock, start-time reuse guard, live gateway command line, home match) then `get_runtime_status_running_pid(..., expected_home=home)` (honours `gateway_state` stopped/startup_failed). `gateway_multiplex_served`, `gateway_migrate._live_gateway_pid` and the `hermes update` inventory's gateway_state.json fallback (#109680: a `stopped` record + recycled PID fabricated a phantom runtime, so the update exited partial) all route through it. Tests that impersonated a gateway with this pytest PID now wear a gateway command line instead of stubbing `_pid_exists`. --- gateway/status.py | 17 +++++ hermes_cli/gateway_migrate.py | 11 +-- hermes_cli/gateway_multiplex_served.py | 31 ++------ hermes_cli/update_inventory.py | 14 ++-- tests/gateway/test_multiplex_lifecycle.py | 22 +++--- .../test_gateway_migrate_multiplex.py | 3 + .../test_gateway_multiplex_served_record.py | 71 ++++++++---------- .../test_gateway_multiplex_status.py | 73 ++++--------------- ..._pooled_served_profile_backend_unscoped.py | 4 + .../test_served_profile_mirror_platforms.py | 4 + tests/hermes_cli/test_update_inventory.py | 18 ++++- tests/hermes_cli/test_web_server.py | 14 ++-- 12 files changed, 131 insertions(+), 151 deletions(-) diff --git a/gateway/status.py b/gateway/status.py index 9b197b33f1..f90b89b22a 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -1091,6 +1091,23 @@ def get_runtime_status_running_pid( return pid +def live_gateway_pid_for_home(home: Path) -> Optional[int]: + """Verified PID of the gateway owned by ``home`` (pid file + runtime lock first, then the runtime + status record), or None. Every reader of another home's gateway identity goes through this so + they all prove the same thing: the PID passes the start-time reuse guard, its live command line is + a gateway's belonging to ``home``, and the record is not ``stopped``. Bare PID existence is not + identity -- a stale record whose PID was recycled by an unrelated process lent it ``served_profiles`` + and put phantom gateways into the update inventory (#109680) -- while a launch-service gateway whose + ``gateway.pid`` was unlinked is still live (#110166). Never unlinks ``home``'s identity files.""" + home = Path(home) + # Cached: dashboard surfaces poll this for every served profile; the cache invalidates on any + # pid/lock file change, so a stopped or replaced gateway is seen at once. + pid = get_running_pid_cached(home / "gateway.pid", cleanup_stale=False) + if pid is not None: + return pid + return get_runtime_status_running_pid(read_runtime_status(home / "gateway_state.json"), expected_home=home) + + def remove_pid_file() -> None: """Remove the PID file only if it belongs to this process: during --replace the old process's atexit can fire AFTER the new process wrote its own record.""" diff --git a/hermes_cli/gateway_migrate.py b/hermes_cli/gateway_migrate.py index ec0c461085..16684da4cf 100644 --- a/hermes_cli/gateway_migrate.py +++ b/hermes_cli/gateway_migrate.py @@ -157,14 +157,11 @@ def _profile_homes() -> list[tuple[str, Path]]: def _live_gateway_pid(home: Path) -> Optional[int]: - """PID of a standalone gateway owned by ``home`` (pid file, then runtime status), else None.""" - from gateway.status import get_running_pid, get_runtime_status_running_pid, read_runtime_status + """Verified PID of a standalone gateway owned by ``home``, else None (never raises: a probe + failure must not abort a migration plan).""" + from gateway.status import live_gateway_pid_for_home with contextlib.suppress(Exception): - pid = get_running_pid(home / "gateway.pid", cleanup_stale=False) - if pid is not None: - return pid - with contextlib.suppress(Exception): - return get_runtime_status_running_pid(read_runtime_status(home / "gateway_state.json"), expected_home=home) + return live_gateway_pid_for_home(home) return None diff --git a/hermes_cli/gateway_multiplex_served.py b/hermes_cli/gateway_multiplex_served.py index 1514efce97..e69c60e86a 100644 --- a/hermes_cli/gateway_multiplex_served.py +++ b/hermes_cli/gateway_multiplex_served.py @@ -17,32 +17,17 @@ logger = logging.getLogger(__name__) def live_default_gateway_pid() -> Optional[int]: - """PID of the default profile's gateway when it names a live process, else None. + """PID of the default profile's gateway when a VERIFIED live process owns it, else None. - ``gateway.pid`` first, then the runtime record the gateway process itself writes: a - launch-service-managed gateway can be live with no PID file at all (a replace/cleanup path unlinks - it while the process keeps serving), and ``get_running_pid()`` cannot answer for this scoped home -- - an explicit ``pid_path`` deliberately suppresses its own runtime-status fallback. Same order and - same call as ``hermes_cli.gateway_migrate._live_gateway_pid``. Never key this off the record's - ``updated_at``: an idle gateway never advances it, so "recent" would read a live-but-quiet - multiplexer as stopped. + ``gateway.status.live_gateway_pid_for_home``: pid file + lock, then the runtime record the gateway + itself writes, each proven against the live process (start time, gateway command line, home). A + launch-service gateway can be live with no ``gateway.pid`` at all, and a stale record whose PID was + recycled by an unrelated process must not make its ``served_profiles`` authoritative. Never key this + off the record's ``updated_at``: an idle gateway never advances it. """ from hermes_constants import get_default_hermes_root - from gateway.status import ( - _pid_exists, - _pid_from_record, - _read_pid_record, - get_runtime_status_running_pid, - read_runtime_status, - ) - default_root = get_default_hermes_root() - rec = _read_pid_record(default_root / "gateway.pid") - pid = _pid_from_record(rec) if rec else None - if pid and _pid_exists(pid): - return pid - return get_runtime_status_running_pid( - read_runtime_status(default_root / "gateway_state.json"), expected_home=default_root - ) + from gateway.status import live_gateway_pid_for_home + return live_gateway_pid_for_home(get_default_hermes_root()) def recorded_served_profiles(default_root: Optional[Path] = None) -> Optional[list[str]]: diff --git a/hermes_cli/update_inventory.py b/hermes_cli/update_inventory.py index bc9d6bdd6c..757e570e4b 100644 --- a/hermes_cli/update_inventory.py +++ b/hermes_cli/update_inventory.py @@ -172,7 +172,7 @@ def _collect_gateway_runtimes(plan: UpdatePlan, profile_homes: list, seen: set[i mapped gateways no status record covers.""" supervisor = _supervisor_classifier() with _probe("Gateway-state inventory"): - from gateway.status import _pid_exists, read_runtime_status + from gateway.status import live_gateway_pid_for_home, read_runtime_status from hermes_cli.update_receipt import _socket_identity for profile, home in profile_homes: @@ -185,13 +185,13 @@ def _collect_gateway_runtimes(plan: UpdatePlan, profile_homes: list, seen: set[i declared = record.get("supervisor") sup = str(declared) if declared else supervisor(pid) else: + # Verified identity, not bare PID existence: a ``stopped`` record whose PID was recycled + # by an unrelated process fabricated a phantom gateway the restart phase could never + # touch, so `hermes update` exited partial (#109680). + pid = live_gateway_pid_for_home(home) + if pid is None or pid in seen: + continue record = read_runtime_status(home / "gateway_state.json") or {} - try: - pid = int(record.get("pid")) - except (TypeError, ValueError): - continue - if not _pid_exists(pid): - continue seen.add(pid) sup = supervisor(pid) plan.runtimes.append(_runtime("gateway", profile, pid, sup, record.get("code_sha"), record.get("code_version"))) diff --git a/tests/gateway/test_multiplex_lifecycle.py b/tests/gateway/test_multiplex_lifecycle.py index d710f71d41..848a91e7e1 100644 --- a/tests/gateway/test_multiplex_lifecycle.py +++ b/tests/gateway/test_multiplex_lifecycle.py @@ -89,10 +89,14 @@ class TestNamedProfileMultiplexerGuard: monkeypatch.setattr( "hermes_constants.get_default_hermes_root", lambda: tmp_path ) - (tmp_path / "gateway.pid").write_text("12345", encoding="utf-8") - monkeypatch.setattr(status, "_read_pid_record", lambda p: {"pid": 12345}) - monkeypatch.setattr(status, "_pid_from_record", lambda rec: 12345) - monkeypatch.setattr(status, "_pid_exists", lambda pid: True) + import json + import os + # Liveness is a verified identity (live PID + gateway command line + home), so this pytest + # process stands in for the gateway by wearing a gateway command line. + (tmp_path / "gateway.pid").write_text(str(os.getpid()), encoding="utf-8") + (tmp_path / "gateway_state.json").write_text(json.dumps( + {"pid": os.getpid(), "hermes_home": str(tmp_path), "gateway_state": "running"})) + monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: "hermes gateway run") def test_unset_allowlist_preserves_historical_guard(self, monkeypatch, tmp_path): self._fake_running_default_gateway(monkeypatch, tmp_path) @@ -115,11 +119,11 @@ class TestNamedProfileMultiplexerGuard: "gateway:\n multiplex_profiles: true\n", encoding="utf-8", ) - import gateway.status as status - monkeypatch.setattr( - status, "read_runtime_status", - lambda path=None: {"gateway_state": "running", "served_profiles": ["default", "worker"]}, - ) + import json + import os + (tmp_path / "gateway_state.json").write_text(json.dumps({ + "pid": os.getpid(), "hermes_home": str(tmp_path), "gateway_state": "running", + "served_profiles": ["default", "worker"]})) from hermes_cli import gateway as gw diff --git a/tests/hermes_cli/test_gateway_migrate_multiplex.py b/tests/hermes_cli/test_gateway_migrate_multiplex.py index c2341d96bb..3bf11f4575 100644 --- a/tests/hermes_cli/test_gateway_migrate_multiplex.py +++ b/tests/hermes_cli/test_gateway_migrate_multiplex.py @@ -65,6 +65,9 @@ def fleet(tmp_path, monkeypatch): runtime["served_profiles"] = ["default", "coder", "ops"] runtime_path.write_text(json.dumps(runtime)) + import gateway.status as status + # The default gateway the fixture "starts" is this process; the served probe verifies identity. + monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: "hermes gateway run") monkeypatch.setattr(gm, "_installed_service", lambda home: state.services.get(_name(home))) monkeypatch.setattr(gm, "_live_gateway_pid", lambda home: state.pids.get(_name(home))) monkeypatch.setattr(gm, "_service_op", _service_op) diff --git a/tests/hermes_cli/test_gateway_multiplex_served_record.py b/tests/hermes_cli/test_gateway_multiplex_served_record.py index efd9705bce..c73c5ac862 100644 --- a/tests/hermes_cli/test_gateway_multiplex_served_record.py +++ b/tests/hermes_cli/test_gateway_multiplex_served_record.py @@ -27,11 +27,18 @@ def served_root(tmp_path, monkeypatch): (root / "config.yaml").write_text("model: {default: x}\n") # NO multiplex flag: env-only opt-in (root / "gateway.pid").write_text(json.dumps({"pid": os.getpid(), "hermes_home": str(root)})) (root / "gateway_state.json").write_text(json.dumps( - {"pid": os.getpid(), "hermes_home": str(root), "served_profiles": ["default", "coder"]})) + {"pid": os.getpid(), "hermes_home": str(root), "gateway_state": "running", + "served_profiles": ["default", "coder"]})) monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "coder")) monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False) import hermes_constants + import gateway.status as status monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None) + # Liveness is a VERIFIED identity: this pytest process stands in for the default gateway only + # because its command line reads as one; any other PID keeps its real command line. + real_cmdline = status._read_process_cmdline + monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: ( + "python -m hermes_cli.main gateway run" if pid == os.getpid() else real_cmdline(pid))) return root @@ -46,62 +53,46 @@ def test_probe_trusts_live_record_over_cli_side_config(served_root): def test_probe_falls_back_to_config_only_without_recorded_key(served_root): from hermes_cli.gateway import named_profile_served_by_running_multiplexer - (served_root / "gateway_state.json").write_text(json.dumps({"pid": os.getpid()})) + (served_root / "gateway_state.json").write_text(json.dumps( + {"pid": os.getpid(), "hermes_home": str(served_root), "gateway_state": "running"})) assert named_profile_served_by_running_multiplexer("coder") is False (served_root / "config.yaml").write_text("gateway: {multiplex_profiles: true}\n") assert named_profile_served_by_running_multiplexer("coder") is True -def test_probe_survives_a_missing_default_pid_file(served_root, monkeypatch): +def test_probe_survives_a_missing_default_pid_file(served_root): """A launch-service-managed multiplexer can be live with no ``gateway.pid``: a replace/cleanup path unlinks it while the process keeps serving. Keying liveness off that file alone made every surface (``hermes -p X status``, ``cron list``, the dashboard ladder) say "not running" about the gateway that was in fact serving the profile.""" - import gateway.status as status from hermes_cli.gateway import named_profile_served_by_running_multiplexer from hermes_cli.gateway_multiplex_served import live_default_gateway_pid - (served_root / "gateway_state.json").write_text(json.dumps({ - "pid": os.getpid(), "hermes_home": str(served_root), "gateway_state": "running", - "served_profiles": ["default", "coder"]})) (served_root / "gateway.pid").unlink() - # The PID is this test process, so the record's identity check has to see a gateway command line: - # without it the fallback correctly refuses (see the recycled-PID test below). - monkeypatch.setattr( - status, "_read_process_cmdline", lambda pid: "python -m hermes_cli.main gateway run --replace" - ) assert live_default_gateway_pid() == os.getpid() assert named_profile_served_by_running_multiplexer("coder") is True -@pytest.mark.parametrize( - ("gateway_state", "pid_alive"), [("running", False), ("stopped", True), ("startup_failed", True)] -) -def test_missing_pid_file_still_never_reports_a_dead_gateway( - served_root, monkeypatch, gateway_state, pid_alive -): - """Fail closed: the runtime fallback must not resurrect a dead PID or a stopped/failed record.""" +def test_recycled_pid_does_not_lend_a_stale_record_its_served_profiles(served_root): + """A stale default record whose PID now belongs to an unrelated process (start time differs, command + line is not a gateway's) must not make its ``served_profiles`` authoritative: bare PID existence + once did, so `hermes -p coder gateway start` exited 78 for a multiplexer that was long gone.""" + import subprocess import gateway.status as status - from hermes_cli.gateway_multiplex_served import live_default_gateway_pid - (served_root / "gateway_state.json").write_text(json.dumps({ - "pid": os.getpid(), "hermes_home": str(served_root), "gateway_state": gateway_state, - "served_profiles": ["default", "coder"]})) - (served_root / "gateway.pid").unlink() - if not pid_alive: - monkeypatch.setattr(status, "_pid_exists", lambda pid: False) - assert live_default_gateway_pid() is None - - -def test_missing_pid_file_ignores_a_recycled_pid(served_root, monkeypatch): - """A PID recycled onto a non-gateway process must not lend a stale record an identity: the live - command line decides, so the fallback cannot report a foreign process as the multiplexer.""" - import gateway.status as status - from hermes_cli.gateway_multiplex_served import live_default_gateway_pid - (served_root / "gateway_state.json").write_text(json.dumps({ - "pid": os.getpid(), "hermes_home": str(served_root), "gateway_state": "running", - "served_profiles": ["default", "coder"]})) - (served_root / "gateway.pid").unlink() - monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: "/usr/bin/pytest tests/") - assert live_default_gateway_pid() is None + from hermes_cli.gateway import named_profile_served_by_running_multiplexer + from hermes_cli.gateway_multiplex_served import live_default_gateway_pid, recorded_served_profiles + child = subprocess.Popen(["sleep", "60"]) + try: + stale_start = (status._get_process_start_time(child.pid) or 10**9) - 4242 + for name in ("gateway.pid", "gateway_state.json"): + (served_root / name).write_text(json.dumps({ + "pid": child.pid, "hermes_home": str(served_root), "gateway_state": "running", + "start_time": stale_start, "served_profiles": ["default", "coder"]})) + assert live_default_gateway_pid() is None + assert recorded_served_profiles(served_root) is None + assert named_profile_served_by_running_multiplexer("coder") is False + finally: + child.kill() + child.wait() @pytest.mark.parametrize("verb", ["start", "install", "restart"]) diff --git a/tests/hermes_cli/test_gateway_multiplex_status.py b/tests/hermes_cli/test_gateway_multiplex_status.py index 9000d32447..e0c3ae9225 100644 --- a/tests/hermes_cli/test_gateway_multiplex_status.py +++ b/tests/hermes_cli/test_gateway_multiplex_status.py @@ -15,10 +15,13 @@ import os from contextlib import redirect_stdout from types import SimpleNamespace -import pytest +def _fake_multiplexer(monkeypatch, tmp_path, *, multiplex: bool, pid_file: bool = True): + """A live default gateway at ``tmp_path`` whose runtime record names this process; the process passes + the identity check because its command line reads as a gateway's. ``pid_file=False`` models a + launch-service gateway whose ``gateway.pid`` was unlinked while it kept serving.""" + import json -def _fake_multiplexer(monkeypatch, tmp_path, *, multiplex: bool): import hermes_constants import gateway.status as status @@ -26,10 +29,17 @@ def _fake_multiplexer(monkeypatch, tmp_path, *, multiplex: bool): (tmp_path / "config.yaml").write_text( f"gateway:\n multiplex_profiles: {'true' if multiplex else 'false'}\n" ) - (tmp_path / "gateway.pid").write_text(str(os.getpid())) + if pid_file: + (tmp_path / "gateway.pid").write_text(str(os.getpid())) + (tmp_path / "gateway_state.json").write_text(json.dumps({ + "pid": os.getpid(), "kind": "hermes-gateway", "gateway_state": "running", + "start_time": status._get_process_start_time(os.getpid()), "hermes_home": str(tmp_path), + })) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profiles" / "beta")) monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None) - monkeypatch.setattr(status, "_pid_exists", lambda pid: True) + monkeypatch.setattr( + status, "_read_process_cmdline", lambda pid: "python -m hermes_cli.main gateway run --replace" + ) def _run_status(): @@ -63,63 +73,12 @@ def test_unserved_named_profile_still_reports_stopped(monkeypatch, tmp_path): assert _run_status().startswith("✗ Gateway is not running") -def _fake_launchd_multiplexer( - monkeypatch, tmp_path, *, multiplex: bool = True, gateway_state: str = "running", pid_alive: bool = True -): - """A launch-service-managed default gateway: live process + runtime status record, no gateway.pid. - - The PID file is absent (a replace/cleanup path unlinks it while the process keeps serving); the - process is the live multiplexer the ``gateway_state.json`` record points at. - """ - import json - - import hermes_constants - import gateway.status as status - - (tmp_path / "profiles" / "beta").mkdir(parents=True) - (tmp_path / "config.yaml").write_text( - f"gateway:\n multiplex_profiles: {'true' if multiplex else 'false'}\n" - ) - (tmp_path / "gateway_state.json").write_text(json.dumps({ - "pid": os.getpid(), - "kind": "hermes-gateway", - "gateway_state": gateway_state, - # Same call the production PID-reuse guard makes, so the guard compares like with like. - "start_time": status._get_process_start_time(os.getpid()), - "argv": ["hermes", "gateway", "run", "--replace"], - "hermes_home": str(tmp_path), - })) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profiles" / "beta")) - monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None) - monkeypatch.setattr(status, "_pid_exists", lambda pid: pid_alive) - monkeypatch.setattr( - status, "_read_process_cmdline", lambda pid: "python -m hermes_cli.main gateway run --replace" - ) - - def test_served_named_profile_reports_running_without_default_pid_file(monkeypatch, tmp_path): - """A live multiplexer whose PID file is missing still serves the profile it ticks.""" + """A live multiplexer whose PID file is missing still serves the profile it ticks (#110166).""" from hermes_cli.profiles import list_profiles - _fake_launchd_multiplexer(monkeypatch, tmp_path) + _fake_multiplexer(monkeypatch, tmp_path, multiplex=True, pid_file=False) beta = next(p for p in list_profiles() if p.name == "beta") assert beta.gateway_running is True assert _run_status().startswith("✓ Gateway is running via the default-profile multiplexer") - - -@pytest.mark.parametrize( - ("gateway_state", "pid_alive"), - [("stopped", True), ("startup_failed", True), ("running", False)], -) -def test_not_live_multiplexer_without_default_pid_file_reports_stopped( - monkeypatch, tmp_path, gateway_state, pid_alive -): - """Fails closed: a stopped/failed state or a dead PID must never be reported as running.""" - from hermes_cli.profiles import list_profiles - - _fake_launchd_multiplexer(monkeypatch, tmp_path, gateway_state=gateway_state, pid_alive=pid_alive) - - beta = next(p for p in list_profiles() if p.name == "beta") - assert beta.gateway_running is False - assert _run_status().startswith("✗ Gateway is not running") diff --git a/tests/hermes_cli/test_pooled_served_profile_backend_unscoped.py b/tests/hermes_cli/test_pooled_served_profile_backend_unscoped.py index e3f859428b..8fe8673cbe 100644 --- a/tests/hermes_cli/test_pooled_served_profile_backend_unscoped.py +++ b/tests/hermes_cli/test_pooled_served_profile_backend_unscoped.py @@ -31,6 +31,10 @@ def pooled_served_process(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "alpha")) monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False) import hermes_constants + import gateway.status as status + # Liveness is a verified identity; this pytest process passes as the default gateway only by + # wearing a gateway command line. + monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: "hermes gateway run") monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None) from hermes_cli import profiles as profiles_mod monkeypatch.setattr(profiles_mod, "_check_gateway_running", lambda home: False) diff --git a/tests/hermes_cli/test_served_profile_mirror_platforms.py b/tests/hermes_cli/test_served_profile_mirror_platforms.py index 3344ca667e..192379fd9f 100644 --- a/tests/hermes_cli/test_served_profile_mirror_platforms.py +++ b/tests/hermes_cli/test_served_profile_mirror_platforms.py @@ -31,6 +31,10 @@ def served_root(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(root)) monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False) import hermes_constants + import gateway.status as status + # Liveness is a verified identity; this pytest process passes as the default gateway only by + # wearing a gateway command line. + monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: "hermes gateway run") monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None) return root diff --git a/tests/hermes_cli/test_update_inventory.py b/tests/hermes_cli/test_update_inventory.py index 6550c0c5af..f0f2ee39e5 100644 --- a/tests/hermes_cli/test_update_inventory.py +++ b/tests/hermes_cli/test_update_inventory.py @@ -8,8 +8,9 @@ import pytest import hermes_cli.update_inventory as ui -def _write_state(home: Path, pid: int, sha: str | None = None, version: str | None = None): - record = {"pid": pid} +def _write_state(home: Path, pid: int, sha: str | None = None, version: str | None = None, + gateway_state: str = "running"): + record = {"pid": pid, "gateway_state": gateway_state} if sha: record["code_sha"] = sha if version: @@ -31,6 +32,9 @@ def fleet(monkeypatch, tmp_path): monkeypatch.setattr("hermes_cli.profiles._get_profiles_root", lambda: default_home / "profiles") monkeypatch.setattr("hermes_cli.profiles._PROFILE_ID_RE", re.compile(r"^[a-z0-9][a-z0-9_-]*$"), raising=False) monkeypatch.setattr("gateway.status._pid_exists", lambda pid: pid in (100, 200)) + # A runtime is a VERIFIED gateway identity: live PID whose command line is a gateway's for that home. + monkeypatch.setattr("gateway.status._read_process_cmdline", lambda pid: { + 100: "hermes gateway run", 200: "hermes --profile work gateway run"}.get(pid)) monkeypatch.setattr("hermes_cli.gateway._get_service_pids", lambda all_profiles=False: {100}) monkeypatch.setattr("hermes_cli.gateway.supports_systemd_services", lambda: True) monkeypatch.setattr("hermes_cli.gateway.find_profile_gateway_processes", lambda exclude_pids=None: []) @@ -81,6 +85,16 @@ class TestCollectInventory: plan = ui.collect_runtime_inventory() assert plan.runtimes == [] + def test_stopped_record_with_recycled_pid_is_not_a_runtime(self, fleet, monkeypatch): + """#109680: a ``stopped`` record whose PID an unrelated process now holds must not fabricate a + gateway the restart phase can never touch (that phantom made `hermes update` exit partial).""" + work_home = fleet / "home" / "profiles" / "work" + _write_state(work_home, 200, gateway_state="stopped") + monkeypatch.setattr("gateway.status._read_process_cmdline", lambda pid: { + 100: "hermes gateway run", 200: "C:/Windows/system32/dllhost.exe /Processid:{X}"}.get(pid)) + plan = ui.collect_runtime_inventory() + assert [r.profile for r in plan.runtimes] == ["default"] + def test_pid_file_fallback_covers_unstamped_profiles(self, fleet, monkeypatch): """Gateways with a PID file but no runtime-status record still appear.""" from hermes_cli.gateway import ProfileGatewayProcess diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 24acdae6b9..3c63087fc7 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -775,15 +775,17 @@ class TestWebServerEndpoints: seen = {} def _pid(pid_path=None, **kw): - seen["pid_path"] = pid_path + # The served-profile probe also verifies the DEFAULT home's gateway identity; the + # contract here is that the worker's OWN pid file is what the scoped rung reads. + seen.setdefault("pid_paths", []).append(pid_path) return None def _runtime(path=None): - seen["status_path"] = path + seen.setdefault("status_paths", []).append(path) return None def _runtime_pid(runtime=None, *, expected_home=None): - seen["expected_home"] = expected_home + seen.setdefault("expected_homes", []).append(expected_home) return None monkeypatch.setattr(_gw_status, "get_running_pid_cached", _pid) @@ -795,9 +797,9 @@ class TestWebServerEndpoints: resp = self.client.get("/api/messaging/platforms?profile=worker") assert resp.status_code == 200 - assert seen["pid_path"] == worker_home / "gateway.pid" - assert seen["status_path"] == worker_home / "gateway_state.json" - assert seen["expected_home"] == worker_home + assert worker_home / "gateway.pid" in seen["pid_paths"] + assert worker_home / "gateway_state.json" in seen["status_paths"] + assert worker_home in seen["expected_homes"]