Skip to content

Commit

Permalink
bpo-39622: Interrupt the main asyncio task on Ctrl+C (GH-32105)
Browse files Browse the repository at this point in the history
Co-authored-by: Kumar Aditya <59607654+kumaraditya303@users.noreply.github.com>
  • Loading branch information
asvetlov and kumaraditya303 authored Mar 30, 2022
1 parent 04acfa9 commit f08a191
Show file tree
Hide file tree
Showing 4 changed files with 122 additions and 2 deletions.
27 changes: 27 additions & 0 deletions Doc/library/asyncio-runner.rst
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,30 @@ Runner context manager

Embedded *loop* and *context* are created at the :keyword:`with` body entering
or the first call of :meth:`run` or :meth:`get_loop`.


Handling Keyboard Interruption
==============================

.. versionadded:: 3.11

When :const:`signal.SIGINT` is raised by :kbd:`Ctrl-C`, :exc:`KeyboardInterrupt`
exception is raised in the main thread by default. However this doesn't work with
:mod:`asyncio` because it can interrupt asyncio internals and can hang the program from
exiting.

To mitigate this issue, :mod:`asyncio` handles :const:`signal.SIGINT` as follows:

1. :meth:`asyncio.Runner.run` installs a custom :const:`signal.SIGINT` handler before
any user code is executed and removes it when exiting from the function.
2. The :class:`~asyncio.Runner` creates the main task for the passed coroutine for its
execution.
3. When :const:`signal.SIGINT` is raised by :kbd:`Ctrl-C`, the custom signal handler
cancels the main task by calling :meth:`asyncio.Task.cancel` which raises
:exc:`asyncio.CancelledError` inside the the main task. This causes the Python stack
to unwind, ``try/except`` and ``try/finally`` blocks can be used for resource
cleanup. After the main task is cancelled, :meth:`asyncio.Runner.run` raises
:exc:`KeyboardInterrupt`.
4. A user could write a tight loop which cannot be interrupted by
:meth:`asyncio.Task.cancel`, in which case the second following :kbd:`Ctrl-C`
immediately raises the :exc:`KeyboardInterrupt` without cancelling the main task.
37 changes: 36 additions & 1 deletion Lib/asyncio/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

import contextvars
import enum
import functools
import threading
import signal
import sys
from . import coroutines
from . import events
from . import exceptions
from . import tasks


Expand Down Expand Up @@ -47,6 +52,7 @@ def __init__(self, *, debug=None, loop_factory=None):
self._loop_factory = loop_factory
self._loop = None
self._context = None
self._interrupt_count = 0

def __enter__(self):
self._lazy_init()
Expand Down Expand Up @@ -89,7 +95,28 @@ def run(self, coro, *, context=None):
if context is None:
context = self._context
task = self._loop.create_task(coro, context=context)
return self._loop.run_until_complete(task)

if (threading.current_thread() is threading.main_thread()
and signal.getsignal(signal.SIGINT) is signal.default_int_handler
):
sigint_handler = functools.partial(self._on_sigint, main_task=task)
signal.signal(signal.SIGINT, sigint_handler)
else:
sigint_handler = None

self._interrupt_count = 0
try:
return self._loop.run_until_complete(task)
except exceptions.CancelledError:
if self._interrupt_count > 0 and task.uncancel() == 0:
raise KeyboardInterrupt()
else:
raise # CancelledError
finally:
if (sigint_handler is not None
and signal.getsignal(signal.SIGINT) is sigint_handler
):
signal.signal(signal.SIGINT, signal.default_int_handler)

def _lazy_init(self):
if self._state is _State.CLOSED:
Expand All @@ -105,6 +132,14 @@ def _lazy_init(self):
self._context = contextvars.copy_context()
self._state = _State.INITIALIZED

def _on_sigint(self, signum, frame, main_task):
self._interrupt_count += 1
if self._interrupt_count == 1 and not main_task.done():
main_task.cancel()
# wakeup loop if it is blocked by select() with long timeout
self._loop.call_soon_threadsafe(lambda: None)
return
raise KeyboardInterrupt()


def run(main, *, debug=None):
Expand Down
59 changes: 58 additions & 1 deletion Lib/test/test_asyncio/test_runners.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import _thread
import asyncio
import contextvars
import gc
import re
import threading
import unittest

from unittest import mock
Expand All @@ -12,6 +14,10 @@ def tearDownModule():
asyncio.set_event_loop_policy(None)


def interrupt_self():
_thread.interrupt_main()


class TestPolicy(asyncio.AbstractEventLoopPolicy):

def __init__(self, loop_factory):
Expand Down Expand Up @@ -298,7 +304,7 @@ async def get_context():

self.assertEqual(2, runner.run(get_context()).get(cvar))

def test_recursine_run(self):
def test_recursive_run(self):
async def g():
pass

Expand All @@ -318,6 +324,57 @@ async def f():
):
runner.run(f())

def test_interrupt_call_soon(self):
# The only case when task is not suspended by waiting a future
# or another task
assert threading.current_thread() is threading.main_thread()

async def coro():
with self.assertRaises(asyncio.CancelledError):
while True:
await asyncio.sleep(0)
raise asyncio.CancelledError()

with asyncio.Runner() as runner:
runner.get_loop().call_later(0.1, interrupt_self)
with self.assertRaises(KeyboardInterrupt):
runner.run(coro())

def test_interrupt_wait(self):
# interrupting when waiting a future cancels both future and main task
assert threading.current_thread() is threading.main_thread()

async def coro(fut):
with self.assertRaises(asyncio.CancelledError):
await fut
raise asyncio.CancelledError()

with asyncio.Runner() as runner:
fut = runner.get_loop().create_future()
runner.get_loop().call_later(0.1, interrupt_self)

with self.assertRaises(KeyboardInterrupt):
runner.run(coro(fut))

self.assertTrue(fut.cancelled())

def test_interrupt_cancelled_task(self):
# interrupting cancelled main task doesn't raise KeyboardInterrupt
assert threading.current_thread() is threading.main_thread()

async def subtask(task):
await asyncio.sleep(0)
task.cancel()
interrupt_self()

async def coro():
asyncio.create_task(subtask(asyncio.current_task()))
await asyncio.sleep(10)

with asyncio.Runner() as runner:
with self.assertRaises(asyncio.CancelledError):
runner.run(coro())


if __name__ == '__main__':
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Handle Ctrl+C in asyncio programs to interrupt the main task.

0 comments on commit f08a191

Please sign in to comment.