[Python-checkins] bpo-39622: Interrupt the main asyncio task on Ctrl+C (GH-32105)

asvetlov webhook-mailer at python.org
Wed Mar 30 08:15:14 EDT 2022


https://github.com/python/cpython/commit/f08a191882f75bb79d42a49039892105b2212fb9
commit: f08a191882f75bb79d42a49039892105b2212fb9
branch: main
author: Andrew Svetlov <andrew.svetlov at gmail.com>
committer: asvetlov <andrew.svetlov at gmail.com>
date: 2022-03-30T15:15:06+03:00
summary:

bpo-39622: Interrupt the main asyncio task on Ctrl+C (GH-32105)

Co-authored-by: Kumar Aditya <59607654+kumaraditya303 at users.noreply.github.com>

files:
A Misc/NEWS.d/next/Library/2022-03-25-01-27-25.bpo-39622.ieBIMp.rst
M Doc/library/asyncio-runner.rst
M Lib/asyncio/runners.py
M Lib/test/test_asyncio/test_runners.py

diff --git a/Doc/library/asyncio-runner.rst b/Doc/library/asyncio-runner.rst
index 31becf192ada3..a526b459f7474 100644
--- a/Doc/library/asyncio-runner.rst
+++ b/Doc/library/asyncio-runner.rst
@@ -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.
diff --git a/Lib/asyncio/runners.py b/Lib/asyncio/runners.py
index 768a403a85bee..2bb9ca331fd68 100644
--- a/Lib/asyncio/runners.py
+++ b/Lib/asyncio/runners.py
@@ -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
 
 
@@ -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()
@@ -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:
@@ -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):
diff --git a/Lib/test/test_asyncio/test_runners.py b/Lib/test/test_asyncio/test_runners.py
index 94f26797b3309..42aa07a0e089d 100644
--- a/Lib/test/test_asyncio/test_runners.py
+++ b/Lib/test/test_asyncio/test_runners.py
@@ -1,7 +1,9 @@
+import _thread
 import asyncio
 import contextvars
 import gc
 import re
+import threading
 import unittest
 
 from unittest import mock
@@ -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):
@@ -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
 
@@ -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()
diff --git a/Misc/NEWS.d/next/Library/2022-03-25-01-27-25.bpo-39622.ieBIMp.rst b/Misc/NEWS.d/next/Library/2022-03-25-01-27-25.bpo-39622.ieBIMp.rst
new file mode 100644
index 0000000000000..25c6aa3703ad2
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2022-03-25-01-27-25.bpo-39622.ieBIMp.rst
@@ -0,0 +1 @@
+Handle Ctrl+C in asyncio programs to interrupt the main task.



More information about the Python-checkins mailing list