Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 36 additions & 11 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import asyncio
import os
import re
import shutil
import signal
import subprocess
Expand Down Expand Up @@ -59,6 +60,34 @@ def running(self) -> bool:
def has_process_service_mismatch(self) -> bool:
return self.service_installed and self.running and not self.service_running

def _parse_launchctl_pid(output: str, label: str) -> int | None:
"""Extract a launchd-managed PID from either tabular or plist-style output."""
stripped = output.strip()
if not stripped:
return None

for line in stripped.splitlines():
parts = line.split()
if len(parts) >= 3 and parts[2] == label:
try:
pid = int(parts[0])
except ValueError:
continue
return pid if pid > 0 else None

plist_label = re.search(r'"Label"\s*=\s*"([^"]+)"\s*;', stripped)
if plist_label and plist_label.group(1) != label:
return None

plist_pid = re.search(r'"PID"\s*=\s*(\d+)\s*;', stripped)
if not plist_pid:
return None

pid = int(plist_pid.group(1))
return pid if pid > 0 else None



def _get_service_pids() -> set:
"""Return PIDs currently managed by systemd or launchd gateway services.

Expand Down Expand Up @@ -106,16 +135,9 @@ def _get_service_pids() -> set:
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
# Output: "PID\tStatus\tLabel" header, then one data line
for line in result.stdout.strip().splitlines():
parts = line.split()
if len(parts) >= 3 and parts[2] == label:
try:
pid = int(parts[0])
if pid > 0:
pids.add(pid)
except ValueError:
pass
pid = _parse_launchctl_pid(result.stdout, label)
if pid is not None:
pids.add(pid)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass

Expand Down Expand Up @@ -245,8 +267,11 @@ def _matches_current_profile(command: str) -> bool:
pass
current_cmd = ""
else:
ps_cmd = ["ps", "-A", "-ww", "-o", "pid=,command="] if is_macos() else [
"ps", "-A", "eww", "-o", "pid=,command="
]
result = subprocess.run(
["ps", "-A", "eww", "-o", "pid=,command="],
ps_cmd,
capture_output=True,
text=True,
timeout=10,
Expand Down
3 changes: 2 additions & 1 deletion tests/hermes_cli/test_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,11 @@ def test_install_linux_gateway_from_setup_system_choice_as_root_installs(monkeyp
def test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails(monkeypatch):
monkeypatch.setattr(gateway, "_get_service_pids", lambda: set())
monkeypatch.setattr(gateway, "is_windows", lambda: False)
monkeypatch.setattr(gateway, "is_macos", lambda: True)
monkeypatch.setattr("gateway.status.get_running_pid", lambda: 321)

def fake_run(cmd, **kwargs):
if cmd[:4] == ["ps", "-A", "eww", "-o"]:
if cmd == ["ps", "-A", "-ww", "-o", "pid=,command="]:
return SimpleNamespace(returncode=1, stdout="", stderr="ps failed")
raise AssertionError(f"Unexpected command: {cmd}")

Expand Down
54 changes: 54 additions & 0 deletions tests/hermes_cli/test_update_gateway_restart.py
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,33 @@ def fake_run(cmd, **kwargs):
pids = gateway_cli._get_service_pids()
assert 67890 in pids

def test_returns_launchd_pid_from_plist_style_output(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_linux", lambda: False)
monkeypatch.setattr(gateway_cli, "is_macos", lambda: True)
monkeypatch.setattr(gateway_cli, "get_launchd_label", lambda: "ai.hermes.gateway")

def fake_run(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
if "launchctl" in joined and "list" in joined:
return subprocess.CompletedProcess(
cmd,
0,
stdout=(
"{\n"
"\t\"Label\" = \"ai.hermes.gateway\";\n"
"\t\"PID\" = 7817;\n"
"\t\"LastExitStatus\" = 19200;\n"
"};\n"
),
stderr="",
)
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)

pids = gateway_cli._get_service_pids()
assert 7817 in pids

def test_returns_empty_when_no_services(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_linux", lambda: False)
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
Expand Down Expand Up @@ -820,6 +847,33 @@ def fake_run(cmd, **kwargs):
assert pids == [100]


class TestScanGatewayPids:
def test_uses_macos_safe_ps_args(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
monkeypatch.setattr(gateway_cli, "is_macos", lambda: True)
monkeypatch.setattr("os.getpid", lambda: 999)

seen = []

def fake_run(cmd, **kwargs):
seen.append(cmd)
return subprocess.CompletedProcess(
cmd,
0,
stdout=(
"100 /Users/brenner/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway run --replace\n"
),
stderr="",
)

monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)

pids = gateway_cli._scan_gateway_pids(set())

assert pids == [100]
assert seen == [["ps", "-A", "-ww", "-o", "pid=,command="]]


# ---------------------------------------------------------------------------
# Gateway mode writes exit code before restart (#8300)
# ---------------------------------------------------------------------------
Expand Down
Loading