Skip to content

Commit 135a2c6

Browse files
committed
fix ctrl+c handling, #37
1 parent 802e096 commit 135a2c6

2 files changed

Lines changed: 91 additions & 1 deletion

File tree

python/rsloop/_run.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

33
import asyncio as __asyncio
4+
import signal as __signal
45
import sys as __sys
6+
import threading as __threading
57
import typing as __typing
68

79
from ._loop_compat import Loop
@@ -11,6 +13,10 @@
1113
_PREVIOUS_EVENT_LOOP_POLICY: __typing.Optional[__asyncio.AbstractEventLoopPolicy] = None
1214

1315

16+
def _noop() -> None:
17+
pass
18+
19+
1420
def new_event_loop() -> Loop:
1521
return Loop()
1622

@@ -48,6 +54,25 @@ def uninstall() -> None:
4854
_PREVIOUS_EVENT_LOOP_POLICY = None
4955

5056

57+
class _SigintHandler:
58+
def __init__(self, loop: Loop, main_task: __asyncio.Task[__typing.Any]) -> None:
59+
self._loop = loop
60+
self._main_task = main_task
61+
self.interrupt_count = 0
62+
63+
def __call__(
64+
self,
65+
signum: int,
66+
frame: __typing.Optional[__typing.Any],
67+
) -> None:
68+
self.interrupt_count += 1
69+
if self.interrupt_count == 1 and not self._main_task.done():
70+
self._main_task.cancel()
71+
self._loop.call_soon_threadsafe(_noop)
72+
return
73+
raise KeyboardInterrupt()
74+
75+
5176
if __typing.TYPE_CHECKING:
5277

5378
def run(
@@ -86,7 +111,31 @@ async def wrapper():
86111
__asyncio.set_event_loop(loop)
87112
if debug is not None:
88113
loop.set_debug(debug)
89-
return loop.run_until_complete(wrapper())
114+
main_task = loop.create_task(wrapper())
115+
sigint_handler = None
116+
if (
117+
__threading.current_thread() is __threading.main_thread()
118+
and __signal.getsignal(__signal.SIGINT) is __signal.default_int_handler
119+
):
120+
sigint_handler = _SigintHandler(loop, main_task)
121+
try:
122+
__signal.signal(__signal.SIGINT, sigint_handler)
123+
except ValueError:
124+
sigint_handler = None
125+
try:
126+
return loop.run_until_complete(main_task)
127+
except __asyncio.CancelledError:
128+
if sigint_handler is not None and sigint_handler.interrupt_count > 0:
129+
uncancel = getattr(main_task, "uncancel", None)
130+
if uncancel is None or uncancel() == 0:
131+
raise KeyboardInterrupt()
132+
raise
133+
finally:
134+
if (
135+
sigint_handler is not None
136+
and __signal.getsignal(__signal.SIGINT) is sigint_handler
137+
):
138+
__signal.signal(__signal.SIGINT, __signal.default_int_handler)
90139
finally:
91140
try:
92141
__cancel_all_tasks(loop)

tests/test_run.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,47 @@ async def main() -> str:
102102

103103
self.assertEqual(rsloop.run(main()), "ok")
104104

105+
def test_run_fallback_handles_sigint(self) -> None:
106+
script = r"""
107+
import asyncio
108+
import signal
109+
import sys
110+
import threading
111+
112+
import rsloop
113+
import rsloop._run as rsloop_run
114+
115+
rsloop_run.__sys.version_info = (3, 11)
116+
117+
async def main():
118+
try:
119+
await asyncio.sleep(60)
120+
finally:
121+
print("main-cancelled", flush=True)
122+
123+
threading.Timer(0.1, lambda: signal.raise_signal(signal.SIGINT)).start()
124+
try:
125+
rsloop.run(main())
126+
except KeyboardInterrupt:
127+
print("keyboard-interrupt", flush=True)
128+
raise SystemExit(0)
129+
except BaseException as exc:
130+
print(f"unexpected: {type(exc).__name__}: {exc}", flush=True)
131+
raise SystemExit(2)
132+
else:
133+
print("no-interrupt", flush=True)
134+
raise SystemExit(3)
135+
"""
136+
proc = subprocess.run(
137+
[sys.executable, "-c", script],
138+
text=True,
139+
capture_output=True,
140+
timeout=5,
141+
)
142+
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
143+
self.assertIn("main-cancelled", proc.stdout)
144+
self.assertIn("keyboard-interrupt", proc.stdout)
145+
105146
@unittest.skipUnless(
106147
os.name == "posix" and os.path.isdir("/proc/self/fd"),
107148
"requires /proc/self/fd",

0 commit comments

Comments
 (0)