Gateway process detection ps command compatibility issues
Cluster 9069 · hermes cli. Data as of
2 open items. Fix merged in #22860 on 10 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 11 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #22860. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #10569. That is separate from the fix event. - Still open
- 1 issue and 1 PR.
Next step: read #22860, then check whether your report is the one still open.
Read #22860Pick up a Lab task
Multiple issues and fixes addressing incompatibilities with the ps command used for gateway process detection, particularly affecting Docker containers and FreeBSD where BSD-style flags like -ax and eww are not available or behave differently. Proposed solutions include switching to POSIX-compliant flags and/or installing the procps package.
Open issues1
- Issue#9069[Bug]: hermes cron list: Gateway process detection fails on FreeBSD due to `ps eww` output format
Open PRs1
- PR#9396Fix FreeBSD gateway process detection
Fixes on record
-
#10569 fix: use POSIX ps -A instead of BSD -ax for Docker compat
files:
hermes_cli/gateway.pyShow the diff
-
hermes_cli/gateway.py@@ -222,7 +222,7 @@ def _matches_current_profile(command: str) -> bool: current_cmd = "" else: result = subprocess.run( - ["ps", "eww", "-ax", "-o", "pid=,command="], + ["ps", "-A", "eww", "-o", "pid=,command="], capture_output=True, text=True, timeout=10,
-
-
#22860 fix(gateway): detect gateway process via /proc in Docker without procps (salvage #22693)
files:
hermes_cli/gateway.py,tests/hermes_cli/test_gateway.py,tests/hermes_cli/test_gateway_proc_fallback.pyShow the diff
-
hermes_cli/gateway.py@@ -394,42 +394,68 @@ def _matches_current_profile(command: str) -> bool: pass current_cmd = "" else: - result = subprocess.run( - ["ps", "-A", "eww", "-o", "pid=,command="], - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode != 0: - return [] - for line in result.stdout.split("\n"): - stripped = line.strip() - if not stripped or "grep" in stripped: - continue + # Try /proc first (works in Docker without procps installed), + # fall back to ps -A eww. + _found_via_proc = False + if os.path.isdir("/proc"): + try: + my_pid = os.getpid() + for entry in os.listdir("/proc"): + if not entry.isdigit(): + continue + pid = int(entry) + if pid == my_pid or pid in exclude_pids: + continue + try: + cmdline = open(f"/proc/{pid}/cmdline", "rb").read().decode("utf-8", errors="replace") + cmdline = cmdline.replace("\x00", " ") + if any(p in cmdline for p in patterns) and ( + all_profiles or _matches_current_profile(cmdline) + ): + _append_unique_pid(pids, pid, exclude_pids) + except (OSError, PermissionError): + continue + _found_via_proc = True + except Exception: + pass - pid = None - command = "" + if not _found_via_proc: + result = subprocess.run( + ["ps", "-A", "eww", "-o", "pid=,command="], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + return [] + for line in result.stdout.split("\n"): + stripped = line.strip() + if not stripped or "grep" in stripped: + continue - parts = stripped.split(None, 1) - if len(parts) == 2: - try: - pid = int(parts[0]) - command = parts[1] - except ValueError: - pid = None - - if pid is None: - aux_parts = stripped.split() - if len(aux_parts) > 10 and aux_parts[1].isdigit(): - pid = int(aux_parts[1]) - command = " ".join(aux_parts[10:]) - - if pid is None: - continue - if any(pattern in command for pattern in patterns) and ( - all_profiles or _matches_current_profile(command) - ): - _append_unique_pid(pids, pid, exclude_pids) + pid = None + command = "" + + parts = stripped.split(None, 1) + if len(parts) == 2: + try: + pid = int(parts[0]) + command = parts[1] + except ValueError: + pid = None + + if pid is None: + aux_parts = stripped.split() + if len(aux_parts) > 10 and aux_parts[1].isdigit(): + pid = int(aux_parts[1]) + command = " ".join(aux_parts[10:]) + + if pid is None: + continue + if any(pattern in command for pattern in patterns) and ( + all_profiles or _matches_current_profile(command) + ): + _append_unique_pid(pids, pid, exclude_pids) except (OSError, subprocess.TimeoutExpired): return [] -
tests/hermes_cli/test_gateway.py@@ -404,6 +404,15 @@ def test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails(monkey monkeypatch.setattr(gateway, "is_windows", lambda: False) monkeypatch.setattr("gateway.status.get_running_pid", lambda: 321) + # /proc walk is the first path tried (#22693). Force os.listdir on /proc + # to raise so the function falls back to ps, where fake_run takes over. + _real_listdir = gateway.os.listdir + def _no_proc_listdir(path): + if path == "/proc": + raise OSError("test stub: /proc unavailable") + return _real_listdir(path) + monkeypatch.setattr(gateway.os, "listdir", _no_proc_listdir) + def fake_run(cmd, **kwargs): if cmd[:4] == ["ps", "-A", "eww", "-o"]: return SimpleNamespace(returncode=1, stdout="", stderr="ps failed") -
tests/hermes_cli/test_gateway_proc_fallback.py@@ -0,0 +1,138 @@ +"""Tests for /proc-based gateway PID detection in Docker environments. + +Verifies that _scan_gateway_pids() uses /proc/*/cmdline when available +(Docker without procps) and falls back to ps only when /proc is absent. + +See: NousResearch/hermes-agent#7622 +""" + +import os +from unittest.mock import MagicMock, patch + +import hermes_cli.gateway as gateway_mod + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_GATEWAY_CMD = "python -m hermes_cli.main gateway run" +_OTHER_CMD = "python -m some_other_thing" + + +def _fake_proc_dir(entries: dict): + """Return side_effects that simulate /proc: isdir → True, listdir → pids, + open(cmdline) → null-delimited command bytes.""" + def _isdir(path): + return str(path) == "/proc" + + def _listdir(path): + if str(path) == "/proc": + return [str(pid) for pid in entries] + ["self", "version"] + raise FileNotFoundError(path) + + def _open(path, mode="r", **kwargs): + path_str = str(path) + if "/cmdline" in path_str: + pid = int(path_str.split("/proc/")[1].split("/")[0]) + raw = entries.get(pid, "").encode("utf-8").replace(b" ", b"\x00") + m = MagicMock() + m.read.return_value = raw + m.__enter__ = lambda s: s + m.__exit__ = MagicMock(return_value=False) + return m + raise FileNotFoundError(path) + + return _isdir, _listdir, _open + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestProcFallback: + """_scan_gateway_pids reads /proc when available, skips ps.""" + + def test_detects_gateway_pid_via_proc(self): + my_pid = os.getpid() + entries = { + my_pid: "python -m hermes_cli.main", # own process — excluded + 12345: _GATEWAY_CMD, + 99999: _OTHER_CMD, + } + _isdir, _listdir, _open = _fake_proc_dir(entries) + + with ( + patch("hermes_cli.gateway.is_windows", return_value=False), + patch("os.path.isdir", side_effect=_isdir), + patch("os.listdir", side_effect=_listdir), + patch("builtins.open", side_effect=_open), + patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()), + patch("subprocess.run") as mock_ps, + ): + pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True) + + assert 12345 in pids + assert 99999 not in pids + mock_ps.assert_not_called() # ps must NOT be called when /proc worked + + def test_excludes_own_pid_from_proc_scan(self): + my_pid = os.getpid() + entries = {my_pid: _GATEWAY_CMD} + _isdir, _listdir, _open = _fake_proc_dir(entries) + + with ( + patch("hermes_cli.gateway.is_windows", return_value=False), + patch("os.path.isdir", side_effect=_isdir), + patch("os.listdir", side_effect=_listdir), + patch("builtins.open", side_effect=_open), + patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()), + patch("subprocess.run"), + ): + pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True) + + assert my_pid not in pids + + def test_falls_back_to_ps_when_proc_absent(self): + ps_output = f"12345 {_GATEWAY_CMD}\n99999 {_OTHER_CMD}\n" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = ps_output + + with ( + patch("hermes_cli.gateway.is_windows", return_value=False), + patch("os.path.isdir", return_value=False), + patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()), + patch("subprocess.run", return_value=mock_result) as mock_ps, + ): + pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True) + + mock_ps.assert_called_once() + assert 12345 in pids + + def test_proc_permission_error_skips_pid(self): + def _isdir(path): + return str(path) == "/proc" + + def _listdir(path): + if str(path) == "/proc": + return ["12345", "self"] + raise FileNotFoundError + + def _open(path, mode="r", **kwargs): + raise PermissionError("no access") + + with ( + patch("hermes_cli.gateway.is_windows", return_value=False), + patch("os.path.isdir", side_effect=_isdir), + patch("os.listdir", side_effect=_listdir), + patch("builtins.open", side_effect=_open), + patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()), + patch("subprocess.run") as mock_ps, + ): + pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True) + + # PermissionError swallowed — empty result, no crash + assert 12345 not in pids + mock_ps.assert_not_called() # /proc dir existed, so ps not called
-
5 PRs closed without merging
From the close comments: done elsewhere 2 · superseded by another PR 2 · duplicate 1
-
#9100 fix: use ps without environment for gateway scan · superseded by another PR →
#9396,#9069 -
#9762 fix: use POSIX `-e` instead of BSD `-ax` in ps for Docker compatibility · done elsewhere →
#9723,#10569 -
#10186 fix(cli): replace ps -ax with POSIX -e for Docker compatibility · duplicate →
#10815 -
#10806 Fix gateway status detection inside Docker containers · superseded by another PR →
#22860 - #10968 fix(gateway): use ps ww for gateway pid detection · done elsewhere
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.16: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab