Skip to content

Commit 23b2d31

Browse files
jakkdlCoolCat467A5rocks
authored
Add message with debugging info to Cancelled (#3256)
--------- Co-authored-by: CoolCat467 <[email protected]> Co-authored-by: A5Rocks <[email protected]>
1 parent bb63c53 commit 23b2d31

14 files changed

+489
-103
lines changed

newsfragments/3232.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:exc:`Cancelled` strings can now display the source and reason for a cancellation. Trio-internal sources of cancellation will set this string, and :meth:`CancelScope.cancel` now has a ``reason`` string parameter that can be used to attach info to any :exc:`Cancelled` to help in debugging.

src/trio/_channel.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,9 @@ async def context_manager(
547547
yield wrapped_recv_chan
548548
# User has exited context manager, cancel to immediately close the
549549
# abandoned generator if it's still alive.
550-
nursery.cancel_scope.cancel()
550+
nursery.cancel_scope.cancel(
551+
"exited trio.as_safe_channel context manager"
552+
)
551553
except BaseExceptionGroup as eg:
552554
try:
553555
raise_single_exception_from_group(eg)

src/trio/_core/_asyncgens.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,9 @@ async def _finalize_one(
230230
# with an exception, not even a Cancelled. The inside
231231
# is cancelled so there's no deadlock risk.
232232
with _core.CancelScope(shield=True) as cancel_scope:
233-
cancel_scope.cancel()
233+
cancel_scope.cancel(
234+
reason="disallow async work when closing async generators during trio shutdown"
235+
)
234236
await agen.aclose()
235237
except BaseException:
236238
ASYNCGEN_LOGGER.exception(

src/trio/_core/_exceptions.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
3+
from functools import partial
4+
from typing import TYPE_CHECKING, Literal
5+
6+
import attrs
47

58
from trio._util import NoPublicConstructor, final
69

710
if TYPE_CHECKING:
811
from collections.abc import Callable
912

13+
from typing_extensions import Self, TypeAlias
14+
15+
CancelReasonLiteral: TypeAlias = Literal[
16+
"KeyboardInterrupt",
17+
"deadline",
18+
"explicit",
19+
"nursery",
20+
"shutdown",
21+
"unknown",
22+
]
23+
1024

1125
class TrioInternalError(Exception):
1226
"""Raised by :func:`run` if we encounter a bug in Trio, or (possibly) a
@@ -34,6 +48,7 @@ class WouldBlock(Exception):
3448

3549

3650
@final
51+
@attrs.define(eq=False, kw_only=True)
3752
class Cancelled(BaseException, metaclass=NoPublicConstructor):
3853
"""Raised by blocking calls if the surrounding scope has been cancelled.
3954
@@ -67,11 +82,42 @@ class Cancelled(BaseException, metaclass=NoPublicConstructor):
6782
6883
"""
6984

85+
source: CancelReasonLiteral
86+
# repr(Task), so as to avoid gc troubles from holding a reference
87+
source_task: str | None = None
88+
reason: str | None = None
89+
7090
def __str__(self) -> str:
71-
return "Cancelled"
91+
return (
92+
f"cancelled due to {self.source}"
93+
+ ("" if self.reason is None else f" with reason {self.reason!r}")
94+
+ ("" if self.source_task is None else f" from task {self.source_task}")
95+
)
7296

7397
def __reduce__(self) -> tuple[Callable[[], Cancelled], tuple[()]]:
74-
return (Cancelled._create, ())
98+
# The `__reduce__` tuple does not support directly passing kwargs, and the
99+
# kwargs are required so we can't use the third item for adding to __dict__,
100+
# so we use partial.
101+
return (
102+
partial(
103+
Cancelled._create,
104+
source=self.source,
105+
source_task=self.source_task,
106+
reason=self.reason,
107+
),
108+
(),
109+
)
110+
111+
if TYPE_CHECKING:
112+
# for type checking on internal code
113+
@classmethod
114+
def _create(
115+
cls,
116+
*,
117+
source: CancelReasonLiteral,
118+
source_task: str | None = None,
119+
reason: str | None = None,
120+
) -> Self: ...
75121

76122

77123
class BusyResourceError(Exception):

src/trio/_core/_run.py

Lines changed: 124 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@
3636
from ._asyncgens import AsyncGenerators
3737
from ._concat_tb import concat_tb
3838
from ._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+
)
4045
from ._instrumentation import Instruments
4146
from ._ki import KIManager, enable_ki_protection
4247
from ._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)
318337
class 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

Comments
 (0)