"""Gateway runtime status helpers: PID/lock/marker files under ``{HERMES_HOME}`` (one set per home/profile) that tell whether the gateway daemon is running.""" import asyncio import contextlib import copy import hashlib import json import logging import math import os import re import shlex import signal import subprocess import sys import threading import time from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, NamedTuple, Optional from gateway.status_inline_source import ( command_line_runs_inline_source, inline_bootstrap_argv, inline_source_flag_index, ) from hermes_constants import _get_platform_default_hermes_home, get_hermes_home, get_process_hermes_home from hermes_cli._subprocess_compat import pid_exists_stdlib from utils import atomic_json_write if sys.platform == "win32": import msvcrt else: import fcntl _GATEWAY_KIND = "hermes-gateway" _RUNTIME_STATUS_FILE = "gateway_state.json" _LOCKS_DIRNAME = "gateway-locks" _IS_WINDOWS = sys.platform == "win32" _UNSET = object() _GATEWAY_LOCK_FILENAME = "gateway.lock" _gateway_lock_handle = None # Windows byte-range locks are mandatory for other readers: lock a byte well past # the JSON payload so status/PID readers can read while another process holds it. _WINDOWS_LOCK_OFFSET = 1024 * 1024 _GATEWAY_RUNNING_PID_CACHE_TTL_SECONDS = 1.0 _gateway_running_pid_cache_lock = threading.Lock() # key: (pid_path, cleanup_stale, include_runtime_status) -> (cached_at, file signature, pid) _gateway_running_pid_cache: dict[tuple[str, bool, bool], tuple[float, tuple, Optional[int]]] = {} logger = logging.getLogger(__name__) class _RuntimeStatusWriter: """Persist the latest complete status snapshot on one daemon thread. Runtime status is diagnostic state, not an event log. While one write is blocked in filesystem I/O, newer submissions replace the single pending snapshot. This bounds memory and keeps every status producer off asyncio. """ def __init__(self, write_fn: Optional[Callable[[Path, dict[str, Any]], None]] = None): self._write_fn = write_fn self._condition = threading.Condition() self._pending: Optional[tuple[int, Path, dict[str, Any]]] = None self._writing_generation = 0 self._submitted_generation = 0 self._completed_generation = 0 self._successful_generation = 0 self._last_error: Optional[BaseException] = None self._failure_logged = False self._thread: Optional[threading.Thread] = None def submit(self, path: Path, payload: dict[str, Any]) -> int: with self._condition: self._submitted_generation += 1 generation = self._submitted_generation self._pending = (generation, path, copy.deepcopy(payload)) if self._thread is None or not self._thread.is_alive(): self._thread = threading.Thread( target=self._run, daemon=True, name="gateway-runtime-status-writer") self._thread.start() self._condition.notify_all() return generation def wait(self, generation: int, timeout: Optional[float] = None) -> bool: """Block until ``generation`` (or a later snapshot) is persisted; ``False`` on timeout/failure.""" deadline = None if timeout is None else time.monotonic() + max(timeout, 0.0) with self._condition: while (state := self.settled(generation)) is None: if deadline is None: self._condition.wait() continue remaining = deadline - time.monotonic() if remaining <= 0: return False self._condition.wait(timeout=remaining) if not state and timeout is None and self._last_error is not None: raise self._last_error return state def flush(self, timeout: float = 2.0) -> bool: with self._condition: generation = self._submitted_generation return generation == 0 or self.wait(generation, timeout=timeout) def settled(self, generation: int) -> Optional[bool]: """``True`` once ``generation`` persisted, ``False`` once it can no longer, else ``None``.""" with self._condition: if self._successful_generation >= generation: return True no_more_work = ( self._completed_generation >= generation and self._writing_generation == 0 and self._pending is None ) return False if no_more_work else None def _run(self) -> None: while True: with self._condition: while self._pending is None: self._condition.wait() generation, path, payload = self._pending self._pending = None self._writing_generation = generation error: Optional[BaseException] = None try: (self._write_fn or _write_json_file)(path, _merge_over_on_disk(path, payload)) except BaseException as exc: error = exc with self._condition: self._writing_generation = 0 self._completed_generation = max(self._completed_generation, generation) if error is None: self._successful_generation = max(self._successful_generation, generation) self._last_error = None else: self._last_error = error self._condition.notify_all() if error is None: if self._failure_logged: logger.info("Gateway runtime-status persistence recovered") self._failure_logged = False elif not self._failure_logged: logger.warning( "Failed to persist gateway runtime status; later updates will retry: %s", error) self._failure_logged = True else: logger.debug("Failed to persist gateway runtime status: %s", error) _runtime_status_state_lock = threading.RLock() _runtime_status_state_path: Optional[Path] = None _runtime_status_state: Optional[dict[str, Any]] = None def _merge_over_on_disk(path: Path, payload: dict[str, Any]) -> dict[str, Any]: """Lay the canonical snapshot over whatever is on disk right before writing. Out-of-process writers (the migration's compensator clearing multiplex-owned status, container_boot seeding ``desired_state``) stamp this file directly; the gateway's fields win, theirs survive.""" existing = _read_json_file(path) return {**existing, **payload} if isinstance(existing, dict) else payload _runtime_status_writer: Optional[_RuntimeStatusWriter] = None def _get_runtime_status_writer() -> _RuntimeStatusWriter: """Lazily create the single writer; callers serialise on ``_runtime_status_state_lock``.""" global _runtime_status_writer with _runtime_status_state_lock: if _runtime_status_writer is None: _runtime_status_writer = _RuntimeStatusWriter() return _runtime_status_writer def flush_runtime_status(timeout: float = 2.0) -> bool: """Wait boundedly for all runtime-status updates submitted so far.""" writer = _runtime_status_writer return True if writer is None else writer.flush(timeout=timeout) async def flush_runtime_status_async(timeout: float = 2.0) -> bool: """Await the current writer generation without blocking the event loop.""" writer = _runtime_status_writer if writer is None: return True # ``flush()`` returns at its deadline, so the worker thread is bounded. return await asyncio.to_thread(writer.flush, timeout=max(float(timeout), 0.0)) class StormInfo(NamedTuple): """Respawn-storm check result: start count, window, and backoff to sleep.""" count: int window_s: float backoff_s: float def record_start_and_check_storm( max_starts: int = 5, window_s: float = 120.0, *, backoff_cap_s: float = 300.0 ) -> Optional[StormInfo]: """Record this start; :class:`StormInfo` when > ``max_starts`` landed in ``window_s``. Best-effort: a broken ``gateway-starts.log`` ledger is logged and swallowed, never fatal.""" try: path = get_hermes_home() / "gateway-starts.log" path.parent.mkdir(parents=True, exist_ok=True) now = datetime.now(timezone.utc).timestamp() existing: list[float] = [] if path.exists(): for line in path.read_text(encoding="utf-8-sig").splitlines(): line = line.strip() if not line: continue try: existing.append(float(line)) except ValueError: pass # corrupt/truncated ledger line — skip, never fatal existing.append(now) recent = [ts for ts in existing if now - ts <= window_s] # Ring-buffer the persisted file so it stays bounded. to_write = existing[-max(max_starts * 4, 40):] tmp = path.with_suffix(".tmp") tmp.write_text("\n".join(repr(ts) for ts in to_write) + "\n", encoding="utf-8") os.replace(tmp, path) if len(recent) <= max_starts: return None backoff = min(backoff_cap_s, 5.0 * (2 ** min(len(recent) - max_starts, 6))) return StormInfo(count=len(recent), window_s=window_s, backoff_s=backoff) except Exception as _e: logger.debug("respawn-storm breaker bookkeeping failed (non-fatal): %s", _e) return None def _get_process_hermes_home() -> Path: """Launch-home HERMES_HOME for identity files (PID, lock, status, markers): ``get_hermes_home()`` honors the per-session ``_HERMES_HOME_OVERRIDE`` and would misroute them.""" return get_process_hermes_home() def _canonical_hermes_home(path: Path | str) -> Path: """Stable absolute HERMES_HOME path for persisted identity data.""" return Path(path).expanduser().resolve(strict=False) def _same_hermes_home(left: Path | str, right: Path | str) -> bool: """Compare HERMES_HOME paths with the host platform's case semantics.""" left_c = os.path.normcase(str(_canonical_hermes_home(left))) return left_c == os.path.normcase(str(_canonical_hermes_home(right))) def recorded_gateway_home_conflicts( record: Optional[dict[str, Any]], *, expected_home: Optional[Path | str] = None ) -> bool: """True when a persisted gateway record names a DIFFERENT HERMES_HOME (cross-profile kill guard: profile B's stop must never SIGTERM profile A). ``expected_home`` overrides the comparison base. Legacy records without ``hermes_home`` prove nothing -> False; a comparison failure fails closed -> True.""" recorded_home = record.get("hermes_home") if isinstance(record, dict) else None if not isinstance(recorded_home, str) or not recorded_home.strip(): return False try: base = expected_home if expected_home is not None else _get_process_hermes_home() return not _same_hermes_home(recorded_home, base) except Exception: return True # Mirrors hermes_cli.profiles._PROFILE_ID_RE -- duplicated so gateway identity code # stays import-light (hermes_constants + stdlib only). _PROFILE_LABEL_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") def _profile_label_for_home(home: Path | str) -> Optional[str]: """Best-effort label: ``/profiles/`` -> name, root home -> "default", else None.""" try: canonical = _canonical_hermes_home(home) except Exception: return None if canonical.parent.name == "profiles" and _PROFILE_LABEL_RE.match(canonical.name): return canonical.name import hermes_constants default_homes = (hermes_constants.get_default_hermes_root, _get_platform_default_hermes_home) for default_home in default_homes: with contextlib.suppress(Exception): if _same_hermes_home(canonical, default_home()): return "default" return None def scoped_lock_owner_label(record: Optional[dict[str, Any]]) -> Optional[str]: """Profile label of a scoped-lock owner (None: PID-only wording): the validated ``profile`` field stamped by :func:`acquire_scoped_lock`, else inferred from ``hermes_home`` (old locks).""" if not isinstance(record, dict): return None profile = record.get("profile") if isinstance(profile, str) and _PROFILE_LABEL_RE.match(profile.strip()): return profile.strip() home = record.get("hermes_home") return _profile_label_for_home(home) if isinstance(home, str) and home.strip() else None def _get_pid_path() -> Path: return _get_process_hermes_home() / "gateway.pid" def _get_gateway_lock_path(pid_path: Optional[Path] = None) -> Path: return (pid_path or _get_pid_path()).with_name(_GATEWAY_LOCK_FILENAME) def _get_runtime_status_path() -> Path: return _get_process_hermes_home() / _RUNTIME_STATUS_FILE def _get_lock_dir() -> Path: """Cross-profile rendezvous dir for machine-local locks; ``HERMES_GATEWAY_LOCK_DIR`` overrides. Scope is the **OS user**, not the kernel host: separate users have separate ``$HOME``s, separate ``~/.hermes`` profile roots and separate credentials, so "one gateway per host" means "one per host per OS user". Holds the token-scoped locks (:func:`acquire_scoped_lock`) and the host-role lock + rendezvous record (``gateway/host_rendezvous.py``); the per-home ``gateway.pid``/``gateway.lock`` above deliberately stay under each profile's HERMES_HOME. """ override = os.getenv("HERMES_GATEWAY_LOCK_DIR") if override: return Path(override) # XDG spec: a relative $XDG_STATE_HOME is INVALID and must be ignored. Honouring one made the # lock dir CWD-relative, so two serves started from different directories shared no singleton. state_home_env = os.getenv("XDG_STATE_HOME") or "" state_home = Path(state_home_env) if os.path.isabs(state_home_env) else Path.home() / ".local" / "state" return state_home / "hermes" / _LOCKS_DIRNAME def _utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat() # Epochs before 2000-01-01 are corrupt/hand-edited state (e.g. an accidental 0). _EPOCH_MIN_PLAUSIBLE = 946684800.0 # 2000-01-01T00:00:00Z def normalize_updated_at(value: Any) -> Optional[str]: """Coerce a persisted ``updated_at`` (ISO string, legacy epoch, hand edit, garbage) to the RFC3339 ``string | null`` that ``/api/status`` promises. ``str``: iff fromisoformat parses (trailing ``Z`` tolerated; naive -> UTC). Epoch: before 2000-01-01, > 1 day ahead or non-finite -> None. ``bool``/other -> None.""" if isinstance(value, str): raw = value.strip() # Python < 3.11 fromisoformat rejects a trailing 'Z'; tolerate it. if raw.endswith(("Z", "z")): raw = raw[:-1] + "+00:00" try: parsed = datetime.fromisoformat(raw) except ValueError: return None return (parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)).isoformat() if isinstance(value, (int, float)) and not isinstance(value, bool): seconds = float(value) now = datetime.now(timezone.utc).timestamp() if not math.isfinite(seconds) or seconds < _EPOCH_MIN_PLAUSIBLE or seconds > now + 86400: return None try: return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat() except (OverflowError, OSError, ValueError): return None return None # ``exit_reason`` values the out-of-loop watchdogs (gateway/shutdown_watchdog.py) stamp together with # ``gateway_state: degraded`` right before they hard-exit a wedged process (#113372). WATCHDOG_EXIT_REASONS = frozenset({"loop_liveness_watchdog", "shutdown_watchdog"}) def retained_gateway_state(runtime: Any) -> str: """What a NOT-running gateway's retained ``gateway_state.json`` says about it now: ``"startup_failed"`` (or a watchdog-stamped ``"degraded"``) only while the operator still wants it running, else ``"stopped"``. ``hermes gateway stop`` keeps the last ``startup_failed`` + ``exit_reason`` on disk for diagnostics and records the durable stop intent as ``desired_state``; a profile the operator stopped is "stopped", not a current failure. A watchdog exit (``degraded`` + an exit_reason in ``WATCHDOG_EXIT_REASONS``) is the same kind of current failure as ``startup_failed`` and is kept under the same rule, so the dashboard agrees with ``hermes gateway status``. Any other retained state of a dead process (``running``, ``starting``, missing) is just "stopped". Shared by ``/api/status`` and ``/api/messaging/platforms`` so the sidebar strip and the Channels page cannot disagree.""" rt = runtime if isinstance(runtime, dict) else {} if rt.get("desired_state") != "stopped": if rt.get("gateway_state") == "startup_failed": return "startup_failed" if rt.get("gateway_state") == "degraded" and rt.get("exit_reason") in WATCHDOG_EXIT_REASONS: return "degraded" return "stopped" def terminate_pid( pid: int, *, force: bool = False, expected_start_time: Optional[float] = None ) -> None: """Terminate a PID; POSIX SIGTERM/SIGKILL, Windows taskkill /T /F for force. Identity guard: Windows ``force`` REQUIRES a matching ``expected_start_time`` (taskkill on a recycled PID has killed svchost.exe); POSIX optional, but a provided mismatch refuses the kill everywhere. On POSIX an expectation is optional, but when the caller provides one and it no longer matches the live process, the kill is refused on every platform — a mismatched fingerprint always means the PID was recycled. See #89614. """ if force and (_IS_WINDOWS or expected_start_time is not None): if expected_start_time is None: raise OSError(f"refusing to force-kill PID {pid} without a process start-time guard") current_start_time = _get_process_start_time(pid) if current_start_time is None: raise OSError(f"refusing to force-kill PID {pid}; process start time is unavailable") try: if not _start_times_agree(current_start_time, expected_start_time): raise OSError(f"refusing to force-kill PID {pid}; process identity changed") except (TypeError, ValueError) as exc: raise OSError(f"refusing to force-kill PID {pid}; malformed start time") from exc if not (force and _IS_WINDOWS): os.kill(pid, signal.SIGTERM if not force else getattr(signal, "SIGKILL", signal.SIGTERM)) return # Hide flags: a bare taskkill spawn from windowless pythonw.exe would flash a conhost window. from hermes_cli._subprocess_compat import windows_hide_flags try: result = subprocess.run( ["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, creationflags=windows_hide_flags(), ) except FileNotFoundError: os.kill(pid, signal.SIGTERM) return if result.returncode != 0: details = (result.stderr or result.stdout or "").strip() raise OSError(details or f"taskkill failed for PID {pid}") def _start_times_agree(current: Any, *recorded: Any) -> bool: """Same process object: all fingerprints > 0 and within 1ms of ``current``; raises on junk.""" cur = float(current) return cur > 0 and all(r > 0 and abs(r - cur) <= 0.001 for r in map(float, recorded)) # Same-host start-time readings can drift by ~1 s between the claim-time and a later liveness read # (macOS ``kern.boottime`` adjustment, #117505). Both fingerprint scales are ×100 (Linux /proc ticks, # psutil centiseconds), so 200 means 2 s on either platform — a recycled PID is essentially never # that close to the original's start time. START_TIME_DRIFT_TOLERANCE = 200 def start_time_fingerprints_match(recorded: Any, current: Any, tolerance: int = START_TIME_DRIFT_TOLERANCE) -> bool: """Liveness-reconciliation comparator for :func:`get_process_start_time` fingerprints: the recorded owner and the current reading are the same incarnation when they agree within ``tolerance``. Raises on junk; callers decide what an unreadable (``None``) side means.""" return abs(int(current) - int(recorded)) <= tolerance def _scope_hash(identity: str) -> str: return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16] def _get_scope_lock_path(scope: str, identity: str) -> Path: return _get_lock_dir() / f"{scope}-{_scope_hash(identity)}.lock" def _get_process_start_time(pid: int) -> Optional[int]: """Return a stable per-process start-time fingerprint, or None. Used as a PID-reuse guard: a ``(pid, start_time)`` pair uniquely identifies a process, so a recycled PID (same number, different process) yields a different value and is never mistaken for the original. On Linux this is field 22 of ``/proc//stat`` (start time in clock ticks since boot, an int). On platforms without ``/proc`` (macOS, Windows) we fall back to ``psutil.Process(pid).create_time()`` — a float epoch timestamp — quantized to an int (centiseconds) for stable equality. The two sources are never mixed on a single platform: ``/proc`` always succeeds first on Linux, and always fails on macOS/Windows so psutil is always used there. Because the guard only compares the value recorded at spawn against the live value *on the same host*, the differing units across platforms are irrelevant — only same-source equality matters. """ stat_path = Path(f"/proc/{pid}/stat") try: # Field 22 in /proc//stat is process start time (clock ticks). return int(stat_path.read_text(encoding="utf-8").split()[21]) # windows-footgun: ok (/proc is BOM-free) except (FileNotFoundError, IndexError, PermissionError, ValueError, OSError): pass # No /proc (macOS / Windows): psutil is a hard dependency and exposes a # cross-platform creation time. Quantize to centiseconds so repeated reads # of the same process compare equal without float-precision fragility. try: import psutil # type: ignore return int(round(psutil.Process(pid).create_time() * 100)) except Exception: return None def get_process_start_time(pid: int) -> Optional[int]: """Public wrapper for retrieving a process start time when available.""" return _get_process_start_time(pid) def _read_process_cmdline(pid: int) -> Optional[str]: """Process command line as one string: /proc, then psutil, then ``ps``. Order is by cost, and this runs per live gateway on every roster/status poll. ``psutil`` reads the process table in-process (a ``sysctl`` on macOS) where ``ps`` costs a fork+exec — measured 0.02ms against 4.2ms on macOS for the same string. It cannot always answer: on macOS it raises ``AccessDenied`` for a process owned by another user, which ``ps`` still reports, so ``ps`` stays as the fallback rather than being replaced.""" with contextlib.suppress(OSError): raw = Path(f"/proc/{pid}/cmdline").read_bytes() if raw: return raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore").strip() with contextlib.suppress(Exception): import psutil # type: ignore cmdline_parts = psutil.Process(pid).cmdline() if cmdline_parts: return " ".join(cmdline_parts) if not _IS_WINDOWS: with contextlib.suppress(OSError, subprocess.TimeoutExpired): result = subprocess.run( ["ps", "-p", str(pid), "-o", "command="], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, ) if result.returncode == 0 and result.stdout.strip(): return result.stdout.strip() return None def _gateway_command_subcommand(command: str | None) -> str | None: """Hermes gateway lifecycle subcommand from a command line, or None. No loose substring matches (``"gateway" in cmdline`` also matched ``gateway status`` / ``python -m tui_gateway``): needs a Hermes entrypoint plus the ``gateway`` subcommand, or a gateway-dedicated entrypoint. Tokenizes quote-aware (Windows paths with spaces); ``--profile``/``-p`` selectors are stripped anywhere in argv since ``_apply_profile_override`` removes them before argparse.""" if not command: return None try: raw_tokens = shlex.split(command, posix=False) except ValueError: raw_tokens = command.split() # Strip surrounding quotes, normalize slashes + case per token. cased_tokens = [t.strip("\"'").replace("\\", "/") for t in raw_tokens] tokens = [t.lower() for t in cased_tokens] if not tokens: return None basenames = [t.rsplit("/", 1)[-1] for t in tokens] # ``python -c … -m hermes_cli.main gateway run``: the trailing argv belongs to the program # the inline source will spawn later, not to this process (#107002). Case-preserving tokens: # the operand-taking ``-X``/``-W``/``-Q`` must not be conflated with ``-q``/``-b``. if command_line_runs_inline_source(cased_tokens): # …unless the source is a Hermes bootstrap running the entry point in THIS process (store # launcher, launcher script, venv_sync re-entry): then its argv is this process's (#124318). cased_tokens = inline_bootstrap_argv(cased_tokens) if cased_tokens is None: return None tokens = [t.lower() for t in cased_tokens] basenames = [t.rsplit("/", 1)[-1] for t in tokens] # The launchd job's osascript wrapper (gateway_launchd.launchd_program_arguments) carries the gateway argv # inside one JXA script string; the gateway itself is its child and is matched on its own command line. if basenames[0] == "osascript": return None # Gateway-dedicated entrypoints carry no subcommand to inspect. if any(t == "gateway/run.py" or t.endswith("/gateway/run.py") for t in tokens): return "run" # Atomic Hermes' bundled desktop runner shares HERMES_HOME with the CLI; without this, # `gateway run --replace` does not recognise it as a running gateway, skips the # terminate-and-scoped-lock-handoff path, and collides with its still-held scoped locks # (e.g. the Discord bot-token lock). See #22418. if any(b == "desktop-gateway.py" for b in basenames): return "run" if any(b in ("hermes-gateway", "hermes-gateway.exe") for b in basenames): return "run" joined = " ".join(tokens) if "hermes_cli.main" not in joined and "hermes_cli/main.py" not in joined and not any( b in ("hermes", "hermes.exe") for b in basenames ): return None # Drop --profile X / -p X / --profile=X / -p=X (consumes a VALUE of "gateway" too). filtered: list[str] = [] skip_next = False for token in tokens: if skip_next: skip_next = False elif token in ("--profile", "-p"): skip_next = True elif not token.startswith(("--profile=", "-p=")): filtered.append(token) for i, token in enumerate(filtered): if token == "gateway": # Bare `hermes gateway` defaults to `run`. return filtered[i + 1] if i + 1 < len(filtered) else "run" return None def gateway_spawn_intent_subcommand(command: str | None) -> str | None: """Gateway lifecycle subcommand a command line would EVENTUALLY launch, or None. The identity matcher (``_gateway_command_subcommand``) deliberately refuses ``python -c …``: the trailing argv is the inline program's data, not that process's own identity (#107002). Callers that inspect a command line as SPAWN INTENT — "if I launch this, does a gateway runtime eventually appear?" — need the opposite answer, because ``gateway._spawn_gateway_restart_watcher`` hides a real ``… -m hermes_cli.main gateway run`` behind exactly that wrapper. ``tests/_fixtures/live_system_guard.py`` is the canonical caller. Still no substring matching: the wrapper is peeled token-wise and each remaining suffix is handed to the same canonical matcher. """ direct = _gateway_command_subcommand(command) if direct is not None or not command: return direct try: raw_tokens = shlex.split(command, posix=False) except ValueError: raw_tokens = command.split() cased_tokens = [t.strip("\"'").replace("\\", "/") for t in raw_tokens] flag_index = inline_source_flag_index(cased_tokens) if flag_index is None: return None # Skip the interpreter, its options, ``-c`` and the source literal; then try every suffix — # the embedded argv starts at an unknown offset (the watcher prefixes it with the old PID). start = flag_index + 2 for i in range(start, len(cased_tokens)): nested = _gateway_command_subcommand(" ".join(raw_tokens[i:])) if nested is not None: return nested return None def looks_like_gateway_command_line(command: str | None) -> bool: """True only for a real ``gateway run`` process command line.""" return _gateway_command_subcommand(command) == "run" def looks_like_gateway_runtime_command_line(command: str | None) -> bool: """True for command lines that can host the runtime (``run`` or ``restart``: without a service manager the manual restart fallback runs ``run_gateway()`` in-process). For validating Hermes-owned records / cleanup scans only; ``looks_like_gateway_command_line`` stays strict.""" return _gateway_command_subcommand(command) in {"run", "restart"} def _looks_like_gateway_process(pid: int) -> bool: """True when the live PID still looks like the Hermes gateway.""" cmdline = _read_process_cmdline(pid) return bool(cmdline) and looks_like_gateway_command_line(cmdline) def _record_looks_like_gateway(record: dict[str, Any]) -> bool: """Validate gateway identity from PID-file metadata when cmdline is unavailable.""" argv = record.get("argv") if record.get("kind") != _GATEWAY_KIND or not isinstance(argv, list) or not argv: return False return looks_like_gateway_runtime_command_line(" ".join(str(part) for part in argv)) def _profile_name_for_home(profile_home: Path) -> Optional[str]: """Profile id for ``/profiles/``; None for the root/default home (bare gateway).""" return profile_home.name if profile_home.parent.name == "profiles" else None def profile_flag_value(command: str) -> Optional[str]: """The ``-p``/``--profile`` argument of a command line, or None. Token equality is the only safe profile match: a substring test lets ``-p ops`` claim (and ``gateway stop`` SIGTERM) ``-p ops-2``.""" tokens = command.split() for i, tok in enumerate(tokens): if tok.startswith("--profile="): return tok.partition("=")[2] if tok in ("-p", "--profile") and i + 1 < len(tokens): return tokens[i + 1] return None _HERMES_HOME_ASSIGNMENT_RE = re.compile(r"(?:^|\s)hermes_home=(?:\"([^\"]*)\"|'([^']*)'|(\S+))") def hermes_home_assignments(command: str) -> list[str]: """Values of every ``HERMES_HOME=`` assignment in ``command`` (the caller lowercases and normalizes separators). Values are token-bounded, quotes stripped: the substring test this replaces let ``HERMES_HOME=/root/profiles/ops`` claim a ``/root/profiles/ops2`` gateway. The name is token-bounded too (``FOO=hermes_home=/x`` is not an assignment), and a trailing separator on the value is stripped -- ``HERMES_HOME=/root/.hermes/`` (systemd ``Environment=`` or a shell wrapper spelling) is the same home as ``/root/.hermes``; callers strip the profile home the same way.""" return [ next(g for g in m.groups() if g is not None).rstrip("/") for m in _HERMES_HOME_ASSIGNMENT_RE.finditer(command) ] def command_line_names_hermes_home(command_lc: str, home_lc: str) -> bool: """True when ``command_lc`` carries ``HERMES_HOME=`` (both lowercased, ``/``-separated, no trailing separator). Argv reaches us space-joined, so an unquoted value with a space in it (``HERMES_HOME=C:/Users/John Doe/.hermes``) is cut at the space by the token parser; a token-bounded literal match of the whole home recovers that spelling.""" if home_lc in hermes_home_assignments(command_lc): return True return re.search(rf"(?:^|\s)hermes_home={re.escape(home_lc)}/?(?=\s|$)", command_lc) is not None def _command_line_belongs_to_profile(command: str, profile_home: Path) -> bool: """True when a gateway command line belongs to ``profile_home`` (mirrors ``hermes_cli.gateway._matches_current_profile``): a stale state file can record a PID recycled onto ANOTHER profile's live gateway. Named profiles carry ``-p``/``--profile `` or ``HERMES_HOME=`` on argv; the default gateway runs bare. Separators normalized.""" command_lc = command.lower().replace("\\", "/") profile_name = _profile_name_for_home(profile_home) home_lc = str(profile_home).lower().replace("\\", "/").rstrip("/") if profile_name is not None and profile_name != "default": if profile_flag_value(command_lc) == profile_name.lower(): return True return command_line_names_hermes_home(command_lc, home_lc) # Default profile: accept unless argv names ANOTHER profile (any spelling the CLI pre-parser # accepts, ``--profile=ops`` included -- a substring test let that gateway pass as the default's) # or a conflicting explicit HERMES_HOME= (its absence is not disqualifying -- HERMES_HOME usually # arrives via the env). ``--profile default`` names this profile: a hand-written launchd plist # mirrors the named-profile service shape, and rejecting it reported a live default gateway # as stopped (#100817). if profile_flag_value(command_lc) not in (None, "default"): return False return not hermes_home_assignments(command_lc) or command_line_names_hermes_home(command_lc, home_lc) def _host_gateway_serves_home(pid: int, profile_home: Path) -> bool: """Does the ONE host gateway — PID ``pid`` — serve ``profile_home``'s profile? Argv cannot answer this: the host singleton runs ONE home's (usually bare/default) command line while multiplexing every profile, so :func:`_command_line_belongs_to_profile` rejects every secondary and the profile reads as "not running" while its messages are being served. The live served set is the only proof; the argv rule stays as the fallback when no record exists. """ try: from gateway.host_attach import host_gateway, profile_name_for_home owner = host_gateway() except Exception: return False return owner is not None and owner.pid == pid and owner.serves(profile_name_for_home(profile_home)) def _record_matches_live_gateway_pid( record: dict[str, Any], pid: int, *, expected_home: Optional[Path] = None ) -> bool: """True when a live PID still identifies as this gateway record. The live command line wins (a stale record's argv must not make a recycled PID count as a gateway; with ``expected_home`` it must also belong to that profile — or serve it as the host multiplexer); unreadable cmdline (Windows/EACCES) -> persisted record.""" live_cmdline = _read_process_cmdline(pid) if not live_cmdline: return _record_looks_like_gateway(record) if not looks_like_gateway_runtime_command_line(live_cmdline): return False if expected_home is not None and _host_gateway_serves_home(pid, expected_home): return True return expected_home is None or _command_line_belongs_to_profile(live_cmdline, expected_home) def _record_argv() -> list[str]: """``sys.argv`` with the inline-source placeholder replaced by the entry point this process runs. The published launcher script (``_launchers._launcher_script``: the POSIX ``bin/hermes`` shell launcher and the Windows ``.cmd``) imports ``hermes_cli.main`` inside ``python -I -c