mirror of https://github.com/python/cpython
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:
parent
0c080d7c77
commit
033510e11d
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
asyncio REPL is now again properly recognizing KeyboardInterrupts. Display
|
||||||
|
of exceptions raised in secondary threads is fixed.
|
Loading…
Reference in New Issue