10000 bpo-39622: Interrupt the main asyncio task on Ctrl+C (GH-32105) · python/cpython@f08a191 · GitHub
[go: up one dir, main page]

Skip to content

Commit f08a191

Browse files
bpo-39622: Interrupt the main asyncio task on Ctrl+C (GH-32105)
Co-authored-by: Kumar Aditya <59607654+kumaraditya303@users.noreply.github.com>
1 parent 04acfa9 commit f08a191

File tree

4 files changed

+122
-2
lines changed

4 files changed

+122
-2
lines changed

Doc/library/asyncio-runner.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,30 @@ Runner context manager
119119

120120
Embedded *loop* and *context* are created at the :keyword:`with` body entering
121121
or the first call of :meth:`run` or :meth:`get_loop`.
122+
123+
124+
Handling Keyboard Interruption
125+
==============================
126+
127+
.. versionadded:: 3.11
128+
129+
When :const:`signal.SIGINT` is raised by :kbd:`Ctrl-C`, :exc:`KeyboardInterrupt`
130+
exception is raised in the main thread by default. However this doesn't work with
131+
:mod:`asyncio` because it can interrupt asyncio internals and can hang the program from
132+
exiting.
133+
134+
To mitigate this issue, :mod:`asyncio` handles :const:`signal.SIGINT` as follows:
135+
136+
1. :meth:`asyncio.Runner.run` installs a custom :const:`signal.SIGINT` handler before
137+
any user code is executed and removes it when exiting from the function.
138+
2. The :class:`~asyncio.Runner` creates the main task for the passed coroutine for its
139+
execution.
140+
3. When :const:`signal.SIGINT` is raised by :kbd:`Ctrl-C`, the custom signal handler
141+
cancels the main task by calling :meth:`asyncio.Task.cancel` which raises
142+
:exc:`asyncio.CancelledError` inside the the main task. This causes the Python stack
143+
to unwind, ``try/except`` and ``try/finally`` blocks can be used for resource
144+
cleanup. After the main task is cancelled, :meth:`asyncio.Runner.run` raises
145+
:exc:`KeyboardInterrupt`.
146+
4. A user could write a tight loop which cannot be interrupted by
147+
:meth:`asyncio.Task.cancel`, in which case the second following :kbd:`Ctrl-C`
148+
immediately raises the :exc:`KeyboardInterrupt` without cancelling the main task.

Lib/asyncio/runners.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
import contextvars
44
import enum
5+
import functools
6+
import threading
7+
import signal
8+
import sys
59
from . import coroutines
610
from . import events
11+
from . import exceptions
712
from . import tasks
813

914

@@ -47,6 +52,7 @@ def __init__(self, *, debug=None, loop_factory=None):
4752
self._loop_factory = loop_factory
4853
self._loop = None
4954
self._context = None
55+
self._interrupt_count = 0
5056

5157
def __enter__(self):
5258
self._lazy_init()
@@ -89,7 +95,28 @@ def run(self, coro, *, context=None):
8995
if context is None:
9096
context = self._context
9197
task = self._loop.create_task(coro, context=context)
92-
return self._loop.run_until_complete(task)
98+
99+
if (threading.current_thread() is threading.main_thread()
100+
and signal.getsignal(signal.SIGINT) is signal.default_int_handler
101+
):
102+
sigint_handler = functools.partial(self._on_sigint, main_task=task)
103+
signal.signal(signal.SIGINT, sigint_handler)
104+
else:
105+
sigint_handler = None
106+
107+
self._interrupt_count = 0
108+
try:
109+
return self._loop.run_until_complete(task)
110+
except exceptions.CancelledError:
111+
if self._interrupt_count > 0 and task.uncancel() == 0:
112+
raise KeyboardInterrupt()
113+
else:
114+
raise # CancelledError
115+
finally:
116+
if (sigint_handler is not None
117+
and signal.getsignal(signal.SIGINT) is sigint_handler
118+
):
119+
signal.signal(signal.SIGINT, signal.default_int_handler)
93120

94121
def _lazy_init(self):
95122
if self._state is _State.CLOSED:
@@ -105,6 +132,14 @@ def _lazy_init(self):
105132
self._context = contextvars.copy_context()
106133
self._state = _State.INITIALIZED
107134

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

109144

110145
def run(main, *, debug=None):

Lib/test/test_asyncio/test_runners.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import _thread
12
import asyncio
23
import contextvars
34
import gc
45
import re
6+
import threading
57
import unittest
68

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

1416

17+
def interrupt_self():
18+
_thread.interrupt_main()
19+
20+
1521
class TestPolicy(asyncio.AbstractEventLoopPolicy):
1622

1723
def __init__(self, loop_factory):
@@ -298,7 +304,7 @@ async def get_context():
298304

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

301-
def test_recursine_run(self):
307+
def test_recursive_run(self):
302308
async def g():
303309
pass
304310

@@ -318,6 +324,57 @@ async def f():
318324
):
319325
runner.run(f())
320326

327+
def test_interrupt_call_soon(self):
328+
# The only case when task is not suspended by waiting a future
329+
# or another task
330+
assert threading.current_thread() is threading.main_thread()
331+
332+
async def coro():
333+
with self.assertRaises(asyncio.CancelledError):
334+
while True:
335+
await asyncio.sleep(0)
336+
raise asyncio.CancelledError()
337+
338+
with asyncio.Runner() as runner:
339+
runner.get_loop().call_later(0.1, interrupt_self)
340+
with self.assertRaises(KeyboardInterrupt):
341+
runner.run(coro())
342+
343+
def test_interrupt_wait(self):
344+
# interrupting when waiting a future cancels both future and main task
345+
assert threading.current_thread() is threading.main_thread()
346+
347+
async def coro(fut):
348+
with self.assertRaises(asyncio.CancelledError):
349+
await fut
350+
raise asyncio.CancelledError()
351+
352+
with asyncio.Runner() as runner:
353+
fut = runner.get_loop().create_future()
354+
runner.get_loop().call_later(0.1, interrupt_self)
355+
356+
with self.assertRaises(KeyboardInterrupt):
357+
runner.run(coro(fut))
358+
359+
self.assertTrue(fut.cancelled())
360+
361+
def test_interrupt_cancelled_task(self):
362+
# interrupting cancelled main task doesn't raise KeyboardInterrupt
363+
assert threading.current_thread() is threading.main_thread()
364+
365+
async def subtask(task):
366+
await asyncio.sleep(0)
367+
task.cancel()
368+
interrupt_self()
369+
370+
async def coro():
371+
asyncio.create_task(subtask(asyncio.current_task()))
372+
await asyncio.sleep(10)
373+
374+
with asyncio.Runner() as runner:
375+
with self.assertRaises(asyncio.CancelledError):
376+
runner.run(coro())
377+
321378

322379
if __name__ == '__main__':
323380
unittest.main()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Handle Ctrl+C in asyncio programs to interrupt the main task.

0 commit comments

Comments
 (0)
0