Skip to content

gh-135736: Fix asyncio.TaskGroup swallowing errors on GeneratorExit#154538

Open
sreehariannam wants to merge 7 commits into
python:mainfrom
sreehariannam:gh-135736-taskgroup-generatorexit
Open

gh-135736: Fix asyncio.TaskGroup swallowing errors on GeneratorExit#154538
sreehariannam wants to merge 7 commits into
python:mainfrom
sreehariannam:gh-135736-taskgroup-generatorexit

Conversation

@sreehariannam

Copy link
Copy Markdown

Fixes gh-135736.

Problem

When the body of an async with asyncio.TaskGroup() block exits via a bare GeneratorExit (for example, because it's inside an async generator that gets closed with aclose()), the GeneratorExit was wrapped in a BaseExceptionGroup like any other exception. Since callers of aclose()/athrow() only know how to handle GeneratorExit (or StopAsyncIteration) coming back out, this surfaced as a spurious, hard-to-debug ExceptionGroup: unhandled errors in a TaskGroup instead of a clean generator close.

Fix

TaskGroup._aexit() now treats a GeneratorExit raised by the async with body itself as a "base error" (like SystemExit/KeyboardInterrupt) and re-raises it directly instead of wrapping it, so aclose() sees the exception type it expects.

This is deliberately scoped to the body-exception path only. TaskGroup._is_base_error() itself (used separately in _on_task_done() for exceptions raised by child tasks) is untouched — a task raising GeneratorExit internally is not a "close this generator" signal and continues to be wrapped in the group as before (covered by a new test, test_taskgroup_20e).

Since only one exception can propagate out through GeneratorExit-closing APIs, any sibling task errors that would otherwise be silently discarded in this path are now reported via loop.call_exception_handler() instead of disappearing (test_taskgroup_20c).

Testing

Added four tests (test_taskgroup_20b-test_taskgroup_20e) covering: bare propagation, propagation alongside a failing sibling task (checking the exception handler is invoked), a faithful reproduction of the original report via a real async generator + aclose(), and the unchanged task-level behavior. Verified each new test fails without the fix by reverting it locally and re-running. Full test_taskgroups suite (126 tests) passes.

Comment thread Lib/asyncio/taskgroups.py Outdated
assert not self._tasks

if self._base_error is not None:
if isinstance(self._base_error, GeneratorExit):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the exception handler should be called for all base errors.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed — the reporting is no longer gated on isinstance(self._base_error, GeneratorExit), so suppressed sibling-task errors are now reported via call_exception_handler whenever any base error (SystemExit, KeyboardInterrupt, etc.) causes the TaskGroup to exit, not just GeneratorExit. Added test_taskgroup_20f covering the non-GeneratorExit case.

@python-cla-bot

python-cla-bot Bot commented Jul 24, 2026

Copy link
Copy Markdown

All commit authors signed the Contributor License Agreement.

CLA signed

…, 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.
@sreehariannam
sreehariannam force-pushed the gh-135736-taskgroup-generatorexit branch from 331b20c to f878aeb Compare July 24, 2026 15:44
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GeneratorExit and asyncio.TaskGroup - missing from _is_base_error?

2 participants