|
| 1 | +import logging |
1 | 2 | import os |
2 | 3 | import sys |
3 | 4 | from contextlib import asynccontextmanager |
|
6 | 7 |
|
7 | 8 | import anyio |
8 | 9 | import anyio.lowlevel |
| 10 | +from anyio.abc import Process |
9 | 11 | from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream |
10 | 12 | from anyio.streams.text import TextReceiveStream |
11 | 13 | from pydantic import BaseModel, Field |
12 | 14 |
|
13 | 15 | import mcp.types as types |
14 | | -from mcp.shared.message import SessionMessage |
15 | | - |
16 | | -from .win32 import ( |
| 16 | +from mcp.os.posix.utilities import terminate_posix_process_tree |
| 17 | +from mcp.os.win32.utilities import ( |
| 18 | + FallbackProcess, |
17 | 19 | create_windows_process, |
18 | 20 | get_windows_executable_command, |
19 | | - terminate_windows_process, |
| 21 | + terminate_windows_process_tree, |
20 | 22 | ) |
| 23 | +from mcp.shared.message import SessionMessage |
| 24 | + |
| 25 | +logger = logging.getLogger(__name__) |
21 | 26 |
|
22 | 27 | # Environment variables to inherit by default |
23 | 28 | DEFAULT_INHERITED_ENV_VARS = ( |
|
38 | 43 | else ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"] |
39 | 44 | ) |
40 | 45 |
|
| 46 | +# Timeout for process termination before falling back to force kill |
| 47 | +PROCESS_TERMINATION_TIMEOUT = 2.0 |
| 48 | + |
41 | 49 |
|
42 | 50 | def get_default_environment() -> dict[str, str]: |
43 | 51 | """ |
@@ -180,10 +188,12 @@ async def stdin_writer(): |
180 | 188 | finally: |
181 | 189 | # Clean up process to prevent any dangling orphaned processes |
182 | 190 | try: |
183 | | - if sys.platform == "win32": |
184 | | - await terminate_windows_process(process) |
185 | | - else: |
186 | | - process.terminate() |
| 191 | + process.terminate() |
| 192 | + with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT): |
| 193 | + await process.wait() |
| 194 | + except TimeoutError: |
| 195 | + # If process doesn't terminate in time, force kill it |
| 196 | + await _terminate_process_tree(process) |
187 | 197 | except ProcessLookupError: |
188 | 198 | # Process already exited, which is fine |
189 | 199 | pass |
@@ -218,11 +228,38 @@ async def _create_platform_compatible_process( |
218 | 228 | ): |
219 | 229 | """ |
220 | 230 | Creates a subprocess in a platform-compatible way. |
221 | | - Returns a process handle. |
| 231 | +
|
| 232 | + Unix: Creates process in a new session/process group for killpg support |
| 233 | + Windows: Creates process in a Job Object for reliable child termination |
222 | 234 | """ |
223 | 235 | if sys.platform == "win32": |
224 | 236 | process = await create_windows_process(command, args, env, errlog, cwd) |
225 | 237 | else: |
226 | | - process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd) |
| 238 | + process = await anyio.open_process( |
| 239 | + [command, *args], |
| 240 | + env=env, |
| 241 | + stderr=errlog, |
| 242 | + cwd=cwd, |
| 243 | + start_new_session=True, |
| 244 | + ) |
227 | 245 |
|
228 | 246 | return process |
| 247 | + |
| 248 | + |
| 249 | +async def _terminate_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None: |
| 250 | + """ |
| 251 | + Terminate a process and all its children using platform-specific methods. |
| 252 | +
|
| 253 | + Unix: Uses os.killpg() for atomic process group termination |
| 254 | + Windows: Uses Job Objects via pywin32 for reliable child process cleanup |
| 255 | +
|
| 256 | + Args: |
| 257 | + process: The process to terminate |
| 258 | + timeout_seconds: Timeout in seconds before force killing (default: 2.0) |
| 259 | + """ |
| 260 | + if sys.platform == "win32": |
| 261 | + await terminate_windows_process_tree(process, timeout_seconds) |
| 262 | + else: |
| 263 | + # FallbackProcess should only be used for Windows compatibility |
| 264 | + assert isinstance(process, Process) |
| 265 | + await terminate_posix_process_tree(process, timeout_seconds) |
0 commit comments