Repository navigation
asyncio.gather behaves inconsistently when handling KeyboardInterruption/SystemExit #93122
Description
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:
- Why
asyncio.gatherbehaves inconsistently. - Is there any reason to treat
KeyboardInterruptdifferently, since the simplest way
to solve this bug is to handle it same asBaseException.
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
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsTodo
Activity
kumaraditya303 commentedon May 24, 2022
Can you test 3.11 and main branch as the signal handling was changed recently ?
HFrost0 commentedon May 24, 2022
@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 commentedon 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 retrievedSo I tried to cancel this current task when exception raises, updating your
main_taskcoroutine as below: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 thedef _run(self)method ?I'd like to understand too.
HFrost0 commentedon May 24, 2022
@YvesDup In your case
There are two independent
KeyboardInterruptraised. One insub_taskand one inawait asyncio.sleep(0).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 commentedon May 24, 2022
asyncio.sleep(0)raisedCancelErrorcause byasyncio.current_task().cancel()the line aboveSorry, bad solution and no more explanation.
HFrost0 commentedon May 24, 2022
I'm pretty sure it's not a
CancelErrorbut aKeyboradInterrupt.YvesDup commentedon Jun 3, 2022
Hi @HFrost0
I checked the documentation on
asyncio.gatherand there is nothing about thekeyboardInterruptexception. Before opening a PR, I think it would be a good idea to send a message to python-dev asking what the behavior ofasyncio.gatheris when akeyboardInterruptis raised.IMO there is also another gap in the documentation about what happens when two or more exceptions raise in tasks.
HFrost0 commentedon Jun 9, 2022
Hi @YvesDup
I notice you already asked this question link, thank you
YvesDup commentedon Jun 9, 2022
if you catch the exception in the
main_task()coroutine, there is no more error with one or two tasks.HFrost0 commentedon Aug 19, 2022
Any update for this?
tfarago commentedon Sep 20, 2022
I'd like to chip in, hope it's fine. With
waitone gets rid of the unexpected exception (run the excerpt below, changeuse_gathertoTruefor 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 theALL_COMPLETED, which simulates the behavior ofgathertoFIRST_EXCEPTIONand 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.I also bumped into the same problem as the
never reach hereabove, an example and thewait-based solution can be found in my gist.I think
exceptandfinallyblocks not running through is a serious issue which should be addressed and before it's solved the documentation should warn about this.willingc commentedon Jun 21, 2024
This would be a good issue for triagers to test the current behavior and document here.
anton-ryzhov commentedon Feb 26, 2026
I've also stumbled upon it the same or similar bug. In my case after
SystemExittheIOLoopbecame broken and didn't allow other tasks to finish their work gracefully.I was debugging that using relatively fresh version and found this block that already looks incorrect — the code "saves" the exception to "handle it later" (
set_exception) and same time re-raises it right now. That exactly doubles the same exception in cases mentioned above.That causes that issue with this part of code — the
IOLoopand itsHandles don't catch these two errors and just reraise them transparently all the way up torun_until_completeorrun_foreveror so.These exceptions stops the IOLoop abruptly here and now.
Then in case of
asyncio.runuse, it tries to gracefully cancel all tasks. It cancels all the tasks and reruns the IOLoop to let them handle cancellation and exit. But here the copy of the original exception (theset_exception()branch) is re-reraised and immediately breaks the finalization loop as well.I've tried to remove one
super().set_exception(exc)or replace it withself.cancel()— these changes look logical to me. But it didn't work nicely, the task was stuck incancellingstate and the IOLoop was infinitely waiting for it.That looks like another bug that was masked with
set_exception() + raisehack.I didn't use
asyncio.gathernorasyncio.waitanywhere in my tests, these look irrelevant to the problemanton-ryzhov commentedon Feb 26, 2026
There are my test snippets. If we have only one task in the IOLoop, it gracefully stops before crashing the loop, there is nothing to stop after it, everything is nice and clean:
When I've replaced:
then the exception has stopped the
do_raisenormally, thenmainwas cancelled gracefully. But the copy of the exception inside thedo_raise: "Task exception was never retrieved".When I've added a dummy handler:
task = asyncio.create_task(do_raise()) + task.add_done_callback(lambda f: f.result())the
do_raisetask was stopped fine (as it happened before the exception reached the IOLoop) but then everything was messed up as described above:YvesDup commentedon Feb 28, 2026
From your example, when you suggest:
You have to wait for the task
task = asyncio.create_task(do_raise()) + await taskanton-ryzhov commentedon Mar 2, 2026
@YvesDup , sure, normally yes.
With this example I've showed that unawaited task doesn't crash the IOLoop:
But of course this code is incorrect. But all correct cases cause the issue.
For some reason I haven't mentioned the most obvious case, but it also fails:
YvesDup commentedon Mar 10, 2026
Please, would you provide a simple example that causes the issue as I am a bit lost.
anton-ryzhov commentedon Mar 11, 2026
Sure
Run "free-float" background task detached (not awaited) from
main:logs:
✔️ Task is stopped gracefully
✔️ Main too
😬 Task is not awaited, bad practice
❓ IOLoop was stopped indirectly even by a free-floating task
anton-ryzhov commentedon Mar 11, 2026
Second case, detach task from
mainand add dummy callback:Logs:
✔️ Task is stopped gracefully
❌ Main had no chances to shut down gracefully
❌ Main task was cancelled but the IOLoop was closed before that
anton-ryzhov commentedon Mar 11, 2026
These examples are maybe a bit esoteric — I've tried to reproduce that with the minimal amount of isolated tasks — 2 to eliminate
gatherfrom the equation.In real code
maingather-awaitstask1andtask2— no free floating, non-awaited tasks. And thentask1receivesSystemExitandtask2has no chances to complete its job