Skip to content

Commit 5bfba27

Browse files
richardsheridanCoolCat467pre-commit-ci[bot]A5rocks
authored
Improve REPL KI (#3030)
* improve repl KI * apply suggestion from review, ensure calls in handler are safe * grab token and install handler in one go * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Don't add extra newlines on KI * Add some tests * First pass at CI failures * The CI runners have `dev.tty.legacy_tiocsti` set to `0` This means that we cannot test our usage of `TIOCSTI`. This ctrl+c support was dead on arrival! * Hacky fixes for Windows * Try to avoid flakiness * Address PR review and first pass at codecov * Start checking sysctls for `test_ki_newline_injection` * Try enabling the sysctl * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix pre-commit * Give up on TIOCSTI in CI * Actually skip newline injection on Windows * Actually skip newline injection tests on MacOS * Codecov annoyances --------- Co-authored-by: CoolCat467 <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: A5rocks <[email protected]>
1 parent 7cc7f6b commit 5bfba27

File tree

3 files changed

+267
-3
lines changed

3 files changed

+267
-3
lines changed

newsfragments/3007.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Make ctrl+c work in more situations in the Trio REPL (``python -m trio``).

src/trio/_repl.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import contextlib
55
import inspect
66
import sys
7-
import types
87
import warnings
98
from code import InteractiveConsole
9+
from types import CodeType, FrameType, FunctionType
10+
from typing import Callable
1011

1112
import outcome
1213

@@ -15,14 +16,33 @@
1516
from trio._util import final
1617

1718

19+
class SuppressDecorator(contextlib.ContextDecorator, contextlib.suppress):
20+
pass
21+
22+
23+
@SuppressDecorator(KeyboardInterrupt)
24+
@trio.lowlevel.disable_ki_protection
25+
def terminal_newline() -> None: # TODO: test this line
26+
import fcntl
27+
import termios
28+
29+
# Fake up a newline char as if user had typed it at the terminal
30+
try:
31+
fcntl.ioctl(sys.stdin, termios.TIOCSTI, b"\n") # type: ignore[attr-defined, unused-ignore]
32+
except OSError as e:
33+
print(f"\nPress enter! Newline injection failed: {e}", end="", flush=True)
34+
35+
1836
@final
1937
class TrioInteractiveConsole(InteractiveConsole):
2038
def __init__(self, repl_locals: dict[str, object] | None = None) -> None:
2139
super().__init__(locals=repl_locals)
40+
self.token: trio.lowlevel.TrioToken | None = None
2241
self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
42+
self.interrupted = False
2343

24-
def runcode(self, code: types.CodeType) -> None:
25-
func = types.FunctionType(code, self.locals)
44+
def runcode(self, code: CodeType) -> None:
45+
func = FunctionType(code, self.locals)
2646
if inspect.iscoroutinefunction(func):
2747
result = trio.from_thread.run(outcome.acapture, func)
2848
else:
@@ -48,6 +68,55 @@ def runcode(self, code: types.CodeType) -> None:
4868
# We always use sys.excepthook, unlike other implementations.
4969
# This means that overriding self.write also does nothing to tbs.
5070
sys.excepthook(sys.last_type, sys.last_value, sys.last_traceback)
71+
# clear any residual KI
72+
trio.from_thread.run(trio.lowlevel.checkpoint_if_cancelled)
73+
# trio.from_thread.check_cancelled() has too long of a memory
74+
75+
if sys.platform == "win32": # TODO: test this line
76+
77+
def raw_input(self, prompt: str = "") -> str:
78+
try:
79+
return input(prompt)
80+
except EOFError:
81+
# check if trio has a pending KI
82+
trio.from_thread.run(trio.lowlevel.checkpoint_if_cancelled)
83+
raise
84+
85+
else:
86+
87+
def raw_input(self, prompt: str = "") -> str:
88+
from signal import SIGINT, signal
89+
90+
assert not self.interrupted
91+
92+
def install_handler() -> (
93+
Callable[[int, FrameType | None], None] | int | None
94+
):
95+
def handler(
96+
sig: int, frame: FrameType | None
97+
) -> None: # TODO: test this line
98+
self.interrupted = True
99+
token.run_sync_soon(terminal_newline, idempotent=True)
100+
101+
token = trio.lowlevel.current_trio_token()
102+
103+
return signal(SIGINT, handler)
104+
105+
prev_handler = trio.from_thread.run_sync(install_handler)
106+
try:
107+
return input(prompt)
108+
finally:
109+
trio.from_thread.run_sync(signal, SIGINT, prev_handler)
110+
if self.interrupted: # TODO: test this line
111+
raise KeyboardInterrupt
112+
113+
def write(self, output: str) -> None:
114+
if self.interrupted: # TODO: test this line
115+
assert output == "\nKeyboardInterrupt\n"
116+
sys.stderr.write(output[1:])
117+
self.interrupted = False
118+
else:
119+
sys.stderr.write(output)
51120

52121

53122
async def run_repl(console: TrioInteractiveConsole) -> None:

src/trio/_tests/test_repl.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from __future__ import annotations
22

3+
import os
4+
import pathlib
5+
import signal
36
import subprocess
47
import sys
8+
from functools import partial
59
from typing import Protocol
610

711
import pytest
@@ -239,3 +243,193 @@ def test_main_entrypoint() -> None:
239243
"""
240244
repl = subprocess.run([sys.executable, "-m", "trio"], input=b"exit()")
241245
assert repl.returncode == 0
246+
247+
248+
def should_try_newline_injection() -> bool:
249+
if sys.platform != "linux":
250+
return False
251+
252+
sysctl = pathlib.Path("/proc/sys/dev/tty/legacy_tiocsti")
253+
if not sysctl.exists(): # pragma: no cover
254+
return True
255+
256+
else:
257+
return sysctl.read_text() == "1"
258+
259+
260+
@pytest.mark.skipif(
261+
not should_try_newline_injection(),
262+
reason="the ioctl we use is disabled in CI",
263+
)
264+
def test_ki_newline_injection() -> None: # TODO: test this line
265+
# TODO: we want to remove this functionality, eg by using vendored
266+
# pyrepls.
267+
assert sys.platform != "win32"
268+
269+
import pty
270+
271+
# NOTE: this cannot be subprocess.Popen because pty.fork
272+
# does some magic to set the controlling terminal.
273+
# (which I don't know how to replicate... so I copied this
274+
# structure from pty.spawn...)
275+
pid, pty_fd = pty.fork() # type: ignore[attr-defined,unused-ignore]
276+
if pid == 0:
277+
os.execlp(sys.executable, *[sys.executable, "-u", "-m", "trio"])
278+
279+
# setup:
280+
buffer = b""
281+
while not buffer.endswith(b"import trio\r\n>>> "):
282+
buffer += os.read(pty_fd, 4096)
283+
284+
# sanity check:
285+
print(buffer.decode())
286+
buffer = b""
287+
os.write(pty_fd, b'print("hello!")\n')
288+
while not buffer.endswith(b">>> "):
289+
buffer += os.read(pty_fd, 4096)
290+
291+
assert buffer.count(b"hello!") == 2
292+
293+
# press ctrl+c
294+
print(buffer.decode())
295+
buffer = b""
296+
os.kill(pid, signal.SIGINT)
297+
while not buffer.endswith(b">>> "):
298+
buffer += os.read(pty_fd, 4096)
299+
300+
assert b"KeyboardInterrupt" in buffer
301+
302+
# press ctrl+c later
303+
print(buffer.decode())
304+
buffer = b""
305+
os.write(pty_fd, b'print("hello!")')
306+
os.kill(pid, signal.SIGINT)
307+
while not buffer.endswith(b">>> "):
308+
buffer += os.read(pty_fd, 4096)
309+
310+
assert b"KeyboardInterrupt" in buffer
311+
print(buffer.decode())
312+
os.close(pty_fd)
313+
os.waitpid(pid, 0)[1]
314+
315+
316+
async def test_ki_in_repl() -> None:
317+
async with trio.open_nursery() as nursery:
318+
proc = await nursery.start(
319+
partial(
320+
trio.run_process,
321+
[sys.executable, "-u", "-m", "trio"],
322+
stdout=subprocess.PIPE,
323+
stderr=subprocess.STDOUT,
324+
stdin=subprocess.PIPE,
325+
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0, # type: ignore[attr-defined,unused-ignore]
326+
)
327+
)
328+
329+
async with proc.stdout:
330+
# setup
331+
buffer = b""
332+
async for part in proc.stdout: # pragma: no branch
333+
buffer += part
334+
# TODO: consider making run_process stdout have some universal newlines thing
335+
if buffer.replace(b"\r\n", b"\n").endswith(b"import trio\n>>> "):
336+
break
337+
338+
# ensure things work
339+
print(buffer.decode())
340+
buffer = b""
341+
await proc.stdin.send_all(b'print("hello!")\n')
342+
async for part in proc.stdout: # pragma: no branch
343+
buffer += part
344+
if buffer.endswith(b">>> "):
345+
break
346+
347+
assert b"hello!" in buffer
348+
print(buffer.decode())
349+
350+
# this seems to be necessary on Windows for reasons
351+
# (the parents of process groups ignore ctrl+c by default...)
352+
if sys.platform == "win32":
353+
buffer = b""
354+
await proc.stdin.send_all(
355+
b"import ctypes; ctypes.windll.kernel32.SetConsoleCtrlHandler(None, False)\n"
356+
)
357+
async for part in proc.stdout: # pragma: no branch
358+
buffer += part
359+
if buffer.endswith(b">>> "):
360+
break
361+
362+
print(buffer.decode())
363+
364+
# try to decrease flakiness...
365+
buffer = b""
366+
await proc.stdin.send_all(
367+
b"import coverage; trio.lowlevel.enable_ki_protection(coverage.pytracer.PyTracer._trace)\n"
368+
)
369+
async for part in proc.stdout: # pragma: no branch
370+
buffer += part
371+
if buffer.endswith(b">>> "):
372+
break
373+
374+
print(buffer.decode())
375+
376+
# ensure that ctrl+c on a prompt works
377+
# NOTE: for some reason, signal.SIGINT doesn't work for this test.
378+
# Using CTRL_C_EVENT is also why we need subprocess.CREATE_NEW_PROCESS_GROUP
379+
signal_sent = signal.CTRL_C_EVENT if sys.platform == "win32" else signal.SIGINT # type: ignore[attr-defined,unused-ignore]
380+
os.kill(proc.pid, signal_sent)
381+
if sys.platform == "win32":
382+
# we rely on EOFError which... doesn't happen with pipes.
383+
# I'm not sure how to fix it...
384+
await proc.stdin.send_all(b"\n")
385+
else:
386+
# we test injection separately
387+
await proc.stdin.send_all(b"\n")
388+
389+
buffer = b""
390+
async for part in proc.stdout: # pragma: no branch
391+
buffer += part
392+
if buffer.endswith(b">>> "):
393+
break
394+
395+
assert b"KeyboardInterrupt" in buffer
396+
397+
# ensure ctrl+c while a command runs works
398+
print(buffer.decode())
399+
await proc.stdin.send_all(b'print("READY"); await trio.sleep_forever()\n')
400+
killed = False
401+
buffer = b""
402+
async for part in proc.stdout: # pragma: no branch
403+
buffer += part
404+
if buffer.replace(b"\r\n", b"\n").endswith(b"READY\n") and not killed:
405+
os.kill(proc.pid, signal_sent)
406+
killed = True
407+
if buffer.endswith(b">>> "):
408+
break
409+
410+
assert b"trio" in buffer
411+
assert b"KeyboardInterrupt" in buffer
412+
413+
# make sure it works for sync commands too
414+
# (though this would be hard to break)
415+
print(buffer.decode())
416+
await proc.stdin.send_all(
417+
b'import time; print("READY"); time.sleep(99999)\n'
418+
)
419+
killed = False
420+
buffer = b""
421+
async for part in proc.stdout: # pragma: no branch
422+
buffer += part
423+
if buffer.replace(b"\r\n", b"\n").endswith(b"READY\n") and not killed:
424+
os.kill(proc.pid, signal_sent)
425+
killed = True
426+
if buffer.endswith(b">>> "):
427+
break
428+
429+
assert b"Traceback" in buffer
430+
assert b"KeyboardInterrupt" in buffer
431+
432+
print(buffer.decode())
433+
434+
# kill the process
435+
nursery.cancel_scope.cancel()

0 commit comments

Comments
 (0)