From 63f3675ca7b90a29f833b31c5e39cdde08b6b721 Mon Sep 17 00:00:00 2001 From: Sreehari Annam Date: Thu, 23 Jul 2026 08:28:32 -0400 Subject: [PATCH 1/3] gh-135736: Fix asyncio.TaskGroup swallowing errors on GeneratorExit --- Lib/asyncio/taskgroups.py | 17 +++- Lib/test/test_asyncio/test_taskgroups.py | 81 +++++++++++++++++++ ...-07-23-12-27-52.gh-issue-135736.hkuGvu.rst | 5 ++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-23-12-27-52.gh-issue-135736.hkuGvu.rst diff --git a/Lib/asyncio/taskgroups.py b/Lib/asyncio/taskgroups.py index e1ec025791a52e6..63d5a809fa5ea55 100644 --- a/Lib/asyncio/taskgroups.py +++ b/Lib/asyncio/taskgroups.py @@ -87,7 +87,7 @@ async def _aexit(self, et, exc): self._exiting = True if (exc is not None and - self._is_base_error(exc) and + (self._is_base_error(exc) or isinstance(exc, GeneratorExit)) and self._base_error is None): self._base_error = exc @@ -140,6 +140,21 @@ async def _aexit(self, et, exc): assert not self._tasks if self._base_error is not None: + if isinstance(self._base_error, GeneratorExit): + # Unlike SystemExit/KeyboardInterrupt, GeneratorExit can + # only reach here via the "async with" body itself being + # closed (e.g. an enclosing async generator's aclose()), + # not via a child task, so any collected task errors are + # about to be discarded silently. Report them instead of + # losing them. See gh-135736. + for suppressed_exc in self._errors: + self._loop.call_exception_handler({ + 'message': 'TaskGroup task exception was not ' + 'propagated because the TaskGroup body ' + 'is being closed via GeneratorExit', + 'exception': suppressed_exc, + 'task_group': self, + }) try: raise self._base_error finally: diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index e1eaa60e4df85df..fc0a3d957035580 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -608,6 +608,87 @@ async def runner(): get_error_types(cm.exception), {MyBaseExc, ZeroDivisionError} ) + async def test_taskgroup_20b(self): + # See gh-135736: a GeneratorExit raised by the "async with" body + # itself (e.g. propagating out of an enclosing async generator's + # aclose()) must propagate directly, not be wrapped in an + # ExceptionGroup, since callers of GeneratorExit-closing APIs + # only know how to handle GeneratorExit/StopAsyncIteration. + async def nested(): + try: + await asyncio.sleep(10) + finally: + raise GeneratorExit + + async def runner(): + async with taskgroups.TaskGroup() as g: + await nested() + + with self.assertRaises(GeneratorExit): + await runner() + + async def test_taskgroup_20c(self): + # Same as test_taskgroup_20b, but with a sibling task that also + # fails. The sibling's exception can't be raised (only one + # exception can propagate through GeneratorExit-closing APIs), + # but it must be reported via the loop's exception handler + # instead of silently discarded. + async def crash_soon(): + await asyncio.sleep(0.1) + 1 / 0 + + async def nested(): + try: + await asyncio.sleep(10) + finally: + raise GeneratorExit + + async def runner(): + async with taskgroups.TaskGroup() as g: + g.create_task(crash_soon()) + await nested() + + contexts = [] + loop = asyncio.get_running_loop() + loop.set_exception_handler(lambda loop, context: contexts.append(context)) + + with self.assertRaises(GeneratorExit): + await runner() + + self.assertEqual(len(contexts), 1) + self.assertIsInstance(contexts[0]['exception'], ZeroDivisionError) + + async def test_taskgroup_20d(self): + # Reproduces the original report in gh-135736: closing an async + # generator whose body awaits inside a TaskGroup must not raise + # an ExceptionGroup out of aclose(). + async def genfn(): + async with taskgroups.TaskGroup(): + yield 1 + + g = genfn() + await g.asend(None) + await g.aclose() + + async def test_taskgroup_20e(self): + # Unlike a GeneratorExit raised by the "async with" body itself + # (test_taskgroup_20b), a GeneratorExit raised by a *task* inside + # the group is not a "close this generator" signal and continues + # to be wrapped in an ExceptionGroup like any other exception. + async def crash_soon(): + await asyncio.sleep(0.1) + raise GeneratorExit + + async def runner(): + async with taskgroups.TaskGroup() as g: + g.create_task(crash_soon()) + await asyncio.sleep(10) + + with self.assertRaises(BaseExceptionGroup) as cm: + await runner() + + self.assertEqual(get_error_types(cm.exception), {GeneratorExit}) + async def _test_taskgroup_21(self): # This test doesn't work as asyncio, currently, doesn't # correctly propagate KeyboardInterrupt (or SystemExit) -- diff --git a/Misc/NEWS.d/next/Library/2026-07-23-12-27-52.gh-issue-135736.hkuGvu.rst b/Misc/NEWS.d/next/Library/2026-07-23-12-27-52.gh-issue-135736.hkuGvu.rst new file mode 100644 index 000000000000000..3e119ead70148f4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-23-12-27-52.gh-issue-135736.hkuGvu.rst @@ -0,0 +1,5 @@ +Fix :class:`asyncio.TaskGroup` raising a spurious :exc:`ExceptionGroup` instead +of propagating :exc:`GeneratorExit` directly when the ``async with`` block is +exited via an enclosing async generator's :meth:`~agen.aclose`. Errors from +sibling tasks that would otherwise be silently discarded in this case are now +reported via :meth:`loop.call_exception_handler() `. From f878aeb7f0987bd8cfb4704dd6de5384213e7575 Mon Sep 17 00:00:00 2001 From: Sreehari Annam Date: Fri, 24 Jul 2026 11:36:34 -0400 Subject: [PATCH 2/3] gh-135736: Report suppressed sibling errors for all base errors, not just GeneratorExit Per review feedback from kumaraditya303: the exception-handler reporting of suppressed sibling-task errors was previously gated on isinstance(self._base_error, GeneratorExit), but any base error (SystemExit, KeyboardInterrupt, etc.) causes the same silent discarding of collected task errors. Broaden the reporting to run whenever self._base_error is set, and add a regression test covering a non-GeneratorExit base error. --- Lib/asyncio/taskgroups.py | 27 +++++++--------- Lib/test/test_asyncio/test_taskgroups.py | 31 +++++++++++++++++++ ...-07-23-12-27-52.gh-issue-135736.hkuGvu.rst | 7 +++-- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/Lib/asyncio/taskgroups.py b/Lib/asyncio/taskgroups.py index 63d5a809fa5ea55..d9340c470c0c626 100644 --- a/Lib/asyncio/taskgroups.py +++ b/Lib/asyncio/taskgroups.py @@ -140,21 +140,18 @@ async def _aexit(self, et, exc): assert not self._tasks if self._base_error is not None: - if isinstance(self._base_error, GeneratorExit): - # Unlike SystemExit/KeyboardInterrupt, GeneratorExit can - # only reach here via the "async with" body itself being - # closed (e.g. an enclosing async generator's aclose()), - # not via a child task, so any collected task errors are - # about to be discarded silently. Report them instead of - # losing them. See gh-135736. - for suppressed_exc in self._errors: - self._loop.call_exception_handler({ - 'message': 'TaskGroup task exception was not ' - 'propagated because the TaskGroup body ' - 'is being closed via GeneratorExit', - 'exception': suppressed_exc, - 'task_group': self, - }) + # self._base_error (e.g. SystemExit, KeyboardInterrupt, or + # GeneratorExit) is about to propagate out of this method, + # which discards any other collected task errors silently. + # Report them instead of losing them. See gh-135736. + for suppressed_exc in self._errors: + self._loop.call_exception_handler({ + 'message': 'TaskGroup task exception was not ' + 'propagated because the TaskGroup body ' + 'is being closed with a BaseException', + 'exception': suppressed_exc, + 'task_group': self, + }) try: raise self._base_error finally: diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index fc0a3d957035580..096fdb0110c2679 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -689,6 +689,37 @@ async def runner(): self.assertEqual(get_error_types(cm.exception), {GeneratorExit}) + async def test_taskgroup_20f(self): + # See gh-135736: a *non*-GeneratorExit base error (here MyBaseExc, + # standing in for SystemExit/KeyboardInterrupt) must also report a + # sibling task's suppressed exception via the loop's exception + # handler, the same as the GeneratorExit case in + # test_taskgroup_20c, instead of discarding it silently. + async def crash_soon(): + await asyncio.sleep(0.1) + 1 / 0 + + async def nested(): + try: + await asyncio.sleep(10) + finally: + raise MyBaseExc + + async def runner(): + async with taskgroups.TaskGroup() as g: + g.create_task(crash_soon()) + await nested() + + contexts = [] + loop = asyncio.get_running_loop() + loop.set_exception_handler(lambda loop, context: contexts.append(context)) + + with self.assertRaises(MyBaseExc): + await runner() + + self.assertEqual(len(contexts), 1) + self.assertIsInstance(contexts[0]['exception'], ZeroDivisionError) + async def _test_taskgroup_21(self): # This test doesn't work as asyncio, currently, doesn't # correctly propagate KeyboardInterrupt (or SystemExit) -- diff --git a/Misc/NEWS.d/next/Library/2026-07-23-12-27-52.gh-issue-135736.hkuGvu.rst b/Misc/NEWS.d/next/Library/2026-07-23-12-27-52.gh-issue-135736.hkuGvu.rst index 3e119ead70148f4..0e0b3a1ce52f299 100644 --- a/Misc/NEWS.d/next/Library/2026-07-23-12-27-52.gh-issue-135736.hkuGvu.rst +++ b/Misc/NEWS.d/next/Library/2026-07-23-12-27-52.gh-issue-135736.hkuGvu.rst @@ -1,5 +1,8 @@ Fix :class:`asyncio.TaskGroup` raising a spurious :exc:`ExceptionGroup` instead of propagating :exc:`GeneratorExit` directly when the ``async with`` block is exited via an enclosing async generator's :meth:`~agen.aclose`. Errors from -sibling tasks that would otherwise be silently discarded in this case are now -reported via :meth:`loop.call_exception_handler() `. +sibling tasks that would otherwise be silently discarded whenever the +``async with`` block exits with *any* :ref:`base exception ` +(:exc:`GeneratorExit`, :exc:`KeyboardInterrupt`, :exc:`SystemExit`, etc.), not +just :exc:`GeneratorExit`, are now reported via +:meth:`loop.call_exception_handler() `. From bbf008641196504d1d3c945f782f16d12d3de91c Mon Sep 17 00:00:00 2001 From: Sreehari Annam Date: Fri, 24 Jul 2026 12:31:56 -0400 Subject: [PATCH 3/3] gh-135736: Fix test_taskgroup_20f to use KeyboardInterrupt, not a custom BaseException TaskGroup._is_base_error() only recognizes SystemExit and KeyboardInterrupt (GeneratorExit is handled separately); an arbitrary BaseException subclass like MyBaseExc never sets self._base_error, so it takes the BaseExceptionGroup-wrapping path instead of the direct-raise path this test meant to exercise, causing CI failures across all platforms. Use KeyboardInterrupt (matching the existing test_taskgroup_20) instead. --- Lib/test/test_asyncio/test_taskgroups.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py index 096fdb0110c2679..88b2f5d9cddac2d 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -690,11 +690,12 @@ async def runner(): self.assertEqual(get_error_types(cm.exception), {GeneratorExit}) async def test_taskgroup_20f(self): - # See gh-135736: a *non*-GeneratorExit base error (here MyBaseExc, - # standing in for SystemExit/KeyboardInterrupt) must also report a - # sibling task's suppressed exception via the loop's exception - # handler, the same as the GeneratorExit case in - # test_taskgroup_20c, instead of discarding it silently. + # Same setup as test_taskgroup_20 (a KeyboardInterrupt from the + # "async with" body itself, alongside a sibling task's exception), + # but confirms the gh-135736 fix also applies to non-GeneratorExit + # base errors: the sibling's exception must be reported via the + # loop's exception handler, the same as test_taskgroup_20c, instead + # of being discarded silently. async def crash_soon(): await asyncio.sleep(0.1) 1 / 0 @@ -703,7 +704,7 @@ async def nested(): try: await asyncio.sleep(10) finally: - raise MyBaseExc + raise KeyboardInterrupt async def runner(): async with taskgroups.TaskGroup() as g: @@ -714,7 +715,7 @@ async def runner(): loop = asyncio.get_running_loop() loop.set_exception_handler(lambda loop, context: contexts.append(context)) - with self.assertRaises(MyBaseExc): + with self.assertRaises(KeyboardInterrupt): await runner() self.assertEqual(len(contexts), 1)