diff --git a/Lib/asyncio/taskgroups.py b/Lib/asyncio/taskgroups.py index e1ec025791a52e..c09dd20978cf19 100644 --- a/Lib/asyncio/taskgroups.py +++ b/Lib/asyncio/taskgroups.py @@ -140,6 +140,18 @@ async def _aexit(self, et, exc): assert not self._tasks if self._base_error is not None: + # self._base_error (SystemExit or KeyboardInterrupt) 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 e1eaa60e4df85d..a63f71bfebba53 100644 --- a/Lib/test/test_asyncio/test_taskgroups.py +++ b/Lib/test/test_asyncio/test_taskgroups.py @@ -608,6 +608,39 @@ async def runner(): get_error_types(cm.exception), {MyBaseExc, ZeroDivisionError} ) + async def test_taskgroup_20b(self): + # Same setup as test_taskgroup_20 (a KeyboardInterrupt from the + # "async with" body itself, alongside a sibling task's exception): + # raising self._base_error out of _aexit() discards self._errors + # silently. The sibling's exception can't be raised alongside + # the KeyboardInterrupt (only one exception can propagate), but + # it must be reported via the loop's exception handler instead + # of being discarded silently. See gh-135736. + async def crash_soon(): + await asyncio.sleep(0.1) + 1 / 0 + + async def nested(): + try: + await asyncio.sleep(10) + finally: + raise KeyboardInterrupt + + 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(KeyboardInterrupt): + 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 new file mode 100644 index 00000000000000..76a8b7105f7585 --- /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` silently discarding errors from sibling +tasks whenever the ``async with`` block exits with a :exc:`SystemExit` or +:exc:`KeyboardInterrupt`. These errors are now reported via +:meth:`loop.call_exception_handler() ` +instead of being lost.