diff --git a/Lib/asyncio/taskgroups.py b/Lib/asyncio/taskgroups.py index e1ec025791a52e..63d5a809fa5ea5 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 e1eaa60e4df85d..fc0a3d95703558 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 00000000000000..3e119ead70148f --- /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() `.