Skip to content

Commit a1641fd

Browse files
Fix WIndows Poractor loop teardown ValueError (modelcontextprotocol#391) and infinite hang (modelcontextprotocol#552) by recursively terminating all spawned child processes before primary subprocess using psutil
1 parent 5ffc436 commit a1641fd

File tree

4 files changed

+739
-670
lines changed

4 files changed

+739
-670
lines changed

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies = [
3030
"sse-starlette>=1.6.1",
3131
"pydantic-settings>=2.5.2",
3232
"uvicorn>=0.23.1",
33+
"psutil>=7.0.0",
3334
]
3435

3536
[project.optional-dependencies]

src/mcp/client/stdio/__init__.py

+34-5
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import os
22
import sys
3-
from contextlib import asynccontextmanager
3+
from contextlib import asynccontextmanager, suppress
44
from pathlib import Path
55
from typing import Literal, TextIO
66

77
import anyio
88
import anyio.lowlevel
9+
import psutil
10+
from anyio.abc import Process
911
from anyio.streams.memory import (
1012
MemoryObjectReceiveStream,
1113
MemoryObjectSendStream,
@@ -15,7 +17,11 @@
1517

1618
import mcp.types as types
1719

18-
from .win32 import create_windows_process, get_windows_executable_command
20+
from .win32 import (
21+
create_windows_process,
22+
get_windows_executable_command,
23+
terminate_windows_process,
24+
)
1925

2026
# Environment variables to inherit by default
2127
DEFAULT_INHERITED_ENV_VARS = (
@@ -172,9 +178,7 @@ async def stdin_writer():
172178
yield read_stream, write_stream
173179
finally:
174180
# Clean up process to prevent any dangling orphaned processes
175-
tg.cancel_scope.cancel()
176-
process.terminate()
177-
await process.wait()
181+
await _terminate_process(process)
178182

179183

180184
def _get_executable_command(command: str) -> str:
@@ -212,3 +216,28 @@ async def _create_platform_compatible_process(
212216
)
213217

214218
return process
219+
220+
221+
async def _terminate_process(process: Process):
222+
"""
223+
Terminate the process and its children.
224+
225+
Note: On Windows, `process.terminate()` calls the Win32 `TerminateProcess` API,
226+
which only terminates the specified process. Any child
227+
processes remain running, which can lead to unclosed resources and may cause the
228+
parent process to hang during shutdown. This function uses `psutil` to recursively
229+
terminate the full process tree, ensuring a cleaner and more reliable shutdown.
230+
231+
Args:
232+
process: The AnyIO process to terminate
233+
"""
234+
with suppress(psutil.NoSuchProcess):
235+
proc = psutil.Process(process.pid)
236+
children = proc.children(recursive=True)
237+
for child in children:
238+
child.kill()
239+
with suppress(ProcessLookupError):
240+
if sys.platform == "win32":
241+
await terminate_windows_process(process)
242+
else:
243+
process.terminate()

src/mcp/client/stdio/win32.py

+22
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import TextIO
1010

1111
import anyio
12+
from anyio.abc import Process
1213

1314

1415
def get_windows_executable_command(command: str) -> str:
@@ -85,3 +86,24 @@ async def create_windows_process(
8586
[command, *args], env=env, stderr=errlog, cwd=cwd
8687
)
8788
return process
89+
90+
91+
async def terminate_windows_process(process: Process):
92+
"""
93+
Terminate a Windows process.
94+
95+
Note: On Windows, terminating a process with process.terminate() doesn't
96+
always guarantee immediate process termination.
97+
So we give it 2s to exit, or we call process.kill()
98+
which sends a SIGKILL equivalent signal.
99+
100+
Args:
101+
process: The process to terminate
102+
"""
103+
try:
104+
process.terminate()
105+
with anyio.fail_after(2.0):
106+
await process.wait()
107+
except TimeoutError:
108+
# Force kill if it doesn't terminate
109+
process.kill()

0 commit comments

Comments
 (0)