Skip to content

fix(gateway): correct macOS gateway-pid detection (#15225) - #15318

Closed
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/macos-gateway-pid-detection
Closed

fix(gateway): correct macOS gateway-pid detection (#15225)#15318
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/macos-gateway-pid-detection

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes `#15225`. `hermes cron list` falsely reports "Gateway is not running" on macOS even when launchd has the service loaded and cron jobs fire correctly.

Two independent bugs in `hermes_cli/gateway.py` each drop the macOS PID-detection path into an empty-result state. Fixing either alone clears the warning; both are real and both are worth fixing — the detector is the source for several other views (`cron list` warning, secondary `gateway status` check, `hermes update`'s broad sweep) and each should be resilient.

Bug 1 — `_get_service_pids()` mis-parses `launchctl list `

macOS `launchctl list` has two output formats:

Invocation Format
`launchctl list` (no label) tab-separated table: `PID\tStatus\tLabel`
`launchctl list ` plist-dict dump: `"PID" = 855;`, `"Label" = "ai.hermes.gateway";`, …

The old code always called `launchctl list ` but parsed with `string.split()` expecting the tab-separated format. On a real macOS install that means `parts[2]` on the label line is `'"ai.hermes.gateway";'` (quoted, semicolon'd) — so `parts[2] == label` never matched and no PID was ever extracted, even though the service was actively running.

Fix: extracted a `_parse_launchd_list_output(stdout, label)` helper that tries the plist-dict `"PID" = N;` regex first and falls back to the tab-separated path when no plist matches are found. Handles both formats so a future change to the caller can't silently re-break detection.

  • Regex anchored to `"PID"` key — sibling fields like `LastExitStatus` can't match.
  • PID 0 rejected — downstream `os.kill(0, …)` would affect the whole process group.
  • Whitespace-tolerant (`"PID" =`, `"PID"=`, `"PID" = `, leading tabs all match) — launchd's plist dumper is not a stable format across Apple releases.

Bug 2 — `_scan_gateway_pids()` passes `eww` to `ps`

Old: `["ps", "-A", "eww", "-o", "pid=,command="]`

Fix: replaced with `["ps", "-A", "-ww", "-o", "pid=,command="]` — portable across Linux (procps), Darwin, FreeBSD, busybox. Drops env-var leakage as a side benefit.

Related Issue

Fixes #15225

Related: #9069 (same function, different FreeBSD failure mode), #9723 (same function, Docker, fixed via `c483b4c` for that narrower case).

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✅ Tests (adding or improving test coverage)

Test plan

  • 19 new tests in `tests/hermes_cli/test_gateway_pid_detection_macos.py` — all green on py3.11 venv
  • 1 pre-existing test in `test_gateway.py` updated to match the portable `-ww` invocation (explicit `# Note: since [Bug]: hermes cron list falsely reports "Gateway is not running" on macOS (two-stage detection failure in find_gateway_pids) #15225` comment for future readers)
  • Full `tests/hermes_cli/test_gateway.py` suite still green (21 tests)
  • Verified regression guards: temporarily reverted Bug 1 and Bug 2 independently; the relevant test classes correctly failed with clear messages pointing at the regressed invariant. Restored fix → all 39 tests green.

Test coverage detail

`TestParseLaunchdListOutput` (8 cases) — exercises the new helper directly:

`TestGetServicePidsMacOS` (3 cases) — end-to-end macOS branch with mocked `subprocess.run`:

`TestPsInvocationPortability` (4 cases) — captures exact argv:

  • `"eww"` never on the command line (the core regression guard)
  • pins `["ps", "-A", "-ww", "-o", "pid=,command="]` shape
  • parses realistic Darwin-style ps output end-to-end, extracts gateway PID
  • nonzero returncode path returns `[]` without crashing

Not in scope

  • A broader refactor to pass the launchctl plist through a proper plist parser rather than a regex — the regex is narrow, anchored, and tolerant; bigger tooling is unnecessary for two well-defined keys.
  • Fixing the `gateway status` secondary check that shares this helper — it already reports correctly via a different code path (`launchctl list` in `gateway/status.py`). The detector fix clears the warning everywhere as a side effect.

Copilot AI review requested due to automatic review settings April 24, 2026 20:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes macOS gateway PID detection so hermes cron list (and other callers of find_gateway_pids()) no longer incorrectly warns that the Gateway isn’t running when it is managed by launchd.

Changes:

  • Add a launchd output parser that correctly handles both launchctl list output shapes (plist-dict vs tabular).
  • Replace the non-portable ps ... eww ... invocation with a portable ps -A -ww ... form.
  • Add targeted regression tests for the macOS launchctl parsing and the portable ps invocation (and update an existing test expectation).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
hermes_cli/gateway.py Fixes launchctl parsing via a new helper and updates the ps invocation for portability/security.
tests/hermes_cli/test_gateway_pid_detection_macos.py Adds regression tests covering both launchctl output formats and the corrected ps argv.
tests/hermes_cli/test_gateway.py Updates an existing test to expect the new portable ps invocation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread hermes_cli/gateway.py Outdated
Comment on lines +93 to +95
pids: set = set()
if not stdout:
return pids

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The local variable annotation uses an unparameterized set. Since this set is intended to contain integer PIDs, consider annotating it as set[int] for consistency with other PID collections in this module.

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +40
from types import SimpleNamespace
from unittest.mock import patch

import pytest

from hermes_cli import gateway

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

patch is imported but not used in this test module. Removing the unused import will keep the file clean and avoid unused-import warnings in environments that run linting or stricter test settings.

Copilot uses AI. Check for mistakes.
Comment thread hermes_cli/gateway.py Outdated
Comment on lines +72 to +73
def _parse_launchd_list_output(stdout: str, label: str) -> set:
"""Extract PIDs for ``label`` from ``launchctl list`` output.

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type annotations here are less specific than the rest of this module: the helper returns a set of PIDs, so the return type should be set[int] (consistent with e.g. seen: set[int] = set() in this file) to improve readability and static analysis.

Copilot uses AI. Check for mistakes.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery labels Apr 24, 2026
@briandevans

Copy link
Copy Markdown
Contributor Author

Thanks @copilot — all three nits addressed in 0344959d:

  1. _parse_launchd_list_output return type → tightened to set[int] (also the local pids annotation), matching the seen: set[int] = set() style already used elsewhere in this module.
  2. _get_service_pids return type → same parameterization; that was actually pre-existing code on origin/main, but worth tightening as a drive-by since you flagged it on the diff.
  3. Unused patch import → removed. The tests rely on monkeypatch exclusively, so the unittest.mock.patch import was dead.

39/39 tests still pass locally — no behaviour change.

briandevans and others added 2 commits April 30, 2026 08:18
``hermes cron list`` falsely reports "Gateway is not running" on macOS
even when launchd has the service loaded and cron jobs fire correctly.
Two independent bugs in ``find_gateway_pids()`` each drop macOS into
an empty-result path; fixing either alone would clear the warning, but
both are real and both are worth fixing.

### Bug 1 — ``_get_service_pids()`` mis-parses ``launchctl list <label>``

``launchctl list`` returns two different formats depending on whether
a label is passed:

* No label → tab-separated table ``PID\\tStatus\\tLabel``
* With label → plist-dict dump, e.g. ``"PID" = 855;``

The old code always called ``launchctl list <label>`` (the plist-dict
path) but parsed with ``string.split()`` expecting the tab-separated
format.  ``parts[2]`` on ``"Label" = "ai.hermes.gateway";`` is
``'"ai.hermes.gateway";'`` — quoted, semicolon'd — so the ``== label``
comparison never matched and no PID was ever extracted.

Fix: extracted a ``_parse_launchd_list_output(stdout, label)`` helper
that tries the plist-dict ``"PID" = N;`` regex first and falls back to
the tab-separated path when no plist matches were found.  Handles both
formats so a future change to the caller (passing or not passing the
label) can't re-break detection.  Regex anchored to the ``"PID"`` key
so sibling fields like ``LastExitStatus`` can't match; PID 0 rejected
so downstream ``os.kill(0, ...)`` never sees it.

### Bug 2 — ``_scan_gateway_pids()`` passes ``eww`` to ``ps``

The old invocation ``ps -A eww -o pid=,command=`` has two problems:

* **Darwin** rejects ``eww`` as "illegal argument" and exits 1 —
  ``stdout`` is empty, the parse loop iterates nothing, and no PID is
  extracted (NousResearch#15225).
* **FreeBSD** accepts ``eww`` but the ``e`` attaches environment
  variables to the command column, so ``split(None, 1)`` picks up the
  first env var as the command (NousResearch#9069).  The env vars can include API
  keys and tokens, which also leaked them into any log line that
  echoed the command.

Fix: replaced with ``ps -A -ww -o pid=,command=`` — portable across
Linux (procps), Darwin, FreeBSD, and busybox.  Drops env-var leakage
as a side benefit.

### Tests (19 new + 1 updated, all passing)

``tests/hermes_cli/test_gateway_pid_detection_macos.py``:

* **``TestParseLaunchdListOutput``** (8 cases) — exercises the new
  helper directly with the exact plist-dict output from the NousResearch#15225
  repro, the tab-separated format, the dash-PID unloaded case,
  mixed-shape defensive input, PID=0 rejection, and 4
  whitespace-variant tolerance cases for the PID regex (``"PID" =``,
  ``"PID"=``, ``"PID"  =  ``, leading tabs).

* **``TestGetServicePidsMacOS``** (3 cases) — end-to-end macOS branch
  with ``subprocess.run`` patched to return the plist-dict payload;
  asserts the single-PID set is returned, a non-zero launchctl exit
  returns empty, and a missing ``launchctl`` binary (FileNotFoundError)
  falls through cleanly.

* **``TestPsInvocationPortability``** (4 cases) — captures the exact
  argv the production code passes to ``ps``, asserts ``"eww"`` is
  never on the command line, pins the ``["ps", "-A", "-ww", "-o",
  "pid=,command="]`` shape, parses a realistic Darwin-style ps sample
  end-to-end, and exercises the nonzero-returncode fallback.

Also updated ``test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails``
in ``tests/hermes_cli/test_gateway.py`` to match the new portable
``-ww`` invocation; added an inline comment pointing at NousResearch#15225 history.

**Verified tests are real regression guards**: temporarily reverted
Bug 1 and Bug 2 independently; the relevant test classes correctly
failed with clear messages pointing at the regressed invariant.

Closes NousResearch#15225

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three minor cleanups from Copilot's review on NousResearch#15318:

1. ``_parse_launchd_list_output`` return type: ``set`` → ``set[int]``
   (matches the ``seen: set[int] = set()`` style elsewhere in the
   module).
2. ``_get_service_pids`` return type + local: same parameterization;
   was bare ``set`` before this branch existed, but Copilot flagged
   the pre-existing annotation while reviewing the diff so worth
   tightening as a drive-by.
3. ``unittest.mock.patch`` was imported in
   ``test_gateway_pid_detection_macos.py`` but never called — the
   tests use ``monkeypatch`` exclusively.  Removed the unused import.

No behaviour change.  39/39 tests still pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@briandevans
briandevans force-pushed the fix/macos-gateway-pid-detection branch from 0344959 to 7555352 Compare April 30, 2026 15:18
@briandevans

Copy link
Copy Markdown
Contributor Author

Closing to keep the queue clean — 17 days idle and now conflicting on hermes_cli/gateway.py. Happy to reopen if this is still useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: hermes cron list falsely reports "Gateway is not running" on macOS (two-stage detection failure in find_gateway_pids)

3 participants