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`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we are backporting this, in that case it doesn't make sense to keep a version changed entry for 3.16

@msullivan msullivan Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Should I mark it for 3.15, then?
Or are we backporting further than that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've updated it to say 3.15. Let me know if you want something else

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think you should remove it entirely because it will be backported till 3.13.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Would we just totally drop the new documentation of this, then?
It makes me nervous to have it documented there without any versionchanged notice?

We could also list the other point releases it is in; there's precedent for that in the docs (and in asyncio docs specifically)


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