diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst index 86b8c5aa34d2fe9..b6f3662862eb38f 100644 --- a/Doc/library/asyncio-task.rst +++ b/Doc/library/asyncio-task.rst @@ -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 @@ -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 ======== diff --git a/Lib/asyncio/taskgroups.py b/Lib/asyncio/taskgroups.py index e1ec025791a52e6..a431b45a19489d5 100644 --- a/Lib/asyncio/taskgroups.py +++ b/Lib/asyncio/taskgroups.py @@ -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 diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index e1eaa60e4df85df..bc246400b83e9b2 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -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 diff --git a/Misc/NEWS.d/next/Library/2026-07-24-12-29-15.gh-issue-135736.DyvA2m.rst b/Misc/NEWS.d/next/Library/2026-07-24-12-29-15.gh-issue-135736.DyvA2m.rst new file mode 100644 index 000000000000000..5eaa596afba6095 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-24-12-29-15.gh-issue-135736.DyvA2m.rst @@ -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.