Skip to content

Commit fa58e75

Browse files
gvanrossumTinche
andauthored
gh-116720: Fix corner cases of taskgroups (#117407)
This prevents external cancellations of a task group's parent task to be dropped when an internal cancellation happens at the same time. Also strengthen the semantics of uncancel() to clear self._must_cancel when the cancellation count reaches zero. Co-Authored-By: Tin Tvrtković <[email protected]> Co-Authored-By: Arthur Tacca
1 parent 22b25d1 commit fa58e75

File tree

8 files changed

+183
-13
lines changed

8 files changed

+183
-13
lines changed

Doc/library/asyncio-task.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,27 @@ is also included in the exception group.
392392
The same special case is made for
393393
:exc:`KeyboardInterrupt` and :exc:`SystemExit` as in the previous paragraph.
394394

395+
Task groups are careful not to mix up the internal cancellation used to
396+
"wake up" their :meth:`~object.__aexit__` with cancellation requests
397+
for the task in which they are running made by other parties.
398+
In particular, when one task group is syntactically nested in another,
399+
and both experience an exception in one of their child tasks simultaneously,
400+
the inner task group will process its exceptions, and then the outer task group
401+
will receive another cancellation and process its own exceptions.
402+
403+
In the case where a task group is cancelled externally and also must
404+
raise an :exc:`ExceptionGroup`, it will call the parent task's
405+
:meth:`~asyncio.Task.cancel` method. This ensures that a
406+
:exc:`asyncio.CancelledError` will be raised at the next
407+
:keyword:`await`, so the cancellation is not lost.
408+
409+
Task groups preserve the cancellation count
410+
reported by :meth:`asyncio.Task.cancelling`.
411+
412+
.. versionchanged:: 3.13
413+
414+
Improved handling of simultaneous internal and external cancellations
415+
and correct preservation of cancellation counts.
395416

396417
Sleeping
397418
========
@@ -1369,6 +1390,15 @@ Task Object
13691390
catching :exc:`CancelledError`, it needs to call this method to remove
13701391
the cancellation state.
13711392

1393+
When this method decrements the cancellation count to zero,
1394+
the method checks if a previous :meth:`cancel` call had arranged
1395+
for :exc:`CancelledError` to be thrown into the task.
1396+
If it hasn't been thrown yet, that arrangement will be
1397+
rescinded (by resetting the internal ``_must_cancel`` flag).
1398+
1399+
.. versionchanged:: 3.13
1400+
Changed to rescind pending cancellation requests upon reaching zero.
1401+
13721402
.. method:: cancelling()
13731403

13741404
Return the number of pending cancellation requests to this Task, i.e.,

Doc/whatsnew/3.13.rst

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,6 @@ Other Language Changes
196196

197197
(Contributed by Sebastian Pipping in :gh:`115623`.)
198198

199-
* When :func:`asyncio.TaskGroup.create_task` is called on an inactive
200-
:class:`asyncio.TaskGroup`, the given coroutine will be closed (which
201-
prevents a :exc:`RuntimeWarning` about the given coroutine being
202-
never awaited).
203-
204-
(Contributed by Arthur Tacca and Jason Zhang in :gh:`115957`.)
205-
206199
* The :func:`ssl.create_default_context` API now includes
207200
:data:`ssl.VERIFY_X509_PARTIAL_CHAIN` and :data:`ssl.VERIFY_X509_STRICT`
208201
in its default flags.
@@ -300,6 +293,33 @@ asyncio
300293
with the tasks being completed.
301294
(Contributed by Justin Arthur in :gh:`77714`.)
302295

296+
* When :func:`asyncio.TaskGroup.create_task` is called on an inactive
297+
:class:`asyncio.TaskGroup`, the given coroutine will be closed (which
298+
prevents a :exc:`RuntimeWarning` about the given coroutine being
299+
never awaited).
300+
(Contributed by Arthur Tacca and Jason Zhang in :gh:`115957`.)
301+
302+
* Improved behavior of :class:`asyncio.TaskGroup` when an external cancellation
303+
collides with an internal cancellation. For example, when two task groups
304+
are nested and both experience an exception in a child task simultaneously,
305+
it was possible that the outer task group would hang, because its internal
306+
cancellation was swallowed by the inner task group.
307+
308+
In the case where a task group is cancelled externally and also must
309+
raise an :exc:`ExceptionGroup`, it will now call the parent task's
310+
:meth:`~asyncio.Task.cancel` method. This ensures that a
311+
:exc:`asyncio.CancelledError` will be raised at the next
312+
:keyword:`await`, so the cancellation is not lost.
313+
314+
An added benefit of these changes is that task groups now preserve the
315+
cancellation count (:meth:`asyncio.Task.cancelling`).
316+
317+
In order to handle some corner cases, :meth:`asyncio.Task.uncancel` may now
318+
reset the undocumented ``_must_cancel`` flag when the cancellation count
319+
reaches zero.
320+
321+
(Inspired by an issue reported by Arthur Tacca in :gh:`116720`.)
322+
303323
* Add :meth:`asyncio.Queue.shutdown` (along with
304324
:exc:`asyncio.QueueShutDown`) for queue termination.
305325
(Contributed by Laurie Opperman and Yves Duprat in :gh:`104228`.)

Lib/asyncio/taskgroups.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,6 @@ async def __aexit__(self, et, exc, tb):
7777
propagate_cancellation_error = exc
7878
else:
7979
propagate_cancellation_error = None
80-
if self._parent_cancel_requested:
81-
# If this flag is set we *must* call uncancel().
82-
if self._parent_task.uncancel() == 0:
83-
# If there are no pending cancellations left,
84-
# don't propagate CancelledError.
85-
propagate_cancellation_error = None
8680

8781
if et is not None:
8882
if not self._aborting:
@@ -130,6 +124,13 @@ async def __aexit__(self, et, exc, tb):
130124
if self._base_error is not None:
131125
raise self._base_error
132126

127+
if self._parent_cancel_requested:
128+
# If this flag is set we *must* call uncancel().
129+
if self._parent_task.uncancel() == 0:
130+
# If there are no pending cancellations left,
131+
# don't propagate CancelledError.
132+
propagate_cancellation_error = None
133+
133134
# Propagate CancelledError if there is one, except if there
134135
# are other errors -- those have priority.
135136
if propagate_cancellation_error is not None and not self._errors:
@@ -139,6 +140,12 @@ async def __aexit__(self, et, exc, tb):
139140
self._errors.append(exc)
140141

141142
if self._errors:
143+
# If the parent task is being cancelled from the outside
144+
# of the taskgroup, un-cancel and re-cancel the parent task,
145+
# which will keep the cancel count stable.
146+
if self._parent_task.cancelling():
147+
self._parent_task.uncancel()
148+
self._parent_task.cancel()
142149
# Exceptions are heavy objects that can have object
143150
# cycles (bad for GC); let's not keep a reference to
144151
# a bunch of them.

Lib/asyncio/tasks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ def uncancel(self):
255255
"""
256256
if self._num_cancels_requested > 0:
257257
self._num_cancels_requested -= 1
258+
if self._num_cancels_requested == 0:
259+
self._must_cancel = False
258260
return self._num_cancels_requested
259261

260262
def __eager_start(self):

Lib/test/test_asyncio/test_taskgroups.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,72 @@ async def run_coro_after_tg_closes():
833833
loop = asyncio.get_event_loop()
834834
loop.run_until_complete(run_coro_after_tg_closes())
835835

836+
async def test_cancelling_level_preserved(self):
837+
async def raise_after(t, e):
838+
await asyncio.sleep(t)
839+
raise e()
840+
841+
try:
842+
async with asyncio.TaskGroup() as tg:
843+
tg.create_task(raise_after(0.0, RuntimeError))
844+
except* RuntimeError:
845+
pass
846+
self.assertEqual(asyncio.current_task().cancelling(), 0)
847+
848+
async def test_nested_groups_both_cancelled(self):
849+
async def raise_after(t, e):
850+
await asyncio.sleep(t)
851+
raise e()
852+
853+
try:
854+
async with asyncio.TaskGroup() as outer_tg:
855+
try:
856+
async with asyncio.TaskGroup() as inner_tg:
857+
inner_tg.create_task(raise_after(0, RuntimeError))
858+
outer_tg.create_task(raise_after(0, ValueError))
859+
except* RuntimeError:
860+
pass
861+
else:
862+
self.fail("RuntimeError not raised")
863+
self.assertEqual(asyncio.current_task().cancelling(), 1)
864+
except* ValueError:
865+
pass
866+
else:
867+
self.fail("ValueError not raised")
868+
self.assertEqual(asyncio.current_task().cancelling(), 0)
869+
870+
async def test_error_and_cancel(self):
871+
event = asyncio.Event()
872+
873+
async def raise_error():
874+
event.set()
875+
await asyncio.sleep(0)
876+
raise RuntimeError()
877+
878+
async def inner():
879+
try:
880+
async with taskgroups.TaskGroup() as tg:
881+
tg.create_task(raise_error())
882+
await asyncio.sleep(1)
883+
self.fail("Sleep in group should have been cancelled")
884+
except* RuntimeError:
885+
self.assertEqual(asyncio.current_task().cancelling(), 1)
886+
self.assertEqual(asyncio.current_task().cancelling(), 1)
887+
await asyncio.sleep(1)
888+
self.fail("Sleep after group should have been cancelled")
889+
890+
async def outer():
891+
t = asyncio.create_task(inner())
892+
await event.wait()
893+
self.assertEqual(t.cancelling(), 0)
894+
t.cancel()
895+
self.assertEqual(t.cancelling(), 1)
896+
with self.assertRaises(asyncio.CancelledError):
897+
await t
898+
self.assertTrue(t.cancelled())
899+
900+
await outer()
901+
836902

837903
if __name__ == "__main__":
838904
unittest.main()

Lib/test/test_asyncio/test_tasks.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,30 @@ def on_timeout():
684684
finally:
685685
loop.close()
686686

687+
def test_uncancel_resets_must_cancel(self):
688+
689+
async def coro():
690+
await fut
691+
return 42
692+
693+
loop = asyncio.new_event_loop()
694+
fut = asyncio.Future(loop=loop)
695+
task = self.new_task(loop, coro())
696+
loop.run_until_complete(asyncio.sleep(0)) # Get task waiting for fut
697+
fut.set_result(None) # Make task runnable
698+
try:
699+
task.cancel() # Enter cancelled state
700+
self.assertEqual(task.cancelling(), 1)
701+
self.assertTrue(task._must_cancel)
702+
703+
task.uncancel() # Undo cancellation
704+
self.assertEqual(task.cancelling(), 0)
705+
self.assertFalse(task._must_cancel)
706+
finally:
707+
res = loop.run_until_complete(task)
708+
self.assertEqual(res, 42)
709+
loop.close()
710+
687711
def test_cancel(self):
688712

689713
def gen():
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
Improved behavior of :class:`asyncio.TaskGroup` when an external cancellation
2+
collides with an internal cancellation. For example, when two task groups
3+
are nested and both experience an exception in a child task simultaneously,
4+
it was possible that the outer task group would misbehave, because
5+
its internal cancellation was swallowed by the inner task group.
6+
7+
In the case where a task group is cancelled externally and also must
8+
raise an :exc:`ExceptionGroup`, it will now call the parent task's
9+
:meth:`~asyncio.Task.cancel` method. This ensures that a
10+
:exc:`asyncio.CancelledError` will be raised at the next
11+
:keyword:`await`, so the cancellation is not lost.
12+
13+
An added benefit of these changes is that task groups now preserve the
14+
cancellation count (:meth:`asyncio.Task.cancelling`).
15+
16+
In order to handle some corner cases, :meth:`asyncio.Task.uncancel` may now
17+
reset the undocumented ``_must_cancel`` flag when the cancellation count
18+
reaches zero.

Modules/_asynciomodule.c

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2393,6 +2393,9 @@ _asyncio_Task_uncancel_impl(TaskObj *self)
23932393
{
23942394
if (self->task_num_cancels_requested > 0) {
23952395
self->task_num_cancels_requested -= 1;
2396+
if (self->task_num_cancels_requested == 0) {
2397+
self->task_must_cancel = 0;
2398+
}
23962399
}
23972400
return PyLong_FromLong(self->task_num_cancels_requested);
23982401
}

0 commit comments

Comments
 (0)