mirror of
				https://github.com/python/cpython.git
				synced 2025-11-04 07:31:38 +00:00 
			
		
		
		
	bpo-39622: Interrupt the main asyncio task on Ctrl+C (GH-32105)
Co-authored-by: Kumar Aditya <59607654+kumaraditya303@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									04acfa94bb
								
							
						
					
					
						commit
						f08a191882
					
				
					 4 changed files with 122 additions and 2 deletions
				
			
		| 
						 | 
				
			
			@ -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.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
 | 
			
		||||
        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):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
Handle Ctrl+C in asyncio programs to interrupt the main task.
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue