[3.15] gh-135736: Fix interaction between TaskGroup and aclose() (GH-154649) (#154900)

1st1 <[email protected]> Mon, 03 Aug 2026 17:21:40 -0400 (EDT)
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
https://github.com/python/cpython/commit/5bc7459474b7d3391b1d566f83728d8822654d1a
commit: 5bc7459474b7d3391b1d566f83728d8822654d1a
branch: 3.15
author: Miss Islington (bot) <[email protected]>
committer: 1st1 <[email protected]>
date: 2026-08-03T14:21:11-07:00
summary:

[3.15] gh-135736: Fix interaction between TaskGroup and aclose() (GH-154649) (#154900)

gh-135736: Fix interaction between TaskGroup and aclose() (GH-154649)

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).
(cherry picked from commit 49f96670d98c50eca769dd3f6fb5b6de11010617)

Co-authored-by: Michael J. Sullivan <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-07-24-12-29-15.gh-issue-135736.DyvA2m.rst
M Doc/library/asyncio-task.rst
M Lib/asyncio/taskgroups.py
M Lib/test/test_asyncio/test_taskgroups.py

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 8925884b9dcf731..e34c552fc19ff80 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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]