88import time
99import types
1010import weakref
11- from contextlib import ExitStack , contextmanager , suppress
11+ from contextlib import (
12+ AsyncExitStack ,
13+ ExitStack ,
14+ asynccontextmanager ,
15+ contextmanager ,
16+ suppress ,
17+ )
1218from math import inf , nan
1319from typing import TYPE_CHECKING , NoReturn , TypeVar
1420from unittest import mock
@@ -761,7 +767,7 @@ async def enter_scope() -> None:
761767 assert scope .cancel_called # never become un-cancelled
762768
763769
764- async def test_cancel_scope_misnesting () -> None :
770+ async def test_cancel_scope_misnesting_1 () -> None :
765771 outer = _core .CancelScope ()
766772 inner = _core .CancelScope ()
767773 with ExitStack () as stack :
@@ -771,6 +777,8 @@ async def test_cancel_scope_misnesting() -> None:
771777 stack .close ()
772778 # No further error is raised when exiting the inner context
773779
780+
781+ async def test_cancel_scope_misnesting_2 () -> None :
774782 # If there are other tasks inside the abandoned part of the cancel tree,
775783 # they get cancelled when the misnesting is detected
776784 async def task1 () -> None :
@@ -828,6 +836,8 @@ def no_context(exc: RuntimeError) -> bool:
828836 )
829837 assert group .matches (exc_info .value .__context__ )
830838
839+
840+ async def test_cancel_scope_misnesting_3 () -> None :
831841 # Trying to exit a cancel scope from an unrelated task raises an error
832842 # without affecting any state
833843 async def task3 (task_status : _core .TaskStatus [_core .CancelScope ]) -> None :
@@ -844,6 +854,130 @@ async def task3(task_status: _core.TaskStatus[_core.CancelScope]) -> None:
844854 scope .cancel ()
845855
846856
857+ # helper to check we're not outputting overly verbose tracebacks
858+ def no_cause_or_context (e : BaseException ) -> bool :
859+ return e .__cause__ is None and e .__context__ is None
860+
861+
862+ async def test_nursery_misnest () -> None :
863+ # See https://github.com/python-trio/trio/issues/3298
864+ async def inner_func () -> None :
865+ inner_nursery = await inner_cm .__aenter__ ()
866+ inner_nursery .start_soon (sleep , 1 )
867+
868+ with pytest .RaisesGroup (
869+ pytest .RaisesExc (
870+ RuntimeError , match = "Nursery stack corrupted" , check = no_cause_or_context
871+ ),
872+ check = no_cause_or_context ,
873+ ):
874+ async with _core .open_nursery () as outer_nursery :
875+ inner_cm = _core .open_nursery ()
876+ outer_nursery .start_soon (inner_func )
877+
878+
879+ def test_nursery_nested_child_misnest () -> None :
880+ # Note that this example does *not* raise an exception group.
881+ async def main () -> None :
882+ async with _core .open_nursery ():
883+ inner_cm = _core .open_nursery ()
884+ await inner_cm .__aenter__ ()
885+
886+ with pytest .raises (RuntimeError , match = "Nursery stack corrupted" ) as excinfo :
887+ _core .run (main )
888+ assert excinfo .value .__cause__ is None
889+ # This AssertionError is kind of redundant, but I don't think we want to remove
890+ # the assertion and don't think we care enough to suppress it in this specific case.
891+ assert pytest .RaisesExc (
892+ AssertionError , match = "^Nursery misnesting detected!$"
893+ ).matches (excinfo .value .__context__ )
894+ assert excinfo .value .__context__ .__cause__ is None
895+ assert excinfo .value .__context__ .__context__ is None
896+
897+
898+ async def test_asyncexitstack_nursery_misnest () -> None :
899+ # This example is trickier than the above ones, and is the one that requires
900+ # special logic of abandoned nurseries to avoid nasty internal errors that masks
901+ # the RuntimeError.
902+ @asynccontextmanager
903+ async def asynccontextmanager_that_creates_a_nursery_internally () -> (
904+ AsyncGenerator [None ]
905+ ):
906+ async with _core .open_nursery () as nursery :
907+ await nursery .start (started_sleeper )
908+ nursery .start_soon (unstarted_task )
909+ yield
910+
911+ async def started_sleeper (task_status : _core .TaskStatus [None ]) -> None :
912+ task_status .started ()
913+ await sleep_forever ()
914+
915+ async def unstarted_task () -> None :
916+ await _core .checkpoint ()
917+
918+ with pytest .RaisesGroup (
919+ pytest .RaisesGroup (
920+ pytest .RaisesExc (
921+ RuntimeError , match = "Nursery stack corrupted" , check = no_cause_or_context
922+ ),
923+ check = no_cause_or_context ,
924+ ),
925+ check = no_cause_or_context ,
926+ ):
927+ async with AsyncExitStack () as stack , _core .open_nursery () as nursery :
928+ # The asynccontextmanager is going to create a nursery that outlives this nursery!
929+ nursery .start_soon (
930+ stack .enter_async_context ,
931+ asynccontextmanager_that_creates_a_nursery_internally (),
932+ )
933+
934+
935+ def test_asyncexitstack_nursery_misnest_cleanup () -> None :
936+ # We guarantee that abandoned tasks get to do cleanup *eventually*, but exceptions
937+ # are lost. With more effort it's possible we could reschedule child tasks to exit
938+ # promptly.
939+ finally_entered = []
940+
941+ async def main () -> None :
942+ async def unstarted_task () -> None :
943+ try :
944+ await _core .checkpoint ()
945+ finally :
946+ finally_entered .append (True )
947+ raise ValueError ("this exception is lost" )
948+
949+ # rest of main() is ~identical to the above test
950+ @asynccontextmanager
951+ async def asynccontextmanager_that_creates_a_nursery_internally () -> (
952+ AsyncGenerator [None ]
953+ ):
954+ async with _core .open_nursery () as nursery :
955+ nursery .start_soon (unstarted_task )
956+ yield
957+
958+ with pytest .RaisesGroup (
959+ pytest .RaisesGroup (
960+ pytest .RaisesExc (
961+ RuntimeError ,
962+ match = "Nursery stack corrupted" ,
963+ check = no_cause_or_context ,
964+ ),
965+ check = no_cause_or_context ,
966+ ),
967+ check = no_cause_or_context ,
968+ ):
969+ async with AsyncExitStack () as stack , _core .open_nursery () as nursery :
970+ # The asynccontextmanager is going to create a nursery that outlives this nursery!
971+ nursery .start_soon (
972+ stack .enter_async_context ,
973+ asynccontextmanager_that_creates_a_nursery_internally (),
974+ )
975+ assert not finally_entered # abandoned task still hasn't been cleaned up
976+
977+ _core .run (main )
978+ assert finally_entered # now it has
979+
980+
847981@slow
848982async def test_timekeeping () -> None :
849983 # probably a good idea to use a real clock for *one* test anyway...
0 commit comments