Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions Doc/library/asyncio-task.rst
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,10 @@ unless it is :exc:`asyncio.CancelledError`,
is also included in the exception group.
The same special case is made for
:exc:`KeyboardInterrupt` and :exc:`SystemExit` as in the previous paragraph.
There is an additional special case made only for the body of the
``async with``: if it raises :exc:`GeneratorExit` and none of the
other tasks raise exceptions that would be reported, then the
:exc:`GeneratorExit` is reraised.

Task groups are careful not to mix up the internal cancellation used to
"wake up" their :meth:`~object.__aexit__` with cancellation requests
Expand All @@ -456,6 +460,10 @@ reported by :meth:`asyncio.Task.cancelling`.
Improved handling of simultaneous internal and external cancellations
and correct preservation of cancellation counts.

.. versionchanged:: 3.15

Addition of the special case for :exc:`GeneratorExit`.

Sleeping
========

Expand Down
21 changes: 17 additions & 4 deletions Lib/asyncio/taskgroups.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,23 @@ async def _aexit(self, et, exc):
self._parent_task.uncancel()
self._parent_task.cancel()
try:
raise BaseExceptionGroup(
'unhandled errors in a TaskGroup',
self._errors,
) from None
# If the *only* error is a GeneratorExit from the body
# of the group, then instead of raising an
# ExceptionGroup we raise GeneratorExit. This ensures
# that async generators that use TaskGroup properly
# swallow the exception on `aclose()` while ensuring
# that no exceptions from subtasks are swallowed.
if (
et is not None
and issubclass(et, GeneratorExit)
and len(self._errors) == 1
):
raise exc
else:
raise BaseExceptionGroup(
'unhandled errors in a TaskGroup',
self._errors,
) from None
finally:
exc = None

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 @@ -1227,6 +1227,72 @@ async def fn_3():
self.assertEqual(await race(fn_1, fn_2, fn_3), 1)
self.assertListEqual(record, ["1 started", "2 started", "3 started", "1 finished"])

async def test_taskgroup_generator_exit_01(self):
# GeneratorExit in a TaskGroup should be fine
async def gen():
yield 1

async def fn():
async with asyncio.TaskGroup() as tg:
async for n in gen():
yield n

g = fn()
await g.asend(None)
await g.aclose()

async def test_taskgroup_generator_exit_02(self):
# A lone GeneratorExit in a task should still give an ExceptionGroup
async def t():
raise GeneratorExit

async def fn():
async with asyncio.TaskGroup() as tg:
tg.create_task(t())

with self.assertRaises(BaseExceptionGroup) as cm:
await fn()
self.assertEqual(get_error_types(cm.exception), {GeneratorExit})

async def test_taskgroup_generator_exit_03(self):
# A GeneratorExit in one task and an error in another should
# still give an ExceptionGroup
async def t1():
raise GeneratorExit

async def t2():
raise AssertionError('t2 failed')

async def fn():
async with asyncio.TaskGroup() as tg:
tg.create_task(t1())
tg.create_task(t2())

with self.assertRaises(BaseExceptionGroup) as cm:
await fn()

self.assertEqual(get_error_types(cm.exception), {GeneratorExit, AssertionError})

async def test_taskgroup_generator_exit_04(self):
event = asyncio.Event()
async def t():
event.set()
raise AssertionError('t failed')

async def fn():
async with asyncio.TaskGroup() as tg:
tg.create_task(t())
yield 1

g = fn()
await g.asend(None)
await event.wait() # wait for t() to run

with self.assertRaises(BaseExceptionGroup) as cm:
await g.aclose()

self.assertEqual(get_error_types(cm.exception), {GeneratorExit, AssertionError})


class TestTaskGroup(BaseTestTaskGroup, unittest.IsolatedAsyncioTestCase):
loop_factory = asyncio.EventLoop
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fix ::class:`asyncio.TaskGroup` to not wrap a :exc:`GeneratorExit` into a
:exc:`BaseExceptionGroup` if it was raised by the body of the task group and
none of the tasks in the group raised exceptions.
Loading