Skip to content

Commit 63f3675

Browse files
committed
gh-135736: Fix asyncio.TaskGroup swallowing errors on GeneratorExit
1 parent 7745710 commit 63f3675

3 files changed

Lines changed: 102 additions & 1 deletion

File tree

Lib/asyncio/taskgroups.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ async def _aexit(self, et, exc):
8787
self._exiting = True
8888

8989
if (exc is not None and
90-
self._is_base_error(exc) and
90+
(self._is_base_error(exc) or isinstance(exc, GeneratorExit)) and
9191
self._base_error is None):
9292
self._base_error = exc
9393

@@ -140,6 +140,21 @@ async def _aexit(self, et, exc):
140140
assert not self._tasks
141141

142142
if self._base_error is not None:
143+
if isinstance(self._base_error, GeneratorExit):
144+
# Unlike SystemExit/KeyboardInterrupt, GeneratorExit can
145+
# only reach here via the "async with" body itself being
146+
# closed (e.g. an enclosing async generator's aclose()),
147+
# not via a child task, so any collected task errors are
148+
# about to be discarded silently. Report them instead of
149+
# losing them. See gh-135736.
150+
for suppressed_exc in self._errors:
151+
self._loop.call_exception_handler({
152+
'message': 'TaskGroup task exception was not '
153+
'propagated because the TaskGroup body '
154+
'is being closed via GeneratorExit',
155+
'exception': suppressed_exc,
156+
'task_group': self,
157+
})
143158
try:
144159
raise self._base_error
145160
finally:

Lib/test/test_asyncio/test_taskgroups.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,87 @@ async def runner():
608608
get_error_types(cm.exception), {MyBaseExc, ZeroDivisionError}
609609
)
610610

611+
async def test_taskgroup_20b(self):
612+
# See gh-135736: a GeneratorExit raised by the "async with" body
613+
# itself (e.g. propagating out of an enclosing async generator's
614+
# aclose()) must propagate directly, not be wrapped in an
615+
# ExceptionGroup, since callers of GeneratorExit-closing APIs
616+
# only know how to handle GeneratorExit/StopAsyncIteration.
617+
async def nested():
618+
try:
619+
await asyncio.sleep(10)
620+
finally:
621+
raise GeneratorExit
622+
623+
async def runner():
624+
async with taskgroups.TaskGroup() as g:
625+
await nested()
626+
627+
with self.assertRaises(GeneratorExit):
628+
await runner()
629+
630+
async def test_taskgroup_20c(self):
631+
# Same as test_taskgroup_20b, but with a sibling task that also
632+
# fails. The sibling's exception can't be raised (only one
633+
# exception can propagate through GeneratorExit-closing APIs),
634+
# but it must be reported via the loop's exception handler
635+
# instead of silently discarded.
636+
async def crash_soon():
637+
await asyncio.sleep(0.1)
638+
1 / 0
639+
640+
async def nested():
641+
try:
642+
await asyncio.sleep(10)
643+
finally:
644+
raise GeneratorExit
645+
646+
async def runner():
647+
async with taskgroups.TaskGroup() as g:
648+
g.create_task(crash_soon())
649+
await nested()
650+
651+
contexts = []
652+
loop = asyncio.get_running_loop()
653+
loop.set_exception_handler(lambda loop, context: contexts.append(context))
654+
655+
with self.assertRaises(GeneratorExit):
656+
await runner()
657+
658+
self.assertEqual(len(contexts), 1)
659+
self.assertIsInstance(contexts[0]['exception'], ZeroDivisionError)
660+
661+
async def test_taskgroup_20d(self):
662+
# Reproduces the original report in gh-135736: closing an async
663+
# generator whose body awaits inside a TaskGroup must not raise
664+
# an ExceptionGroup out of aclose().
665+
async def genfn():
666+
async with taskgroups.TaskGroup():
667+
yield 1
668+
669+
g = genfn()
670+
await g.asend(None)
671+
await g.aclose()
672+
673+
async def test_taskgroup_20e(self):
674+
# Unlike a GeneratorExit raised by the "async with" body itself
675+
# (test_taskgroup_20b), a GeneratorExit raised by a *task* inside
676+
# the group is not a "close this generator" signal and continues
677+
# to be wrapped in an ExceptionGroup like any other exception.
678+
async def crash_soon():
679+
await asyncio.sleep(0.1)
680+
raise GeneratorExit
681+
682+
async def runner():
683+
async with taskgroups.TaskGroup() as g:
684+
g.create_task(crash_soon())
685+
await asyncio.sleep(10)
686+
687+
with self.assertRaises(BaseExceptionGroup) as cm:
688+
await runner()
689+
690+
self.assertEqual(get_error_types(cm.exception), {GeneratorExit})
691+
611692
async def _test_taskgroup_21(self):
612693
# This test doesn't work as asyncio, currently, doesn't
613694
# correctly propagate KeyboardInterrupt (or SystemExit) --
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix :class:`asyncio.TaskGroup` raising a spurious :exc:`ExceptionGroup` instead
2+
of propagating :exc:`GeneratorExit` directly when the ``async with`` block is
3+
exited via an enclosing async generator's :meth:`~agen.aclose`. Errors from
4+
sibling tasks that would otherwise be silently discarded in this case are now
5+
reported via :meth:`loop.call_exception_handler() <asyncio.loop.call_exception_handler>`.

0 commit comments

Comments
 (0)