https://github.com/python/cpython/commit/49f96670d98c50eca769dd3f6fb5b6de11010617
commit: 49f96670d98c50eca769dd3f6fb5b6de11010617
branch: main
author: Michael J. Sullivan <[email protected]>
committer: 1st1 <[email protected]>
date: 2026-07-29T14:08:45-07:00
summary:
gh-135736: Fix interaction between TaskGroup and aclose() (#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).
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 86b8c5aa34d2fe..b6f3662862eb38 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 e1ec025791a52e..a431b45a19489d 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 e1eaa60e4df85d..bc246400b83e9b 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 00000000000000..5eaa596afba609
--- /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]