Files
hermes-agent/tools/mcp_stdio_watchdog.py
T

115 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""Parent-death watchdog supervisor for stdio MCP subprocesses.
If Hermes dies hard (kill -9, crash, force-quit) its graceful teardown never
runs and the stdio child plus its descendants are orphaned (macOS has no
``PR_SET_PDEATHSIG``); piled-up orphans then race the legitimate new connection
for the same upstream session. So the MCP command is spawned via this
supervisor, which (1) runs the real command as its own child in a new process
group so the whole tree can be killpg'd, (2) passes stdin/stdout/stderr straight
through — the MCP stdio protocol talks over those pipes, so this must be a
no-op relay, not a proxy — and (3) polls ``getppid()`` against the recorded
parent PID and, once the parent is gone, SIGTERMs the child's group, waits,
then SIGKILLs. Standard-library only so it starts fast and cannot itself leak.
Usage (see ``_wrap_command_with_watchdog``)::
python3 -m tools.mcp_stdio_watchdog \\
--ppid <original_parent_pid> -- <real_command> <arg1> <arg2> ...
"""
from __future__ import annotations
import argparse
import os
import signal
import subprocess
import sys
import threading
import time
_POLL_INTERVAL_S = 2.0
_TERM_GRACE_S = 3.0
def _is_orphaned(original_ppid: int, getppid=os.getppid) -> bool:
"""Return whether this process no longer has its original POSIX parent."""
return getppid() != original_ppid
def _terminate_process_group(proc: subprocess.Popen) -> None:
"""Best-effort SIGTERM-then-SIGKILL of the child's process group; guards the
POSIX-only primitives so an accidental Windows run degrades to a plain child
kill instead of AttributeError."""
killpg = getattr(os, "killpg", None)
if killpg is None: # windows-footgun: ok — non-POSIX fallback
try:
proc.terminate()
proc.wait(timeout=_TERM_GRACE_S)
except (OSError, subprocess.TimeoutExpired):
proc.kill()
return
try:
pgid = os.getpgid(proc.pid)
except (ProcessLookupError, OSError):
return
sigkill = getattr(signal, "SIGKILL", signal.SIGTERM)
for sig in (signal.SIGTERM, sigkill):
try:
killpg(pgid, sig)
except (ProcessLookupError, PermissionError, OSError):
return
try:
proc.wait(timeout=_TERM_GRACE_S)
return
except subprocess.TimeoutExpired:
continue
def _watchdog_loop(proc: subprocess.Popen, original_ppid: int) -> None:
while proc.poll() is None:
if _is_orphaned(original_ppid):
_terminate_process_group(proc)
return
time.sleep(_POLL_INTERVAL_S)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Parent-death watchdog for a stdio MCP subprocess.")
parser.add_argument("--ppid", type=int, required=True)
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args(argv)
real_argv = list(args.command)
if real_argv and real_argv[0] == "--":
real_argv = real_argv[1:]
if not real_argv:
print("mcp_stdio_watchdog: no command given after '--'", file=sys.stderr)
return 2
# New process group: killpg() reaches the whole tree the real command may
# spawn without touching our own group or the original parent's.
proc = subprocess.Popen(real_argv, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, start_new_session=True)
# The server lives in its OWN group, so the parent's shutdown killpg of *our*
# group no longer reaches it: forward SIGTERM/SIGINT to the child's group so
# graceful teardown still kills a wedged server that ignores stdin EOF.
def _forward_shutdown(signum, frame): # noqa: ARG001
_terminate_process_group(proc)
sys.exit(128 + signum)
signal.signal(signal.SIGTERM, _forward_shutdown)
signal.signal(signal.SIGINT, _forward_shutdown)
threading.Thread(target=_watchdog_loop, args=(proc, args.ppid), daemon=True).start()
try:
return proc.wait()
except KeyboardInterrupt:
_terminate_process_group(proc)
return 130
if __name__ == "__main__":
sys.exit(main())