Improve child process termination on POSIX & Windows (#1078)
Co-authored-by: jingx8885 <jingxu8885@qq.com> Co-authored-by: Surya Prakash Susarla <susarla.surya.prakash.1998@gmail.com>
This commit is contained in:
parent
cf72565d9a
commit
e0336213e8
9 changed files with 552 additions and 15 deletions
|
|
@ -32,6 +32,7 @@ dependencies = [
|
|||
"pydantic-settings>=2.5.2",
|
||||
"uvicorn>=0.23.1; sys_platform != 'emscripten'",
|
||||
"jsonschema>=4.20.0",
|
||||
"pywin32>=310; sys_platform == 'win32'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
@ -125,4 +126,6 @@ filterwarnings = [
|
|||
"ignore::DeprecationWarning:websockets",
|
||||
"ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning",
|
||||
"ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel",
|
||||
# pywin32 internal deprecation warning
|
||||
"ignore:getargs.*The 'u' format is deprecated:DeprecationWarning"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import logging
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
|
@ -6,17 +7,22 @@ from typing import Literal, TextIO
|
|||
|
||||
import anyio
|
||||
import anyio.lowlevel
|
||||
from anyio.abc import Process
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from anyio.streams.text import TextReceiveStream
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
import mcp.types as types
|
||||
from mcp.shared.message import SessionMessage
|
||||
|
||||
from .win32 import (
|
||||
from mcp.os.posix.utilities import terminate_posix_process_tree
|
||||
from mcp.os.win32.utilities import (
|
||||
FallbackProcess,
|
||||
create_windows_process,
|
||||
get_windows_executable_command,
|
||||
terminate_windows_process_tree,
|
||||
)
|
||||
from mcp.shared.message import SessionMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variables to inherit by default
|
||||
DEFAULT_INHERITED_ENV_VARS = (
|
||||
|
|
@ -187,7 +193,7 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
|
|||
await process.wait()
|
||||
except TimeoutError:
|
||||
# If process doesn't terminate in time, force kill it
|
||||
process.kill()
|
||||
await _terminate_process_tree(process)
|
||||
except ProcessLookupError:
|
||||
# Process already exited, which is fine
|
||||
pass
|
||||
|
|
@ -222,11 +228,38 @@ async def _create_platform_compatible_process(
|
|||
):
|
||||
"""
|
||||
Creates a subprocess in a platform-compatible way.
|
||||
Returns a process handle.
|
||||
|
||||
Unix: Creates process in a new session/process group for killpg support
|
||||
Windows: Creates process in a Job Object for reliable child termination
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
process = await create_windows_process(command, args, env, errlog, cwd)
|
||||
else:
|
||||
process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd)
|
||||
process = await anyio.open_process(
|
||||
[command, *args],
|
||||
env=env,
|
||||
stderr=errlog,
|
||||
cwd=cwd,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
return process
|
||||
|
||||
|
||||
async def _terminate_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None:
|
||||
"""
|
||||
Terminate a process and all its children using platform-specific methods.
|
||||
|
||||
Unix: Uses os.killpg() for atomic process group termination
|
||||
Windows: Uses Job Objects via pywin32 for reliable child process cleanup
|
||||
|
||||
Args:
|
||||
process: The process to terminate
|
||||
timeout_seconds: Timeout in seconds before force killing (default: 2.0)
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
await terminate_windows_process_tree(process, timeout_seconds)
|
||||
else:
|
||||
# FallbackProcess should only be used for Windows compatibility
|
||||
assert isinstance(process, Process)
|
||||
await terminate_posix_process_tree(process, timeout_seconds)
|
||||
|
|
|
|||
1
src/mcp/os/__init__.py
Normal file
1
src/mcp/os/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Platform-specific utilities for MCP."""
|
||||
1
src/mcp/os/posix/__init__.py
Normal file
1
src/mcp/os/posix/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""POSIX-specific utilities for MCP."""
|
||||
60
src/mcp/os/posix/utilities.py
Normal file
60
src/mcp/os/posix/utilities.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""
|
||||
POSIX-specific functionality for stdio client operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
|
||||
import anyio
|
||||
from anyio.abc import Process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def terminate_posix_process_tree(process: Process, timeout_seconds: float = 2.0) -> None:
|
||||
"""
|
||||
Terminate a process and all its children on POSIX systems.
|
||||
|
||||
Uses os.killpg() for atomic process group termination.
|
||||
|
||||
Args:
|
||||
process: The process to terminate
|
||||
timeout_seconds: Timeout in seconds before force killing (default: 2.0)
|
||||
"""
|
||||
pid = getattr(process, "pid", None) or getattr(getattr(process, "popen", None), "pid", None)
|
||||
if not pid:
|
||||
# No PID means there's no process to terminate - it either never started,
|
||||
# already exited, or we have an invalid process object
|
||||
return
|
||||
|
||||
try:
|
||||
pgid = os.getpgid(pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
|
||||
with anyio.move_on_after(timeout_seconds):
|
||||
while True:
|
||||
try:
|
||||
# Check if process group still exists (signal 0 = check only)
|
||||
os.killpg(pgid, 0)
|
||||
await anyio.sleep(0.1)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
except (ProcessLookupError, PermissionError, OSError) as e:
|
||||
logger.warning(f"Process group termination failed for PID {pid}: {e}, falling back to simple terminate")
|
||||
try:
|
||||
process.terminate()
|
||||
with anyio.fail_after(timeout_seconds):
|
||||
await process.wait()
|
||||
except Exception as term_error:
|
||||
logger.warning(f"Process termination failed for PID {pid}: {term_error}, attempting force kill")
|
||||
try:
|
||||
process.kill()
|
||||
except Exception as kill_error:
|
||||
logger.error(f"Failed to kill process {pid}: {kill_error}")
|
||||
1
src/mcp/os/win32/__init__.py
Normal file
1
src/mcp/os/win32/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Windows-specific utilities for MCP."""
|
||||
338
src/mcp/os/win32/utilities.py
Normal file
338
src/mcp/os/win32/utilities.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
"""
|
||||
Windows-specific functionality for stdio client operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO, TextIO, cast
|
||||
|
||||
import anyio
|
||||
from anyio import to_thread
|
||||
from anyio.abc import Process
|
||||
from anyio.streams.file import FileReadStream, FileWriteStream
|
||||
from typing_extensions import deprecated
|
||||
|
||||
logger = logging.getLogger("client.stdio.win32")
|
||||
|
||||
# Windows-specific imports for Job Objects
|
||||
if sys.platform == "win32":
|
||||
import pywintypes
|
||||
import win32api
|
||||
import win32con
|
||||
import win32job
|
||||
else:
|
||||
# Type stubs for non-Windows platforms
|
||||
win32api = None
|
||||
win32con = None
|
||||
win32job = None
|
||||
pywintypes = None
|
||||
|
||||
JobHandle = int
|
||||
|
||||
|
||||
def get_windows_executable_command(command: str) -> str:
|
||||
"""
|
||||
Get the correct executable command normalized for Windows.
|
||||
|
||||
On Windows, commands might exist with specific extensions (.exe, .cmd, etc.)
|
||||
that need to be located for proper execution.
|
||||
|
||||
Args:
|
||||
command: Base command (e.g., 'uvx', 'npx')
|
||||
|
||||
Returns:
|
||||
str: Windows-appropriate command path
|
||||
"""
|
||||
try:
|
||||
# First check if command exists in PATH as-is
|
||||
if command_path := shutil.which(command):
|
||||
return command_path
|
||||
|
||||
# Check for Windows-specific extensions
|
||||
for ext in [".cmd", ".bat", ".exe", ".ps1"]:
|
||||
ext_version = f"{command}{ext}"
|
||||
if ext_path := shutil.which(ext_version):
|
||||
return ext_path
|
||||
|
||||
# For regular commands or if we couldn't find special versions
|
||||
return command
|
||||
except OSError:
|
||||
# Handle file system errors during path resolution
|
||||
# (permissions, broken symlinks, etc.)
|
||||
return command
|
||||
|
||||
|
||||
class FallbackProcess:
|
||||
"""
|
||||
A fallback process wrapper for Windows to handle async I/O
|
||||
when using subprocess.Popen, which provides sync-only FileIO objects.
|
||||
|
||||
This wraps stdin and stdout into async-compatible
|
||||
streams (FileReadStream, FileWriteStream),
|
||||
so that MCP clients expecting async streams can work properly.
|
||||
"""
|
||||
|
||||
def __init__(self, popen_obj: subprocess.Popen[bytes]):
|
||||
self.popen: subprocess.Popen[bytes] = popen_obj
|
||||
self.stdin_raw = popen_obj.stdin # type: ignore[assignment]
|
||||
self.stdout_raw = popen_obj.stdout # type: ignore[assignment]
|
||||
self.stderr = popen_obj.stderr # type: ignore[assignment]
|
||||
|
||||
self.stdin = FileWriteStream(cast(BinaryIO, self.stdin_raw)) if self.stdin_raw else None
|
||||
self.stdout = FileReadStream(cast(BinaryIO, self.stdout_raw)) if self.stdout_raw else None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Support async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: BaseException | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: object | None,
|
||||
) -> None:
|
||||
"""Terminate and wait on process exit inside a thread."""
|
||||
self.popen.terminate()
|
||||
await to_thread.run_sync(self.popen.wait)
|
||||
|
||||
# Close the file handles to prevent ResourceWarning
|
||||
if self.stdin:
|
||||
await self.stdin.aclose()
|
||||
if self.stdout:
|
||||
await self.stdout.aclose()
|
||||
if self.stdin_raw:
|
||||
self.stdin_raw.close()
|
||||
if self.stdout_raw:
|
||||
self.stdout_raw.close()
|
||||
if self.stderr:
|
||||
self.stderr.close()
|
||||
|
||||
async def wait(self):
|
||||
"""Async wait for process completion."""
|
||||
return await to_thread.run_sync(self.popen.wait)
|
||||
|
||||
def terminate(self):
|
||||
"""Terminate the subprocess immediately."""
|
||||
return self.popen.terminate()
|
||||
|
||||
def kill(self) -> None:
|
||||
"""Kill the subprocess immediately (alias for terminate)."""
|
||||
self.terminate()
|
||||
|
||||
@property
|
||||
def pid(self) -> int:
|
||||
"""Return the process ID."""
|
||||
return self.popen.pid
|
||||
|
||||
|
||||
# ------------------------
|
||||
# Updated function
|
||||
# ------------------------
|
||||
|
||||
|
||||
async def create_windows_process(
|
||||
command: str,
|
||||
args: list[str],
|
||||
env: dict[str, str] | None = None,
|
||||
errlog: TextIO | None = sys.stderr,
|
||||
cwd: Path | str | None = None,
|
||||
) -> Process | FallbackProcess:
|
||||
"""
|
||||
Creates a subprocess in a Windows-compatible way with Job Object support.
|
||||
|
||||
Attempt to use anyio's open_process for async subprocess creation.
|
||||
In some cases this will throw NotImplementedError on Windows, e.g.
|
||||
when using the SelectorEventLoop which does not support async subprocesses.
|
||||
In that case, we fall back to using subprocess.Popen.
|
||||
|
||||
The process is automatically added to a Job Object to ensure all child
|
||||
processes are terminated when the parent is terminated.
|
||||
|
||||
Args:
|
||||
command (str): The executable to run
|
||||
args (list[str]): List of command line arguments
|
||||
env (dict[str, str] | None): Environment variables
|
||||
errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr)
|
||||
cwd (Path | str | None): Working directory for the subprocess
|
||||
|
||||
Returns:
|
||||
Process | FallbackProcess: Async-compatible subprocess with stdin and stdout streams
|
||||
"""
|
||||
job = _create_job_object()
|
||||
process = None
|
||||
|
||||
try:
|
||||
# First try using anyio with Windows-specific flags to hide console window
|
||||
process = await anyio.open_process(
|
||||
[command, *args],
|
||||
env=env,
|
||||
# Ensure we don't create console windows for each process
|
||||
creationflags=subprocess.CREATE_NO_WINDOW # type: ignore
|
||||
if hasattr(subprocess, "CREATE_NO_WINDOW")
|
||||
else 0,
|
||||
stderr=errlog,
|
||||
cwd=cwd,
|
||||
)
|
||||
except NotImplementedError:
|
||||
# If Windows doesn't support async subprocess creation, use fallback
|
||||
process = await _create_windows_fallback_process(command, args, env, errlog, cwd)
|
||||
except Exception:
|
||||
# Try again without creation flags
|
||||
process = await anyio.open_process(
|
||||
[command, *args],
|
||||
env=env,
|
||||
stderr=errlog,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
_maybe_assign_process_to_job(process, job)
|
||||
return process
|
||||
|
||||
|
||||
async def _create_windows_fallback_process(
|
||||
command: str,
|
||||
args: list[str],
|
||||
env: dict[str, str] | None = None,
|
||||
errlog: TextIO | None = sys.stderr,
|
||||
cwd: Path | str | None = None,
|
||||
) -> FallbackProcess:
|
||||
"""
|
||||
Create a subprocess using subprocess.Popen as a fallback when anyio fails.
|
||||
|
||||
This function wraps the sync subprocess.Popen in an async-compatible interface.
|
||||
"""
|
||||
try:
|
||||
# Try launching with creationflags to avoid opening a new console window
|
||||
popen_obj = subprocess.Popen(
|
||||
[command, *args],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=errlog,
|
||||
env=env,
|
||||
cwd=cwd,
|
||||
bufsize=0, # Unbuffered output
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except Exception:
|
||||
# If creationflags failed, fallback without them
|
||||
popen_obj = subprocess.Popen(
|
||||
[command, *args],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=errlog,
|
||||
env=env,
|
||||
cwd=cwd,
|
||||
bufsize=0,
|
||||
)
|
||||
return FallbackProcess(popen_obj)
|
||||
|
||||
|
||||
def _create_job_object() -> int | None:
|
||||
"""
|
||||
Create a Windows Job Object configured to terminate all processes when closed.
|
||||
"""
|
||||
if sys.platform != "win32" or not win32job:
|
||||
return None
|
||||
|
||||
try:
|
||||
job = win32job.CreateJobObject(None, "")
|
||||
extended_info = win32job.QueryInformationJobObject(job, win32job.JobObjectExtendedLimitInformation)
|
||||
|
||||
extended_info["BasicLimitInformation"]["LimitFlags"] |= win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
win32job.SetInformationJobObject(job, win32job.JobObjectExtendedLimitInformation, extended_info)
|
||||
return job
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create Job Object for process tree management: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: JobHandle | None) -> None:
|
||||
"""
|
||||
Try to assign a process to a job object. If assignment fails
|
||||
for any reason, the job handle is closed.
|
||||
"""
|
||||
if not job:
|
||||
return
|
||||
|
||||
if sys.platform != "win32" or not win32api or not win32con or not win32job:
|
||||
return
|
||||
|
||||
try:
|
||||
process_handle = win32api.OpenProcess(
|
||||
win32con.PROCESS_SET_QUOTA | win32con.PROCESS_TERMINATE, False, process.pid
|
||||
)
|
||||
if not process_handle:
|
||||
raise Exception("Failed to open process handle")
|
||||
|
||||
try:
|
||||
win32job.AssignProcessToJobObject(job, process_handle)
|
||||
process._job_object = job
|
||||
finally:
|
||||
win32api.CloseHandle(process_handle)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to assign process {process.pid} to Job Object: {e}")
|
||||
if win32api:
|
||||
win32api.CloseHandle(job)
|
||||
|
||||
|
||||
async def terminate_windows_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None:
|
||||
"""
|
||||
Terminate a process and all its children on Windows.
|
||||
|
||||
If the process has an associated job object, it will be terminated.
|
||||
Otherwise, falls back to basic process termination.
|
||||
|
||||
Args:
|
||||
process: The process to terminate
|
||||
timeout_seconds: Timeout in seconds before force killing (default: 2.0)
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
|
||||
job = getattr(process, "_job_object", None)
|
||||
if job and win32job:
|
||||
try:
|
||||
win32job.TerminateJobObject(job, 1)
|
||||
except Exception:
|
||||
# Job might already be terminated
|
||||
pass
|
||||
finally:
|
||||
if win32api:
|
||||
try:
|
||||
win32api.CloseHandle(job)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Always try to terminate the process itself as well
|
||||
try:
|
||||
process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@deprecated(
|
||||
"terminate_windows_process is deprecated and will be removed in a future version. "
|
||||
"Process termination is now handled internally by the stdio_client context manager."
|
||||
)
|
||||
async def terminate_windows_process(process: Process | FallbackProcess):
|
||||
"""
|
||||
Terminate a Windows process.
|
||||
|
||||
Note: On Windows, terminating a process with process.terminate() doesn't
|
||||
always guarantee immediate process termination.
|
||||
So we give it 2s to exit, or we call process.kill()
|
||||
which sends a SIGKILL equivalent signal.
|
||||
|
||||
Args:
|
||||
process: The process to terminate
|
||||
"""
|
||||
try:
|
||||
process.terminate()
|
||||
with anyio.fail_after(2.0):
|
||||
await process.wait()
|
||||
except TimeoutError:
|
||||
# Force kill if it doesn't terminate
|
||||
process.kill()
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
|
||||
|
|
@ -9,6 +11,7 @@ import pytest
|
|||
from mcp.client.session import ClientSession
|
||||
from mcp.client.stdio import (
|
||||
StdioServerParameters,
|
||||
_create_platform_compatible_process,
|
||||
stdio_client,
|
||||
)
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
|
@ -219,3 +222,306 @@ async def test_stdio_client_sigint_only_process():
|
|||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
class TestChildProcessCleanup:
|
||||
"""
|
||||
Tests for child process cleanup functionality using _terminate_process_tree.
|
||||
|
||||
These tests verify that child processes are properly terminated when the parent
|
||||
is killed, addressing the issue where processes like npx spawn child processes
|
||||
that need to be cleaned up. The tests cover various process tree scenarios:
|
||||
|
||||
- Basic parent-child relationship (single child process)
|
||||
- Multi-level process trees (parent → child → grandchild)
|
||||
- Race conditions where parent exits during cleanup
|
||||
|
||||
Note on Windows ResourceWarning:
|
||||
On Windows, we may see ResourceWarning about subprocess still running. This is
|
||||
expected behavior due to how Windows process termination works:
|
||||
- anyio's process.terminate() calls Windows TerminateProcess() API
|
||||
- TerminateProcess() immediately kills the process without allowing cleanup
|
||||
- subprocess.Popen objects in the killed process can't run their cleanup code
|
||||
- Python detects this during garbage collection and issues a ResourceWarning
|
||||
|
||||
This warning does NOT indicate a process leak - the processes are properly
|
||||
terminated. It only means the Popen objects couldn't clean up gracefully.
|
||||
This is a fundamental difference between Windows and Unix process termination.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _escape_path_for_python(path: str) -> str:
|
||||
"""Escape a file path for use in Python code strings."""
|
||||
# Use forward slashes which work on all platforms and don't need escaping
|
||||
return repr(path.replace("\\", "/"))
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default")
|
||||
async def test_basic_child_process_cleanup(self):
|
||||
"""
|
||||
Test basic parent-child process cleanup.
|
||||
Parent spawns a single child process that writes continuously to a file.
|
||||
"""
|
||||
# Create a marker file for the child process to write to
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
marker_file = f.name
|
||||
|
||||
# Also create a file to verify parent started
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
parent_marker = f.name
|
||||
|
||||
try:
|
||||
# Parent script that spawns a child process
|
||||
parent_script = textwrap.dedent(
|
||||
f"""
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import os
|
||||
|
||||
# Mark that parent started
|
||||
with open({self._escape_path_for_python(parent_marker)}, 'w') as f:
|
||||
f.write('parent started\\n')
|
||||
|
||||
# Child script that writes continuously
|
||||
child_script = f'''
|
||||
import time
|
||||
with open({self._escape_path_for_python(marker_file)}, 'a') as f:
|
||||
while True:
|
||||
f.write(f"{time.time()}")
|
||||
f.flush()
|
||||
time.sleep(0.1)
|
||||
'''
|
||||
|
||||
# Start the child process
|
||||
child = subprocess.Popen([sys.executable, '-c', child_script])
|
||||
|
||||
# Parent just sleeps
|
||||
while True:
|
||||
time.sleep(0.1)
|
||||
"""
|
||||
)
|
||||
|
||||
print("\nStarting child process termination test...")
|
||||
|
||||
# Start the parent process
|
||||
proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script])
|
||||
|
||||
# Wait for processes to start
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
# Verify parent started
|
||||
assert os.path.exists(parent_marker), "Parent process didn't start"
|
||||
|
||||
# Verify child is writing
|
||||
if os.path.exists(marker_file):
|
||||
initial_size = os.path.getsize(marker_file)
|
||||
await anyio.sleep(0.3)
|
||||
size_after_wait = os.path.getsize(marker_file)
|
||||
assert size_after_wait > initial_size, "Child process should be writing"
|
||||
print(f"Child is writing (file grew from {initial_size} to {size_after_wait} bytes)")
|
||||
|
||||
# Terminate using our function
|
||||
print("Terminating process and children...")
|
||||
from mcp.client.stdio import _terminate_process_tree
|
||||
|
||||
await _terminate_process_tree(proc)
|
||||
|
||||
# Verify processes stopped
|
||||
await anyio.sleep(0.5)
|
||||
if os.path.exists(marker_file):
|
||||
size_after_cleanup = os.path.getsize(marker_file)
|
||||
await anyio.sleep(0.5)
|
||||
final_size = os.path.getsize(marker_file)
|
||||
|
||||
print(f"After cleanup: file size {size_after_cleanup} -> {final_size}")
|
||||
assert final_size == size_after_cleanup, (
|
||||
f"Child process still running! File grew by {final_size - size_after_cleanup} bytes"
|
||||
)
|
||||
|
||||
print("SUCCESS: Child process was properly terminated")
|
||||
|
||||
finally:
|
||||
# Clean up files
|
||||
for f in [marker_file, parent_marker]:
|
||||
try:
|
||||
os.unlink(f)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default")
|
||||
async def test_nested_process_tree(self):
|
||||
"""
|
||||
Test nested process tree cleanup (parent → child → grandchild).
|
||||
Each level writes to a different file to verify all processes are terminated.
|
||||
"""
|
||||
# Create temporary files for each process level
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f1:
|
||||
parent_file = f1.name
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f2:
|
||||
child_file = f2.name
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f3:
|
||||
grandchild_file = f3.name
|
||||
|
||||
try:
|
||||
# Simple nested process tree test
|
||||
# We create parent -> child -> grandchild, each writing to a file
|
||||
parent_script = textwrap.dedent(
|
||||
f"""
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import os
|
||||
|
||||
# Child will spawn grandchild and write to child file
|
||||
child_script = f'''import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Grandchild just writes to file
|
||||
grandchild_script = \"\"\"import time
|
||||
with open({self._escape_path_for_python(grandchild_file)}, 'a') as f:
|
||||
while True:
|
||||
f.write(f"gc {{time.time()}}")
|
||||
f.flush()
|
||||
time.sleep(0.1)\"\"\"
|
||||
|
||||
# Spawn grandchild
|
||||
subprocess.Popen([sys.executable, '-c', grandchild_script])
|
||||
|
||||
# Child writes to its file
|
||||
with open({self._escape_path_for_python(child_file)}, 'a') as f:
|
||||
while True:
|
||||
f.write(f"c {time.time()}")
|
||||
f.flush()
|
||||
time.sleep(0.1)'''
|
||||
|
||||
# Spawn child process
|
||||
subprocess.Popen([sys.executable, '-c', child_script])
|
||||
|
||||
# Parent writes to its file
|
||||
with open({self._escape_path_for_python(parent_file)}, 'a') as f:
|
||||
while True:
|
||||
f.write(f"p {time.time()}")
|
||||
f.flush()
|
||||
time.sleep(0.1)
|
||||
"""
|
||||
)
|
||||
|
||||
# Start the parent process
|
||||
proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script])
|
||||
|
||||
# Let all processes start
|
||||
await anyio.sleep(1.0)
|
||||
|
||||
# Verify all are writing
|
||||
for file_path, name in [(parent_file, "parent"), (child_file, "child"), (grandchild_file, "grandchild")]:
|
||||
if os.path.exists(file_path):
|
||||
initial_size = os.path.getsize(file_path)
|
||||
await anyio.sleep(0.3)
|
||||
new_size = os.path.getsize(file_path)
|
||||
assert new_size > initial_size, f"{name} process should be writing"
|
||||
|
||||
# Terminate the whole tree
|
||||
from mcp.client.stdio import _terminate_process_tree
|
||||
|
||||
await _terminate_process_tree(proc)
|
||||
|
||||
# Verify all stopped
|
||||
await anyio.sleep(0.5)
|
||||
for file_path, name in [(parent_file, "parent"), (child_file, "child"), (grandchild_file, "grandchild")]:
|
||||
if os.path.exists(file_path):
|
||||
size1 = os.path.getsize(file_path)
|
||||
await anyio.sleep(0.3)
|
||||
size2 = os.path.getsize(file_path)
|
||||
assert size1 == size2, f"{name} still writing after cleanup!"
|
||||
|
||||
print("SUCCESS: All processes in tree terminated")
|
||||
|
||||
finally:
|
||||
# Clean up all marker files
|
||||
for f in [parent_file, child_file, grandchild_file]:
|
||||
try:
|
||||
os.unlink(f)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default")
|
||||
async def test_early_parent_exit(self):
|
||||
"""
|
||||
Test cleanup when parent exits during termination sequence.
|
||||
Tests the race condition where parent might die during our termination
|
||||
sequence but we can still clean up the children via the process group.
|
||||
"""
|
||||
# Create a temporary file for the child
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
marker_file = f.name
|
||||
|
||||
try:
|
||||
# Parent that spawns child and waits briefly
|
||||
parent_script = textwrap.dedent(
|
||||
f"""
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
|
||||
# Child that continues running
|
||||
child_script = f'''import time
|
||||
with open({self._escape_path_for_python(marker_file)}, 'a') as f:
|
||||
while True:
|
||||
f.write(f"child {time.time()}")
|
||||
f.flush()
|
||||
time.sleep(0.1)'''
|
||||
|
||||
# Start child in same process group
|
||||
subprocess.Popen([sys.executable, '-c', child_script])
|
||||
|
||||
# Parent waits a bit then exits on SIGTERM
|
||||
def handle_term(sig, frame):
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_term)
|
||||
|
||||
# Wait
|
||||
while True:
|
||||
time.sleep(0.1)
|
||||
"""
|
||||
)
|
||||
|
||||
# Start the parent process
|
||||
proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script])
|
||||
|
||||
# Let child start writing
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
# Verify child is writing
|
||||
if os.path.exists(marker_file):
|
||||
size1 = os.path.getsize(marker_file)
|
||||
await anyio.sleep(0.3)
|
||||
size2 = os.path.getsize(marker_file)
|
||||
assert size2 > size1, "Child should be writing"
|
||||
|
||||
# Terminate - this will kill the process group even if parent exits first
|
||||
from mcp.client.stdio import _terminate_process_tree
|
||||
|
||||
await _terminate_process_tree(proc)
|
||||
|
||||
# Verify child stopped
|
||||
await anyio.sleep(0.5)
|
||||
if os.path.exists(marker_file):
|
||||
size3 = os.path.getsize(marker_file)
|
||||
await anyio.sleep(0.3)
|
||||
size4 = os.path.getsize(marker_file)
|
||||
assert size3 == size4, "Child should be terminated"
|
||||
|
||||
print("SUCCESS: Child terminated even with parent exit during cleanup")
|
||||
|
||||
finally:
|
||||
# Clean up marker file
|
||||
try:
|
||||
os.unlink(marker_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
|
|
|||
21
uv.lock
generated
21
uv.lock
generated
|
|
@ -583,6 +583,7 @@ dependencies = [
|
|||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
|
|
@ -630,6 +631,7 @@ requires-dist = [
|
|||
{ name = "pydantic-settings", specifier = ">=2.5.2" },
|
||||
{ name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=310" },
|
||||
{ name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" },
|
||||
{ name = "sse-starlette", specifier = ">=1.6.1" },
|
||||
{ name = "starlette", specifier = ">=0.27" },
|
||||
|
|
@ -1426,6 +1428,25 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "310"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/da/a5f38fffbba2fb99aa4aa905480ac4b8e83ca486659ac8c95bce47fb5276/pywin32-310-cp310-cp310-win32.whl", hash = "sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1", size = 8848240, upload-time = "2025-03-17T00:55:46.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/fe/d873a773324fa565619ba555a82c9dabd677301720f3660a731a5d07e49a/pywin32-310-cp310-cp310-win_amd64.whl", hash = "sha256:c3e78706e4229b915a0821941a84e7ef420bf2b77e08c9dae3c76fd03fd2ae3d", size = 9601854, upload-time = "2025-03-17T00:55:48.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/84/1a8e3d7a15490d28a5d816efa229ecb4999cdc51a7c30dd8914f669093b8/pywin32-310-cp310-cp310-win_arm64.whl", hash = "sha256:33babed0cf0c92a6f94cc6cc13546ab24ee13e3e800e61ed87609ab91e4c8213", size = 8522963, upload-time = "2025-03-17T00:55:50.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/b1/68aa2986129fb1011dabbe95f0136f44509afaf072b12b8f815905a39f33/pywin32-310-cp311-cp311-win32.whl", hash = "sha256:1e765f9564e83011a63321bb9d27ec456a0ed90d3732c4b2e312b855365ed8bd", size = 8784284, upload-time = "2025-03-17T00:55:53.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/bd/d1592635992dd8db5bb8ace0551bc3a769de1ac8850200cfa517e72739fb/pywin32-310-cp311-cp311-win_amd64.whl", hash = "sha256:126298077a9d7c95c53823934f000599f66ec9296b09167810eb24875f32689c", size = 9520748, upload-time = "2025-03-17T00:55:55.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/b1/ac8b1ffce6603849eb45a91cf126c0fa5431f186c2e768bf56889c46f51c/pywin32-310-cp311-cp311-win_arm64.whl", hash = "sha256:19ec5fc9b1d51c4350be7bb00760ffce46e6c95eaf2f0b2f1150657b1a43c582", size = 8455941, upload-time = "2025-03-17T00:55:57.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ec/4fdbe47932f671d6e348474ea35ed94227fb5df56a7c30cbbb42cd396ed0/pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d", size = 8796239, upload-time = "2025-03-17T00:55:58.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/e5/b0627f8bb84e06991bea89ad8153a9e50ace40b2e1195d68e9dff6b03d0f/pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060", size = 9503839, upload-time = "2025-03-17T00:56:00.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/32/9ccf53748df72301a89713936645a664ec001abd35ecc8578beda593d37d/pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966", size = 8459470, upload-time = "2025-03-17T00:56:02.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/09/9c1b978ffc4ae53999e89c19c77ba882d9fce476729f23ef55211ea1c034/pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab", size = 8794384, upload-time = "2025-03-17T00:56:04.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/3c/b4640f740ffebadd5d34df35fecba0e1cfef8fde9f3e594df91c28ad9b50/pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e", size = 9503039, upload-time = "2025-03-17T00:56:06.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f4/f785020090fb050e7fb6d34b780f2231f302609dc964672f72bfaeb59a28/pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33", size = 8458152, upload-time = "2025-03-17T00:56:07.819Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.2"
|
||||
|
|
|
|||
Loading…
Reference in a new issue