X Tutup
The Wayback Machine - https://web.archive.org/web/20250519072214/https://github.com/python/cpython/issues/93122
Skip to content

asyncio.gather behaves inconsistently when handling KeyboardInterruption/SystemExit #93122

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
HFrost0 opened this issue May 23, 2022 · 12 comments
Labels
topic-asyncio type-bug An unexpected behavior, bug, or error

Comments

@HFrost0
Copy link

HFrost0 commented May 23, 2022

Bug report

Here is a minimal example:

import asyncio

e = KeyboardInterrupt  # or SystemExit


async def main_task():
    await asyncio.gather(
        sub_task(),
    )


async def sub_task():
    raise e


if __name__ == '__main__':
    try:
        asyncio.run(main_task())
    except e:
        print(f'Handle {e}')

This code handles the Interrupt normally as I expected.

Handle <class 'KeyboardInterrupt'>

Process finished with exit code 0

But when I add the asyncio.sleep(0) (can be replaced by other task, not important) into main_task's asyncio.gather

import asyncio

e = KeyboardInterrupt  # or SystemExit


async def main_task():
    await asyncio.gather(
        sub_task(),
        asyncio.sleep(0)
    )


async def sub_task():
    raise e


if __name__ == '__main__':
    try:
        asyncio.run(main_task())
    except e:
        print(f'Handle {e}')

There is an unexpected traceback print out which is really confusing 💦, this traceback indicates that there is
another KeyboardInterrupt raised.

Full traceback
Traceback (most recent call last):
  File "/Users/huanghuiling/PycharmProjects/Lighting-bilibili-download/tests/log_test.py", line 45, in <module>
    asyncio.run(main_task())
  File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/runners.py", line 47, in run
    _cancel_all_tasks(loop)
  File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/runners.py", line 63, in _cancel_all_tasks
    loop.run_until_complete(
  File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/base_events.py", line 629, in run_until_complete
    self.run_forever()
  File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/base_events.py", line 596, in run_forever
    self._run_once()
  File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/base_events.py", line 1890, in _run_once
    handle._run()
  File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/events.py", line 80, in _run
    self._context.run(self._callback, *self._args)
  File "/Users/huanghuiling/PycharmProjects/Lighting-bilibili-download/tests/log_test.py", line 7, in main_task
    await asyncio.gather(
  File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/base_events.py", line 629, in run_until_complete
    self.run_forever()
  File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/base_events.py", line 596, in run_forever
    self._run_once()
  File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/base_events.py", line 1890, in _run_once
    handle._run()
  File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/events.py", line 80, in _run
    self._context.run(self._callback, *self._args)
  File "/Users/huanghuiling/PycharmProjects/Lighting-bilibili-download/tests/log_test.py", line 14, in sub_task
    raise e
KeyboardInterrupt

Process finished with exit code 0

So I go deeply into the asyncio.run, and write code (a simplified asyncio.run) below to figure out what happen.

import asyncio

e = KeyboardInterrupt  # or SystemExit


async def main_task():
    await asyncio.gather(
        sub_task(),
        asyncio.sleep(0)
    )


async def sub_task():
    raise e


if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    try:
        loop.run_until_complete(main_task())
    except e:
        print(f'Expected {e}')
    finally:
        try:
            tasks = asyncio.all_tasks(loop)
            for t in tasks:
                t.cancel()
            # ⬇️ this line will raise another KeyboardInterrupt which is unexpected ⬇️ 
            loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
        except e:
            print(f'Unexpected {e} !!!!')

This line will raise another KeyboardInterrupt which is unexpected.

loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
Expected <class 'KeyboardInterrupt'>
Unexpected <class 'KeyboardInterrupt'> !!!!

Process finished with exit code 0

Note that this line is used to cancel all tasks during gracefully shutdown (also in asyncio.run), and when I change
e to other error like IndexError(any BaseException), this code works fine without unexpected another exception.
I believe this is related to the asyncio treats SystemExit and KeyboardInterrupt in different way. For example
in events.py

def _run(self):
    try:
        self._context.run(self._callback, *self._args)
    except (SystemExit, KeyboardInterrupt):
        raise
    except BaseException as exc:
        cb = format_helpers._format_callback_source(
            self._callback, self._args)
        msg = f'Exception in callback {cb}'
        context = {
            'message': msg,
            'exception': exc,
            'handle': self,
        }
        if self._source_traceback:
            context['source_traceback'] = self._source_traceback
        self._loop.call_exception_handler(context)
    self = None  # Needed to break cycles when an exception occurs.

My question is:

  1. Why asyncio.gather behaves inconsistently.
  2. Is there any reason to treat KeyboardInterrupt differently, since the simplest way
    to solve this bug is to handle it same as BaseException.

I think user would like to handle all error consistently during the running of a task whether
it's KeyboardInterrupt or BaseException.

Even asyncio treats them in different way (incase really necessary)

asyncio.gather(sub_task())

and

asyncio.gather(sub_task(), asyncio.sleep(0))

should behave consistently, so I think this is a bug in asyncio.

Your environment

  • CPython versions tested on: 3.8, 3.9, 3.10
  • Operating system and architecture: both macOS and windows
@HFrost0 HFrost0 added the type-bug An unexpected behavior, bug, or error label May 23, 2022
@kumaraditya303
Copy link
Contributor

Can you test 3.11 and main branch as the signal handling was changed recently ?

@HFrost0
Copy link
Author

HFrost0 commented May 24, 2022

Can you test 3.11 and main branch as the signal handling was changed recently ?

@kumaraditya303 I have tested on docker 3.11.0b1-bullseye, but still got the same problem. May test main branch later.

update: tested on main branch still have the same problem

@YvesDup
Copy link
Contributor

YvesDup commented May 24, 2022

The traceback message appears at the end of the script after the last line of __name__ == __main__ block.
This is bout the main_taskcoroutine: Task exception was never retrieved

So I tried to cancel this current task when exception raises, updating your main_task coroutine as below:

async def main_task():
    try:
        r = await asyncio.gather(sub_task(), asyncio.sleep(1))

    except (SystemExit, KeyboardInterrupt): 
        # cancel current task
       asyncio.current_task().cancel()

        # give time to other tasks to run (including me)
        await asyncio.sleep(0)
        raise

    except:
        raise

I don't really figure out in detail why but now the traceback message does not appear.
I suppose that other tasks run updating their internal state. This is including the current task main_wait.
And may be some loop flags are updated too.

I wonder whether something is not missing in except (SystemExit, KeyboardInterrupt): part of the def _run(self)method ?

I'd like to understand too.

@HFrost0
Copy link
Author

HFrost0 commented May 24, 2022

@YvesDup In your case

import asyncio

e = KeyboardInterrupt  # or SystemExit


async def main_task():
    try:
        r = await asyncio.gather(sub_task(), asyncio.sleep(1))

    except (SystemExit, KeyboardInterrupt):
        # cancel current task
        asyncio.current_task().cancel()
        # give time to other tasks to run (including me)
        await asyncio.sleep(0)
        print('never reach here')
        raise
    except:
        print('')
        raise


async def sub_task():
    raise e


if __name__ == '__main__':
    try:
        asyncio.run(main_task())
    except e:
        print(f'Handle {e}')

There are two independent KeyboardInterrupt raised. One in sub_task and one in await asyncio.sleep(0).

print('never reach here')
raise

is nerver reached (test by debug). So two try except catch these two interrupt, no traceback anymore, but still very confusing why await asyncio.sleep(0) raised another interrupt🥲, or not in sleep but in event loop somewhere, I dont know.

@YvesDup
Copy link
Contributor

YvesDup commented May 24, 2022

asyncio.sleep(0) raised CancelErrorcause by asyncio.current_task().cancel() the line above
Sorry, bad solution and no more explanation.

@HFrost0
Copy link
Author

HFrost0 commented May 24, 2022

asyncio.sleep(0) raised CancelErrorcause by asyncio.current_task().cancel() the line above Sorry, bad solution and no more explanation.

I'm pretty sure it's not a CancelError but a KeyboradInterrupt.

@YvesDup
Copy link
Contributor

YvesDup commented Jun 3, 2022

Hi @HFrost0
I checked the documentation on asyncio.gather and there is nothing about the keyboardInterrupt exception. Before opening a PR, I think it would be a good idea to send a message to python-dev asking what the behavior of asyncio.gather is when a keyboardInterrupt is raised.

IMO there is also another gap in the documentation about what happens when two or more exceptions raise in tasks.

@HFrost0
Copy link
Author

HFrost0 commented Jun 9, 2022

Hi @YvesDup

Agree, but I'm a little bit busy right now and not familiar with python-dev. Could you send this message please?

I notice you already asked this question link, thank you

@YvesDup
Copy link
Contributor

YvesDup commented Jun 9, 2022

async def main_task():
    try:
        await asyncio.gather(
            sub_task(),
            asyncio.sleep(0)
        )
    except Exception as e:
        raise
   
    except BaseException as e:
        print(repr(e))

if you catch the exception in the main_task() coroutine, there is no more error with one or two tasks.

@ezio-melotti ezio-melotti moved this to Todo in asyncio Jul 17, 2022
@HFrost0
Copy link
Author

HFrost0 commented Aug 19, 2022

Any update for this?

@tfarago
Copy link

tfarago commented Sep 20, 2022

I'd like to chip in, hope it's fine. With wait one gets rid of the unexpected exception (run the excerpt below, change use_gather to True for the old behavior), although to make the code run without the additional "Task exception was never retrieved" it gets a bit messy. One could also change the ALL_COMPLETED, which simulates the behavior of gather to FIRST_EXCEPTION and then the task creations and retrieving their exceptions all goes away, however, one would need to iterate and make sure pending tasks are finished, but that's another topic.

import asyncio

e = KeyboardInterrupt  # or SystemExit


async def main_task(use_gather=True):
    loop = asyncio.get_running_loop()

    tasks = [loop.create_task(sub_task()), loop.create_task(asyncio.sleep(0))]

    try:
        if use_gather:
            await asyncio.gather(*tasks)
        else:
            await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)
    except:
        for t in tasks:
            try:
                # Prevent "Task exception was never retrieved"
                t.exception()
            except:
                pass
        raise


async def sub_task():
    raise e


if __name__ == '__main__':
    try:
        asyncio.run(main_task(use_gather=False))
    except e:
        print(f'Handle {e}')

I also bumped into the same problem as the never reach here above, an example and the wait-based solution can be found in my gist.

I think except and finally blocks not running through is a serious issue which should be addressed and before it's solved the documentation should warn about this.

@willingc
Copy link
Contributor

This would be a good issue for triagers to test the current behavior and document here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic-asyncio type-bug An unexpected behavior, bug, or error
Projects
Status: Todo
Development

No branches or pull requests

6 participants
X Tutup