fix: self-terminate orphaned stdio MCP servers when parent dies - #112
Draft
AhmedHamadto wants to merge 1 commit into
Draft
fix: self-terminate orphaned stdio MCP servers when parent dies#112AhmedHamadto wants to merge 1 commit into
AhmedHamadto wants to merge 1 commit into
Conversation
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.
|
@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. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
There was a problem hiding this comment.
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.pywith arun_with_watchdog()orchestrator and parent-death polling loop. - Enable watchdog by default for
stdiotransport inmcp/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", |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 stdioprocess is reparented to PID 1 and keeps holding:Multiple orphans accumulating across sessions caused 3+ GB of zombie memory in observed production usage:
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 pollsos.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.Behavior
stdio--no-parent-death-check; tune cadence with--parent-death-poll-interval.sse,httpgetppid()reparenting semantics differ.Also installs
SIGTERM,SIGHUP,SIGPIPEhandlers 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:Tradeoffs / non-goals
uv run,npx) — the watchdog only sees the wrapper as parent. Forneo4j-agent-memorythis is fine: clients invoke the venv Python directly. A "watchdog proxy" mode for wrappers is out of scope.Backwards compatibility
--no-parent-death-check,--parent-death-poll-interval) opt-in only.run_server()API gains two kwargs with defaults; existing callers unaffected.Test plan