11import os
22import shutil
3+ import subprocess
34import sys
45import tempfile
56import textwrap
2122python : str = shutil .which ("python" ) # type: ignore
2223
2324
25+ def escape_path_for_python (path : str ) -> str :
26+ """Escape a file path for use in Python code strings."""
27+ # Use forward slashes which work on all platforms and don't need escaping
28+ return repr (path .replace ("\\ " , "/" ))
29+
30+
31+ class WindowsProcessWrapper :
32+ """Minimal wrapper for subprocess.Popen to work with anyio-style process interface."""
33+
34+ def __init__ (self , popen ):
35+ self .pid = popen .pid
36+ self ._popen = popen
37+ # Add popen attribute for compatibility with _terminate_process_with_children
38+ self .popen = popen
39+
40+ async def wait (self ):
41+ while self ._popen .poll () is None :
42+ await anyio .sleep (0.1 )
43+ return self ._popen .returncode
44+
45+ def terminate (self ):
46+ self ._popen .terminate ()
47+
48+ def kill (self ):
49+ self ._popen .kill ()
50+
51+
2452@pytest .mark .anyio
2553@pytest .mark .skipif (tee is None , reason = "could not find tee command" )
2654async def test_stdio_context_manager_exiting ():
@@ -122,12 +150,13 @@ async def test_stdio_client_universal_cleanup():
122150 )
123151
124152 server_params = StdioServerParameters (
125- command = "python" ,
153+ command = sys . executable ,
126154 args = ["-c" , long_running_script ],
127155 )
128156
129157 start_time = time .time ()
130158
159+ # Use move_on_after which is more reliable for cleanup scenarios
131160 # Increased timeout to account for Windows process termination overhead
132161 with anyio .move_on_after (8.0 ) as cancel_scope :
133162 async with stdio_client (server_params ) as (read_stream , write_stream ):
@@ -335,11 +364,24 @@ def sigterm_handler(signum, frame):
335364
336365
337366@pytest .mark .anyio
367+ @pytest .mark .filterwarnings ("ignore::ResourceWarning" if sys .platform == "win32" else "default" )
338368async def test_stdio_client_child_process_cleanup ():
339369 """
340370 Test that child processes are properly terminated when the parent is killed.
341371 This addresses the issue where processes like npx spawn child processes
342372 that need to be cleaned up.
373+
374+ Note on Windows ResourceWarning:
375+ On Windows, we may see ResourceWarning about subprocess still running. This is
376+ expected behavior due to how Windows process termination works:
377+ - anyio's process.terminate() calls Windows TerminateProcess() API
378+ - TerminateProcess() immediately kills the process without allowing cleanup
379+ - subprocess.Popen objects in the killed process can't run their cleanup code
380+ - Python detects this during garbage collection and issues a ResourceWarning
381+
382+ This warning does NOT indicate a process leak - the processes are properly
383+ terminated. It only means the Popen objects couldn't clean up gracefully.
384+ This is a fundamental difference between Windows and Unix process termination.
343385 """
344386
345387 # Create a marker file for the child process to write to
@@ -360,15 +402,15 @@ async def test_stdio_client_child_process_cleanup():
360402 import os
361403
362404 # Mark that parent started
363- with open({ repr (parent_marker )} , 'w') as f:
405+ with open({ escape_path_for_python (parent_marker )} , 'w') as f:
364406 f.write('parent started\\ n')
365407
366408 # Child script that writes continuously
367- child_script = '''
409+ child_script = f '''
368410 import time
369- with open({ repr (marker_file )} , 'a') as f:
411+ with open({ escape_path_for_python (marker_file )} , 'a') as f:
370412 while True:
371- f.write(f"{{ time.time()}} \\ \\ n ")
413+ f.write(f"{ time .time ()} ")
372414 f.flush()
373415 time.sleep(0.1)
374416 '''
@@ -385,7 +427,14 @@ async def test_stdio_client_child_process_cleanup():
385427 print ("\n Starting child process termination test..." )
386428
387429 # Start the parent process directly with process group
388- proc = await anyio .open_process ([sys .executable , "-c" , parent_script ], start_new_session = True )
430+ if sys .platform == "win32" :
431+ # Windows: Don't use CREATE_NEW_PROCESS_GROUP as it isolates the process
432+ # Instead, let it inherit the parent's console which allows taskkill /T to work
433+ popen = subprocess .Popen ([sys .executable , "-c" , parent_script ])
434+ proc = WindowsProcessWrapper (popen )
435+ else :
436+ # Unix: Use start_new_session for process group creation
437+ proc = await anyio .open_process ([sys .executable , "-c" , parent_script ], start_new_session = True )
389438
390439 # Wait for processes to start
391440 await anyio .sleep (0.5 )
@@ -431,6 +480,7 @@ async def test_stdio_client_child_process_cleanup():
431480
432481
433482@pytest .mark .anyio
483+ @pytest .mark .filterwarnings ("ignore::ResourceWarning" if sys .platform == "win32" else "default" )
434484async def test_stdio_client_nested_process_tree ():
435485 """
436486 Test that a nested process tree (parent → child → grandchild) is properly cleaned up.
@@ -456,42 +506,49 @@ async def test_stdio_client_nested_process_tree():
456506 import os
457507
458508 # Child will spawn grandchild and write to child file
459- child_script = '''import subprocess
509+ child_script = f '''import subprocess
460510 import sys
461511 import time
462512
463513 # Grandchild just writes to file
464- grandchild_script = \\ " \\ " \ \ "import time
465- with open({ repr (grandchild_file )} , 'a') as f:
514+ grandchild_script = \" \" \" import time
515+ with open({ escape_path_for_python (grandchild_file )} , 'a') as f:
466516 while True:
467- f.write(f"gc {{time.time()}}\\ \\ \\ \\ n ")
517+ f.write(f"gc {{time.time()}}")
468518 f.flush()
469- time.sleep(0.1)\\ " \\ " \ \ "
519+ time.sleep(0.1)\" \" \"
470520
471521 # Spawn grandchild
472522 subprocess.Popen([sys.executable, '-c', grandchild_script])
473523
474524 # Child writes to its file
475- with open({ repr (child_file )} , 'a') as f:
525+ with open({ escape_path_for_python (child_file )} , 'a') as f:
476526 while True:
477- f.write(f"c {{ time.time()}} \\ \\ \\ \\ n ")
527+ f.write(f"c { time .time ()} ")
478528 f.flush()
479529 time.sleep(0.1)'''
480530
481531 # Spawn child process
482532 subprocess.Popen([sys.executable, '-c', child_script])
483533
484534 # Parent writes to its file
485- with open({ repr (parent_file )} , 'a') as f:
535+ with open({ escape_path_for_python (parent_file )} , 'a') as f:
486536 while True:
487- f.write(f"p {{ time.time()}} \\ \\ n ")
537+ f.write(f"p { time .time ()} ")
488538 f.flush()
489539 time.sleep(0.1)
490540 """
491541 )
492542
493543 # Start parent process directly
494- proc = await anyio .open_process ([sys .executable , "-c" , parent_script ], start_new_session = True )
544+ if sys .platform == "win32" :
545+ # Windows: Don't use CREATE_NEW_PROCESS_GROUP as it isolates the process
546+ # Instead, let it inherit the parent's console which allows taskkill /T to work
547+ popen = subprocess .Popen ([sys .executable , "-c" , parent_script ])
548+ proc = WindowsProcessWrapper (popen )
549+ else :
550+ # Unix: Use start_new_session for process group creation
551+ proc = await anyio .open_process ([sys .executable , "-c" , parent_script ], start_new_session = True )
495552
496553 # Let all processes start
497554 await anyio .sleep (1.0 )
@@ -530,6 +587,7 @@ async def test_stdio_client_nested_process_tree():
530587
531588
532589@pytest .mark .anyio
590+ @pytest .mark .filterwarnings ("ignore::ResourceWarning" if sys .platform == "win32" else "default" )
533591async def test_stdio_client_early_parent_exit ():
534592 """
535593 Test that child processes are cleaned up when parent exits during cleanup.
@@ -551,10 +609,10 @@ async def test_stdio_client_early_parent_exit():
551609 import signal
552610
553611 # Child that continues running
554- child_script = '''import time
555- with open({ repr (marker_file )} , 'a') as f:
612+ child_script = f '''import time
613+ with open({ escape_path_for_python (marker_file )} , 'a') as f:
556614 while True:
557- f.write(f"child {{ time.time()}} \\ \\ n ")
615+ f.write(f"child { time .time ()} ")
558616 f.flush()
559617 time.sleep(0.1)'''
560618
@@ -574,7 +632,14 @@ def handle_term(sig, frame):
574632 )
575633
576634 # Start the parent process
577- proc = await anyio .open_process ([sys .executable , "-c" , parent_script ], start_new_session = True )
635+ if sys .platform == "win32" :
636+ # Windows: Don't use CREATE_NEW_PROCESS_GROUP as it isolates the process
637+ # Instead, let it inherit the parent's console which allows taskkill /T to work
638+ popen = subprocess .Popen ([sys .executable , "-c" , parent_script ])
639+ proc = WindowsProcessWrapper (popen )
640+ else :
641+ # Unix: Use start_new_session for process group creation
642+ proc = await anyio .open_process ([sys .executable , "-c" , parent_script ], start_new_session = True )
578643
579644 # Let child start writing
580645 await anyio .sleep (0.5 )
0 commit comments