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.
This commit is contained in:
Łukasz Langa 2024-09-06 21:28:29 +02:00 committed by GitHub
parent 0c080d7c77
commit 033510e11d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 133 additions and 21 deletions

View File

@ -0,0 +1,74 @@
from __future__ import annotations
from dataclasses import dataclass, field
import traceback
TYPE_CHECKING = False
if TYPE_CHECKING:
from threading import Thread
from types import TracebackType
from typing import Protocol
class ExceptHookArgs(Protocol):
@property
def exc_type(self) -> type[BaseException]: ...
@property
def exc_value(self) -> BaseException | None: ...
@property
def exc_traceback(self) -> TracebackType | None: ...
@property
def thread(self) -> Thread | None: ...
class ShowExceptions(Protocol):
def __call__(self) -> int: ...
def add(self, s: str) -> None: ...
from .reader import Reader
def install_threading_hook(reader: Reader) -> None:
import threading
@dataclass
class ExceptHookHandler:
lock: threading.Lock = field(default_factory=threading.Lock)
messages: list[str] = field(default_factory=list)
def show(self) -> int:
count = 0
with self.lock:
if not self.messages:
return 0
reader.restore()
for tb in self.messages:
count += 1
if tb:
print(tb)
self.messages.clear()
reader.scheduled_commands.append("ctrl-c")
reader.prepare()
return count
def add(self, s: str) -> None:
with self.lock:
self.messages.append(s)
def exception(self, args: ExceptHookArgs) -> None:
lines = traceback.format_exception(
args.exc_type,
args.exc_value,
args.exc_traceback,
colorize=reader.can_colorize,
) # type: ignore[call-overload]
pre = f"\nException in {args.thread.name}:\n" if args.thread else "\n"
tb = pre + "".join(lines)
self.add(tb)
def __call__(self) -> int:
return self.show()
handler = ExceptHookHandler()
reader.threading_hook = handler
threading.excepthook = handler.exception

View File

@ -36,8 +36,7 @@ from .trace import trace
# types # types
Command = commands.Command Command = commands.Command
if False: from .types import Callback, SimpleContextManager, KeySpec, CommandName
from .types import Callback, SimpleContextManager, KeySpec, CommandName
def disp_str(buffer: str) -> tuple[str, list[int]]: def disp_str(buffer: str) -> tuple[str, list[int]]:
@ -247,6 +246,7 @@ class Reader:
lxy: tuple[int, int] = field(init=False) lxy: tuple[int, int] = field(init=False)
scheduled_commands: list[str] = field(default_factory=list) scheduled_commands: list[str] = field(default_factory=list)
can_colorize: bool = False can_colorize: bool = False
threading_hook: Callback | None = None
## cached metadata to speed up screen refreshes ## cached metadata to speed up screen refreshes
@dataclass @dataclass
@ -722,6 +722,24 @@ class Reader:
self.console.finish() self.console.finish()
self.finish() self.finish()
def run_hooks(self) -> None:
threading_hook = self.threading_hook
if threading_hook is None and 'threading' in sys.modules:
from ._threading_handler import install_threading_hook
install_threading_hook(self)
if threading_hook is not None:
try:
threading_hook()
except Exception:
pass
input_hook = self.console.input_hook
if input_hook:
try:
input_hook()
except Exception:
pass
def handle1(self, block: bool = True) -> bool: def handle1(self, block: bool = True) -> bool:
"""Handle a single event. Wait as long as it takes if block """Handle a single event. Wait as long as it takes if block
is true (the default), otherwise return False if no event is is true (the default), otherwise return False if no event is
@ -732,16 +750,13 @@ class Reader:
self.dirty = True self.dirty = True
while True: while True:
input_hook = self.console.input_hook # We use the same timeout as in readline.c: 100ms
if input_hook: self.run_hooks()
input_hook() self.console.wait(100)
# We use the same timeout as in readline.c: 100ms event = self.console.get_event(block=False)
while not self.console.wait(100): if not event:
input_hook() if block:
event = self.console.get_event(block=False) continue
else:
event = self.console.get_event(block)
if not event: # can only happen if we're not blocking
return False return False
translate = True translate = True
@ -763,8 +778,7 @@ class Reader:
if cmd is None: if cmd is None:
if block: if block:
continue continue
else: return False
return False
self.do_cmd(cmd) self.do_cmd(cmd)
return True return True

View File

@ -199,8 +199,14 @@ class UnixConsole(Console):
self.event_queue = EventQueue(self.input_fd, self.encoding) self.event_queue = EventQueue(self.input_fd, self.encoding)
self.cursor_visible = 1 self.cursor_visible = 1
def more_in_buffer(self) -> bool:
return bool(
self.input_buffer
and self.input_buffer_pos < len(self.input_buffer)
)
def __read(self, n: int) -> bytes: def __read(self, n: int) -> bytes:
if not self.input_buffer or self.input_buffer_pos >= len(self.input_buffer): if not self.more_in_buffer():
self.input_buffer = os.read(self.input_fd, 10000) self.input_buffer = os.read(self.input_fd, 10000)
ret = self.input_buffer[self.input_buffer_pos : self.input_buffer_pos + n] ret = self.input_buffer[self.input_buffer_pos : self.input_buffer_pos + n]
@ -393,6 +399,7 @@ class UnixConsole(Console):
""" """
if not block and not self.wait(timeout=0): if not block and not self.wait(timeout=0):
return None return None
while self.event_queue.empty(): while self.event_queue.empty():
while True: while True:
try: try:
@ -413,7 +420,11 @@ class UnixConsole(Console):
""" """
Wait for events on the console. Wait for events on the console.
""" """
return bool(self.pollob.poll(timeout)) return (
not self.event_queue.empty()
or self.more_in_buffer()
or bool(self.pollob.poll(timeout))
)
def set_cursor_vis(self, visible): def set_cursor_vis(self, visible):
""" """

View File

@ -479,7 +479,7 @@ class WindowsConsole(Console):
while True: while True:
if msvcrt.kbhit(): # type: ignore[attr-defined] if msvcrt.kbhit(): # type: ignore[attr-defined]
return True return True
if timeout and time.time() - start_time > timeout: if timeout and time.time() - start_time > timeout / 1000:
return False return False
time.sleep(0.01) time.sleep(0.01)

View File

@ -127,6 +127,15 @@ class REPLThread(threading.Thread):
loop.call_soon_threadsafe(loop.stop) loop.call_soon_threadsafe(loop.stop)
def interrupt(self) -> None:
if not CAN_USE_PYREPL:
return
from _pyrepl.simple_interact import _get_reader
r = _get_reader()
if r.threading_hook is not None:
r.threading_hook.add("") # type: ignore
if __name__ == '__main__': if __name__ == '__main__':
sys.audit("cpython.run_stdin") sys.audit("cpython.run_stdin")
@ -184,6 +193,7 @@ if __name__ == '__main__':
keyboard_interrupted = True keyboard_interrupted = True
if repl_future and not repl_future.done(): if repl_future and not repl_future.done():
repl_future.cancel() repl_future.cancel()
repl_thread.interrupt()
continue continue
else: else:
break break

View File

@ -161,8 +161,8 @@ class FakeConsole(Console):
def forgetinput(self) -> None: def forgetinput(self) -> None:
pass pass
def wait(self) -> None: def wait(self, timeout: float | None = None) -> bool:
pass return True
def repaint(self) -> None: def repaint(self) -> None:
pass pass

View File

@ -242,6 +242,7 @@ class TestInteractiveInterpreter(unittest.TestCase):
def test_asyncio_repl_is_ok(self): def test_asyncio_repl_is_ok(self):
m, s = pty.openpty() m, s = pty.openpty()
cmd = [sys.executable, "-I", "-m", "asyncio"] cmd = [sys.executable, "-I", "-m", "asyncio"]
env = os.environ.copy()
proc = subprocess.Popen( proc = subprocess.Popen(
cmd, cmd,
stdin=s, stdin=s,
@ -249,7 +250,7 @@ class TestInteractiveInterpreter(unittest.TestCase):
stderr=s, stderr=s,
text=True, text=True,
close_fds=True, close_fds=True,
env=os.environ, env=env,
) )
os.close(s) os.close(s)
os.write(m, b"await asyncio.sleep(0)\n") os.write(m, b"await asyncio.sleep(0)\n")
@ -270,7 +271,7 @@ class TestInteractiveInterpreter(unittest.TestCase):
proc.kill() proc.kill()
exit_code = proc.wait() exit_code = proc.wait()
self.assertEqual(exit_code, 0) self.assertEqual(exit_code, 0, "".join(output))
class TestInteractiveModeSyntaxErrors(unittest.TestCase): class TestInteractiveModeSyntaxErrors(unittest.TestCase):

View File

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