From 147f0f40e94e0f6867346a42871718bf116f31f4 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 24 Jul 2026 11:22:51 -0700 Subject: [PATCH 1/3] gh-135736: Fix interaction between TaskGroup and aclose() Currently if you have a generator like: ``` async def test(): async with asyncio.TaskGroup() as tg: async for x in whatever(): yield x ``` If aclose() is called on the generator and GeneratorExit is raised, the TaskGroup's __aexit__ will raise a BaseExceptionGroup, and that will be raised from the aclose() call instead of it being swallowed. Fix this by raising GeneratorExit from __aexit__ when: 1. The body of task group raised it 2. No subtasks raised exceptions This makes GeneratorExit work without swallowing other exceptions (like it would if we just added it to _is_base_error). --- Doc/library/asyncio-task.rst | 8 +++ Lib/asyncio/taskgroups.py | 21 ++++-- Lib/test/test_asyncio/test_taskgroups.py | 65 +++++++++++++++++++ ...-07-24-12-29-15.gh-issue-135736.DyvA2m.rst | 3 + 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-24-12-29-15.gh-issue-135736.DyvA2m.rst diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst index 86b8c5aa34d2fe9..ae0657529982097 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.16 + + 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..b86ea53e9eb1812 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -1227,6 +1227,71 @@ 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): + await fn() + + 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. From aad4126659af86c3b13de419ac062e287ae2f438 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 28 Jul 2026 11:04:44 -0700 Subject: [PATCH 2/3] Update Lib/test/test_asyncio/test_taskgroups.py Co-authored-by: Kumar Aditya --- Lib/test/test_asyncio/test_taskgroups.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index b86ea53e9eb1812..bc246400b83e9b2 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -1250,8 +1250,9 @@ async def fn(): async with asyncio.TaskGroup() as tg: tg.create_task(t()) - with self.assertRaises(BaseExceptionGroup): + 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 From 17bbd04f1935239bfba36995ea99bdd9e4c8cb7b Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 28 Jul 2026 11:06:57 -0700 Subject: [PATCH 3/3] Mark as versionchanged:: 3.15 --- Doc/library/asyncio-task.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst index ae0657529982097..b6f3662862eb38f 100644 --- a/Doc/library/asyncio-task.rst +++ b/Doc/library/asyncio-task.rst @@ -460,7 +460,7 @@ reported by :meth:`asyncio.Task.cancelling`. Improved handling of simultaneous internal and external cancellations and correct preservation of cancellation counts. -.. versionchanged:: 3.16 +.. versionchanged:: 3.15 Addition of the special case for :exc:`GeneratorExit`.