Skip to content

Commit 033510e

Browse files
authored
gh-120221: Support KeyboardInterrupt in asyncio REPL (#123795)
This switches the main pyrepl event loop to always be non-blocking so that it can listen to incoming interruptions from other threads. This also resolves invalid display of exceptions from other threads (gh-123178). This also fixes freezes with pasting and an active input hook.
1 parent 0c080d7 commit 033510e

File tree

8 files changed

+133
-21
lines changed

8 files changed

+133
-21
lines changed

Lib/_pyrepl/_threading_handler.py

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
import traceback
5+
6+
7+
TYPE_CHECKING = False
8+
if TYPE_CHECKING:
9+
from threading import Thread
10+
from types import TracebackType
11+
from typing import Protocol
12+
13+
class ExceptHookArgs(Protocol):
14+
@property
15+
def exc_type(self) -> type[BaseException]: ...
16+
@property
17+
def exc_value(self) -> BaseException | None: ...
18+
@property
19+
def exc_traceback(self) -> TracebackType | None: ...
20+
@property
21+
def thread(self) -> Thread | None: ...
22+
23+
class ShowExceptions(Protocol):
24+
def __call__(self) -> int: ...
25+
def add(self, s: str) -> None: ...
26+
27+
from .reader import Reader
28+
29+
30+
def install_threading_hook(reader: Reader) -> None:
31+
import threading
32+
33+
@dataclass
34+
class ExceptHookHandler:
35+
lock: threading.Lock = field(default_factory=threading.Lock)
36+
messages: list[str] = field(default_factory=list)
37+
38+
def show(self) -> int:
39+
count = 0
40+
with self.lock:
41+
if not self.messages:
42+
return 0
43+
reader.restore()
44+
for tb in self.messages:
45+
count += 1
46+
if tb:
47+
print(tb)
48+
self.messages.clear()
49+
reader.scheduled_commands.append("ctrl-c")
50+
reader.prepare()
51+
return count
52+
53+
def add(self, s: str) -> None:
54+
with self.lock:
55+
self.messages.append(s)
56+
57+
def exception(self, args: ExceptHookArgs) -> None:
58+
lines = traceback.format_exception(
59+
args.exc_type,
60+
args.exc_value,
61+
args.exc_traceback,
62+
colorize=reader.can_colorize,
63+
) # type: ignore[call-overload]
64+
pre = f"\nException in {args.thread.name}:\n" if args.thread else "\n"
65+
tb = pre + "".join(lines)
66+
self.add(tb)
67+
68+
def __call__(self) -> int:
69+
return self.show()
70+
71+
72+
handler = ExceptHookHandler()
73+
reader.threading_hook = handler
74+
threading.excepthook = handler.exception

Lib/_pyrepl/reader.py

+28-14
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@
3636

3737
# types
3838
Command = commands.Command
39-
if False:
40-
from .types import Callback, SimpleContextManager, KeySpec, CommandName
39+
from .types import Callback, SimpleContextManager, KeySpec, CommandName
4140

4241

4342
def disp_str(buffer: str) -> tuple[str, list[int]]:
@@ -247,6 +246,7 @@ class Reader:
247246
lxy: tuple[int, int] = field(init=False)
248247
scheduled_commands: list[str] = field(default_factory=list)
249248
can_colorize: bool = False
249+
threading_hook: Callback | None = None
250250

251251
## cached metadata to speed up screen refreshes
252252
@dataclass
@@ -722,6 +722,24 @@ def do_cmd(self, cmd: tuple[str, list[str]]) -> None:
722722
self.console.finish()
723723
self.finish()
724724

725+
def run_hooks(self) -> None:
726+
threading_hook = self.threading_hook
727+
if threading_hook is None and 'threading' in sys.modules:
728+
from ._threading_handler import install_threading_hook
729+
install_threading_hook(self)
730+
if threading_hook is not None:
731+
try:
732+
threading_hook()
733+
except Exception:
734+
pass
735+
736+
input_hook = self.console.input_hook
737+
if input_hook:
738+
try:
739+
input_hook()
740+
except Exception:
741+
pass
742+
725743
def handle1(self, block: bool = True) -> bool:
726744
"""Handle a single event. Wait as long as it takes if block
727745
is true (the default), otherwise return False if no event is
@@ -732,16 +750,13 @@ def handle1(self, block: bool = True) -> bool:
732750
self.dirty = True
733751

734752
while True:
735-
input_hook = self.console.input_hook
736-
if input_hook:
737-
input_hook()
738-
# We use the same timeout as in readline.c: 100ms
739-
while not self.console.wait(100):
740-
input_hook()
741-
event = self.console.get_event(block=False)
742-
else:
743-
event = self.console.get_event(block)
744-
if not event: # can only happen if we're not blocking
753+
# We use the same timeout as in readline.c: 100ms
754+
self.run_hooks()
755+
self.console.wait(100)
756+
event = self.console.get_event(block=False)
757+
if not event:
758+
if block:
759+
continue
745760
return False
746761

747762
translate = True
@@ -763,8 +778,7 @@ def handle1(self, block: bool = True) -> bool:
763778
if cmd is None:
764779
if block:
765780
continue
766-
else:
767-
return False
781+
return False
768782

769783
self.do_cmd(cmd)
770784
return True

Lib/_pyrepl/unix_console.py

+13-2
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,14 @@ def _my_getstr(cap: str, optional: bool = False) -> bytes | None:
199199
self.event_queue = EventQueue(self.input_fd, self.encoding)
200200
self.cursor_visible = 1
201201

202+
def more_in_buffer(self) -> bool:
203+
return bool(
204+
self.input_buffer
205+
and self.input_buffer_pos < len(self.input_buffer)
206+
)
207+
202208
def __read(self, n: int) -> bytes:
203-
if not self.input_buffer or self.input_buffer_pos >= len(self.input_buffer):
209+
if not self.more_in_buffer():
204210
self.input_buffer = os.read(self.input_fd, 10000)
205211

206212
ret = self.input_buffer[self.input_buffer_pos : self.input_buffer_pos + n]
@@ -393,6 +399,7 @@ def get_event(self, block: bool = True) -> Event | None:
393399
"""
394400
if not block and not self.wait(timeout=0):
395401
return None
402+
396403
while self.event_queue.empty():
397404
while True:
398405
try:
@@ -413,7 +420,11 @@ def wait(self, timeout: float | None = None) -> bool:
413420
"""
414421
Wait for events on the console.
415422
"""
416-
return bool(self.pollob.poll(timeout))
423+
return (
424+
not self.event_queue.empty()
425+
or self.more_in_buffer()
426+
or bool(self.pollob.poll(timeout))
427+
)
417428

418429
def set_cursor_vis(self, visible):
419430
"""

Lib/_pyrepl/windows_console.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ def wait(self, timeout: float | None) -> bool:
479479
while True:
480480
if msvcrt.kbhit(): # type: ignore[attr-defined]
481481
return True
482-
if timeout and time.time() - start_time > timeout:
482+
if timeout and time.time() - start_time > timeout / 1000:
483483
return False
484484
time.sleep(0.01)
485485

Lib/asyncio/__main__.py

+10
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,15 @@ def run(self):
127127

128128
loop.call_soon_threadsafe(loop.stop)
129129

130+
def interrupt(self) -> None:
131+
if not CAN_USE_PYREPL:
132+
return
133+
134+
from _pyrepl.simple_interact import _get_reader
135+
r = _get_reader()
136+
if r.threading_hook is not None:
137+
r.threading_hook.add("") # type: ignore
138+
130139

131140
if __name__ == '__main__':
132141
sys.audit("cpython.run_stdin")
@@ -184,6 +193,7 @@ def run(self):
184193
keyboard_interrupted = True
185194
if repl_future and not repl_future.done():
186195
repl_future.cancel()
196+
repl_thread.interrupt()
187197
continue
188198
else:
189199
break

Lib/test/test_pyrepl/support.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,8 @@ def flushoutput(self) -> None:
161161
def forgetinput(self) -> None:
162162
pass
163163

164-
def wait(self) -> None:
165-
pass
164+
def wait(self, timeout: float | None = None) -> bool:
165+
return True
166166

167167
def repaint(self) -> None:
168168
pass

Lib/test/test_repl.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -242,14 +242,15 @@ def test_asyncio_repl_reaches_python_startup_script(self):
242242
def test_asyncio_repl_is_ok(self):
243243
m, s = pty.openpty()
244244
cmd = [sys.executable, "-I", "-m", "asyncio"]
245+
env = os.environ.copy()
245246
proc = subprocess.Popen(
246247
cmd,
247248
stdin=s,
248249
stdout=s,
249250
stderr=s,
250251
text=True,
251252
close_fds=True,
252-
env=os.environ,
253+
env=env,
253254
)
254255
os.close(s)
255256
os.write(m, b"await asyncio.sleep(0)\n")
@@ -270,7 +271,7 @@ def test_asyncio_repl_is_ok(self):
270271
proc.kill()
271272
exit_code = proc.wait()
272273

273-
self.assertEqual(exit_code, 0)
274+
self.assertEqual(exit_code, 0, "".join(output))
274275

275276
class TestInteractiveModeSyntaxErrors(unittest.TestCase):
276277

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
asyncio REPL is now again properly recognizing KeyboardInterrupts. Display
2+
of exceptions raised in secondary threads is fixed.

0 commit comments

Comments
 (0)