MCP stdio servers are spawned via the SDK's stdio_client, which on
Linux uses start_new_session=True (setsid). When a cron job is
cancelled mid-way (timeout, agent finish, exception), the subprocess
often escapes the SDK's teardown and survives as a session leader.
Because setsid() detaches the child from the gateway's process group
/ cgroup tree, systemd does not reap it on service restart either —
so every cron tick that touches an MCP tool leaks a dangling server
process.
Fix:
* tools/mcp_tool.py — _run_stdio now wraps the whole stdio+session
context in try/finally. On any exit path (clean, exception,
cancellation), PIDs still alive are moved from the active
_stdio_pids set into a new _orphan_stdio_pids set. Orphan
detection is done via os.kill(pid, 0) — a cheap liveness probe
that never signals the target.
* tools/mcp_tool.py — _kill_orphaned_mcp_children gains an
include_active=False flag. Default behaviour now only reaps the
orphan set so concurrent sessions (other parallel cron jobs or
live user chats) are never disrupted. The existing shutdown path
passes include_active=True to keep the previous "kill everything"
semantics after the MCP loop is stopped.
* cron/scheduler.py — the cleanup hook is moved from run_job()'s
finally (which would race with parallel siblings after NousResearch#13021)
into tick() after the ThreadPoolExecutor has joined every future.
At that point there are no in-flight sessions from this tick, so
sweeping the orphan set is always safe.
Net effect: zero regression for healthy sessions, and orphan MCP
servers no longer accumulate between gateway restarts.
Made-with: Cursor
What does this PR do?
Fixes a resource leak where MCP stdio subprocesses (e.g.
mempalace.mcp_server, anystdio-configured MCP server) accumulate as orphan session leaders whenever the SDK's subprocess teardown fails — most commonly when a cron job is cancelled, times out, or raises mid-flight.Root cause:
stdio_clientspawns the MCP server child viastart_new_session=True(forkillpg()support), which makes it a session leader (Sslinps,PID == PGID == SID). When the enclosing asyncio task is cancelled or the context exits with an exception, the SDK sometimes fails to reap the child before the async generator finalizes. The surviving subprocess escapes the gateway's process group / cgroup tree, so neither systemd (KillMode=mixedandKillMode=control-groupon soft restarts) nor_stop_mcp_loop()at shutdown can reliably kill it. Every cron tick that touches an MCP tool leaks one more server, each holding ~25–100 MB.Fix — v2 (parallel-safe, rebased on #13021):
Previous iterations of this PR called
_kill_orphaned_mcp_children()fromrun_job()'sfinally:block, clearing the full_stdio_pidsset. That races with:tick()use aThreadPoolExecutor(a job finishing first would kill the MCP servers of its still-running siblings);This revision separates active from orphaned PIDs at the source:
tools/mcp_tool.py—_run_stdionow wraps thestdio_client+ClientSessioncontexts in atry/finally. On any exit path (clean, exception, cancellation) PIDs are removed from_stdio_pids; if a PID is still alive (probed viaos.kill(pid, 0)) it is moved into a new_orphan_stdio_pidsset. Liveness is the signal, not the code path — so we catch cancellation-based leaks without anyexcept CancelledErrorplumbing. Upstream now tracks each stdio child aspid -> server nameand redirects server stderr to the shared log; that stays intact, integrated with thetry/finally+ orphan set.tools/mcp_tool.py—_kill_orphaned_mcp_children(include_active: bool = False). The default now reaps only the orphan set, so concurrent sessions are never disturbed. The existing_stop_mcp_loop()call passesinclude_active=Trueto preserve the previous "kill everything" semantics after the loop has stopped. Reaping uses the same graceful path as upstream (SIGTERM, short wait, SIGKILL) when clearing worklists.cron/scheduler.py— the cleanup sweep moved fromrun_job()intotick(), after theThreadPoolExecutorhas joined every future. At that point no in-flight sessions from this tick can exist, so the sweep is always safe regardless ofcron.max_parallel_jobs.Related Issue
Related: #11202
Type of Change
Changes Made
tools/mcp_tool.py_orphan_stdio_pids: setseparated from active_stdio_pids(active set is adictof pid → server name, per upstream)._run_stdiowrapped intry/finally; on exit any still-alive PID is migrated to the orphan set (liveness probed with signal 0). Keeps upstream stderr log routing (errlog=) in the same block._kill_orphaned_mcp_childrengainsinclude_active: bool = False; shutdown call site updated toinclude_active=True.cron/scheduler.pyrun_job()'sfinally:.tick(), after_results = [f.result() for f in _futures]but before the lock-releasefinally:, so it runs exactly once per tick and never during a sibling job.tests/tools/test_mcp_stability.py—TestStdioPidTrackingtargets_orphan_stdio_pidsfor the default (orphan-only) kill path, matching the v2 contract.How to Test
Reproduction (before fix):
mempalace).ps -eo pid,lstart,cmd | grep mempalace.mcp_serverbefore and after a few ticks — each tick that suffers a cancellation leaves a surviving session-leader.After fix:
HERMES_CRON_MAX_PARALLEL=unbounded(default).psshows at most the actively connected MCP servers; previous ticks' orphans are reaped in the next tick's post-executor sweep. The currently active sessions of concurrent siblings are never touched.Live-run observation on a Fedora/Asahi gateway running 7 cron jobs: before the patch, 6 orphan
mempalace.mcp_serverprocesses had accumulated across 24h (~570 MB RSS). After rebasing to this revision and restarting the gateway, the orphan count stays at 0 across the cron schedule.Checklist
TestStdioPidTrackingfor orphan-only reaping; full suite not run in CI from this environment)TestStdioPidTrackingrun for this change)