Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-116720: Fix corner cases of taskgroups #117407

Merged
merged 21 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
90695c5
Fix taskgroups handling of parent cancellation
gvanrossum Mar 31, 2024
6c4876d
When errors and parent cancellation are both pending, re-cancel parent
gvanrossum Mar 31, 2024
df6e8b7
When uncancel() reaches zero, clear _must_cancel
gvanrossum Mar 31, 2024
c762e36
Remove 'experimental' comment
gvanrossum Apr 1, 2024
4989042
Remove unneeded Py_CLEAR(self->task_cancel_msg)
gvanrossum Apr 4, 2024
3f91f2d
Simple test for preserving cancelling() level
gvanrossum Apr 4, 2024
55ff73f
Add test for nested task groups
gvanrossum Apr 4, 2024
50fd8d6
Add test that is fixed by the re-cancel in __aexit__
gvanrossum Apr 4, 2024
93ba76a
Test that uncancel() sets _must_cancel to False
gvanrossum Apr 4, 2024
f1f89c0
Move a stray asyncio news item into the asyncio section
gvanrossum Apr 4, 2024
48c6dda
Add blurb
gvanrossum Apr 4, 2024
1e20728
Add what's new entry
gvanrossum Apr 4, 2024
bc1522c
Update uncancel docs
gvanrossum Apr 5, 2024
7f6e5ff
Update task group docs
gvanrossum Apr 5, 2024
56251ff
Merge remote-tracking branch 'origin/main' into fix-tg
gvanrossum Apr 5, 2024
c54fb22
Fix markup error
gvanrossum Apr 5, 2024
6d4b3d7
Add attribution to Arthur Tacca issue
gvanrossum Apr 5, 2024
b086609
Wording changes suggested by Carol Willing
gvanrossum Apr 8, 2024
8ac42f6
Changelog/whatsnew updates from Arthur Tacca
gvanrossum Apr 8, 2024
ac87a8e
Attempted doc improvements inspired by Arthur Tacca's feedback
gvanrossum Apr 9, 2024
be9365c
Merge remote-tracking branch 'origin/main' into fix-tg
gvanrossum Apr 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions Doc/library/asyncio-task.rst
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,28 @@ is also included in the exception group.
The same special case is made for
:exc:`KeyboardInterrupt` and :exc:`SystemExit` as in the previous paragraph.

Task groups are careful not to drop outside cancellations
when they collide with a cancellation internal to the task group.
In particular, when one task group is syntactically nested in another,
and both experience an exception in one of their child tasks simultaneously,
the inner task group will process its exceptions, and then the outer task group
will experience another cancellation and process its own exceptions.

In the case where a task group is cancelled from the outside and also must
raise an :exc:`ExceptionGroup`, it will call the parent task's
:meth:`~asyncio.Task.cancel` method. This allows a :keyword:`try` /
:keyword:`except* <except_star>` surrounding the task group to handle
the exceptions in the ``ExceptionGroup`` without losing the cancellation.
The :exc:`asyncio.CancelledError` will be raised at the next
:keyword:`await` (which may be implied at the end of a surrounding
:keyword:`async with` block).

Task groups now preserve the cancellation count
(as reported by :meth:`asyncio.Task.cancelling`).

.. versionchanged:: 3.13

Improved handling of simultaneous inside and outside cancellation.

Sleeping
========
Expand Down Expand Up @@ -1369,6 +1391,15 @@ Task Object
catching :exc:`CancelledError`, it needs to call this method to remove
the cancellation state.

When this method decrements the cancellation count to zero,
if a previous :meth:`cancel` call had arranged for a
Copy link
Contributor

@willingc willingc Apr 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if a previous :meth:`cancel` call had arranged for a
the method checks if a previous :meth:`cancel` call had arranged for

:exc:`CancelledError` to be thrown into the task,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
:exc:`CancelledError` to be thrown into the task,
:exc:`CancelledError` to be thrown into the task.

but this hadn't been done yet, that arrangement will be
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
but this hadn't been done yet, that arrangement will be
If this hadn't been done yet, that arrangement will be

rescinded (by resetting the internal ``_must_cancel`` flag).

.. versionchanged:: 3.13
Changed to rescind pending cancellation requests upon reaching zero.

.. method:: cancelling()

Return the number of pending cancellation requests to this Task, i.e.,
Expand Down
35 changes: 28 additions & 7 deletions Doc/whatsnew/3.13.rst
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,6 @@ Other Language Changes

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

* When :func:`asyncio.TaskGroup.create_task` is called on an inactive
:class:`asyncio.TaskGroup`, the given coroutine will be closed (which
prevents a :exc:`RuntimeWarning` about the given coroutine being
never awaited).

(Contributed by Arthur Tacca and Jason Zhang in :gh:`115957`.)

* The :func:`ssl.create_default_context` API now includes
:data:`ssl.VERIFY_X509_PARTIAL_CHAIN` and :data:`ssl.VERIFY_X509_STRICT`
in its default flags.
Expand Down Expand Up @@ -296,6 +289,34 @@ asyncio
with the tasks being completed.
(Contributed by Justin Arthur in :gh:`77714`.)

* When :func:`asyncio.TaskGroup.create_task` is called on an inactive
:class:`asyncio.TaskGroup`, the given coroutine will be closed (which
prevents a :exc:`RuntimeWarning` about the given coroutine being
never awaited).
(Contributed by Arthur Tacca and Jason Zhang in :gh:`115957`.)

* Improved behavior of :class:`asyncio.TaskGroup` when an outside cancellation
collides with an internal cancellation. For example, when two task groups
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

are nested and both experience an exception in a child task simultaneously,
it was possible that the outer task group would hang, because its internal
cancellation was swallowed by the inner task group.

In the case where a task group is cancelled from the outside and also must
raise an :exc:`ExceptionGroup`, it will now call the parent task's
:meth:`~asyncio.Task.cancel` method. This allows a :keyword:`try` /
:keyword:`except* <except_star>` surrounding the task group to handle
the exceptions in the ``ExceptionGroup`` without losing the cancellation.
The :exc:`asyncio.CancelledError` will be raised at the next
:keyword:`await` (which may be implied at the end of a surrounding
:keyword:`async with` block).

An added benefit of these changes is that task groups now preserve the
cancellation count (:meth:`asyncio.Task.cancelling`).

In order to handle some corner cases, :meth:`asyncio.Task.uncancel` may now
reset the undocumented ``_must_cancel`` flag when the cancellation count
reaches zero.

base64
------

Expand Down
18 changes: 12 additions & 6 deletions Lib/asyncio/taskgroups.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,6 @@ async def __aexit__(self, et, exc, tb):
propagate_cancellation_error = exc
else:
propagate_cancellation_error = None
if self._parent_cancel_requested:
# If this flag is set we *must* call uncancel().
if self._parent_task.uncancel() == 0:
# If there are no pending cancellations left,
# don't propagate CancelledError.
propagate_cancellation_error = None

if et is not None:
if not self._aborting:
Expand Down Expand Up @@ -130,6 +124,13 @@ async def __aexit__(self, et, exc, tb):
if self._base_error is not None:
raise self._base_error

if self._parent_cancel_requested:
# If this flag is set we *must* call uncancel().
if self._parent_task.uncancel() == 0:
# If there are no pending cancellations left,
# don't propagate CancelledError.
propagate_cancellation_error = None

# Propagate CancelledError if there is one, except if there
# are other errors -- those have priority.
if propagate_cancellation_error is not None and not self._errors:
Expand All @@ -139,6 +140,11 @@ async def __aexit__(self, et, exc, tb):
self._errors.append(exc)

if self._errors:
# If the parent task is being cancelled from the outside,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# If the parent task is being cancelled from the outside,
# If the parent task is being cancelled from the outside of the taskgroup,

# re-cancel it, while keeping the cancel count stable.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# re-cancel it, while keeping the cancel count stable.
# un-cancel and re-cancel the parent task, which will keep the cancel count stable.

if self._parent_task.cancelling():
self._parent_task.uncancel()
self._parent_task.cancel()
# Exceptions are heavy objects that can have object
# cycles (bad for GC); let's not keep a reference to
# a bunch of them.
Expand Down
2 changes: 2 additions & 0 deletions Lib/asyncio/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ def uncancel(self):
"""
if self._num_cancels_requested > 0:
self._num_cancels_requested -= 1
if self._num_cancels_requested == 0:
self._must_cancel = False
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this also clear the cancel message?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I doubt it -- self._cancel_message is never cleared, and it's never used unless self._must_cancel is set.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay – I was just confused by that Py_CLEAR() in the C version, but this explains it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, you're right, that CLEAR is unnecessary there too. :-)

return self._num_cancels_requested

def __eager_start(self):
Expand Down
66 changes: 66 additions & 0 deletions Lib/test/test_asyncio/test_taskgroups.py
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,72 @@ async def run_coro_after_tg_closes():
loop = asyncio.get_event_loop()
loop.run_until_complete(run_coro_after_tg_closes())

async def test_cancelling_level_preserved(self):
async def raise_after(t, e):
await asyncio.sleep(t)
raise e()

try:
async with asyncio.TaskGroup() as tg:
tg.create_task(raise_after(0.0, RuntimeError))
except* RuntimeError:
pass
self.assertEqual(asyncio.current_task().cancelling(), 0)

async def test_nested_groups_both_cancelled(self):
async def raise_after(t, e):
await asyncio.sleep(t)
raise e()

try:
async with asyncio.TaskGroup() as outer_tg:
try:
async with asyncio.TaskGroup() as inner_tg:
inner_tg.create_task(raise_after(0, RuntimeError))
outer_tg.create_task(raise_after(0, ValueError))
except* RuntimeError:
pass
else:
self.fail("RuntimeError not raised")
self.assertEqual(asyncio.current_task().cancelling(), 1)
except* ValueError:
pass
else:
self.fail("ValueError not raised")
self.assertEqual(asyncio.current_task().cancelling(), 0)

async def test_error_and_cancel(self):
event = asyncio.Event()

async def raise_error():
event.set()
await asyncio.sleep(0)
raise RuntimeError()

async def inner():
try:
async with taskgroups.TaskGroup() as tg:
tg.create_task(raise_error())
await asyncio.sleep(1)
self.fail("Sleep in group should have been cancelled")
except* RuntimeError:
self.assertEqual(asyncio.current_task().cancelling(), 1)
self.assertEqual(asyncio.current_task().cancelling(), 1)
await asyncio.sleep(1)
self.fail("Sleep after group should have been cancelled")

async def outer():
t = asyncio.create_task(inner())
await event.wait()
self.assertEqual(t.cancelling(), 0)
t.cancel()
self.assertEqual(t.cancelling(), 1)
with self.assertRaises(asyncio.CancelledError):
await t
self.assertTrue(t.cancelled())

await outer()


if __name__ == "__main__":
unittest.main()
24 changes: 24 additions & 0 deletions Lib/test/test_asyncio/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,30 @@ def on_timeout():
finally:
loop.close()

def test_uncancel_resets_must_cancel(self):

async def coro():
await fut
return 42

loop = asyncio.new_event_loop()
fut = asyncio.Future(loop=loop)
task = self.new_task(loop, coro())
loop.run_until_complete(asyncio.sleep(0)) # Get task waiting for fut
fut.set_result(None) # Make task runnable
try:
task.cancel() # Enter cancelled state
self.assertEqual(task.cancelling(), 1)
self.assertTrue(task._must_cancel)

task.uncancel() # Undo cancellation
self.assertEqual(task.cancelling(), 0)
self.assertFalse(task._must_cancel)
finally:
res = loop.run_until_complete(task)
self.assertEqual(res, 42)
loop.close()

def test_cancel(self):

def gen():
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Improved behavior of :class:`asyncio.TaskGroup` when an outside cancellation
collides with an internal cancellation. For example, when two task groups
are nested and both experience an exception in a child task simultaneously,
it was possible that the outer task group would hang, because its internal
cancellation was swallowed by the inner task group.

In the case where a task group is cancelled from the outside and also must
raise an :exc:`ExceptionGroup`, it will now call the parent task's
:meth:`~asyncio.Task.cancel` method. This allows a :keyword:`try` /
:keyword:`except* <except_star>` surrounding the task group to handle
the exceptions in the ``ExceptionGroup`` without losing the cancellation.
The :exc:`asyncio.CancelledError` will be raised at the next
:keyword:`await` (which may be implied at the end of a surrounding
:keyword:`async with` block).

An added benefit of these changes is that task groups now preserve the
cancellation count (:meth:`asyncio.Task.cancelling`).

In order to handle some corner cases, :meth:`asyncio.Task.uncancel` may now
reset the undocumented ``_must_cancel`` flag when the cancellation count
reaches zero.
3 changes: 3 additions & 0 deletions Modules/_asynciomodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -2393,6 +2393,9 @@ _asyncio_Task_uncancel_impl(TaskObj *self)
{
if (self->task_num_cancels_requested > 0) {
self->task_num_cancels_requested -= 1;
if (self->task_num_cancels_requested == 0) {
self->task_must_cancel = 0;
}
}
return PyLong_FromLong(self->task_num_cancels_requested);
}
Expand Down
Loading