]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-135736: Fix interaction between TaskGroup and aclose() (#154649)
authorMichael J. Sullivan <sully@msully.net>
Wed, 29 Jul 2026 21:08:45 +0000 (14:08 -0700)
committerGitHub <noreply@github.com>
Wed, 29 Jul 2026 21:08:45 +0000 (14:08 -0700)
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
Lib/asyncio/taskgroups.py
Lib/test/test_asyncio/test_taskgroups.py
Misc/NEWS.d/next/Library/2026-07-24-12-29-15.gh-issue-135736.DyvA2m.rst [new file with mode: 0644]

index 86b8c5aa34d2fe945d64ab827b74e28f12a2cade..b6f3662862eb38fb8f81b7c31ce8c543b637b978 100644 (file)
@@ -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
 ========
 
index e1ec025791a52e61e533962f689077054460e070..a431b45a19489d52f79e49f0dcd0c6ea41ae7274 100644 (file)
@@ -174,10 +174,23 @@ class TaskGroup:
                 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
 
index e1eaa60e4df85df83b53e9e663014d62cd65c973..bc246400b83e9b2f9f591fcabe53bf6ab2bd8f21 100644 (file)
@@ -1227,6 +1227,72 @@ class BaseTestTaskGroup:
         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 (file)
index 0000000..5eaa596
--- /dev/null
@@ -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.