Skip to content

asyncio.gather with a single task raises an unexpected CancelledError exception when SystemExit occurs in this task. #108549

Closed

Description

@YvesDup

Checklist

  • I am confident this is a bug in CPython, not a bug in a third-party project
  • I have searched the CPython issue tracker,
    and am confident this bug has not been reported before

CPython versions tested on:

3.8, 3.9, 3.10, 3.11, 3.12

Operating systems tested on:

Linux, macOS

Description:

Occasionaly since last summer, I am working on this issue #93122 to figure out why behaviors are different in asyncio.gather when there is at least one task passed to this function, and when one of them, raises an SystemExit or KeybordInterrupt.

And I found odd behavior when there is only one task that raises SystemExit (or KeybordInterrupt), as below:

import asyncio

async def sub_task():
    raise SystemExit

async def asyncio_gather():
    try:
        await asyncio.gather(
            sub_task(),
        )

    except BaseException as ee:
        print(f'{ee = }, id={hex(id(ee))}')
        raise ee

if __name__ == '__main__':
    try:
        asyncio.run(asyncio_gather())
    except BaseException as ee:
        print(f'END {ee = }, id={hex(id(ee))}')

Result is:

ee = CancelledError(), id=0x102c01e40
END ee = SystemExit(), id=0x102c01d80

Raising CancelledError exception in asyncio_gather coroutine is really unexpected. IMO it should be SystemExit. Just to check, I created a new example with a TaskGroup where the same sub_task()coroutine is created in.

import asyncio

async def sub_task():
    raise SystemExit

async def asyncio_taskgroup():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(sub_task())

    except BaseException as ee:
        print(f'{ee = }, id={hex(id(ee))}')
        raise ee

if __name__ == '__main__':
    try:
        asyncio.run(asyncio_taskgroup())
    except BaseException as ee:
        print(f'END {ee = }, id={hex(id(ee))}') 

Result is:

ee = SystemExit(), id=0x103174520
END ee = SystemExit(), id=0x103174520
Task exception was never retrieved
future: <Task finished name='Task-1' coro=<asyncio_taskgroup() done, defined at /Users/yves/Desktop/Bugs/test_ghxxxxx.py:31> exception=SystemExit()>
Traceback (most recent call last):
...
...

And to complete the problem analysis, from the first example, when asyncio.gather is called with 2 tasks as await asyncio.gather(sub_task(), asyncio.sleep(0.5)), SystemExit is raised, not CancelledError. It seems really inconsistent to raise a CancelledError exception.

Analysis:

I tried to understand why CancelledError is raised in the first example.

When the sub_task coroutine raises systemExit, this exception is propagated up to the RunnerContext Manager created in asyncio.run and finally treated in the __exit__ method of this CM.

Through the Runner.close method, there is a call to cancel_all_tasks. In this function, only one task is pending and candidate to cancel: asyncio_gather. The sub_task coroutine which raised SystemExit is finsihed. This part is processing in the call to asyncio.run_until_complete(asyncio.gather(*to_cancel, return_exceptions=True)).

At this point, asyncio_gather task waits for its GatheringFuture future ending. Later in the processing of event loop, two callbacks are executed:

  • gather.<locals>._done_callback on the pending GatheringFuture future, setting its attribut _exception to SystemExit as result of sub_task coroutine.
  • Task.__wakeup on asyncio_gather pending task, where a call to future.result() cause an SystemExit exception that is going to pass as argument to self.__step.

In this _step method, exc is changed to CancelledError, and passed to self.__step_run_and_handle_result(exc). That triggers the CancelledError exception.

Question:

Is this really an issue ? Could a cancelling task be raised CancelledError when its _exception attribute is already set to SystemExit or KeybordInterrupt ?
Maybe we need to discuss this before validating the problem and finding a fix?

Possible fix (if this is really an issue):

In case of issue/feature, a fix is to change the behavior of Task.__step as follow:

when a task is 'to cancel', the exc variable could be overwrite to CancelledError, only if exc is not already an instance of CancelledError, SystemExit or KeybordInterrupt, as below:

index 8d5bde09ea..07f916dc6d 100644
--- a/Lib/asyncio/tasks.py
+++ b/Lib/asyncio/tasks.py
@@ -283,7 +283,12 @@ def __step(self, exc=None):
             raise exceptions.InvalidStateError(
                 f'_step(): already done: {self!r}, {exc!r}')
         if self._must_cancel:
-            if not isinstance(exc, exceptions.CancelledError):
+            if not isinstance(exc, (exceptions.CancelledError,
+                                   SystemExit, KeyboardInterrupt
+                                   )):
                 exc = self._make_cancelled_error()
             self._must_cancel = False
         self._fut_waiter = None

if we validate this feature, the fix should also be made to the asyncio module: _asynciomodule.c to validate test.test_asyncio.

Linked PRs

Activity

  1. Hels15 commented on Aug 31, 2023

    @Hels15
    Contributor

    Well, I don't know much about exception handling in asyncio, but this solution sounds interesting, As you may know, the __step method is low-level and used to do one iteration which the event loop can use, also using generator terminology. We are not sure if this is an issue but this might not be the best solution, or at least looks strange to me.

  2. Hels15 commented on Sep 1, 2023

    @Hels15
    Contributor

    @YvesDup not sure what you have in mind, Your research definitely valuable might as well ask about it here(under async tag): https://discuss.python.org/.

  3. YvesDup commented on Sep 4, 2023

    @YvesDup
    ContributorAuthor

    We are not sure if this is an issue

    I think this a bug, what is your opinion ?

    this might not be the best solution

    Have you any suggestion ?

  4. gvanrossum commented on Sep 4, 2023

    @gvanrossum
    Member

    @YvesDup You're probably better off ignoring Hels15 -- even though they seem interested in learning, it seems like they are out of their depth here. I (or @kumaraditya303 or @1st1 or another asyncio expert) will get back to you for this issue, but since it's old and complicated it may be a while.

    In the meantime you may want to just treat this as a feature: asyncio treats SystemExit (and KeyboardInterrupt) different from other BaseException subclasses, and this is deeply ingrained in the code base, for reasons of backwards compatibility and user expectation. It might be better if you could avoid raising those exceptions in your application (but I understand this may be beyond your control).

  5. YvesDup commented on Aug 1, 2026

    @YvesDup
    ContributorAuthor

    @kumaraditya303 could you please take a look at this old issue. If you feel this direct request is inappropriate, please accept my apologies for bothering you.

  6. kumaraditya303 commented on Aug 3, 2026

    @kumaraditya303
    Contributor

    I'll try to take a look soon.

  7. kumaraditya303 commented on Aug 14, 2026

    @kumaraditya303
    Contributor

    I think your provided fix is correct, can you create a PR with tests?

  8. added
    stdlibStandard Library Python modules in the Lib/ directory
    on Aug 14, 2026
  9. YvesDup commented on Aug 15, 2026

    @YvesDup
    ContributorAuthor

    Thank for your feedback @kumaraditya303, I will create a PR soon.

  10. added a commit that references this issue on Sep 11, 2026
  11. moved this from Todo to Done in asyncioon Sep 11, 2026
  12. added a commit that references this issue on Sep 12, 2026
  13. added a commit that references this issue on Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    3.16new features, bugs and security fixesstdlibStandard Library Python modules in the Lib/ directorytopic-asynciotype-bugAn unexpected behavior, bug, or error

    Projects

    • Status
      Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions