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:
MongLong0214
2026-09-25 00:38:40 +09:00
committed by Teknium
parent 5dc05faf79
commit 64804eea40
2 changed files with 104 additions and 10 deletions

View File

@@ -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]

View 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)