refactor(hermes_cli): dashboard_procs.py — shared ps run kwargs, platform kill dispatch, hoist _pid_exists import, compact docstrings
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
"""Dashboard process-hygiene helpers — extracted from ``hermes_cli/main.py``.
|
||||
|
||||
Helpers that STAY in ``hermes_cli.main`` are reached through the lazy ``_m()``
|
||||
reference so monkeypatches on ``hermes_cli.main.<name>`` keep working and
|
||||
imports stay one-way (main.py imports this module, never the reverse).
|
||||
Helpers that STAY in ``hermes_cli.main`` are reached through the lazy ``_m()`` reference so
|
||||
monkeypatches on ``hermes_cli.main.<name>`` keep working and imports stay one-way.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -10,14 +9,15 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Cmdline substrings identifying the long-lived server. ``hermes serve`` is the
|
||||
# same server under the headless name the desktop app spawns; it is reaped on
|
||||
# update for the same frontend/backend-mismatch reason as ``dashboard``.
|
||||
# Cmdline substrings identifying the long-lived server. ``hermes serve`` is the same server
|
||||
# under the headless name the desktop app spawns; it is reaped on update for the same
|
||||
# frontend/backend-mismatch reason as ``dashboard``.
|
||||
_DASHBOARD_PATTERNS = tuple(
|
||||
f"{launcher} {cmd}"
|
||||
for cmd in ("dashboard", "serve")
|
||||
for launcher in ("hermes", "hermes_cli.main", "hermes_cli/main.py")
|
||||
)
|
||||
_PS_RUN_KWARGS = dict(capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def _m():
|
||||
@@ -34,13 +34,11 @@ def _empty_result() -> dict[str, list]:
|
||||
def _scan_dashboard_processes(*, exclude_pids: set[int] | None = None) -> list[tuple[int, str]]:
|
||||
"""Return matching ``dashboard``/``serve`` processes with their cmdlines.
|
||||
|
||||
``hermes update`` swaps files on disk while a forgotten dashboard keeps the
|
||||
old Python backend in memory against the new JS bundle — a silent mismatch
|
||||
(new auth headers → every API call 401s). *exclude_pids* must never be
|
||||
returned: Hermes Desktop sets ``HERMES_DESKTOP_CHILD_PID`` on the backend
|
||||
it spawns so an auto-update never kills the backend it manages itself.
|
||||
|
||||
Returns an empty list on any scan error (missing ps/wmic, timeout, etc.).
|
||||
``hermes update`` swaps files on disk while a forgotten dashboard keeps the old Python
|
||||
backend in memory against the new JS bundle — a silent mismatch (new auth headers → every
|
||||
API call 401s). *exclude_pids* must never be returned: Hermes Desktop sets
|
||||
``HERMES_DESKTOP_CHILD_PID`` on the backend it spawns so an auto-update never kills the
|
||||
backend it manages itself. Empty list on any scan error (missing ps/wmic, timeout, ...).
|
||||
"""
|
||||
self_pid = os.getpid()
|
||||
found: list[tuple[int, str]] = []
|
||||
@@ -51,11 +49,10 @@ def _scan_dashboard_processes(*, exclude_pids: set[int] | None = None) -> list[t
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# errors="ignore": wmic may emit the system code page; a decode
|
||||
# error would leave stdout=None. bounded_probe_run (not run()):
|
||||
# run()'s post-timeout cleanup joins pipe readers unbounded and a
|
||||
# conhost descendant holding duplicated handles wedges it forever.
|
||||
# It also passes CREATE_NO_WINDOW for the pythonw.exe backend.
|
||||
# errors="ignore": wmic may emit the system code page; a decode error would leave
|
||||
# stdout=None. bounded_probe_run (not run()): run()'s post-timeout cleanup joins
|
||||
# pipe readers unbounded and a conhost descendant holding duplicated handles
|
||||
# wedges it forever. It also passes CREATE_NO_WINDOW for the pythonw.exe backend.
|
||||
from hermes_cli._subprocess_compat import bounded_probe_run
|
||||
|
||||
result = bounded_probe_run(
|
||||
@@ -77,12 +74,9 @@ def _scan_dashboard_processes(*, exclude_pids: set[int] | None = None) -> list[t
|
||||
pass
|
||||
else:
|
||||
# ps (not `pgrep -f "hermes.*dashboard"`) keeps us consistent with
|
||||
# gateway._scan_gateway_pids and avoids a greedy regex matching
|
||||
# unrelated cmdlines that merely contain both words.
|
||||
result = subprocess.run(
|
||||
["ps", "-A", "-o", "pid=,command="],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10,
|
||||
)
|
||||
# gateway._scan_gateway_pids and avoids a greedy regex matching unrelated
|
||||
# cmdlines that merely contain both words.
|
||||
result = subprocess.run(["ps", "-A", "-o", "pid=,command="], timeout=10, **_PS_RUN_KWARGS)
|
||||
if result.returncode == 0:
|
||||
for line in getattr(result, "stdout", "").split("\n"):
|
||||
parts = line.strip().split(None, 1)
|
||||
@@ -99,9 +93,9 @@ def _scan_dashboard_processes(*, exclude_pids: set[int] | None = None) -> list[t
|
||||
found = [proc for proc in found if proc[0] not in exclude_pids]
|
||||
|
||||
# Spawn-ledger augmentation: substring patterns miss profiled launches
|
||||
# (`hermes --profile p serve ...`). Every serve/dashboard registers itself
|
||||
# in the spawn ledger with live-verified (pid, create_time) — positive
|
||||
# identity. Add entries the scan missed, preferring the ledger's full argv.
|
||||
# (`hermes --profile p serve ...`). Every serve/dashboard registers itself in the spawn
|
||||
# ledger with live-verified (pid, create_time) — positive identity. Add entries the scan
|
||||
# missed, preferring the ledger's full argv.
|
||||
try:
|
||||
from hermes_cli.process_identity import ledger_entries
|
||||
|
||||
@@ -163,11 +157,10 @@ def _profile_flag_value(argv: list[str]) -> str | None:
|
||||
def _is_ephemeral_port_zero_backend(argv: list[str]) -> bool:
|
||||
"""True for Desktop-style ``serve|dashboard --port 0`` backends.
|
||||
|
||||
Ephemeral-port backends are owned by Hermes Desktop (or are PPID-1 orphans
|
||||
of a prior update respawn). Replaying them after ``hermes update``
|
||||
multiplies listening backends because ``--port 0`` always binds a fresh
|
||||
port. Covers both ``serve`` and the legacy ``dashboard --no-open``
|
||||
fallback older Desktop runtimes use.
|
||||
Ephemeral-port backends are owned by Hermes Desktop (or are PPID-1 orphans of a prior
|
||||
update respawn). Replaying them after ``hermes update`` multiplies listening backends
|
||||
because ``--port 0`` always binds a fresh port. Covers ``serve`` and the legacy
|
||||
``dashboard --no-open`` fallback older Desktop runtimes use.
|
||||
"""
|
||||
if _dashboard_subcommand_index(argv) is None:
|
||||
return False
|
||||
@@ -206,17 +199,16 @@ def _resolved_home(home: str) -> Path:
|
||||
|
||||
|
||||
def _normalized_home_for_compare(home: str) -> str:
|
||||
"""Install-identity key for *home*: symlinked / differently-spelled roots
|
||||
compare equal (same normalization as ``home:`` respawn keys)."""
|
||||
"""Install-identity key for *home*: symlinked / differently-spelled roots compare equal."""
|
||||
return os.path.normcase(str(_resolved_home(home)))
|
||||
|
||||
|
||||
def _profile_key_for_respawn(argv: list[str], hermes_home: str | None = None) -> str:
|
||||
"""Stable owner key: ``HERMES_HOME`` when known, else ``--profile`` / ``-p``.
|
||||
|
||||
``HERMES_HOME`` ending in ``profiles/<name>`` → ``profile:<name>`` so it
|
||||
shares a cap with an explicit ``--profile``; other homes keep a resolved
|
||||
``home:`` key so unrelated installs never collapse together.
|
||||
``HERMES_HOME`` ending in ``profiles/<name>`` → ``profile:<name>`` so it shares a cap with
|
||||
an explicit ``--profile``; other homes keep a resolved ``home:`` key so unrelated installs
|
||||
never collapse together.
|
||||
"""
|
||||
if hermes_home:
|
||||
parts = _resolved_home(hermes_home).parts
|
||||
@@ -233,19 +225,18 @@ def _filter_dashboard_respawn_candidates(
|
||||
) -> list[list[str]]:
|
||||
"""Select which killed manual backends to respawn after ``hermes update``.
|
||||
|
||||
Candidates are ``(pid, argv, hermes_home)``; *own_home* (default
|
||||
``get_hermes_home()``) is a parameter so tests can pin it. Rules:
|
||||
1. Never resurrect Desktop ephemeral ``--port 0`` backends — Desktop owns
|
||||
their lifecycle; they are the PPID-1 orphans that multiplied across updates.
|
||||
2. Never replay a backend from a **foreign** ``HERMES_HOME``: the respawn
|
||||
is argv-only (no ``env=``), so it would come back on the *updating*
|
||||
install's home and steal the foreign install's fixed port, leaving its
|
||||
supervisor to crash-loop on ``EADDRINUSE``. Unreadable (``None``) stays eligible.
|
||||
Candidates are ``(pid, argv, hermes_home)``; *own_home* (default ``get_hermes_home()``)
|
||||
is a parameter so tests can pin it. Rules:
|
||||
1. Never resurrect Desktop ephemeral ``--port 0`` backends — Desktop owns their lifecycle.
|
||||
2. Never replay a backend from a **foreign** ``HERMES_HOME``: the respawn is argv-only
|
||||
(no ``env=``), so it would come back on the *updating* install's home and steal the
|
||||
foreign install's fixed port, leaving its supervisor to crash-loop on ``EADDRINUSE``.
|
||||
Unreadable (``None``) stays eligible.
|
||||
3. Dedupe by normalized cmdline. 4. At most one backend per profile / home.
|
||||
|
||||
Does **not** blanket-skip PPID-1: a prior update respawn detaches with
|
||||
``start_new_session=True``, so fixed-port manual backends sit under init
|
||||
and must stay eligible next update.
|
||||
``start_new_session=True``, so fixed-port manual backends sit under init and must stay
|
||||
eligible next update.
|
||||
"""
|
||||
if own_home is None:
|
||||
try:
|
||||
@@ -277,11 +268,7 @@ def _filter_dashboard_respawn_candidates(
|
||||
|
||||
|
||||
def _exclude_pids_from_env() -> set[int]:
|
||||
"""PIDs Desktop marks as live backends (``HERMES_DESKTOP_CHILD_PID``).
|
||||
|
||||
Desktop may manage several backends (one per active profile) and passes
|
||||
them comma-separated; a lone int still parses for back-compat.
|
||||
"""
|
||||
"""PIDs Desktop marks as live backends (``HERMES_DESKTOP_CHILD_PID``, comma-separated or a lone int)."""
|
||||
out: set[int] = set()
|
||||
for part in os.environ.get("HERMES_DESKTOP_CHILD_PID", "").split(","):
|
||||
part = part.strip()
|
||||
@@ -299,8 +286,8 @@ def _kill_pids_windows(pids: list[int], killed: list[int], failed: list[tuple[in
|
||||
from gateway.status import get_process_start_time
|
||||
from hermes_cli._subprocess_compat import pid_is_hermes, windows_hide_flags
|
||||
|
||||
# Capture identity immediately after discovery: a PID reused before the
|
||||
# destructive action fails the start-time check.
|
||||
# Capture identity immediately after discovery: a PID reused before the destructive
|
||||
# action fails the start-time check.
|
||||
pid_start_times = {pid: get_process_start_time(pid) for pid in pids}
|
||||
for pid in pids:
|
||||
try:
|
||||
@@ -330,6 +317,8 @@ def _kill_pids_posix(pids: list[int], killed: list[int], failed: list[tuple[int,
|
||||
import signal as _signal
|
||||
import time as _time
|
||||
|
||||
from gateway.status import _pid_exists
|
||||
|
||||
def _send(pid: int, sig) -> None:
|
||||
try:
|
||||
os.kill(pid, sig)
|
||||
@@ -348,7 +337,6 @@ def _kill_pids_posix(pids: list[int], killed: list[int], failed: list[tuple[int,
|
||||
while pending and _time.monotonic() < deadline:
|
||||
_time.sleep(0.1)
|
||||
# os.kill(pid, 0) is NOT a no-op on Windows; use the portable check.
|
||||
from gateway.status import _pid_exists
|
||||
alive = [p for p in pending if _pid_exists(p)]
|
||||
killed.extend(p for p in pending if p not in alive)
|
||||
pending = alive
|
||||
@@ -366,22 +354,20 @@ def _kill_stale_dashboard_processes(
|
||||
"""Kill running ``hermes dashboard`` / ``hermes serve`` processes.
|
||||
|
||||
Called at the end of ``hermes update`` (default ``reason``) and from
|
||||
``hermes dashboard --stop``; after an update the running process serves
|
||||
stale Python against a fresh JS bundle. POSIX: SIGTERM, ~3s grace, SIGKILL
|
||||
survivors. Windows: ``taskkill /F``.
|
||||
``hermes dashboard --stop``. POSIX: SIGTERM, ~3s grace, SIGKILL survivors. Windows:
|
||||
``taskkill /F``.
|
||||
|
||||
With ``restart_managed`` (update path only — ``--stop`` never restarts) a
|
||||
detected ``hermes-dashboard.service`` is restarted through systemd, any
|
||||
other killed PID owned by a systemd unit has that unit restarted after the
|
||||
kill (systemd treats our SIGTERM as a clean stop, so ``Restart=on-failure``
|
||||
never fires), and manual PIDs are respawned from their captured argv.
|
||||
*already_restarted_units* (no ``.service`` suffix) were restarted by the
|
||||
With ``restart_managed`` (update path only — ``--stop`` never restarts) a detected
|
||||
``hermes-dashboard.service`` is restarted through systemd, any other killed PID owned by a
|
||||
systemd unit has that unit restarted after the kill (systemd treats our SIGTERM as a clean
|
||||
stop, so ``Restart=on-failure`` never fires), and manual PIDs are respawned from their
|
||||
captured argv. *already_restarted_units* (no ``.service`` suffix) were restarted by the
|
||||
caller already; PIDs they own are left untouched, not killed twice.
|
||||
"""
|
||||
if restart_managed and _m()._restart_managed_dashboard_service(reason):
|
||||
# The dashboard unit is handled but every OTHER backend is not (a host
|
||||
# may also run hermes-serve.service hosting tui_gateway): record the
|
||||
# unit as handled (the filter below drops PIDs it owns) and keep going.
|
||||
# The dashboard unit is handled but every OTHER backend is not (a host may also run
|
||||
# hermes-serve.service hosting tui_gateway): record the unit as handled (the filter
|
||||
# below drops PIDs it owns) and keep going.
|
||||
_dash_unit = getattr(_m(), "_DASHBOARD_SYSTEMD_UNIT", "hermes-dashboard.service")
|
||||
already_restarted_units = set(already_restarted_units or ()) | {
|
||||
str(_dash_unit).removesuffix(".service")
|
||||
@@ -389,17 +375,17 @@ def _kill_stale_dashboard_processes(
|
||||
|
||||
exclude = _exclude_pids_from_env()
|
||||
if restart_managed:
|
||||
# An SSH-owned backend belongs to an attached Desktop client even when
|
||||
# the updater runs from an unrelated shell; killing it strands that
|
||||
# client's fixed SSH port-forward. Same ownership records as the reaper.
|
||||
# An SSH-owned backend belongs to an attached Desktop client even when the updater
|
||||
# runs from an unrelated shell; killing it strands that client's fixed SSH
|
||||
# port-forward. Same ownership records as the reaper.
|
||||
exclude |= _lock_owned_serve_pids()
|
||||
|
||||
pids = _m()._find_stale_dashboard_pids(exclude_pids=exclude or None)
|
||||
if not pids:
|
||||
return _empty_result()
|
||||
|
||||
# Snapshot systemd cgroup/unit and argv BEFORE killing (the cgroup
|
||||
# disappears with the process). Linux + update path only.
|
||||
# Snapshot systemd cgroup/unit and argv BEFORE killing (the cgroup disappears with the
|
||||
# process). Linux + update path only.
|
||||
pid_cgroup: dict[int, str | None] = {}
|
||||
pid_service: dict[int, str | None] = {}
|
||||
pid_cmdline: dict[int, list[str]] = {}
|
||||
@@ -409,8 +395,8 @@ def _kill_stale_dashboard_processes(
|
||||
pid_cgroup[pid] = _m()._get_pid_cgroup_path(pid)
|
||||
pid_service[pid] = _m()._get_systemd_service_for_pid(pid)
|
||||
if not pid_service[pid]:
|
||||
# Manual process: keep exact argv + HERMES_HOME for the
|
||||
# post-update respawn and its per-profile cap.
|
||||
# Manual process: keep exact argv + HERMES_HOME for the post-update respawn
|
||||
# and its per-profile cap.
|
||||
cmdline = _m()._dashboard_cmdline_for_pid(pid)
|
||||
if cmdline:
|
||||
pid_cmdline[pid] = cmdline
|
||||
@@ -428,10 +414,7 @@ def _kill_stale_dashboard_processes(
|
||||
|
||||
killed: list[int] = []
|
||||
failed: list[tuple[int, str]] = []
|
||||
if sys.platform == "win32":
|
||||
_kill_pids_windows(pids, killed, failed)
|
||||
else:
|
||||
_kill_pids_posix(pids, killed, failed)
|
||||
(_kill_pids_windows if sys.platform == "win32" else _kill_pids_posix)(pids, killed, failed)
|
||||
|
||||
for pid in killed:
|
||||
print(f" ✓ stopped PID {pid}")
|
||||
@@ -452,11 +435,10 @@ def _restart_killed_backends(
|
||||
killed: list[int], pid_service: dict[int, str | None], pid_cgroup: dict[int, str | None],
|
||||
pid_cmdline: dict[int, list[str]], pid_home: dict[int, str | None],
|
||||
) -> list[int]:
|
||||
"""Update path: restart systemd-owned units, respawn manual argv.
|
||||
"""Update path: restart systemd-owned units, respawn manual argv. Returns PIDs not brought back.
|
||||
|
||||
Respawns are detached, headless, logged to logs/dashboard-restart.log;
|
||||
Desktop ``--port 0`` backends are filtered out and duplicates collapse to
|
||||
one per profile. Returns the PIDs that were not brought back.
|
||||
Respawns are detached, headless, logged to logs/dashboard-restart.log; Desktop ``--port 0``
|
||||
backends are filtered out and duplicates collapse to one per profile.
|
||||
"""
|
||||
unrecovered: list[int] = []
|
||||
failed_restarts: list[tuple[str, str]] = []
|
||||
@@ -505,17 +487,16 @@ def _detect_concurrent_hermes_instances(
|
||||
) -> list[tuple[int, str]]:
|
||||
"""Find other live processes whose .exe is one of our entry-point shims.
|
||||
|
||||
Windows blocks DELETE/REPLACE on a running .exe (and RENAME when opened
|
||||
without ``FILE_SHARE_DELETE``); Desktop spawns ``hermes.EXE`` as a backend
|
||||
child, so the update's quarantine rename fails with ``[WinError 32]``.
|
||||
Windows blocks DELETE/REPLACE on a running .exe (and RENAME when opened without
|
||||
``FILE_SHARE_DELETE``); Desktop spawns ``hermes.EXE`` as a backend child, so the update's
|
||||
quarantine rename fails with ``[WinError 32]``.
|
||||
|
||||
Returns ``(pid, process_name)`` for processes whose ``exe`` matches a venv
|
||||
shim (``hermes.exe`` / ``hermes-gateway.exe``). Excludes our own PID and
|
||||
every *shim* ancestor: the setuptools launcher is a separate native process
|
||||
from the ``python.exe`` it loads, so otherwise every update reports its own
|
||||
launcher. ``proc.parents()`` (whole chain at once) because a per-hop loop
|
||||
bailed on the first AccessDenied. Only shim ancestors are excluded so a
|
||||
second hermes.exe under a non-Hermes parent (Desktop child) is still flagged.
|
||||
Returns ``(pid, process_name)`` for processes whose ``exe`` matches a venv shim. Excludes
|
||||
our own PID and every *shim* ancestor: the setuptools launcher is a separate native
|
||||
process from the ``python.exe`` it loads, so otherwise every update reports its own
|
||||
launcher. ``proc.parents()`` (whole chain at once) because a per-hop loop bailed on the
|
||||
first AccessDenied. Only shim ancestors are excluded so a second hermes.exe under a
|
||||
non-Hermes parent (Desktop child) is still flagged.
|
||||
|
||||
Empty off-Windows, without psutil, or with no other instances. Never raises.
|
||||
"""
|
||||
@@ -568,10 +549,11 @@ def _detect_concurrent_hermes_instances(
|
||||
|
||||
|
||||
def _is_desktop_local_serve_cmdline(command: str) -> bool:
|
||||
"""True for the Desktop-local serve shape ``hermes serve [--isolated]
|
||||
--host 127.0.0.1 --port 0``. Long-lived headless serves (``--host
|
||||
<tailscale-ip> --port 9119``) must never match — those are operator-managed
|
||||
remote backends that legitimately run with ppid 1 under launchd/nohup."""
|
||||
"""True for the Desktop-local serve shape ``hermes serve [--isolated] --host 127.0.0.1 --port 0``.
|
||||
|
||||
Long-lived headless serves (``--host <tailscale-ip> --port 9119``) must never match —
|
||||
those are operator-managed remote backends that legitimately run with ppid 1.
|
||||
"""
|
||||
cmd = command.lower()
|
||||
if "serve" not in cmd or ("hermes" not in cmd and "hermes_cli" not in cmd):
|
||||
return False
|
||||
@@ -584,15 +566,11 @@ def _is_desktop_local_serve_cmdline(command: str) -> bool:
|
||||
|
||||
|
||||
def _process_ppid(pid: int) -> int | None:
|
||||
"""Best-effort parent pid lookup. None on failure (and always on Windows,
|
||||
where orphan reap is handled by the desktop tree-kill)."""
|
||||
"""Best-effort parent pid; None on failure (and always on Windows, where the desktop tree-kill reaps orphans)."""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
return None
|
||||
result = subprocess.run(
|
||||
["ps", "-o", "ppid=", "-p", str(pid)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5,
|
||||
)
|
||||
result = subprocess.run(["ps", "-o", "ppid=", "-p", str(pid)], timeout=5, **_PS_RUN_KWARGS)
|
||||
if result.returncode != 0 or not result.stdout:
|
||||
return None
|
||||
return int(result.stdout.strip().split()[0])
|
||||
@@ -601,13 +579,12 @@ def _process_ppid(pid: int) -> int | None:
|
||||
|
||||
|
||||
# --- SSH remote-backend lock ownership -------------------------------------
|
||||
# ``backend.lock.json`` is written by the Desktop SSH runtime on the *remote*
|
||||
# host for every ``hermes serve`` it spawns (apps/desktop/electron/
|
||||
# remote-lifecycle.ts). A backend another client/machine started is legitimate
|
||||
# and lock-owned even with no parent here (sshd exited → ppid 1). The reap must
|
||||
# NEVER kill a PID a valid lock claims — that once killed a production backend.
|
||||
# Schema constants mirror the writer; a mismatched record is simply ignored
|
||||
# (the reap only ever *spares*).
|
||||
# ``backend.lock.json`` is written by the Desktop SSH runtime on the *remote* host for every
|
||||
# ``hermes serve`` it spawns (apps/desktop/electron/remote-lifecycle.ts). A backend another
|
||||
# client/machine started is legitimate and lock-owned even with no parent here (sshd exited →
|
||||
# ppid 1). The reap must NEVER kill a PID a valid lock claims — that once killed a production
|
||||
# backend. Schema constants mirror the writer; a mismatched record is simply ignored (the reap
|
||||
# only ever *spares*).
|
||||
_LOCKFILE_SCHEMA_VERSION = 2
|
||||
_PROTOCOL_VERSION = 1
|
||||
_REMOTE_LOCK_SUBDIR = "desktop-ssh"
|
||||
@@ -627,8 +604,8 @@ def _is_hex(value: object, length: int) -> bool:
|
||||
def _valid_lockfile_payload(parsed: object, ownership_id: str) -> bool:
|
||||
"""Validate a parsed ``backend.lock.json`` body, mirroring readLockfile().
|
||||
|
||||
An invalid lock is "no ownership claim", which never causes a kill — the
|
||||
reap only ever *adds* lock-owned PIDs to its spare-set.
|
||||
An invalid lock is "no ownership claim", which never causes a kill — the reap only ever
|
||||
*adds* lock-owned PIDs to its spare-set.
|
||||
"""
|
||||
if (
|
||||
not isinstance(parsed, dict)
|
||||
@@ -650,16 +627,18 @@ def _valid_lockfile_payload(parsed: object, ownership_id: str) -> bool:
|
||||
value = parsed.get(field)
|
||||
if not isinstance(value, str) or len(value) > 1024:
|
||||
return False
|
||||
# logPath is ``{lock_root}/{ownershipId}/{spawnNonce}.log``. Only the
|
||||
# suffix is checked so a relocated HERMES_HOME doesn't falsely reject a
|
||||
# legitimate remote-owned backend (a false reject re-introduces the kill).
|
||||
# logPath is ``{lock_root}/{ownershipId}/{spawnNonce}.log``. Only the suffix is checked so
|
||||
# a relocated HERMES_HOME doesn't falsely reject a legitimate remote-owned backend (a false
|
||||
# reject re-introduces the kill).
|
||||
return parsed["logPath"].endswith(f"/{ownership_id}/{parsed['spawnNonce']}.log")
|
||||
|
||||
|
||||
def _lock_owned_serve_pids(base_dir: Path | None = None) -> set[int]:
|
||||
"""PIDs claimed by valid ``{hermes_home}/desktop-ssh/<ownershipId>/backend.lock.json``
|
||||
records — legitimately owned (incl. SSH backends other clients started) and
|
||||
spared by the reap. Best-effort: a bad record contributes no PID; never raises."""
|
||||
"""PIDs claimed by valid ``{hermes_home}/desktop-ssh/<ownershipId>/backend.lock.json`` records.
|
||||
|
||||
Legitimately owned (incl. SSH backends other clients started) and spared by the reap.
|
||||
Best-effort: a bad record contributes no PID; never raises.
|
||||
"""
|
||||
import json
|
||||
|
||||
root = base_dir if base_dir is not None else _hermes_home_dir() / _REMOTE_LOCK_SUBDIR
|
||||
@@ -695,8 +674,8 @@ def _lock_owned_serve_pids(base_dir: Path | None = None) -> set[int]:
|
||||
return owned
|
||||
|
||||
|
||||
# Grace window before an orphaned-looking backend may be reaped. Covers the
|
||||
# gap between process start and the Desktop client writing backend.lock.json.
|
||||
# Grace window before an orphaned-looking backend may be reaped: covers the gap between
|
||||
# process start and the Desktop client writing backend.lock.json.
|
||||
_REAP_MIN_AGE_SECONDS = 180.0
|
||||
|
||||
|
||||
@@ -715,22 +694,19 @@ def _reap_orphaned_desktop_local_serves(
|
||||
) -> dict[str, list]:
|
||||
"""Kill leftover Desktop-local ``hermes serve`` backends with no parent.
|
||||
|
||||
When Electron dies uncleanly, ``serve --host 127.0.0.1 --port 0`` children
|
||||
get reparented to pid 1 with their MCP trees alive; each Desktop boot then
|
||||
stacks a fresh backend on the corpses until EMFILE. The parent-death
|
||||
watchdog (HERMES_PARENT_PID) prevents *future* orphans; this clears
|
||||
*already* orphaned ones when a new Desktop backend starts.
|
||||
When Electron dies uncleanly, ``serve --host 127.0.0.1 --port 0`` children get reparented
|
||||
to pid 1 with their MCP trees alive; each Desktop boot then stacks a fresh backend on the
|
||||
corpses until EMFILE. The parent-death watchdog (HERMES_PARENT_PID) prevents *future*
|
||||
orphans; this clears *already* orphaned ones when a new Desktop backend starts.
|
||||
|
||||
A candidate is reaped only if ALL hold: Desktop-local shape (never a
|
||||
fixed-port remote serve); ppid 1 (or 0 on some supervisors); not self /
|
||||
parent / a HERMES_DESKTOP_CHILD_PID; not claimed by a valid
|
||||
``backend.lock.json`` (SSH backends other clients started legitimately sit
|
||||
at ppid 1 — killing them is an incident, not cleanup); older than
|
||||
``_REAP_MIN_AGE_SECONDS`` with a determinable age — Desktop writes the lock
|
||||
only after HERMES_BACKEND_READY, so during concurrent multi-profile startup
|
||||
a live sibling is briefly unowned and indistinguishable from a corpse
|
||||
(mutual-reap storm); a real corpse just waits for a later scan.
|
||||
Best-effort; failures never raise to the caller.
|
||||
A candidate is reaped only if ALL hold: Desktop-local shape (never a fixed-port remote
|
||||
serve); ppid 1 (or 0 on some supervisors); not self / parent / a HERMES_DESKTOP_CHILD_PID;
|
||||
not claimed by a valid ``backend.lock.json`` (SSH backends other clients started
|
||||
legitimately sit at ppid 1 — killing them is an incident, not cleanup); older than
|
||||
``_REAP_MIN_AGE_SECONDS`` with a determinable age — Desktop writes the lock only after
|
||||
HERMES_BACKEND_READY, so during concurrent multi-profile startup a live sibling is briefly
|
||||
unowned and indistinguishable from a corpse (mutual-reap storm); a real corpse just waits
|
||||
for a later scan. Best-effort; failures never raise to the caller.
|
||||
"""
|
||||
import signal as _signal
|
||||
import time as _time
|
||||
@@ -795,8 +771,8 @@ def _reap_orphaned_desktop_local_serves(
|
||||
except OSError:
|
||||
failed.append(pid)
|
||||
|
||||
# Brief grace, then SIGKILL survivors. psutil.pid_exists rather than
|
||||
# os.kill(pid, 0), which is a Windows footgun the linter blocks everywhere.
|
||||
# Brief grace, then SIGKILL survivors. psutil.pid_exists rather than os.kill(pid, 0),
|
||||
# which is a Windows footgun the linter blocks everywhere.
|
||||
sleep_fn(1.5)
|
||||
import psutil
|
||||
|
||||
|
||||
Reference in New Issue
Block a user