|
| 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 | """ |
@@ -178,12 +186,25 @@ async def stdin_writer(): |
178 | 186 | try: |
179 | 187 | yield read_stream, write_stream |
180 | 188 | finally: |
181 | | - # Clean up process to prevent any dangling orphaned processes |
| 189 | + # MCP spec: stdio shutdown sequence |
| 190 | + # 1. Close input stream to server |
| 191 | + # 2. Wait for server to exit, or send SIGTERM if it doesn't exit in time |
| 192 | + # 3. Send SIGKILL if still not exited |
| 193 | + if process.stdin: |
| 194 | + try: |
| 195 | + await process.stdin.aclose() |
| 196 | + except Exception: |
| 197 | + # stdin might already be closed, which is fine |
| 198 | + pass |
| 199 | + |
182 | 200 | try: |
183 | | - if sys.platform == "win32": |
184 | | - await terminate_windows_process(process) |
185 | | - else: |
186 | | - process.terminate() |
| 201 | + # Give the process time to exit gracefully after stdin closes |
| 202 | + with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT): |
| 203 | + await process.wait() |
| 204 | + except TimeoutError: |
| 205 | + # Process didn't exit from stdin closure, use platform-specific termination |
| 206 | + # which handles SIGTERM -> SIGKILL escalation |
| 207 | + await _terminate_process_tree(process) |
187 | 208 | except ProcessLookupError: |
188 | 209 | # Process already exited, which is fine |
189 | 210 | pass |
@@ -218,11 +239,38 @@ async def _create_platform_compatible_process( |
218 | 239 | ): |
219 | 240 | """ |
220 | 241 | Creates a subprocess in a platform-compatible way. |
221 | | - Returns a process handle. |
| 242 | +
|
| 243 | + Unix: Creates process in a new session/process group for killpg support |
| 244 | + Windows: Creates process in a Job Object for reliable child termination |
222 | 245 | """ |
223 | 246 | if sys.platform == "win32": |
224 | 247 | process = await create_windows_process(command, args, env, errlog, cwd) |
225 | 248 | else: |
226 | | - process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd) |
| 249 | + process = await anyio.open_process( |
| 250 | + [command, *args], |
| 251 | + env=env, |
| 252 | + stderr=errlog, |
| 253 | + cwd=cwd, |
| 254 | + start_new_session=True, |
| 255 | + ) |
227 | 256 |
|
228 | 257 | return process |
| 258 | + |
| 259 | + |
| 260 | +async def _terminate_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None: |
| 261 | + """ |
| 262 | + Terminate a process and all its children using platform-specific methods. |
| 263 | +
|
| 264 | + Unix: Uses os.killpg() for atomic process group termination |
| 265 | + Windows: Uses Job Objects via pywin32 for reliable child process cleanup |
| 266 | +
|
| 267 | + Args: |
| 268 | + process: The process to terminate |
| 269 | + timeout_seconds: Timeout in seconds before force killing (default: 2.0) |
| 270 | + """ |
| 271 | + if sys.platform == "win32": |
| 272 | + await terminate_windows_process_tree(process, timeout_seconds) |
| 273 | + else: |
| 274 | + # FallbackProcess should only be used for Windows compatibility |
| 275 | + assert isinstance(process, Process) |
| 276 | + await terminate_posix_process_tree(process, timeout_seconds) |
0 commit comments