66
77import anyio
88import anyio .lowlevel
9+ import anyio .to_thread
10+ import psutil
11+ from anyio .abc import Process
912from anyio .streams .memory import MemoryObjectReceiveStream , MemoryObjectSendStream
1013from anyio .streams .text import TextReceiveStream
1114from pydantic import BaseModel , Field
1417from mcp .shared .message import SessionMessage
1518
1619from .win32 import (
20+ FallbackProcess ,
1721 create_windows_process ,
1822 get_windows_executable_command ,
1923)
@@ -180,7 +184,7 @@ async def stdin_writer():
180184 # MCP spec: stdio shutdown sequence
181185 # 1. Close input stream to server
182186 # 2. Wait for server to exit, or send SIGTERM if it doesn't exit in time
183- # 3. Send SIGKILL if still not exited
187+ # 3. Send SIGKILL if still not exited (forcibly kill on Windows)
184188 if process .stdin :
185189 try :
186190 await process .stdin .aclose ()
@@ -193,18 +197,9 @@ async def stdin_writer():
193197 with anyio .fail_after (2.0 ):
194198 await process .wait ()
195199 except TimeoutError :
196- # Process didn't exit from stdin closure, escalate to SIGTERM
197- try :
198- process .terminate ()
199- with anyio .fail_after (2.0 ):
200- await process .wait ()
201- except TimeoutError :
202- # Process didn't respond to SIGTERM, force kill it
203- process .kill ()
204- await process .wait ()
205- except ProcessLookupError :
206- # Process already exited, which is fine
207- pass
200+ # Process didn't exit from stdin closure, use our termination function
201+ # that handles child processes properly
202+ await _terminate_process_with_children (process )
208203 except ProcessLookupError :
209204 # Process already exited, which is fine
210205 pass
@@ -245,6 +240,70 @@ async def _create_platform_compatible_process(
245240 if sys .platform == "win32" :
246241 process = await create_windows_process (command , args , env , errlog , cwd )
247242 else :
248- process = await anyio .open_process ([command , * args ], env = env , stderr = errlog , cwd = cwd )
243+ process = await anyio .open_process (
244+ [command , * args ],
245+ env = env ,
246+ stderr = errlog ,
247+ cwd = cwd ,
248+ start_new_session = True ,
249+ )
249250
250251 return process
252+
253+
254+ async def _terminate_process_with_children (process : Process | FallbackProcess , timeout : float = 2.0 ) -> None :
255+ """
256+ Terminate a process and all its children using psutil.
257+
258+ This provides consistent behavior across platforms and properly
259+ handles process trees without shell commands.
260+
261+ Platform behavior:
262+ - On Unix: psutil.terminate() sends SIGTERM, allowing graceful shutdown
263+ - On Windows: psutil.terminate() calls TerminateProcess() which is immediate
264+ and doesn't allow cleanup handlers to run. This can cause ResourceWarnings
265+ for subprocess.Popen objects that don't get to clean up.
266+ """
267+ pid = getattr (process , "pid" , None )
268+ if pid is None :
269+ popen = getattr (process , "popen" , None )
270+ if popen :
271+ pid = getattr (popen , "pid" , None )
272+
273+ if not pid :
274+ # Process has no PID, cannot terminate
275+ return
276+
277+ try :
278+ parent = psutil .Process (pid )
279+ children = parent .children (recursive = True )
280+
281+ # First, try graceful termination for all children
282+ for child in children :
283+ try :
284+ child .terminate ()
285+ except psutil .NoSuchProcess :
286+ pass
287+
288+ # Then, also terminate the parent process
289+ try :
290+ parent .terminate ()
291+ except psutil .NoSuchProcess :
292+ return
293+
294+ # Wait for processes to exit gracefully, force kill any that remain
295+ all_procs = children + [parent ]
296+ _ , alive = await anyio .to_thread .run_sync (lambda : psutil .wait_procs (all_procs , timeout = timeout ))
297+ for proc in alive :
298+ try :
299+ proc .kill ()
300+ except psutil .NoSuchProcess :
301+ pass
302+
303+ # Wait a bit more for force-killed processes
304+ if alive :
305+ await anyio .to_thread .run_sync (lambda : psutil .wait_procs (alive , timeout = 0.5 ))
306+
307+ except psutil .NoSuchProcess :
308+ # Process already terminated
309+ pass
0 commit comments