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
85 changes: 85 additions & 0 deletions gateway/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
import signal
import subprocess
import sys
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from hermes_constants import get_hermes_home
Expand All @@ -40,6 +42,9 @@
# past the JSON payload so runtime status / PID readers can still read the file
# while another process holds the mutual-exclusion lock.
_WINDOWS_LOCK_OFFSET = 1024 * 1024
_GATEWAY_RUNNING_PID_CACHE_TTL_SECONDS = 1.0
_gateway_running_pid_cache_lock = threading.Lock()
_gateway_running_pid_cache: dict[tuple[str, bool, bool], tuple[float, tuple[Any, ...], Optional[int]]] = {}


def _get_pid_path() -> Path:
Expand Down Expand Up @@ -473,6 +478,33 @@ def _pid_from_record(record: Optional[dict[str, Any]]) -> Optional[int]:
return None


def _clear_running_pid_cache() -> None:
with _gateway_running_pid_cache_lock:
_gateway_running_pid_cache.clear()


def _file_cache_signature(path: Path) -> tuple[bool, Optional[int], Optional[int]]:
try:
st = path.stat()
except OSError:
return (False, None, None)
return (True, st.st_mtime_ns, st.st_size)


def _running_pid_cache_signature(
pid_path: Path,
*,
include_runtime_status: bool,
) -> tuple[Any, ...]:
parts: list[Any] = [
_file_cache_signature(pid_path),
_file_cache_signature(_get_gateway_lock_path(pid_path)),
]
if include_runtime_status:
parts.append(_file_cache_signature(_get_runtime_status_path()))
return tuple(parts)


def _cleanup_invalid_pid_path(pid_path: Path, *, cleanup_stale: bool) -> None:
"""Delete a stale gateway PID file (and its sibling lock metadata).

Expand All @@ -485,6 +517,7 @@ def _cleanup_invalid_pid_path(pid_path: Path, *, cleanup_stale: bool) -> None:
"""
if not cleanup_stale:
return
_clear_running_pid_cache()
try:
pid_path.unlink(missing_ok=True)
except Exception:
Expand Down Expand Up @@ -626,6 +659,7 @@ def acquire_gateway_runtime_lock() -> bool:
return False
_write_gateway_lock_record(handle)
_gateway_lock_handle = handle
_clear_running_pid_cache()
return True


Expand All @@ -641,6 +675,7 @@ def release_gateway_runtime_lock() -> None:
handle.close()
except OSError:
pass
_clear_running_pid_cache()


def is_gateway_runtime_lock_active(lock_path: Optional[Path] = None) -> bool:
Expand Down Expand Up @@ -683,6 +718,7 @@ def write_pid_file() -> None:
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(record)
_clear_running_pid_cache()
except Exception:
try:
path.unlink(missing_ok=True)
Expand Down Expand Up @@ -875,6 +911,7 @@ def remove_pid_file() -> None:
# PID file belongs to a different process — leave it alone.
return
path.unlink(missing_ok=True)
_clear_running_pid_cache()
except Exception:
pass

Expand Down Expand Up @@ -1363,6 +1400,54 @@ def get_running_pid(
return None


def get_running_pid_cached(
pid_path: Optional[Path] = None,
*,
cleanup_stale: bool = True,
ttl_seconds: float = _GATEWAY_RUNNING_PID_CACHE_TTL_SECONDS,
) -> Optional[int]:
"""Cached read-side wrapper for dashboard/status polling.

``get_running_pid()`` probes the runtime lock by briefly opening and locking
``gateway.lock``. That is the right authoritative check for control paths,
but high-frequency read-only HTTP polling can call it hundreds of times per
minute. Cache for a short window and invalidate on PID/lock/runtime-status
file changes so status endpoints do not churn file descriptors while still
noticing gateway start/stop transitions quickly.
"""
if ttl_seconds <= 0:
return get_running_pid(pid_path, cleanup_stale=cleanup_stale)

resolved_pid_path = pid_path or _get_pid_path()
include_runtime_status = pid_path is None
signature = _running_pid_cache_signature(
resolved_pid_path,
include_runtime_status=include_runtime_status,
)
key = (str(resolved_pid_path), bool(cleanup_stale), include_runtime_status)
now = time.monotonic()

with _gateway_running_pid_cache_lock:
cached = _gateway_running_pid_cache.get(key)
if cached is not None:
cached_at, cached_signature, cached_pid = cached
if now - cached_at <= ttl_seconds and cached_signature == signature:
return cached_pid

pid = get_running_pid(pid_path, cleanup_stale=cleanup_stale)
refreshed_signature = _running_pid_cache_signature(
resolved_pid_path,
include_runtime_status=include_runtime_status,
)
with _gateway_running_pid_cache_lock:
_gateway_running_pid_cache[key] = (
time.monotonic(),
refreshed_signature,
pid,
)
return pid


def is_gateway_running(
pid_path: Optional[Path] = None,
*,
Expand Down
3 changes: 2 additions & 1 deletion hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
from gateway.status import (
derive_gateway_busy,
derive_gateway_drainable,
get_running_pid_cached,
get_running_pid,
get_runtime_status_running_pid,
parse_active_agents,
Expand Down Expand Up @@ -1931,7 +1932,7 @@ async def get_status(profile: Optional[str] = None):
# Try local PID check first (same-host). If that fails and a remote
# GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the
# dashboard works when the gateway runs in a separate container.
gateway_pid = get_running_pid()
gateway_pid = get_running_pid_cached()
gateway_running = gateway_pid is not None
remote_health_body: dict | None = None

Expand Down
65 changes: 65 additions & 0 deletions tests/gateway/test_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,71 @@ def test_get_running_pid_falls_back_to_no_supervisor_runtime_state(self, tmp_pat

assert status.get_running_pid() == os.getpid()

def test_get_running_pid_cached_reuses_runtime_lock_probe(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
status._clear_running_pid_cache()

pid_path = tmp_path / "gateway.pid"
record = {
"pid": os.getpid(),
"kind": "hermes-gateway",
"argv": ["python", "-m", "hermes_cli.main", "gateway"],
"start_time": 123,
}
pid_path.write_text(json.dumps(record))
(tmp_path / "gateway.lock").write_text(json.dumps(record))

calls = {"lock_active": 0}

def _lock_active(lock_path=None):
calls["lock_active"] += 1
return True

monkeypatch.setattr(status, "is_gateway_runtime_lock_active", _lock_active)
monkeypatch.setattr(status, "_pid_exists", lambda pid: True)
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 123)
monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: None)

assert status.get_running_pid_cached(ttl_seconds=60) == os.getpid()
assert status.get_running_pid_cached(ttl_seconds=60) == os.getpid()
assert calls["lock_active"] == 1

def test_get_running_pid_cached_invalidates_when_pid_file_changes(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
status._clear_running_pid_cache()

pid_path = tmp_path / "gateway.pid"

def _write_record(pid: int, start_time: int) -> None:
record = {
"pid": pid,
"kind": "hermes-gateway",
"argv": ["python", "-m", "hermes_cli.main", "gateway"],
"start_time": start_time,
}
pid_path.write_text(json.dumps(record))
(tmp_path / "gateway.lock").write_text(json.dumps(record))

_write_record(111, 123)

calls = {"lock_active": 0}

def _lock_active(lock_path=None):
calls["lock_active"] += 1
return True

monkeypatch.setattr(status, "is_gateway_runtime_lock_active", _lock_active)
monkeypatch.setattr(status, "_pid_exists", lambda pid: True)
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 123 if pid == 111 else 456)
monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: None)

assert status.get_running_pid_cached(ttl_seconds=60) == 111

_write_record(2222, 456)

assert status.get_running_pid_cached(ttl_seconds=60) == 2222
assert calls["lock_active"] == 2

def test_get_running_pid_cleans_stale_metadata_from_dead_foreign_pid(self, tmp_path, monkeypatch):
"""Stale PID file from a *different* PID (crashed process) must still be cleaned.

Expand Down
44 changes: 30 additions & 14 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,22 @@ def test_get_status(self):
assert "active_sessions" in data
assert data["can_update_hermes"] is True

def test_get_status_uses_cached_gateway_pid_probe(self, monkeypatch):
import hermes_cli.web_server as web_server

calls = {"get_running_pid_cached": 0}

def _cached_pid():
calls["get_running_pid_cached"] += 1
return None

monkeypatch.setattr(web_server, "get_running_pid_cached", _cached_pid)

resp = self.client.get("/api/status")

assert resp.status_code == 200
assert calls["get_running_pid_cached"] == 1

def test_gateway_drain_begin_writes_marker(self):
from gateway import drain_control

Expand Down Expand Up @@ -1277,7 +1293,7 @@ class _GatewayConfig:
def get_connected_platforms(self):
return [_Platform("telegram")]

monkeypatch.setattr(web_server, "get_running_pid", lambda: 1234)
monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(
web_server,
"read_runtime_status",
Expand Down Expand Up @@ -1309,7 +1325,7 @@ class _GatewayConfig:
def get_connected_platforms(self):
return []

monkeypatch.setattr(web_server, "get_running_pid", lambda: None)
monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(
web_server,
"read_runtime_status",
Expand Down Expand Up @@ -4321,7 +4337,7 @@ def test_status_falls_back_to_remote_probe(self, monkeypatch):
"""When local PID check fails and remote probe succeeds, gateway shows running."""
import hermes_cli.web_server as ws

monkeypatch.setattr(ws, "get_running_pid", lambda: None)
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(ws, "read_runtime_status", lambda: None)
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
monkeypatch.setattr(ws, "_probe_gateway_health", lambda: (True, {
Expand All @@ -4343,7 +4359,7 @@ def test_status_remote_probe_not_attempted_when_local_pid_found(self, monkeypatc
"""When local PID check succeeds, the remote probe is never called."""
import hermes_cli.web_server as ws

monkeypatch.setattr(ws, "get_running_pid", lambda: 1234)
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(ws, "read_runtime_status", lambda: {
"gateway_state": "running",
"platforms": {},
Expand All @@ -4366,7 +4382,7 @@ def test_status_remote_probe_not_attempted_when_no_url(self, monkeypatch):
"""When GATEWAY_HEALTH_URL is unset, no probe is attempted."""
import hermes_cli.web_server as ws

monkeypatch.setattr(ws, "get_running_pid", lambda: None)
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(ws, "read_runtime_status", lambda: None)
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", None)

Expand All @@ -4380,7 +4396,7 @@ def test_status_remote_running_null_pid(self, monkeypatch):
"""Remote gateway running but PID not in response — pid should be None."""
import hermes_cli.web_server as ws

monkeypatch.setattr(ws, "get_running_pid", lambda: None)
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(ws, "read_runtime_status", lambda: None)
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
monkeypatch.setattr(ws, "_probe_gateway_health", lambda: (True, {
Expand Down Expand Up @@ -4419,7 +4435,7 @@ def test_busy_when_running_with_active_agents(self, monkeypatch):
"""gateway_busy is True iff running AND active_agents > 0."""
import hermes_cli.web_server as ws

monkeypatch.setattr(ws, "get_running_pid", lambda: 1234)
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(ws, "read_runtime_status", lambda: {
"gateway_state": "running",
"platforms": {},
Expand All @@ -4437,7 +4453,7 @@ def test_idle_running_is_drainable_but_not_busy(self, monkeypatch):
"""A running gateway with zero in-flight turns is drainable, not busy."""
import hermes_cli.web_server as ws

monkeypatch.setattr(ws, "get_running_pid", lambda: 1234)
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(ws, "read_runtime_status", lambda: {
"gateway_state": "running",
"platforms": {},
Expand All @@ -4455,7 +4471,7 @@ def test_draining_state_is_neither_busy_nor_drainable(self, monkeypatch):
gate dominates."""
import hermes_cli.web_server as ws

monkeypatch.setattr(ws, "get_running_pid", lambda: 1234)
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(ws, "read_runtime_status", lambda: {
"gateway_state": "draining",
"platforms": {},
Expand All @@ -4471,7 +4487,7 @@ def test_down_gateway_degrades_to_safe_falsy(self, monkeypatch):
active_agents 0 — never a spurious busy that would wedge NAS."""
import hermes_cli.web_server as ws

monkeypatch.setattr(ws, "get_running_pid", lambda: None)
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(ws, "read_runtime_status", lambda: None)
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", None)

Expand All @@ -4487,9 +4503,9 @@ def test_down_gateway_with_stale_busy_file_still_not_busy(self, monkeypatch):
wins over the file."""
import hermes_cli.web_server as ws

monkeypatch.setattr(ws, "get_running_pid", lambda: None)
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", None)
# File says running with active turns, but get_running_pid()==None and
# File says running with active turns, but get_running_pid_cached()==None and
# get_runtime_status_running_pid finds no live PID → gateway_running False.
monkeypatch.setattr(ws, "get_runtime_status_running_pid", lambda *_a, **_k: None)
monkeypatch.setattr(ws, "read_runtime_status", lambda: {
Expand All @@ -4508,7 +4524,7 @@ def test_restart_drain_timeout_surfaced_and_numeric(self, monkeypatch):
float so NAS can size its poll deadline without out-of-band knowledge."""
import hermes_cli.web_server as ws

monkeypatch.setattr(ws, "get_running_pid", lambda: 1234)
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(ws, "read_runtime_status", lambda: {
"gateway_state": "running",
"platforms": {},
Expand All @@ -4526,7 +4542,7 @@ def test_active_agents_unparseable_in_file_degrades_to_zero(self, monkeypatch):
produce a spurious busy — it degrades to 0/not-busy."""
import hermes_cli.web_server as ws

monkeypatch.setattr(ws, "get_running_pid", lambda: 1234)
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(ws, "read_runtime_status", lambda: {
"gateway_state": "running",
"platforms": {},
Expand Down