Skip to content

fix: self-terminate orphaned stdio MCP servers when parent dies - #112

Draft
AhmedHamadto wants to merge 1 commit into
neo4j-labs:mainfrom
AhmedHamadto:fix/mcp-orphan-lifecycle-pr
Draft

fix: self-terminate orphaned stdio MCP servers when parent dies#112
AhmedHamadto wants to merge 1 commit into
neo4j-labs:mainfrom
AhmedHamadto:fix/mcp-orphan-lifecycle-pr

Conversation

@AhmedHamadto

Copy link
Copy Markdown
Contributor

Problem

When an MCP client (Claude Code, Claude Desktop, Cursor, Zed, etc.) crashes or is killed without cleanly closing stdin, the spawned mcp serve --transport stdio process is reparented to PID 1 and keeps holding:

  • a TLS connection to Neo4j
  • file handles
  • ~1.5 GB resident memory per orphan (POLE+O loaded model + driver state)

Multiple orphans accumulating across sessions caused 3+ GB of zombie memory in observed production usage:

PID  RSS    COMMAND
1532 1.65G  python3 .venv/bin/neo4j-agent-memory mcp serve ...  # 29h orphan
44020 1.55G python3 .venv/bin/neo4j-agent-memory mcp serve ...  # 29min orphan
55934  130M python3 .venv/bin/neo4j-agent-memory mcp serve ...  # current session (legitimate)

The MCP stdio protocol assumes the client owns server lifecycle. When the client closes stdin cleanly, the server reads EOF and exits. When the client dies abnormally, no EOF is delivered. The server is blocked on a network recv() to Neo4j and never observes that its parent is gone.

Fix

A lightweight parent-death watchdog (mcp/_lifecycle.py) that polls os.getppid() every 5 s on the stdio transport. When ppid is 1 or 0, the watchdog cancels the server task; cancellation flows through FastMCP's existing lifespan, so the Neo4j driver closes cleanly.

async def run_with_watchdog(server_coro, *, poll_interval=5.0, install_signals=True):
    server_task = asyncio.create_task(_await(server_coro), name="mcp-server")
    watchdog_task = asyncio.create_task(_parent_death_loop(poll_interval), ...)
    # First to complete cancels the other
    done, pending = await asyncio.wait({server_task, watchdog_task}, return_when=FIRST_COMPLETED)
    ...

Behavior

Transport Watchdog
stdio On by default (the bugfix). Disable with --no-parent-death-check; tune cadence with --parent-death-poll-interval.
sse, http Unchanged — these are designed to outlive their starter (Cloud Run, daemonized deploys).
Windows Passthrough — getppid() reparenting semantics differ.

Also installs SIGTERM, SIGHUP, SIGPIPE handlers that route through the same cancel path so graceful kills close the lifespan cleanly.

Tests

  • Unit tests in tests/unit/mcp/test_lifecycle.py (6 active, 1 Windows-skipped) — cover polling loop, exception propagation, server cancellation on orphan, and Windows passthrough. Pass in 0.21 s.

  • Mypy clean on the new module.

  • Runtime-verified: spawned the venv Python under start_new_session=True, exited the parent, observed clean self-termination within ~1.5 s:

    [child 17:12:36] starting run_with_watchdog (poll=0.5s)
    [child 17:12:36] fake_server started; pid=62432 ppid=62431
    [child 17:12:38] MCP parent process exited (ppid=1); initiating self-termination
    [child 17:12:38] MCP server self-terminated: parent process is gone
    [child 17:12:38] run_with_watchdog returned after 1.51s (ppid now=1)
    

Tradeoffs / non-goals

  • 5 s default poll — cheap (one syscall every 5 s); fast enough to prevent orphan accumulation without spamming.
  • Idle-timeout (exit if no JSON-RPC traffic for N s) deferred to a follow-up. Parent-death detection alone solves the actual reported issue.
  • Wrapper-launched servers (e.g. uv run, npx) — the watchdog only sees the wrapper as parent. For neo4j-agent-memory this is fine: clients invoke the venv Python directly. A "watchdog proxy" mode for wrappers is out of scope.
  • No new dependencies.

Backwards compatibility

  • Default behavior change: stdio servers self-terminate on parent death (this is the fix).
  • All other surfaces unchanged.
  • Two new CLI flags (--no-parent-death-check, --parent-death-poll-interval) opt-in only.
  • run_server() API gains two kwargs with defaults; existing callers unaffected.

Test plan

  • Unit tests for parent-death loop, watchdog orchestration, exception propagation
  • Mypy clean on new module
  • Runtime verification with orphaned subprocess
  • Maintainer testing with their preferred MCP client

When an MCP client crashes or is killed without closing stdin cleanly,
the spawned `mcp serve --transport stdio` process is reparented to
PID 1 and keeps holding the Neo4j connection, file handles, and ~1.5GB
of memory indefinitely. Observed in production: multiple orphans
accumulating across sessions consumed 3+ GB resident memory.

This adds a lightweight parent-death watchdog
(`_lifecycle.run_with_watchdog`) that polls `os.getppid()` every 5s
on the stdio transport. When the parent is gone (ppid in {0, 1}), it
cancels the server task; cancellation flows through FastMCP's existing
lifespan, so the Neo4j driver closes cleanly.

Behavior:
- stdio transport: parent-death detection on by default (the bugfix).
  Opt out with `--no-parent-death-check`; tune cadence with
  `--parent-death-poll-interval`.
- sse / http transports: unchanged. They're designed to outlive their
  starter (Cloud Run, daemonized deploys), so the watchdog does not
  apply.
- Windows: passthrough (getppid reparenting semantics differ).

Also installs SIGTERM/SIGHUP/SIGPIPE handlers that route through the
same cancel path so graceful kills close the lifespan cleanly.

Tests:
- Unit tests in `tests/unit/mcp/test_lifecycle.py` cover the polling
  loop, exception propagation, server cancellation on orphan, and
  Windows passthrough.
- Runtime-verified by spawning the venv Python under
  start_new_session=True, exiting the parent, and observing clean
  self-termination within ~1.5s.
@vercel

vercel Bot commented May 3, 2026

Copy link
Copy Markdown

@AhmedHamadto is attempting to deploy a commit to the lyonwj's projects Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented May 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-memory Ready Ready Preview, Comment May 4, 2026 3:57am

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a parent-death watchdog for MCP servers running over stdio to prevent orphaned server processes from lingering after an MCP client crashes, and wires it into both the internal MCP server runner and the Click CLI.

Changes:

  • Introduce mcp/_lifecycle.py with a run_with_watchdog() orchestrator and parent-death polling loop.
  • Enable watchdog by default for stdio transport in mcp/server.py, with new CLI controls for disabling/tuning.
  • Add unit tests covering watchdog orchestration and parent-death polling behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.

File Description
src/neo4j_agent_memory/mcp/_lifecycle.py New watchdog + signal-cancel integration for stdio server lifecycle.
src/neo4j_agent_memory/mcp/server.py Runs stdio transport via watchdog by default; adds argparse flags/kwargs.
src/neo4j_agent_memory/cli/main.py Adds Click flags to control parent-death check and poll interval.
tests/unit/mcp/test_lifecycle.py Adds unit tests for polling loop, cancellation, exception propagation, and Windows passthrough.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +120 to +123
# Server completed (normally or with an error). Propagate exceptions.
for task in done:
if task is watchdog_task:
continue
Comment on lines +69 to +74
async def run_with_watchdog(
server_coro: Awaitable[None],
*,
poll_interval: float = 5.0,
install_signals: bool = True,
) -> None:
Comment on lines +386 to +391
parser.add_argument(
"--parent-death-poll-interval",
type=float,
default=5.0,
help="Seconds between parent-process checks (default: 5.0).",
)
)
@click.option(
"--parent-death-poll-interval",
type=float,
readings = iter([os.getppid(), 0])
monkeypatch.setattr(os, "getppid", lambda: next(readings))
await asyncio.wait_for(_parent_death_loop(0.01), timeout=1.0)

Comment on lines +38 to +47
conditions.
"""
initial_ppid = os.getppid()
logger.debug("MCP parent-death watcher started (initial ppid=%d)", initial_ppid)
while True:
await asyncio.sleep(poll_interval)
ppid = os.getppid()
if ppid in (0, 1):
logger.warning(
"MCP parent process exited (ppid=%d); initiating self-termination",
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants