Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user