Files
aiturk-hermes-ide/tools/mcp_death_supervisor.py
T

217 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""One parent-death supervisor per Hermes process, shared by all stdio MCP servers.
Why this exists
---------------
When Hermes dies without running its cleanup path (SIGKILL, OOM killer, a hard
crash), stdio MCP servers it spawned are reparented to init and keep running
forever. macOS has no ``PR_SET_PDEATHSIG``, so something has to outlive Hermes
and reap them.
This module is deliberately standard-library-only and must not import anything
from ``tools/``: it runs after Hermes may already be dead, and pulling in
``mcp_tool`` would drag the whole agent with it. The TERM -> grace -> KILL
``killpg`` sweep in ``_reap`` therefore duplicates similar sweeps elsewhere in
the tree on purpose.
The predecessor (``mcp_stdio_watchdog.py``) solved this with one CPython
*per MCP server*, wrapping each server command and polling ``getppid()`` every
two seconds. That costs ~10 MB of resident memory per server and detects death
up to one poll interval late. This module replaces the whole fleet of pollers
with a single supervisor per Hermes process:
* **Death detection is a blocking read on a pipe.** Hermes holds the only write
end. When Hermes dies -- by any means, including SIGKILL -- the write end
closes and the read returns EOF. Exact, instant, and free.
* **Servers are spawned unwrapped.** The MCP SDK already spawns stdio children
with ``start_new_session=True``, so each one is its own process-group leader
and ``killpg`` still reaches its descendants. Removing the wrapper also
removes the signal-forwarding layer the wrapper needed to avoid inverting the
bug it fixed.
Protocol (line-based, on stdin)
-------------------------------
register <pgid>\n start reaping this process group on parent death
unregister <pgid>\n stop reaping it (its server shut down cleanly)
On EOF the supervisor SIGTERMs every still-registered process group, waits a
short grace period, SIGKILLs the survivors, and exits. A registered group that
Hermes never unregistered *is* the orphan set, so a clean Hermes shutdown --
which unregisters as it tears each server down -- ends with nothing to kill.
Unparseable lines are ignored rather than fatal: a corrupted byte on the control
pipe must not cost us the reaping guarantee for every other server.
Residual risk: process-group reuse
----------------------------------
We reap by pgid, so a registration is only as meaningful as the group's
identity. A group we deliberately keep registered -- an orphan that teardown
failed to kill, such as the ``node`` ``mcp-remote`` leaves behind -- can
eventually exit on its own, after which the kernel is free to hand that pgid to
an unrelated process owned by the same user. If Hermes then dies ungracefully
while the registration is still stale, we would signal a stranger.
``_is_safe_target`` cannot catch this: the value is stale, not invalid.
Two things narrow the window. Hermes prunes registrations whose group has no
members left (``_prune_dead_supervised_pgids``) on every registration change,
and the orphan sweep unregisters whatever it reaps. Neither closes it -- a
group can die and its pgid be recycled between two probes -- so the exposure is
real but bounded to that gap, and requires an ungraceful death inside it.
Closing it completely means proving group identity at reap time, e.g. stamping
MCP children with a boot-unique env marker and checking that some member still
carries it before signalling. That was judged not worth putting a ``ps`` parse
into the one process whose job is to stay simple enough to always work; it is
the obvious next step if this class of bug ever actually bites. Note the same
exposure already exists in Hermes's own killpg-based orphan cleanup, which this
module did not introduce (see upstream issue #88350).
"""
from __future__ import annotations
import argparse
import os
import signal
import sys
import time
# Matches the grace period the per-server watchdog used before it escalated.
_TERM_GRACE_S = 3.0
# How often we re-check for survivors during that grace period.
_REAP_POLL_S = 0.1
# A command is "unregister <pgid>" -- around 20 characters. The cap only has to
# be generous enough for a legitimate line; see _serve for why it exists.
_MAX_LINE_CHARS = 256
def _is_safe_target(pgid: int, *, own_pgid: int, parent_pgid: int) -> bool:
"""Return True if ``pgid`` is a process group we may signal.
Defensive only -- Hermes already filters non-MCP children before it
registers anything (see ``_filter_mcp_children`` in ``tools/mcp_tool.py``).
But this process signals whole process *groups*, so a bad value here is
unusually expensive: ``killpg(0, ...)`` signals our own group, and pgid 1
is init. A caller bug should cost us one unreaped server, never the
Hermes process tree or the session.
"""
if pgid <= 1:
return False
if pgid == own_pgid or pgid == parent_pgid:
return False
return True
def _reap(pgids: set[int]) -> None:
"""SIGTERM every group, then SIGKILL whatever is still alive.
Every process-group call below is POSIX-only by construction: this whole
module only ever runs as a child of ``_update_death_supervisor``, which
returns early unless ``os.name == "posix"``, so the supervisor is never
spawned on Windows in the first place.
"""
if not pgids:
return
alive = set()
for pgid in pgids:
try:
os.killpg(pgid, signal.SIGTERM) # windows-footgun: ok — POSIX-only process
alive.add(pgid)
except (ProcessLookupError, PermissionError, OSError):
# Already gone, or not ours to signal. Either way, nothing to reap.
pass
deadline = time.monotonic() + _TERM_GRACE_S
while alive and time.monotonic() < deadline:
time.sleep(_REAP_POLL_S)
for pgid in list(alive):
try:
# Signal 0 probes liveness: succeeds iff some member survives.
os.killpg(pgid, 0) # windows-footgun: ok — POSIX-only process
except (ProcessLookupError, PermissionError, OSError):
alive.discard(pgid)
for pgid in alive:
try:
os.killpg(pgid, signal.SIGKILL) # windows-footgun: ok — POSIX-only
except (ProcessLookupError, PermissionError, OSError):
pass
def _serve(stream, *, own_pgid: int, parent_pgid: int) -> set[int]:
"""Read control lines until EOF; return the groups still registered.
Reads are length-capped rather than newline-terminated. Iterating the
stream instead lets a writer that never sends a newline grow this process
without bound -- feeding it ``/dev/zero`` reached 15 GB before it was
stopped. Nothing in Hermes can produce that today, but this process is the
last line of defense against leaked servers, so it must not be the thing
that dies under memory pressure. A line truncated by the cap fails to parse
and is skipped; the remainder resyncs at the next newline.
"""
registered: set[int] = set()
while True:
line = stream.readline(_MAX_LINE_CHARS)
if not line:
break # EOF: the parent is gone.
if not line.endswith("\n"):
# Truncated by the cap, or an unterminated tail at EOF. Either way
# it is not a command we are willing to act on.
continue
parts = line.split()
if len(parts) != 2:
continue
verb, raw = parts
try:
pgid = int(raw)
except ValueError:
continue
if verb == "register":
if _is_safe_target(pgid, own_pgid=own_pgid, parent_pgid=parent_pgid):
registered.add(pgid)
elif verb == "unregister":
registered.discard(pgid)
return registered
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description="Reap registered process groups when the parent dies."
)
parser.add_argument(
"--parent-pgid",
type=int,
required=True,
help="Process group of the spawning Hermes process; never signalled.",
)
args = parser.parse_args(argv)
# The parent may be torn down with killpg on its own group. We are spawned
# with start_new_session=True precisely so that sweep cannot take us with
# it before we have reaped -- assert that here rather than trust the caller.
own_pgid = os.getpgid(0)
if own_pgid == args.parent_pgid:
print(
"mcp_death_supervisor: refusing to run inside the parent's process "
"group (a killpg of the parent would kill us before we can reap)",
file=sys.stderr,
)
return 2
# A dying parent's SIGINT/SIGHUP must not preempt the reap; the pipe's EOF
# is our only shutdown signal. SIGHUP is POSIX-only, which is fine here --
# this process is never spawned on Windows (see _reap's docstring).
for sig in (signal.SIGINT, signal.SIGHUP): # windows-footgun: ok — POSIX-only process
try:
signal.signal(sig, signal.SIG_IGN)
except (ValueError, OSError):
pass
registered = _serve(sys.stdin, own_pgid=own_pgid, parent_pgid=args.parent_pgid)
_reap(registered)
return 0
if __name__ == "__main__":
sys.exit(main())