From 64804eea4013c991563ae6b1bc046f194bea3199 Mon Sep 17 00:00:00 2001 From: MongLong0214 <97578200+MongLong0214@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:38:40 +0900 Subject: [PATCH] fix(gateway): stop the orphan reaper claiming another home's gateway When the Desktop server starts, its lifespan reaps unsupervised gateway orphans. The scan behind it (_scan_gateway_pids) counted a gateway as "ours" whenever its argv named no profile or home, and the exclusion spared only the service manager's own pid. So a Desktop server on a temp or custom home, which a test suite starts, SIGTERMed the native home's launchd-managed gateway. A gateway whose argv names no home got that home from its environment (launchd plist, systemd unit, a test's child), so argv proves nothing about it. The scan now reads the home from argv first, then from the process's readable environment, replaying the profile resolution the target ran at startup (dashboard_procs._hermes_home_for_pid). When the environment cannot be read, only the native default home keeps the old bare-argv claim, so `hermes gateway stop` on the native home still reaches its gateway; a custom or temp home skips it. --- hermes_cli/gateway.py | 38 +++++++--- .../test_gateway_scan_home_identity.py | 76 +++++++++++++++++++ 2 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 tests/hermes_cli/test_gateway_scan_home_identity.py diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 711db052bf..701715b53e 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -579,7 +579,9 @@ def _scan_gateway_pids( hermes_home_assignments, command_line_names_hermes_home, ) - current_home = str(get_hermes_home().resolve()) + from hermes_cli.dashboard_procs import _hermes_home_for_pid, _normalized_home_for_compare + current_home_path = get_hermes_home().resolve() + current_home = str(current_home_path) # Forward slashes on both sides of the HERMES_HOME= match (mirrors gateway.status), and no # trailing separator: the assignments parser strips one, so the systemd ``Environment=`` # spelling (``HERMES_HOME=/root/.hermes/``) compares equal to the resolved home. @@ -587,8 +589,11 @@ def _scan_gateway_pids( current_profile_arg = _profile_arg(current_home) current_profile_name = current_profile_arg.split()[-1] if current_profile_arg else "" current_profile_name_lc = current_profile_name.lower() + # ``_profile_arg`` is "" for ANY root, and get_default_hermes_root() calls a temp or Docker + # HERMES_HOME "the root itself", so only the home owning the bare service name is the default one. + current_home_is_native = not current_profile_name and _home_owns_bare_service_name(current_home_path) - def _matches_current_profile(command: str) -> bool: + def _matches_current_profile(pid: int, command: str) -> bool: command_lc = command.lower().replace("\\", "/") if current_profile_name: # Token equality, not substring: `-p ops` must not claim (or SIGTERM) an `-p ops-2` gateway. @@ -596,20 +601,28 @@ def _scan_gateway_pids( return True return command_line_names_hermes_home(command_lc, current_home_lc) - # Default profile: accept unless argv advertises another profile in any spelling the CLI - # pre-parser accepts (``--profile=ops`` slipped past a substring test, so a default-profile - # fallback stop could SIGTERM the named gateway). HERMES_HOME may come via env (invisible to - # wmic/CIM), so only a non-matching explicit HERMES_HOME= disqualifies. + # Root home: reject argv that advertises another profile in any spelling the CLI pre-parser + # accepts (``--profile=ops`` slipped past a substring test, so a default-profile fallback stop + # could SIGTERM the named gateway) or a HERMES_HOME= naming another home. if profile_flag_value(command_lc) is not None: return False - return (not hermes_home_assignments(command_lc) - or command_line_names_hermes_home(command_lc, current_home_lc)) + if hermes_home_assignments(command_lc): + return command_line_names_hermes_home(command_lc, current_home_lc) + # No home on argv: it came through the environment (launchd plist, systemd unit, a test's + # child), so argv proves nothing. Treating that as "ours" let a temp-home web server's orphan + # reaper SIGTERM the operator's launchd gateway. The process's own environment decides. + owner_home = _hermes_home_for_pid(pid) + if owner_home is not None: + return _normalized_home_for_compare(owner_home) == _normalized_home_for_compare(current_home) + # Unreadable environment (another user, hardened /proc, an elevated process behind the + # wmic/CIM listing): only the native default home keeps the legacy bare-argv claim. + return current_home_is_native def _consider(pid: int, command: str) -> None: matches_runtime = looks_like_gateway_command_line(command) or ( include_restart_managers and looks_like_gateway_runtime_command_line(command) ) - if matches_runtime and (all_profiles or _matches_current_profile(command)): + if matches_runtime and (all_profiles or _matches_current_profile(pid, command)): _append_unique_pid(pids, pid, exclude_pids) try: @@ -2227,6 +2240,11 @@ def _bare_unit_pinned_home() -> Path | None: return None +def _home_owns_bare_service_name(home: Path) -> bool: + """True for the resolved ``home`` that owns the bare service name (see ``_profile_suffix``).""" + return home in _native_service_homes() or home == _bare_unit_pinned_home() + + def _profile_suffix() -> str: """Service-name suffix for HERMES_HOME: "" for a home that owns the bare name, the profile name for ``/profiles/``, else a short hash of the path. @@ -2249,7 +2267,7 @@ def _profile_suffix() -> str: import hashlib from hermes_constants import get_default_hermes_root home = get_hermes_home().resolve() - if home in _native_service_homes() or home == _bare_unit_pinned_home(): + if _home_owns_bare_service_name(home): return "" name = _profile_name_from_home(home, get_default_hermes_root().resolve()) return name or hashlib.sha256(str(home).encode()).hexdigest()[:8] diff --git a/tests/hermes_cli/test_gateway_scan_home_identity.py b/tests/hermes_cli/test_gateway_scan_home_identity.py new file mode 100644 index 0000000000..560311644e --- /dev/null +++ b/tests/hermes_cli/test_gateway_scan_home_identity.py @@ -0,0 +1,76 @@ +"""The gateway pid scan follows a process's real home. + +Regression: a web server started on a temp HERMES_HOME runs the orphan reaper at startup. Its +current-profile scan claimed every ``gateway run`` with no profile flag and no ``HERMES_HOME=`` on +argv, so it SIGTERMed the operator's launchd gateway, whose home arrives through the plist +environment and never appears on argv. + +The test spawns only its own child and reads the process table. Nothing is signalled except that +child, and it exits on its own once its parent is gone. +""" + +import os +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +import hermes_cli.gateway as gateway +from hermes_cli import dashboard_procs + +# Named ``hermes`` so ``python /hermes gateway run`` satisfies the canonical gateway matcher. +_GATEWAY_STUB = """ +import os, time +open(os.environ["STUB_READY"], "w").close() +parent, deadline = os.getppid(), time.monotonic() + 120 +while os.getppid() == parent and time.monotonic() < deadline: + time.sleep(0.1) +""" + +def _gateway_argv(tmp_path: Path) -> list[str]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + script = bin_dir / "hermes" + script.write_text(_GATEWAY_STUB, encoding="utf-8") + return [sys.executable, str(script), "gateway", "run"] + + +def _wait_for(path: Path, proc: subprocess.Popen) -> None: + deadline = time.monotonic() + 15.0 + while not path.exists(): + if proc.poll() is not None or time.monotonic() > deadline: + raise RuntimeError("gateway stub never came up") + time.sleep(0.05) + + +@pytest.mark.spawns_gateway_lookalike +def test_scan_claims_a_bare_gateway_only_for_the_home_its_environment_names(tmp_path, monkeypatch): + home_a, home_b = tmp_path / "home-a", tmp_path / "home-b" + home_a.mkdir() + home_b.mkdir() + ready = tmp_path / "gateway.ready" + monkeypatch.setattr(gateway, "_get_service_pids", lambda all_profiles=False: set()) + proc = subprocess.Popen( + _gateway_argv(tmp_path), + env={**os.environ, "HERMES_HOME": str(home_a), "STUB_READY": str(ready)}, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + # Any other gateway on the host reads as an unreadable environment, so resolving it never + # touches the operator's real home; only the stub's own environment is replayed. + real_home_for_pid = dashboard_procs._hermes_home_for_pid + monkeypatch.setattr( + dashboard_procs, "_hermes_home_for_pid", + lambda pid: real_home_for_pid(pid) if pid == proc.pid else None, + ) + try: + _wait_for(ready, proc) + monkeypatch.setenv("HERMES_HOME", str(home_b)) + assert proc.pid not in gateway.find_gateway_pids() + monkeypatch.setenv("HERMES_HOME", str(home_a)) + assert proc.pid in gateway.find_gateway_pids() + finally: + proc.terminate() + proc.wait(timeout=10) +