|
6 | 6 |
|
7 | 7 | import anyio
|
8 | 8 | import anyio.lowlevel
|
| 9 | +import anyio.to_thread |
| 10 | +import psutil |
| 11 | +from anyio.abc import Process |
9 | 12 | from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
10 | 13 | from anyio.streams.text import TextReceiveStream
|
11 | 14 | from pydantic import BaseModel, Field
|
|
14 | 17 | from mcp.shared.message import SessionMessage
|
15 | 18 |
|
16 | 19 | from .win32 import (
|
| 20 | + FallbackProcess, |
17 | 21 | create_windows_process,
|
18 | 22 | get_windows_executable_command,
|
19 | 23 | )
|
@@ -184,7 +188,7 @@ async def stdin_writer():
|
184 | 188 | await process.wait()
|
185 | 189 | except TimeoutError:
|
186 | 190 | # If process doesn't terminate in time, force kill it
|
187 |
| - process.kill() |
| 191 | + await _terminate_process_with_children(process) |
188 | 192 | except ProcessLookupError:
|
189 | 193 | # Process already exited, which is fine
|
190 | 194 | pass
|
@@ -224,6 +228,70 @@ async def _create_platform_compatible_process(
|
224 | 228 | if sys.platform == "win32":
|
225 | 229 | process = await create_windows_process(command, args, env, errlog, cwd)
|
226 | 230 | else:
|
227 |
| - process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd) |
| 231 | + process = await anyio.open_process( |
| 232 | + [command, *args], |
| 233 | + env=env, |
| 234 | + stderr=errlog, |
| 235 | + cwd=cwd, |
| 236 | + start_new_session=True, |
| 237 | + ) |
228 | 238 |
|
229 | 239 | return process
|
| 240 | + |
| 241 | + |
| 242 | +async def _terminate_process_with_children(process: Process | FallbackProcess, timeout: float = 2.0) -> None: |
| 243 | + """ |
| 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 | +
|
| 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. |
| 254 | + """ |
| 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: |
| 280 | + return |
| 281 | + |
| 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: |
| 286 | + try: |
| 287 | + proc.kill() |
| 288 | + except psutil.NoSuchProcess: |
| 289 | + pass |
| 290 | + |
| 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 |
0 commit comments