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.
This commit is contained in:
@@ -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
|
||||
``<root>/profiles/<name>``, 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]
|
||||
|
||||
76
tests/hermes_cli/test_gateway_scan_home_identity.py
Normal file
76
tests/hermes_cli/test_gateway_scan_home_identity.py
Normal file
@@ -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 <dir>/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)
|
||||
|
||||
Reference in New Issue
Block a user