8
8
import time
9
9
import types
10
10
import weakref
11
- from contextlib import ExitStack , contextmanager , suppress
11
+ from contextlib import (
12
+ AsyncExitStack ,
13
+ ExitStack ,
14
+ asynccontextmanager ,
15
+ contextmanager ,
16
+ suppress ,
17
+ )
12
18
from math import inf , nan
13
19
from typing import TYPE_CHECKING , NoReturn , TypeVar
14
20
from unittest import mock
@@ -761,7 +767,7 @@ async def enter_scope() -> None:
761
767
assert scope .cancel_called # never become un-cancelled
762
768
763
769
764
- async def test_cancel_scope_misnesting () -> None :
770
+ async def test_cancel_scope_misnesting_1 () -> None :
765
771
outer = _core .CancelScope ()
766
772
inner = _core .CancelScope ()
767
773
with ExitStack () as stack :
@@ -771,6 +777,8 @@ async def test_cancel_scope_misnesting() -> None:
771
777
stack .close ()
772
778
# No further error is raised when exiting the inner context
773
779
780
+
781
+ async def test_cancel_scope_misnesting_2 () -> None :
774
782
# If there are other tasks inside the abandoned part of the cancel tree,
775
783
# they get cancelled when the misnesting is detected
776
784
async def task1 () -> None :
@@ -828,6 +836,8 @@ def no_context(exc: RuntimeError) -> bool:
828
836
)
829
837
assert group .matches (exc_info .value .__context__ )
830
838
839
+
840
+ async def test_cancel_scope_misnesting_3 () -> None :
831
841
# Trying to exit a cancel scope from an unrelated task raises an error
832
842
# without affecting any state
833
843
async def task3 (task_status : _core .TaskStatus [_core .CancelScope ]) -> None :
@@ -844,6 +854,130 @@ async def task3(task_status: _core.TaskStatus[_core.CancelScope]) -> None:
844
854
scope .cancel ()
845
855
846
856
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
+
847
981
@slow
848
982
async def test_timekeeping () -> None :
849
983
# probably a good idea to use a real clock for *one* test anyway...
0 commit comments