Skip to content

Commit b932452

Browse files
Replace psutil with platform-specific process termination
- Remove psutil dependency entirely - Unix: Use os.killpg() for atomic process group termination - Windows: Use pywin32 Job Objects for reliable child process cleanup - Job Objects ensure all child processes are terminated even if parent crashes - Fixes race conditions in process tree termination on both platforms
1 parent 49d2394 commit b932452

File tree

4 files changed

+247
-73
lines changed

4 files changed

+247
-73
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +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",
35+
"pywin32>=300; sys_platform == 'win32'",
3636
]
3737

3838
[project.optional-dependencies]

src/mcp/client/stdio/__init__.py

Lines changed: 55 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import os
2+
import signal
23
import sys
34
from contextlib import asynccontextmanager
45
from pathlib import Path
56
from typing import Literal, TextIO
67

78
import anyio
89
import anyio.lowlevel
9-
import anyio.to_thread
10-
import psutil
1110
from anyio.abc import Process
1211
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
1312
from anyio.streams.text import TextReceiveStream
@@ -20,6 +19,7 @@
2019
FallbackProcess,
2120
create_windows_process,
2221
get_windows_executable_command,
22+
terminate_windows_process_tree,
2323
)
2424

2525
# Environment variables to inherit by default
@@ -223,11 +223,15 @@ async def _create_platform_compatible_process(
223223
):
224224
"""
225225
Creates a subprocess in a platform-compatible way.
226-
Returns a process handle.
226+
227+
Unix: Creates process in a new session/process group for killpg support
228+
Windows: Creates process in a Job Object for reliable child termination
227229
"""
228230
if sys.platform == "win32":
231+
# Windows: Use Job Objects for proper process tree management
229232
process = await create_windows_process(command, args, env, errlog, cwd)
230233
else:
234+
# Unix: Create process in new session for process group termination
231235
process = await anyio.open_process(
232236
[command, *args],
233237
env=env,
@@ -241,57 +245,58 @@ async def _create_platform_compatible_process(
241245

242246
async def _terminate_process_with_children(process: Process | FallbackProcess, timeout: float = 2.0) -> None:
243247
"""
244-
Terminate a process and all its children using psutil.
245-
246-
This provides consistent behavior across platforms and properly
247-
handles process trees without shell commands.
248+
Terminate a process and all its children using platform-specific methods.
248249
249-
Platform behavior:
250-
- On Unix: psutil.terminate() sends SIGTERM, allowing graceful shutdown
251-
- On Windows: psutil.terminate() calls TerminateProcess() which is immediate
252-
and doesn't allow cleanup handlers to run. This can cause ResourceWarnings
253-
for subprocess.Popen objects that don't get to clean up.
250+
Unix: Uses os.killpg() for atomic process group termination
251+
Windows: Uses Job Objects via pywin32 for reliable child process cleanup
254252
"""
255-
pid = getattr(process, "pid", None)
256-
if pid is None:
257-
popen = getattr(process, "popen", None)
258-
if popen:
259-
pid = getattr(popen, "pid", None)
260-
261-
if not pid:
262-
# Process has no PID, cannot terminate
263-
return
264-
265-
try:
266-
parent = psutil.Process(pid)
267-
children = parent.children(recursive=True)
268-
269-
# First, try graceful termination for all children
270-
for child in children:
271-
try:
272-
child.terminate()
273-
except psutil.NoSuchProcess:
274-
pass
275-
276-
# Then, also terminate the parent process
277-
try:
278-
parent.terminate()
279-
except psutil.NoSuchProcess:
253+
if sys.platform == "win32":
254+
# Windows: Use Job Object termination
255+
await terminate_windows_process_tree(process)
256+
else:
257+
# Unix: Use process groups for atomic termination
258+
pid = getattr(process, "pid", None)
259+
if pid is None:
260+
popen = getattr(process, "popen", None)
261+
if popen:
262+
pid = getattr(popen, "pid", None)
263+
264+
if not pid:
280265
return
281266

282-
# Wait for processes to exit gracefully, force kill any that remain
283-
all_procs = children + [parent]
284-
_, alive = await anyio.to_thread.run_sync(lambda: psutil.wait_procs(all_procs, timeout=timeout))
285-
for proc in alive:
267+
try:
268+
# Get process group ID (we use start_new_session=True)
269+
pgid = os.getpgid(pid)
270+
271+
# Send SIGTERM to entire process group atomically
272+
os.killpg(pgid, signal.SIGTERM)
273+
274+
# Wait for graceful termination
275+
deadline = anyio.current_time() + timeout
276+
while anyio.current_time() < deadline:
277+
try:
278+
# Check if process group still exists (signal 0 = check only)
279+
os.killpg(pgid, 0)
280+
await anyio.sleep(0.1)
281+
except ProcessLookupError:
282+
# Process group terminated successfully
283+
return
284+
285+
# Force kill if still alive after timeout
286286
try:
287-
proc.kill()
288-
except psutil.NoSuchProcess:
287+
os.killpg(pgid, signal.SIGKILL)
288+
except ProcessLookupError:
289+
# Already dead
289290
pass
290291

291-
# Wait a bit more for force-killed processes
292-
if alive:
293-
await anyio.to_thread.run_sync(lambda: psutil.wait_procs(alive, timeout=0.5))
294-
295-
except psutil.NoSuchProcess:
296-
# Process already terminated
297-
pass
292+
except (ProcessLookupError, PermissionError, OSError):
293+
# Fall back to simple terminate if process group approach fails
294+
try:
295+
process.terminate()
296+
with anyio.fail_after(timeout):
297+
await process.wait()
298+
except Exception:
299+
try:
300+
process.kill()
301+
except Exception:
302+
pass

0 commit comments

Comments
 (0)