Skip to content
15 changes: 15 additions & 0 deletions gateway/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,21 @@ def looks_like_gateway_runtime_command_line(command: str | None) -> bool:
state. Keep the public ``looks_like_gateway_command_line()`` strict, and
use this broader matcher only when validating Hermes-owned runtime records
or no-supervisor cleanup scans.

Verified launch shapes (all must keep working):
- ``python -m hermes_cli.main gateway run|restart`` (venv shim / CLI)
- ``python gateway/run.py ...`` or an absolute path ending in it
- ``hermes-gateway[.exe]`` standalone entrypoints
- ``--profile``/``-p`` selectors on either side of the subcommand
(stripped before matching)

Deliberately NOT extended with a fallback signal (e.g. "venv exe +
ancestor argv contains gateway"): this matcher is also used to validate
that a candidate process is Hermes-owned, and widening it risks
classifying an unrelated venv process as the gateway. If a future launch
shape needs to be recognised, add it to ``_gateway_command_subcommand``
and extend the test matrix in
``tests/hermes_cli/test_update_venv_launcher_ancestor_gating.py``.
"""
return _gateway_command_subcommand(command) in {"run", "restart"}

Expand Down
49 changes: 39 additions & 10 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,10 +506,17 @@ def _scan_gateway_pids(
a live gateway when the PID file is stale/missing, and ``--all`` sweeps can
discover gateways outside the current profile.
"""
# Exclude the entire ancestor chain so the CLI process that invoked this
# scan (e.g. ``hermes gateway status``) is never mistaken for a running
# gateway. See #13242.
exclude_pids = exclude_pids | _get_ancestor_pids()
# Ancestors are suppressed so the CLI process that invoked this scan (e.g.
# ``hermes gateway status``) is never mistaken for a running gateway (see
# #13242) -- but NOT unconditionally. ``hermes update --gateway`` is
# spawned BY the gateway when ``/update`` is issued from a messaging
# platform, which puts a real ``gateway run`` in our own ancestor chain.
# Excluding it hid the gateway from the update pause machinery, so the
# update never paused it and then aborted on the venv-holder guard with
# "Other Hermes processes are running from this install's venv" (#87594).
# Held separately from ``exclude_pids`` (which the CALLER owns and means
# unconditionally) and consulted with the command line in hand below.
ancestor_pids = _get_ancestor_pids()
pids: list[int] = []
# Strict command-line matcher shared with gateway.status: requires the
# actual ``gateway run`` subcommand (or the dedicated entrypoints), so this
Expand Down Expand Up @@ -558,6 +565,20 @@ def _matches_gateway_runtime(command: str) -> bool:
return True
return include_restart_managers and looks_like_gateway_runtime_command_line(command)

def _suppressed_as_ancestor(pid: int, command: str) -> bool:
"""True when ``pid`` is our own ancestor and is not a gateway runtime.

The #13242 exclusion exists to keep the invoking CLI (``hermes gateway
status``, ``hermes update``) from being counted as a gateway. Those
command lines are not gateway runtimes, so gating the exclusion on the
matcher preserves that intent exactly while leaving a real ``gateway
run`` parent visible -- which is what the update pause path needs when
the gateway is the process that spawned it.
"""
if pid not in ancestor_pids:
return False
return not looks_like_gateway_runtime_command_line(command)

try:
if is_windows():
# Prefer wmic when present (fast, stable output format). On
Expand Down Expand Up @@ -626,9 +647,13 @@ def _matches_gateway_runtime(command: str) -> bool:
all_profiles or _matches_current_profile(current_cmd)
):
try:
_append_unique_pid(pids, int(pid_str), exclude_pids)
scanned_pid = int(pid_str)
except ValueError:
pass
scanned_pid = None
if scanned_pid is not None and not _suppressed_as_ancestor(
scanned_pid, current_cmd
):
_append_unique_pid(pids, scanned_pid, exclude_pids)
current_cmd = ""
else:
# Try /proc first (works in Docker without procps installed),
Expand All @@ -647,8 +672,10 @@ def _matches_gateway_runtime(command: str) -> bool:
with open(f"/proc/{pid}/cmdline", "rb") as _f:
cmdline = _f.read().decode("utf-8", errors="replace")
cmdline = cmdline.replace("\x00", " ")
if _matches_gateway_runtime(cmdline) and (
all_profiles or _matches_current_profile(cmdline)
if (
_matches_gateway_runtime(cmdline)
and (all_profiles or _matches_current_profile(cmdline))
and not _suppressed_as_ancestor(pid, cmdline)
):
_append_unique_pid(pids, pid, exclude_pids)
except (OSError, PermissionError):
Expand Down Expand Up @@ -696,8 +723,10 @@ def _matches_gateway_runtime(command: str) -> bool:

if pid is None:
continue
if _matches_gateway_runtime(command) and (
all_profiles or _matches_current_profile(command)
if (
_matches_gateway_runtime(command)
and (all_profiles or _matches_current_profile(command))
and not _suppressed_as_ancestor(pid, command)
):
_append_unique_pid(pids, pid, exclude_pids)
except (OSError, subprocess.TimeoutExpired):
Expand Down
53 changes: 49 additions & 4 deletions hermes_cli/update_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -3909,12 +3909,57 @@ def _venv_launcher_ancestors(pids: list[int]) -> list[int]:

# Never return ourselves or our own ancestry: a CLI ``hermes update``
# runs from the venv python and would otherwise nominate itself.
# Exception (mirrors PR #87608 / #87594): an ancestor that is ITSELF a
# running gateway (command line matches ``gateway run``) is a real
# venv-holder, not a false positive — when the update was spawned by the
# gateway (e.g. /update from a messaging platform), the gateway launcher
# sits in this process's ancestry, and skipping it leaves the launcher
# locking venv .pyd files so the update still aborts on the venv-holder
# guard. Only skip ancestors that are NOT gateways.
Comment on lines +3912 to +3918
skip: set[int] = {os.getpid()}
# Narrow the try to the fragile pieces (import + walking the ancestry)
# instead of wrapping the whole loop: per-ancestor cmdline() errors are
# already handled by the inner try (anc_cmdline = ""), and a fallback
# that re-walks the ancestry would silently duplicate the loop. If the
# matcher cannot be imported we degrade to the previous behaviour —
# skip everything — and log why.
try:
for anc in psutil.Process().parents():
skip.add(int(anc.pid))
except Exception:
pass
from gateway.status import looks_like_gateway_runtime_command_line
ancestors = psutil.Process().parents()
except Exception as exc:
# Fallback to the previous behaviour: skip the whole ancestry.
# Log it: without this, an update that trips the venv-holder guard
# (silently) never shows *why* — the gateway launcher got hidden by
# the very fallback this fix was meant to remove.
logger.warning("ancestor gating unavailable (%s); skipping whole ancestry", exc)
try:
for anc in psutil.Process().parents():
skip.add(int(anc.pid))
except Exception:
pass
ancestors = []

for anc in ancestors:
anc_pid = int(anc.pid)
try:
# Keep quotes when re-serializing: the matcher tokenizes via
# shlex, so a path containing spaces (e.g. "C:\Program Files\...")
# must stay quoted or it would be split and fail to match.
# NOTE: `os.name == "nt"` is unreachable here — the function
# already returned [] unless _is_windows(). The branch is
# deliberately kept so the re-serialization stays correct if
# this helper is ever lifted out of the Windows guard.
raw = anc.cmdline() or []
anc_cmdline = (
subprocess.list2cmdline(raw) if os.name == "nt" else " ".join(raw)
)
except Exception:
anc_cmdline = ""
if anc_cmdline and looks_like_gateway_runtime_command_line(anc_cmdline):
# A gateway ancestor is the very process we want to pause —
# keep it out of the skip set so the launcher is found below.
continue
skip.add(anc_pid)

found: list[int] = []
for pid in pids:
Expand Down
45 changes: 45 additions & 0 deletions tests/hermes_cli/test_gateway_proc_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,51 @@ def _open(path, mode="r", **kwargs):
mock_ps.assert_not_called() # /proc dir existed, so ps not called


# ---------------------------------------------------------------------------
# Windows wmic branch: ancestor suppression gated on the gateway matcher
# (#87594 / #87608). The /proc tests above are Linux-only; these run anywhere.
# (Reuses the _GATEWAY_CMD / _OTHER_CMD constants defined at module top.)
# ---------------------------------------------------------------------------


def _wmic_result(entries: dict):
"""Return a subprocess.run()-shaped result with WMIC LIST-format output."""
out = ""
for pid, cmd in entries.items():
out += f"CommandLine={cmd}\r\nProcessId={pid}\r\n\r\n"
return MagicMock(returncode=0, stdout=out, stderr="")


class TestWindowsAncestorSuppression:
"""_suppressed_as_ancestor: an ancestor that IS a gateway runtime is kept
visible (so `hermes update --gateway` can pause it) instead of being
dropped by the old blanket ancestor exclusion (#13242 changed by #87594)."""

def _run_scan(self, monkeypatch, entries: dict, ancestor_pids: set):
monkeypatch.setattr(gateway_mod, "is_windows", lambda: True)
monkeypatch.setattr(gateway_mod, "is_macos", lambda: False)
monkeypatch.setattr(
gateway_mod, "_get_ancestor_pids", lambda: set(ancestor_pids)
)
with (
patch("shutil.which", return_value=r"C:\Windows\System32\wbem\wmic.exe"),
patch(
"hermes_cli._subprocess_compat.bounded_probe_run",
return_value=_wmic_result(entries),
),
):
return gateway_mod._scan_gateway_pids(set(), all_profiles=True)

def test_gateway_runtime_ancestor_is_kept(self, monkeypatch):
"""The gateway launcher sits in our ancestry with a ``gateway run``
cmdline; it must still be returned (the update pause path needs it)."""
entries = {
100: _GATEWAY_CMD, # gateway runtime AND our ancestor
200: _GATEWAY_CMD, # gateway runtime, not an ancestor
}
pids = self._run_scan(monkeypatch, entries, ancestor_pids={100})
assert 100 in pids, "gateway-runtime ancestor must not be suppressed"
assert 200 in pids
class TestPsFallbackBsdCompat:
"""Verify the ps fallback command uses BSD/macOS-compatible flags.

Expand Down
Loading