Skip to content

Commit ce7baa1

Browse files
Fix child process cleanup in stdio termination
When terminating MCP servers, child processes were being orphaned because only the parent process was killed. This caused resource leaks and prevented proper cleanup, especially with tools like npx that spawn child processes for the actual server implementation. This was happening on both POSIX and Windows systems - however because of implementation details, resolving this is non-trivial and requires introducing psutil to introduce cross-platform utilities for dealing with children and process trees. This addresses critical issues where MCP servers using process spawning tools would leave zombie processes running after client shutdown. resolves #850 resolves #729
1 parent ae0aee2 commit ce7baa1

File tree

5 files changed

+438
-14
lines changed

5 files changed

+438
-14
lines changed

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dependencies = [
3232
"pydantic-settings>=2.5.2",
3333
"uvicorn>=0.23.1; sys_platform != 'emscripten'",
3434
"jsonschema>=4.20.0",
35+
"psutil>=5.9.0,<6.0.0",
3536
]
3637

3738
[project.optional-dependencies]
@@ -110,6 +111,9 @@ members = ["examples/servers/*"]
110111
[tool.uv.sources]
111112
mcp = { workspace = true }
112113

114+
[[tool.uv.index]]
115+
url = "https://pypi.org/simple"
116+
113117
[tool.pytest.ini_options]
114118
log_cli = true
115119
xfail_strict = true

src/mcp/client/stdio/__init__.py

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
import anyio
88
import anyio.lowlevel
9+
import anyio.to_thread
10+
import psutil
11+
from anyio.abc import Process
912
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
1013
from anyio.streams.text import TextReceiveStream
1114
from pydantic import BaseModel, Field
@@ -14,6 +17,7 @@
1417
from mcp.shared.message import SessionMessage
1518

1619
from .win32 import (
20+
FallbackProcess,
1721
create_windows_process,
1822
get_windows_executable_command,
1923
)
@@ -180,7 +184,7 @@ async def stdin_writer():
180184
# MCP spec: stdio shutdown sequence
181185
# 1. Close input stream to server
182186
# 2. Wait for server to exit, or send SIGTERM if it doesn't exit in time
183-
# 3. Send SIGKILL if still not exited
187+
# 3. Send SIGKILL if still not exited (forcibly kill on Windows)
184188
if process.stdin:
185189
try:
186190
await process.stdin.aclose()
@@ -193,18 +197,9 @@ async def stdin_writer():
193197
with anyio.fail_after(2.0):
194198
await process.wait()
195199
except TimeoutError:
196-
# Process didn't exit from stdin closure, escalate to SIGTERM
197-
try:
198-
process.terminate()
199-
with anyio.fail_after(2.0):
200-
await process.wait()
201-
except TimeoutError:
202-
# Process didn't respond to SIGTERM, force kill it
203-
process.kill()
204-
await process.wait()
205-
except ProcessLookupError:
206-
# Process already exited, which is fine
207-
pass
200+
# Process didn't exit from stdin closure, use our termination function
201+
# that handles child processes properly
202+
await _terminate_process_with_children(process)
208203
except ProcessLookupError:
209204
# Process already exited, which is fine
210205
pass
@@ -245,6 +240,70 @@ async def _create_platform_compatible_process(
245240
if sys.platform == "win32":
246241
process = await create_windows_process(command, args, env, errlog, cwd)
247242
else:
248-
process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd)
243+
process = await anyio.open_process(
244+
[command, *args],
245+
env=env,
246+
stderr=errlog,
247+
cwd=cwd,
248+
start_new_session=True,
249+
)
249250

250251
return process
252+
253+
254+
async def _terminate_process_with_children(process: Process | FallbackProcess, timeout: float = 2.0) -> None:
255+
"""
256+
Terminate a process and all its children using psutil.
257+
258+
This provides consistent behavior across platforms and properly
259+
handles process trees without shell commands.
260+
261+
Platform behavior:
262+
- On Unix: psutil.terminate() sends SIGTERM, allowing graceful shutdown
263+
- On Windows: psutil.terminate() calls TerminateProcess() which is immediate
264+
and doesn't allow cleanup handlers to run. This can cause ResourceWarnings
265+
for subprocess.Popen objects that don't get to clean up.
266+
"""
267+
pid = getattr(process, "pid", None)
268+
if pid is None:
269+
popen = getattr(process, "popen", None)
270+
if popen:
271+
pid = getattr(popen, "pid", None)
272+
273+
if not pid:
274+
# Process has no PID, cannot terminate
275+
return
276+
277+
try:
278+
parent = psutil.Process(pid)
279+
children = parent.children(recursive=True)
280+
281+
# First, try graceful termination for all children
282+
for child in children:
283+
try:
284+
child.terminate()
285+
except psutil.NoSuchProcess:
286+
pass
287+
288+
# Then, also terminate the parent process
289+
try:
290+
parent.terminate()
291+
except psutil.NoSuchProcess:
292+
return
293+
294+
# Wait for processes to exit gracefully, force kill any that remain
295+
all_procs = children + [parent]
296+
_, alive = await anyio.to_thread.run_sync(lambda: psutil.wait_procs(all_procs, timeout=timeout))
297+
for proc in alive:
298+
try:
299+
proc.kill()
300+
except psutil.NoSuchProcess:
301+
pass
302+
303+
# Wait a bit more for force-killed processes
304+
if alive:
305+
await anyio.to_thread.run_sync(lambda: psutil.wait_procs(alive, timeout=0.5))
306+
307+
except psutil.NoSuchProcess:
308+
# Process already terminated
309+
pass

src/mcp/client/stdio/win32.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ def kill(self) -> None:
115115
"""Kill the subprocess immediately (alias for terminate)."""
116116
self.terminate()
117117

118+
@property
119+
def pid(self) -> int:
120+
"""Return the process ID."""
121+
return self.popen.pid
122+
118123

119124
# ------------------------
120125
# Updated function

0 commit comments

Comments
 (0)