3636from ._asyncgens import AsyncGenerators
3737from ._concat_tb import concat_tb
3838from ._entry_queue import EntryQueue , TrioToken
39- from ._exceptions import Cancelled , RunFinishedError , TrioInternalError
39+ from ._exceptions import (
40+ Cancelled ,
41+ CancelReasonLiteral ,
42+ RunFinishedError ,
43+ TrioInternalError ,
44+ )
4045from ._instrumentation import Instruments
4146from ._ki import KIManager , enable_ki_protection
4247from ._parking_lot import GLOBAL_PARKING_LOT_BREAKER
@@ -305,7 +310,7 @@ def expire(self, now: float) -> bool:
305310 did_something = True
306311 # This implicitly calls self.remove(), so we don't need to
307312 # decrement _active here
308- cancel_scope .cancel ( )
313+ cancel_scope ._cancel ( CancelReason ( source = "deadline" ) )
309314 # If we've accumulated too many stale entries, then prune the heap to
310315 # keep it under control. (We only do this occasionally in a batch, to
311316 # keep the amortized cost down)
@@ -314,6 +319,20 @@ def expire(self, now: float) -> bool:
314319 return did_something
315320
316321
322+ @attrs .define
323+ class CancelReason :
324+ """Attached to a :class:`CancelScope` upon cancellation with details of the source of the
325+ cancellation, which is then used to construct the string in a :exc:`Cancelled`.
326+ Users can pass a ``reason`` str to :meth:`CancelScope.cancel` to set it.
327+
328+ Not publicly exported or documented.
329+ """
330+
331+ source : CancelReasonLiteral
332+ source_task : str | None = None
333+ reason : str | None = None
334+
335+
317336@attrs .define (eq = False )
318337class CancelStatus :
319338 """Tracks the cancellation status for a contiguous extent
@@ -468,6 +487,14 @@ def recalculate(self) -> None:
468487 or current .parent_cancellation_is_visible_to_us
469488 )
470489 if new_state != current .effectively_cancelled :
490+ if (
491+ current ._scope ._cancel_reason is None
492+ and current .parent_cancellation_is_visible_to_us
493+ ):
494+ assert current ._parent is not None
495+ current ._scope ._cancel_reason = (
496+ current ._parent ._scope ._cancel_reason
497+ )
471498 current .effectively_cancelled = new_state
472499 if new_state :
473500 for task in current ._tasks :
@@ -558,6 +585,8 @@ class CancelScope:
558585 _cancel_called : bool = attrs .field (default = False , init = False )
559586 cancelled_caught : bool = attrs .field (default = False , init = False )
560587
588+ _cancel_reason : CancelReason | None = attrs .field (default = None , init = False )
589+
561590 # Constructor arguments:
562591 _relative_deadline : float = attrs .field (
563592 default = inf ,
@@ -594,7 +623,7 @@ def __enter__(self) -> Self:
594623 self ._relative_deadline = inf
595624
596625 if current_time () >= self ._deadline :
597- self .cancel ( )
626+ self ._cancel ( CancelReason ( source = "deadline" ) )
598627 with self ._might_change_registered_deadline ():
599628 self ._cancel_status = CancelStatus (scope = self , parent = task ._cancel_status )
600629 task ._activate_cancel_status (self ._cancel_status )
@@ -883,19 +912,42 @@ def shield(self, new_value: bool) -> None:
883912 self ._cancel_status .recalculate ()
884913
885914 @enable_ki_protection
886- def cancel (self ) -> None :
887- """Cancels this scope immediately.
888-
889- This method is idempotent, i.e., if the scope was already
890- cancelled then this method silently does nothing.
915+ def _cancel (self , cancel_reason : CancelReason | None ) -> None :
916+ """Internal sources of cancellation should use this instead of :meth:`cancel`
917+ in order to set a more detailed :class:`CancelReason`
918+ Helper or high-level functions can use `cancel`.
891919 """
892920 if self ._cancel_called :
893921 return
922+
923+ if self ._cancel_reason is None :
924+ self ._cancel_reason = cancel_reason
925+
894926 with self ._might_change_registered_deadline ():
895927 self ._cancel_called = True
928+
896929 if self ._cancel_status is not None :
897930 self ._cancel_status .recalculate ()
898931
932+ @enable_ki_protection
933+ def cancel (self , reason : str | None = None ) -> None :
934+ """Cancels this scope immediately.
935+
936+ The optional ``reason`` argument accepts a string, which will be attached to
937+ any resulting :exc:`Cancelled` exception to help you understand where that
938+ cancellation is coming from and why it happened.
939+
940+ This method is idempotent, i.e., if the scope was already
941+ cancelled then this method silently does nothing.
942+ """
943+ try :
944+ current_task = repr (_core .current_task ())
945+ except RuntimeError :
946+ current_task = None
947+ self ._cancel (
948+ CancelReason (reason = reason , source = "explicit" , source_task = current_task )
949+ )
950+
899951 @property
900952 def cancel_called (self ) -> bool :
901953 """Readonly :class:`bool`. Records whether cancellation has been
@@ -924,7 +976,7 @@ def cancel_called(self) -> bool:
924976 # but it makes the value returned by cancel_called more
925977 # closely match expectations.
926978 if not self ._cancel_called and current_time () >= self ._deadline :
927- self .cancel ( )
979+ self ._cancel ( CancelReason ( source = "deadline" ) )
928980 return self ._cancel_called
929981
930982
@@ -1192,9 +1244,9 @@ def parent_task(self) -> Task:
11921244 "(`~trio.lowlevel.Task`): The Task that opened this nursery."
11931245 return self ._parent_task
11941246
1195- def _add_exc (self , exc : BaseException ) -> None :
1247+ def _add_exc (self , exc : BaseException , reason : CancelReason | None ) -> None :
11961248 self ._pending_excs .append (exc )
1197- self .cancel_scope .cancel ( )
1249+ self .cancel_scope ._cancel ( reason )
11981250
11991251 def _check_nursery_closed (self ) -> None :
12001252 if not any ([self ._nested_child_running , self ._children , self ._pending_starts ]):
@@ -1210,7 +1262,14 @@ def _child_finished(
12101262 ) -> None :
12111263 self ._children .remove (task )
12121264 if isinstance (outcome , Error ):
1213- self ._add_exc (outcome .error )
1265+ self ._add_exc (
1266+ outcome .error ,
1267+ CancelReason (
1268+ source = "nursery" ,
1269+ source_task = repr (task ),
1270+ reason = f"child task raised exception { outcome .error !r} " ,
1271+ ),
1272+ )
12141273 self ._check_nursery_closed ()
12151274
12161275 async def _nested_child_finished (
@@ -1220,7 +1279,14 @@ async def _nested_child_finished(
12201279 # Returns ExceptionGroup instance (or any exception if the nursery is in loose mode
12211280 # and there is just one contained exception) if there are pending exceptions
12221281 if nested_child_exc is not None :
1223- self ._add_exc (nested_child_exc )
1282+ self ._add_exc (
1283+ nested_child_exc ,
1284+ reason = CancelReason (
1285+ source = "nursery" ,
1286+ source_task = repr (self ._parent_task ),
1287+ reason = f"Code block inside nursery contextmanager raised exception { nested_child_exc !r} " ,
1288+ ),
1289+ )
12241290 self ._nested_child_running = False
12251291 self ._check_nursery_closed ()
12261292
@@ -1231,7 +1297,13 @@ async def _nested_child_finished(
12311297 def aborted (raise_cancel : _core .RaiseCancelT ) -> Abort :
12321298 exn = capture (raise_cancel ).error
12331299 if not isinstance (exn , Cancelled ):
1234- self ._add_exc (exn )
1300+ self ._add_exc (
1301+ exn ,
1302+ CancelReason (
1303+ source = "KeyboardInterrupt" ,
1304+ source_task = repr (self ._parent_task ),
1305+ ),
1306+ )
12351307 # see test_cancel_scope_exit_doesnt_create_cyclic_garbage
12361308 del exn # prevent cyclic garbage creation
12371309 return Abort .FAILED
@@ -1245,7 +1317,8 @@ def aborted(raise_cancel: _core.RaiseCancelT) -> Abort:
12451317 try :
12461318 await cancel_shielded_checkpoint ()
12471319 except BaseException as exc :
1248- self ._add_exc (exc )
1320+ # there's no children to cancel, so don't need to supply cancel reason
1321+ self ._add_exc (exc , reason = None )
12491322
12501323 popped = self ._parent_task ._child_nurseries .pop ()
12511324 assert popped is self
@@ -1575,8 +1648,17 @@ def _attempt_delivery_of_any_pending_cancel(self) -> None:
15751648 if not self ._cancel_status .effectively_cancelled :
15761649 return
15771650
1651+ reason = self ._cancel_status ._scope ._cancel_reason
1652+
15781653 def raise_cancel () -> NoReturn :
1579- raise Cancelled ._create ()
1654+ if reason is None :
1655+ raise Cancelled ._create (source = "unknown" , reason = "misnesting" )
1656+ else :
1657+ raise Cancelled ._create (
1658+ source = reason .source ,
1659+ reason = reason .reason ,
1660+ source_task = reason .source_task ,
1661+ )
15801662
15811663 self ._attempt_abort (raise_cancel )
15821664
@@ -2075,15 +2157,27 @@ async def init(
20752157 )
20762158
20772159 # Main task is done; start shutting down system tasks
2078- self .system_nursery .cancel_scope .cancel ()
2160+ self .system_nursery .cancel_scope ._cancel (
2161+ CancelReason (
2162+ source = "shutdown" ,
2163+ reason = "main task done, shutting down system tasks" ,
2164+ source_task = repr (self .init_task ),
2165+ )
2166+ )
20792167
20802168 # System nursery is closed; finalize remaining async generators
20812169 await self .asyncgens .finalize_remaining (self )
20822170
20832171 # There are no more asyncgens, which means no more user-provided
20842172 # code except possibly run_sync_soon callbacks. It's finally safe
20852173 # to stop the run_sync_soon task and exit run().
2086- run_sync_soon_nursery .cancel_scope .cancel ()
2174+ run_sync_soon_nursery .cancel_scope ._cancel (
2175+ CancelReason (
2176+ source = "shutdown" ,
2177+ reason = "main task done, shutting down run_sync_soon callbacks" ,
2178+ source_task = repr (self .init_task ),
2179+ )
2180+ )
20872181
20882182 ################
20892183 # Outside context problems
@@ -2926,7 +3020,18 @@ async def checkpoint() -> None:
29263020 if task ._cancel_status .effectively_cancelled or (
29273021 task is task ._runner .main_task and task ._runner .ki_pending
29283022 ):
2929- with CancelScope (deadline = - inf ):
3023+ cs = CancelScope (deadline = - inf )
3024+ if (
3025+ task ._cancel_status ._scope ._cancel_reason is None
3026+ and task is task ._runner .main_task
3027+ and task ._runner .ki_pending
3028+ ):
3029+ task ._cancel_status ._scope ._cancel_reason = CancelReason (
3030+ source = "KeyboardInterrupt"
3031+ )
3032+ assert task ._cancel_status ._scope ._cancel_reason is not None
3033+ cs ._cancel_reason = task ._cancel_status ._scope ._cancel_reason
3034+ with cs :
29303035 await _core .wait_task_rescheduled (lambda _ : _core .Abort .SUCCEEDED )
29313036
29323037
0 commit comments