11import os
2+ import signal
23import sys
34from contextlib import asynccontextmanager
45from pathlib import Path
56from typing import Literal , TextIO
67
78import anyio
89import anyio .lowlevel
9- import anyio .to_thread
10- import psutil
1110from anyio .abc import Process
1211from anyio .streams .memory import MemoryObjectReceiveStream , MemoryObjectSendStream
1312from anyio .streams .text import TextReceiveStream
2019 FallbackProcess ,
2120 create_windows_process ,
2221 get_windows_executable_command ,
22+ terminate_windows_process_tree ,
2323)
2424
2525# Environment variables to inherit by default
@@ -223,11 +223,15 @@ async def _create_platform_compatible_process(
223223):
224224 """
225225 Creates a subprocess in a platform-compatible way.
226- Returns a process handle.
226+
227+ Unix: Creates process in a new session/process group for killpg support
228+ Windows: Creates process in a Job Object for reliable child termination
227229 """
228230 if sys .platform == "win32" :
231+ # Windows: Use Job Objects for proper process tree management
229232 process = await create_windows_process (command , args , env , errlog , cwd )
230233 else :
234+ # Unix: Create process in new session for process group termination
231235 process = await anyio .open_process (
232236 [command , * args ],
233237 env = env ,
@@ -241,57 +245,58 @@ async def _create_platform_compatible_process(
241245
242246async def _terminate_process_with_children (process : Process | FallbackProcess , timeout : float = 2.0 ) -> None :
243247 """
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+ Terminate a process and all its children using platform-specific methods.
248249
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.
250+ Unix: Uses os.killpg() for atomic process group termination
251+ Windows: Uses Job Objects via pywin32 for reliable child process cleanup
254252 """
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 :
253+ if sys .platform == "win32" :
254+ # Windows: Use Job Object termination
255+ await terminate_windows_process_tree (process )
256+ else :
257+ # Unix: Use process groups for atomic termination
258+ pid = getattr (process , "pid" , None )
259+ if pid is None :
260+ popen = getattr (process , "popen" , None )
261+ if popen :
262+ pid = getattr (popen , "pid" , None )
263+
264+ if not pid :
280265 return
281266
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 :
267+ try :
268+ # Get process group ID (we use start_new_session=True)
269+ pgid = os .getpgid (pid )
270+
271+ # Send SIGTERM to entire process group atomically
272+ os .killpg (pgid , signal .SIGTERM )
273+
274+ # Wait for graceful termination
275+ deadline = anyio .current_time () + timeout
276+ while anyio .current_time () < deadline :
277+ try :
278+ # Check if process group still exists (signal 0 = check only)
279+ os .killpg (pgid , 0 )
280+ await anyio .sleep (0.1 )
281+ except ProcessLookupError :
282+ # Process group terminated successfully
283+ return
284+
285+ # Force kill if still alive after timeout
286286 try :
287- proc .kill ()
288- except psutil .NoSuchProcess :
287+ os .killpg (pgid , signal .SIGKILL )
288+ except ProcessLookupError :
289+ # Already dead
289290 pass
290291
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
292+ except (ProcessLookupError , PermissionError , OSError ):
293+ # Fall back to simple terminate if process group approach fails
294+ try :
295+ process .terminate ()
296+ with anyio .fail_after (timeout ):
297+ await process .wait ()
298+ except Exception :
299+ try :
300+ process .kill ()
301+ except Exception :
302+ pass
0 commit comments