Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion Lib/asyncio/taskgroups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
81 changes: 81 additions & 0 deletions Lib/test/test_asyncio/test_taskgroups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) --
Expand Down
Original file line number Diff line number Diff line change
@@ -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() <asyncio.loop.call_exception_handler>`.
Loading