33import sys
44import textwrap
55import time
6+ from contextlib import AsyncExitStack
67
78import anyio
89import anyio .abc
@@ -302,14 +303,8 @@ async def _assert_stream_closed(stream: anyio.abc.SocketStream) -> None:
302303 Either is a deterministic, kernel-level signal that the process is dead —
303304 no sleeps or polling required.
304305 """
305- with anyio .fail_after (5.0 ):
306- try :
307- data = await stream .receive (1 )
308- except (anyio .EndOfStream , anyio .BrokenResourceError ):
309- return
310- pytest .fail ( # pragma: no cover
311- f"subprocess still alive after _terminate_process_tree (received { data !r} )"
312- )
306+ with anyio .fail_after (5.0 ), pytest .raises ((anyio .EndOfStream , anyio .BrokenResourceError )):
307+ await stream .receive (1 )
313308
314309
315310async def _terminate_and_reap (proc : anyio .abc .Process | FallbackProcess ) -> None :
@@ -332,10 +327,10 @@ async def _terminate_and_reap(proc: anyio.abc.Process | FallbackProcess) -> None
332327 with anyio .move_on_after (5.0 ):
333328 await _terminate_process_tree (proc )
334329 await proc .wait ()
335- if proc .stdin is not None : # pragma: no branch
336- await proc . stdin . aclose ()
337- if proc .stdout is not None : # pragma: no branch
338- await proc .stdout .aclose ()
330+ assert proc .stdin is not None
331+ assert proc . stdout is not None
332+ await proc .stdin . aclose ()
333+ await proc .stdout .aclose ()
339334
340335
341336class TestChildProcessCleanup :
@@ -348,39 +343,34 @@ class TestChildProcessCleanup:
348343 @pytest .mark .filterwarnings ("ignore::ResourceWarning" if sys .platform == "win32" else "default" )
349344 async def test_basic_child_process_cleanup (self ):
350345 """Parent spawns one child; terminating the tree kills both."""
351- sock , port = await _open_liveness_listener ()
352- stream : anyio . abc . SocketStream | None = None
353- proc = None
354- try :
346+ async with AsyncExitStack () as stack :
347+ sock , port = await _open_liveness_listener ()
348+ stack . push_async_callback ( sock . aclose )
349+
355350 # Parent spawns a child; the child connects back to us.
356351 parent_script = _spawn_then_block (_connect_back_script (port ))
357352 proc = await _create_platform_compatible_process (sys .executable , ["-c" , parent_script ])
353+ stack .push_async_callback (_terminate_and_reap , proc )
358354
359355 # Deterministic: accept() blocks until the child connects. No sleep.
360356 with anyio .fail_after (10.0 ):
361357 stream = await _accept_alive (sock )
358+ stack .push_async_callback (stream .aclose )
362359
363360 # Terminate the process tree (the behavior under test).
364361 await _terminate_process_tree (proc )
365362
366363 # Deterministic: kernel closed child's socket when it died.
367364 await _assert_stream_closed (stream )
368365
369- finally :
370- if proc is not None : # pragma: no branch
371- await _terminate_and_reap (proc )
372- if stream is not None : # pragma: no branch
373- await stream .aclose ()
374- await sock .aclose ()
375-
376366 @pytest .mark .anyio
377367 @pytest .mark .filterwarnings ("ignore::ResourceWarning" if sys .platform == "win32" else "default" )
378368 async def test_nested_process_tree (self ):
379369 """Parent → child → grandchild; terminating the tree kills all three."""
380- sock , port = await _open_liveness_listener ()
381- streams : list [ anyio . abc . SocketStream ] = []
382- proc = None
383- try :
370+ async with AsyncExitStack () as stack :
371+ sock , port = await _open_liveness_listener ()
372+ stack . push_async_callback ( sock . aclose )
373+
384374 # Build a three-level chain: parent spawns child, child spawns
385375 # grandchild. Every level connects back to our socket.
386376 grandchild = _connect_back_script (port )
@@ -393,13 +383,15 @@ async def test_nested_process_tree(self):
393383 f"subprocess.Popen([sys.executable, '-c', { child !r} ])\n " + _connect_back_script (port )
394384 )
395385 proc = await _create_platform_compatible_process (sys .executable , ["-c" , parent_script ])
386+ stack .push_async_callback (_terminate_and_reap , proc )
396387
397388 # Deterministic: three blocking accepts, one per tree level.
389+ streams : list [anyio .abc .SocketStream ] = []
398390 with anyio .fail_after (10.0 ):
399391 for _ in range (3 ):
400- # Append-in-loop intentional: preserves partially-accepted
401- # streams for cleanup in `finally` if a later accept fails.
402- streams .append (await _accept_alive ( sock )) # noqa: PERF401
392+ stream = await _accept_alive ( sock )
393+ stack . push_async_callback ( stream . aclose )
394+ streams .append (stream )
403395
404396 # Terminate the entire tree.
405397 await _terminate_process_tree (proc )
@@ -408,23 +400,16 @@ async def test_nested_process_tree(self):
408400 for stream in streams :
409401 await _assert_stream_closed (stream )
410402
411- finally :
412- if proc is not None : # pragma: no branch
413- await _terminate_and_reap (proc )
414- for stream in streams :
415- await stream .aclose ()
416- await sock .aclose ()
417-
418403 @pytest .mark .anyio
419404 @pytest .mark .filterwarnings ("ignore::ResourceWarning" if sys .platform == "win32" else "default" )
420405 async def test_early_parent_exit (self ):
421406 """Parent exits immediately on SIGTERM; process-group termination still
422407 catches the child (exercises the race where the parent dies mid-cleanup).
423408 """
424- sock , port = await _open_liveness_listener ()
425- stream : anyio . abc . SocketStream | None = None
426- proc = None
427- try :
409+ async with AsyncExitStack () as stack :
410+ sock , port = await _open_liveness_listener ()
411+ stack . push_async_callback ( sock . aclose )
412+
428413 # Parent installs a SIGTERM handler that exits immediately, spawns a
429414 # child that connects back to us, then blocks.
430415 child = _connect_back_script (port )
@@ -435,10 +420,12 @@ async def test_early_parent_exit(self):
435420 f"time.sleep(3600)\n "
436421 )
437422 proc = await _create_platform_compatible_process (sys .executable , ["-c" , parent_script ])
423+ stack .push_async_callback (_terminate_and_reap , proc )
438424
439425 # Deterministic: child connected means both parent and child are up.
440426 with anyio .fail_after (10.0 ):
441427 stream = await _accept_alive (sock )
428+ stack .push_async_callback (stream .aclose )
442429
443430 # Parent will sys.exit(0) on SIGTERM, but the process-group kill
444431 # (POSIX killpg / Windows Job Object) must still terminate the child.
@@ -447,13 +434,6 @@ async def test_early_parent_exit(self):
447434 # Child must be dead despite parent's early exit.
448435 await _assert_stream_closed (stream )
449436
450- finally :
451- if proc is not None : # pragma: no branch
452- await _terminate_and_reap (proc )
453- if stream is not None : # pragma: no branch
454- await stream .aclose ()
455- await sock .aclose ()
456-
457437
458438@pytest .mark .anyio
459439async def test_stdio_client_graceful_stdin_exit ():
0 commit comments