Skip to content

Commit b038de5

Browse files
committed
Make TaskStatus.started() never return success upon failure
Specifically, make started() reparent the task even if the start() call is in an effectively cancelled scope when started() is called, and check sys.exc_info() to prevent calling task_status.started() while handling active Cancelled(s).
1 parent 39bc92a commit b038de5

File tree

2 files changed

+42
-8
lines changed

2 files changed

+42
-8
lines changed

newsfragments/2895.bugfix.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
When :func:`trio.TaskStatus.started` is called in a cancelled scope, it no
2+
longer returns (indicating that the task has been moved and is now running as a
3+
child of the nursery) without actually moving the task. Previously,
4+
:func:`trio.TaskStatus.started` would not move the task in this scenario,
5+
causing the call to :func:`trio.Nursery.start` to incorrectly wait until the
6+
started task finished, which caused deadlocks and other issues.
7+
8+
:func:`trio.TaskStatus.started` is no longer allowed to be called by an
9+
exception handler that is handling one or more :exc:`Cancelled` exceptions;
10+
attempting to do so will now raise a :exc:`RuntimeError`. Note that any code
11+
that did this prior to this ban was already buggy, because it was attempting to
12+
teleport :exc:`Cancelled` exception(s) to a place in the task tree without the
13+
corresponding :class:`CancelScope`(s) to catch them.

src/trio/_core/_run.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -856,20 +856,35 @@ def started(self: _TaskStatus[StatusT], value: StatusT) -> None:
856856
def started(self, value: StatusT | None = None) -> None:
857857
if self._value is not _NoStatus:
858858
raise RuntimeError("called 'started' twice on the same task status")
859-
self._value = cast(StatusT, value) # If None, StatusT == None
860859

861-
# If the old nursery is cancelled, then quietly quit now; the child
862-
# will eventually exit on its own, and we don't want to risk moving
863-
# children that might have propagating Cancelled exceptions into
864-
# a place with no cancelled cancel scopes to catch them.
865-
assert self._old_nursery._cancel_status is not None
866-
if self._old_nursery._cancel_status.effectively_cancelled:
867-
return
860+
# Make sure we don't move a task with propagating Cancelled exception(s)
861+
# to a place in the tree without the corresponding cancel scope(s).
862+
#
863+
# N.B.: This check is limited to the task that calls started(). If the
864+
# user uses lowlevel.current_task().parent_nursery to add other tasks to
865+
# the private implementation-detail nursery of start(), this won't be
866+
# able to check those tasks. See #1599.
867+
_, exc, _ = sys.exc_info()
868+
while exc is not None:
869+
handling_cancelled = False
870+
if isinstance(exc, Cancelled):
871+
handling_cancelled = True
872+
elif isinstance(exc, BaseExceptionGroup):
873+
matched, _ = exc.split(Cancelled)
874+
if matched:
875+
handling_cancelled = True
876+
if handling_cancelled:
877+
raise RuntimeError(
878+
"task_status.started() cannot be called while handling Cancelled(s)"
879+
)
880+
exc = exc.__context__
868881

869882
# Can't be closed, b/c we checked in start() and then _pending_starts
870883
# should keep it open.
871884
assert not self._new_nursery._closed
872885

886+
self._value = cast(StatusT, value) # If None, StatusT == None
887+
873888
# Move tasks from the old nursery to the new
874889
tasks = self._old_nursery._children
875890
self._old_nursery._children = set()
@@ -1209,6 +1224,12 @@ async def async_fn(arg1, arg2, *, task_status=trio.TASK_STATUS_IGNORED):
12091224
12101225
If the child task passes a value to :meth:`task_status.started(value) <TaskStatus.started>`,
12111226
then :meth:`start` returns this value. Otherwise, it returns ``None``.
1227+
1228+
:meth:`task_status.started() <TaskStatus.started>` cannot be called by
1229+
an exception handler (or other cleanup code, like ``finally`` blocks,
1230+
``__aexit__`` handlers, and so on) that is handling one or more
1231+
:exc:`Cancelled` exceptions. (It'll raise a :exc:`RuntimeError` if you
1232+
violate this rule.)
12121233
"""
12131234
if self._closed:
12141235
raise RuntimeError("Nursery is closed to new arrivals")

0 commit comments

Comments
 (0)