From 39d0ca2686a6c4eae3d54022f44e520cc2500bca Mon Sep 17 00:00:00 2001 From: richardsheridan Date: Thu, 13 Jun 2024 15:52:10 -0400 Subject: [PATCH 01/19] improve repl KI --- src/trio/_repl.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index 73f050140e..c24762df0c 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -53,6 +53,42 @@ def runcode(self, code: types.CodeType) -> None: # We always use sys.excepthook, unlike other implementations. # This means that overriding self.write also does nothing to tbs. sys.excepthook(sys.last_type, sys.last_value, sys.last_traceback) + # clear any residual KI + trio.from_thread.run(trio.lowlevel.checkpoint_if_cancelled) + # trio.from_thread.check_cancelled() has too long of a memory + + if sys.platform == "win32": + + def raw_input(self, prompt: str = "") -> str: + try: + return input(prompt) + except EOFError: + # check if trio has a pending KI + trio.from_thread.run(trio.lowlevel.checkpoint_if_cancelled) + raise + + else: + + def raw_input(self, prompt: str = "") -> str: + import fcntl + import termios + from signal import SIGINT, signal + + interrupted = False + + def handler(sig: int, frame: types.FrameType | None) -> None: + nonlocal interrupted + interrupted = True + # Fake up a newline char as if user had typed it at terminal + fcntl.ioctl(sys.stdin, termios.TIOCSTI, b"\n") + + prev_handler = trio.from_thread.run_sync(signal, SIGINT, handler) + try: + return input(prompt) + finally: + trio.from_thread.run_sync(signal, SIGINT, prev_handler) + if interrupted: + raise KeyboardInterrupt async def run_repl(console: TrioInteractiveConsole) -> None: From a5dcdbfa0528554d0138667f5860c0c7c6233451 Mon Sep 17 00:00:00 2001 From: richardsheridan Date: Sun, 23 Mar 2025 15:55:53 -0400 Subject: [PATCH 02/19] apply suggestion from review, ensure calls in handler are safe --- src/trio/_repl.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index 185580607d..26b2ed0e6b 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -15,6 +15,10 @@ from trio._util import final +class SuppressDecorator(contextlib.ContextDecorator, contextlib.suppress): + pass + + @final class TrioInteractiveConsole(InteractiveConsole): # code.InteractiveInterpreter defines locals as Mapping[str, Any] @@ -24,6 +28,7 @@ class TrioInteractiveConsole(InteractiveConsole): def __init__(self, repl_locals: dict[str, object] | None = None) -> None: super().__init__(locals=repl_locals) + self.token: trio.lowlevel.TrioToken | None = None self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT def runcode(self, code: types.CodeType) -> None: @@ -70,17 +75,26 @@ def raw_input(self, prompt: str = "") -> str: else: def raw_input(self, prompt: str = "") -> str: - import fcntl - import termios from signal import SIGINT, signal interrupted = False + if self.token is None: + self.token = trio.from_thread.run_sync(trio.lowlevel.current_trio_token) + + @SuppressDecorator(KeyboardInterrupt) + @trio.lowlevel.disable_ki_protection + def newline(): + import fcntl + import termios + + # Fake up a newline char as if user had typed it at + fcntl.ioctl(sys.stdin, termios.TIOCSTI, b"\n") + def handler(sig: int, frame: types.FrameType | None) -> None: nonlocal interrupted interrupted = True - # Fake up a newline char as if user had typed it at terminal - fcntl.ioctl(sys.stdin, termios.TIOCSTI, b"\n") + self.token.run_sync_soon(newline, idempotent=True) prev_handler = trio.from_thread.run_sync(signal, SIGINT, handler) try: From 039bad1dc01bedf06493622ab1c1b9cd34dc79b5 Mon Sep 17 00:00:00 2001 From: richardsheridan Date: Thu, 27 Mar 2025 07:45:40 -0400 Subject: [PATCH 03/19] grab token and install handler in one go --- src/trio/_repl.py | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index 26b2ed0e6b..433df88e5e 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -4,9 +4,10 @@ import contextlib import inspect import sys -import types import warnings from code import InteractiveConsole +from types import CodeType, FunctionType, FrameType +from typing import Callable import outcome @@ -19,6 +20,16 @@ class SuppressDecorator(contextlib.ContextDecorator, contextlib.suppress): pass +@SuppressDecorator(KeyboardInterrupt) +@trio.lowlevel.disable_ki_protection +def terminal_newline() -> None: + import fcntl + import termios + + # Fake up a newline char as if user had typed it at the terminal + fcntl.ioctl(sys.stdin, termios.TIOCSTI, b"\n") + + @final class TrioInteractiveConsole(InteractiveConsole): # code.InteractiveInterpreter defines locals as Mapping[str, Any] @@ -31,8 +42,8 @@ def __init__(self, repl_locals: dict[str, object] | None = None) -> None: self.token: trio.lowlevel.TrioToken | None = None self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT - def runcode(self, code: types.CodeType) -> None: - func = types.FunctionType(code, self.locals) + def runcode(self, code: CodeType) -> None: + func = FunctionType(code, self.locals) if inspect.iscoroutinefunction(func): result = trio.from_thread.run(outcome.acapture, func) else: @@ -79,24 +90,19 @@ def raw_input(self, prompt: str = "") -> str: interrupted = False - if self.token is None: - self.token = trio.from_thread.run_sync(trio.lowlevel.current_trio_token) - - @SuppressDecorator(KeyboardInterrupt) - @trio.lowlevel.disable_ki_protection - def newline(): - import fcntl - import termios + def install_handler() -> ( + Callable[[int, FrameType | None], None] | int | None + ): + def handler(sig: int, frame: FrameType | None) -> None: + nonlocal interrupted + interrupted = True + token.run_sync_soon(terminal_newline, idempotent=True) - # Fake up a newline char as if user had typed it at - fcntl.ioctl(sys.stdin, termios.TIOCSTI, b"\n") + token = trio.lowlevel.current_trio_token() - def handler(sig: int, frame: types.FrameType | None) -> None: - nonlocal interrupted - interrupted = True - self.token.run_sync_soon(newline, idempotent=True) + return signal(SIGINT, handler) - prev_handler = trio.from_thread.run_sync(signal, SIGINT, handler) + prev_handler = trio.from_thread.run_sync(install_handler) try: return input(prompt) finally: From 240f9ff5b689e9ab055b1e5291e60f37ae2394d2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 27 Mar 2025 11:49:13 +0000 Subject: [PATCH 04/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/trio/_repl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index 433df88e5e..9297170926 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -6,7 +6,7 @@ import sys import warnings from code import InteractiveConsole -from types import CodeType, FunctionType, FrameType +from types import CodeType, FrameType, FunctionType from typing import Callable import outcome From 301f361c842f96b080464aace384fbb88f7f0117 Mon Sep 17 00:00:00 2001 From: A5rocks Date: Wed, 2 Jul 2025 14:31:44 +0900 Subject: [PATCH 05/19] Don't add extra newlines on KI --- src/trio/_repl.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index 972742a612..ec18d4a79a 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -36,6 +36,7 @@ def __init__(self, repl_locals: dict[str, object] | None = None) -> None: super().__init__(locals=repl_locals) self.token: trio.lowlevel.TrioToken | None = None self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT + self.interrupted = False def runcode(self, code: CodeType) -> None: # https://github.com/python/typeshed/issues/13768 @@ -84,14 +85,13 @@ def raw_input(self, prompt: str = "") -> str: def raw_input(self, prompt: str = "") -> str: from signal import SIGINT, signal - interrupted = False + assert not self.interrupted def install_handler() -> ( Callable[[int, FrameType | None], None] | int | None ): def handler(sig: int, frame: FrameType | None) -> None: - nonlocal interrupted - interrupted = True + self.interrupted = True token.run_sync_soon(terminal_newline, idempotent=True) token = trio.lowlevel.current_trio_token() @@ -103,9 +103,17 @@ def handler(sig: int, frame: FrameType | None) -> None: return input(prompt) finally: trio.from_thread.run_sync(signal, SIGINT, prev_handler) - if interrupted: + if self.interrupted: raise KeyboardInterrupt + def write(self, output: str) -> None: + if self.interrupted: + assert output == "\nKeyboardInterrupt\n" + sys.stderr.write(output[1:]) + self.interrupted = False + else: + sys.stderr.write(output) + async def run_repl(console: TrioInteractiveConsole) -> None: banner = ( From 95f0d33a862ddddae8c474cfd82ed814fab8f75d Mon Sep 17 00:00:00 2001 From: A5rocks Date: Wed, 2 Jul 2025 14:32:50 +0900 Subject: [PATCH 06/19] Add some tests --- src/trio/_repl.py | 4 +- src/trio/_tests/test_repl.py | 132 +++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index ec18d4a79a..938f049f0e 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -27,7 +27,9 @@ def terminal_newline() -> None: import termios # Fake up a newline char as if user had typed it at the terminal - fcntl.ioctl(sys.stdin, termios.TIOCSTI, b"\n") + # on a best-effort basis + with contextlib.suppress(OSError): + fcntl.ioctl(sys.stdin, termios.TIOCSTI, b"\n") @final diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index be9338ce4c..137eb84306 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -1,7 +1,11 @@ from __future__ import annotations +import os +import pty +import signal import subprocess import sys +from functools import partial from typing import Protocol import pytest @@ -239,3 +243,131 @@ def test_main_entrypoint() -> None: """ repl = subprocess.run([sys.executable, "-m", "trio"], input=b"exit()") assert repl.returncode == 0 + + +@pytest.mark.skipif(sys.platform == "win32", reason="uses PTYs") +def test_ki_newline_injection() -> None: + # NOTE: this cannot be subprocess.Popen because pty.fork + # does some magic to set the controlling terminal. + # (which I don't know how to replicate... so I copied this + # structure from pty.spawn...) + pid, pty_fd = pty.fork() + if pid == 0: + os.execlp(sys.executable, *[sys.executable, "-u", "-m", "trio"]) + + # setup: + buffer = b"" + while not buffer.endswith(b"import trio\r\n>>> "): + buffer += os.read(pty_fd, 4096) + + # sanity check: + print(buffer.decode()) + buffer = b"" + os.write(pty_fd, b'print("hello!")\n') + while not buffer.endswith(b">>> "): + buffer += os.read(pty_fd, 4096) + + assert buffer.count(b"hello!") == 2 + + # press ctrl+c + print(buffer.decode()) + buffer = b"" + os.kill(pid, signal.SIGINT) + while not buffer.endswith(b">>> "): + buffer += os.read(pty_fd, 4096) + + assert b"KeyboardInterrupt" in buffer + + # press ctrl+c later + print(buffer.decode()) + buffer = b"" + os.write(pty_fd, b'print("hello!")') + os.kill(pid, signal.SIGINT) + while not buffer.endswith(b">>> "): + buffer += os.read(pty_fd, 4096) + + assert b"KeyboardInterrupt" in buffer + print(buffer.decode()) + os.close(pty_fd) + os.waitpid(pid, 0)[1] + + +async def test_ki_in_repl() -> None: + async with trio.open_nursery() as nursery: + proc = await nursery.start( + partial( + trio.run_process, + [sys.executable, "-u", "-m", "trio"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE, + ) + ) + + async with proc.stdout: + # setup + buffer = b"" + async for part in proc.stdout: + buffer += part + if buffer.endswith(b"import trio\n>>> "): + break + + # ensure things work + buffer = b"" + await proc.stdin.send_all(b'print("hello!")\n') + async for part in proc.stdout: + buffer += part + if buffer.endswith(b">>> "): + break + + assert b"hello!" in buffer + + # ensure that ctrl+c on a prompt works + os.kill(proc.pid, signal.SIGINT) + if sys.platform != "win32": + # we test injection separately + await proc.stdin.send_all(b"\n") + + buffer = b"" + async for part in proc.stdout: + buffer += part + if buffer.endswith(b">>> "): + break + + assert b"KeyboardInterrupt" in buffer + + # ensure ctrl+c while a command runs works + await proc.stdin.send_all(b'print("READY"); await trio.sleep_forever()\n') + killed = False + buffer = b"" + async for part in proc.stdout: + buffer += part + if buffer.endswith(b"READY\n") and not killed: + os.kill(proc.pid, signal.SIGINT) + killed = True + if buffer.endswith(b">>> "): + break + + assert b"trio" in buffer + assert b"KeyboardInterrupt" in buffer + + # make sure it works for sync commands too + # (though this would be hard to break) + await proc.stdin.send_all( + b'import time; print("READY"); time.sleep(99999)\n' + ) + killed = False + buffer = b"" + async for part in proc.stdout: + buffer += part + if buffer.endswith(b"READY\n") and not killed: + os.kill(proc.pid, signal.SIGINT) + killed = True + if buffer.endswith(b">>> "): + break + + assert b"Traceback" in buffer + assert b"KeyboardInterrupt" in buffer + + # kill the process + nursery.cancel_scope.cancel() From b71a1b8ea0ebae7e0d710ba9df55bde015c1a7fb Mon Sep 17 00:00:00 2001 From: A5rocks Date: Wed, 2 Jul 2025 14:43:00 +0900 Subject: [PATCH 07/19] First pass at CI failures --- newsfragments/3007.bugfix.rst | 1 + src/trio/_repl.py | 2 +- src/trio/_tests/test_repl.py | 7 +++++-- 3 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 newsfragments/3007.bugfix.rst diff --git a/newsfragments/3007.bugfix.rst b/newsfragments/3007.bugfix.rst new file mode 100644 index 0000000000..da6732395a --- /dev/null +++ b/newsfragments/3007.bugfix.rst @@ -0,0 +1 @@ +Make ctrl+c work in more situations in the Trio REPL (``python -m trio``). diff --git a/src/trio/_repl.py b/src/trio/_repl.py index 938f049f0e..ad989bdb1d 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -29,7 +29,7 @@ def terminal_newline() -> None: # Fake up a newline char as if user had typed it at the terminal # on a best-effort basis with contextlib.suppress(OSError): - fcntl.ioctl(sys.stdin, termios.TIOCSTI, b"\n") + fcntl.ioctl(sys.stdin, termios.TIOCSTI, b"\n") # type: ignore[attr-defined, unused-ignore] @final diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index 137eb84306..88f364dea8 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import pty import signal import subprocess import sys @@ -247,11 +246,15 @@ def test_main_entrypoint() -> None: @pytest.mark.skipif(sys.platform == "win32", reason="uses PTYs") def test_ki_newline_injection() -> None: + assert sys.platform != "win32" + + import pty + # NOTE: this cannot be subprocess.Popen because pty.fork # does some magic to set the controlling terminal. # (which I don't know how to replicate... so I copied this # structure from pty.spawn...) - pid, pty_fd = pty.fork() + pid, pty_fd = pty.fork() # type: ignore[attr-defined,unused-ignore] if pid == 0: os.execlp(sys.executable, *[sys.executable, "-u", "-m", "trio"]) From 919cc8b0b1407b9d004a7b442e6f7093ce236ac2 Mon Sep 17 00:00:00 2001 From: A5rocks Date: Wed, 2 Jul 2025 15:39:10 +0900 Subject: [PATCH 08/19] 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! --- src/trio/_tests/test_repl.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index 88f364dea8..72444c45cd 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -244,8 +244,11 @@ def test_main_entrypoint() -> None: assert repl.returncode == 0 -@pytest.mark.skipif(sys.platform == "win32", reason="uses PTYs") +# TODO: skip this based on sysctls? Or Linux version? +@pytest.mark.skipif(True, reason="the ioctl we use is disabled in CI") def test_ki_newline_injection() -> None: + # TODO: we want to remove this functionality, eg by using vendored + # pyrepls. assert sys.platform != "win32" import pty From 6c8d1e405cf80eb1d732056aea45fd1f9cc59fe7 Mon Sep 17 00:00:00 2001 From: A5rocks Date: Thu, 3 Jul 2025 08:26:53 +0900 Subject: [PATCH 09/19] Hacky fixes for Windows --- src/trio/_tests/test_repl.py | 43 ++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index 72444c45cd..6dddf0a755 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -307,6 +307,7 @@ async def test_ki_in_repl() -> None: stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.PIPE, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0, # type: ignore[attr-defined,unused-ignore] ) ) @@ -315,10 +316,12 @@ async def test_ki_in_repl() -> None: buffer = b"" async for part in proc.stdout: buffer += part - if buffer.endswith(b"import trio\n>>> "): + # TODO: consider making run_process stdout have some universal newlines thing + if buffer.replace(b"\r\n", b"\n").endswith(b"import trio\n>>> "): break # ensure things work + print(buffer.decode()) buffer = b"" await proc.stdin.send_all(b'print("hello!")\n') async for part in proc.stdout: @@ -327,10 +330,32 @@ async def test_ki_in_repl() -> None: break assert b"hello!" in buffer + print(buffer.decode()) + + # this seems to be necessary on Windows for reasons + # (the parents of process groups ignore ctrl+c by default...) + if sys.platform == "win32": + buffer = b"" + await proc.stdin.send_all( + b"import ctypes; ctypes.windll.kernel32.SetConsoleCtrlHandler(None, False)\n" + ) + async for part in proc.stdout: + buffer += part + if buffer.endswith(b">>> "): + break + + print(buffer.decode()) # ensure that ctrl+c on a prompt works - os.kill(proc.pid, signal.SIGINT) - if sys.platform != "win32": + # NOTE: for some reason, signal.SIGINT doesn't work for this test. + # Using CTRL_C_EVENT is also why we need subprocess.CREATE_NEW_PROCESS_GROUP + signal_sent = signal.CTRL_C_EVENT if sys.platform == "win32" else signal.SIGINT # type: ignore[attr-defined,unused-ignore] + os.kill(proc.pid, signal_sent) + if sys.platform == "win32": + # we rely on EOFError which... doesn't happen with pipes. + # I'm not sure how to fix it... + await proc.stdin.send_all(b"\n") + else: # we test injection separately await proc.stdin.send_all(b"\n") @@ -343,13 +368,14 @@ async def test_ki_in_repl() -> None: assert b"KeyboardInterrupt" in buffer # ensure ctrl+c while a command runs works + print(buffer.decode()) await proc.stdin.send_all(b'print("READY"); await trio.sleep_forever()\n') killed = False buffer = b"" async for part in proc.stdout: buffer += part - if buffer.endswith(b"READY\n") and not killed: - os.kill(proc.pid, signal.SIGINT) + if buffer.replace(b"\r\n", b"\n").endswith(b"READY\n") and not killed: + os.kill(proc.pid, signal_sent) killed = True if buffer.endswith(b">>> "): break @@ -359,6 +385,7 @@ async def test_ki_in_repl() -> None: # make sure it works for sync commands too # (though this would be hard to break) + print(buffer.decode()) await proc.stdin.send_all( b'import time; print("READY"); time.sleep(99999)\n' ) @@ -366,8 +393,8 @@ async def test_ki_in_repl() -> None: buffer = b"" async for part in proc.stdout: buffer += part - if buffer.endswith(b"READY\n") and not killed: - os.kill(proc.pid, signal.SIGINT) + if buffer.replace(b"\r\n", b"\n").endswith(b"READY\n") and not killed: + os.kill(proc.pid, signal_sent) killed = True if buffer.endswith(b">>> "): break @@ -375,5 +402,7 @@ async def test_ki_in_repl() -> None: assert b"Traceback" in buffer assert b"KeyboardInterrupt" in buffer + print(buffer.decode()) + # kill the process nursery.cancel_scope.cancel() From 04abe8d96dc618961b2fc160e42317e5a7c9c45b Mon Sep 17 00:00:00 2001 From: A5rocks Date: Thu, 3 Jul 2025 08:47:45 +0900 Subject: [PATCH 10/19] Try to avoid flakiness --- src/trio/_tests/test_repl.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index 6dddf0a755..6a6aac4ae8 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -346,6 +346,18 @@ async def test_ki_in_repl() -> None: print(buffer.decode()) + # try to decrease flakiness... + buffer = b"" + await proc.stdin.send_all( + b"import coverage; trio.lowlevel.enable_ki_protection(coverage.pytracer.PyTracer._trace)\n" + ) + async for part in proc.stdout: + buffer += part + if buffer.endswith(b">>> "): + break + + print(buffer.decode()) + # ensure that ctrl+c on a prompt works # NOTE: for some reason, signal.SIGINT doesn't work for this test. # Using CTRL_C_EVENT is also why we need subprocess.CREATE_NEW_PROCESS_GROUP From 7f135cc494791254c0e255e6888aa9e391b64ada Mon Sep 17 00:00:00 2001 From: A5rocks Date: Sun, 13 Jul 2025 23:46:49 +0900 Subject: [PATCH 11/19] Address PR review and first pass at codecov --- src/trio/_repl.py | 5 +++-- src/trio/_tests/test_repl.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index ad989bdb1d..9c8c56f6c9 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -27,9 +27,10 @@ def terminal_newline() -> None: import termios # Fake up a newline char as if user had typed it at the terminal - # on a best-effort basis - with contextlib.suppress(OSError): + try: fcntl.ioctl(sys.stdin, termios.TIOCSTI, b"\n") # type: ignore[attr-defined, unused-ignore] + except OSError as e: + print(f"\nPress enter! Newline injection failed: {e}", end="", flush=True) @final diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index 6a6aac4ae8..b4270cc315 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -246,7 +246,7 @@ def test_main_entrypoint() -> None: # TODO: skip this based on sysctls? Or Linux version? @pytest.mark.skipif(True, reason="the ioctl we use is disabled in CI") -def test_ki_newline_injection() -> None: +def test_ki_newline_injection() -> None: # TODO: test this line # TODO: we want to remove this functionality, eg by using vendored # pyrepls. assert sys.platform != "win32" @@ -314,7 +314,7 @@ async def test_ki_in_repl() -> None: async with proc.stdout: # setup buffer = b"" - async for part in proc.stdout: + async for part in proc.stdout: # pragma: no branch buffer += part # TODO: consider making run_process stdout have some universal newlines thing if buffer.replace(b"\r\n", b"\n").endswith(b"import trio\n>>> "): @@ -324,7 +324,7 @@ async def test_ki_in_repl() -> None: print(buffer.decode()) buffer = b"" await proc.stdin.send_all(b'print("hello!")\n') - async for part in proc.stdout: + async for part in proc.stdout: # pragma: no branch buffer += part if buffer.endswith(b">>> "): break @@ -339,7 +339,7 @@ async def test_ki_in_repl() -> None: await proc.stdin.send_all( b"import ctypes; ctypes.windll.kernel32.SetConsoleCtrlHandler(None, False)\n" ) - async for part in proc.stdout: + async for part in proc.stdout: # pragma: no branch buffer += part if buffer.endswith(b">>> "): break @@ -351,7 +351,7 @@ async def test_ki_in_repl() -> None: await proc.stdin.send_all( b"import coverage; trio.lowlevel.enable_ki_protection(coverage.pytracer.PyTracer._trace)\n" ) - async for part in proc.stdout: + async for part in proc.stdout: # pragma: no branch buffer += part if buffer.endswith(b">>> "): break @@ -372,7 +372,7 @@ async def test_ki_in_repl() -> None: await proc.stdin.send_all(b"\n") buffer = b"" - async for part in proc.stdout: + async for part in proc.stdout: # pragma: no branch buffer += part if buffer.endswith(b">>> "): break @@ -384,7 +384,7 @@ async def test_ki_in_repl() -> None: await proc.stdin.send_all(b'print("READY"); await trio.sleep_forever()\n') killed = False buffer = b"" - async for part in proc.stdout: + async for part in proc.stdout: # pragma: no branch buffer += part if buffer.replace(b"\r\n", b"\n").endswith(b"READY\n") and not killed: os.kill(proc.pid, signal_sent) @@ -403,7 +403,7 @@ async def test_ki_in_repl() -> None: ) killed = False buffer = b"" - async for part in proc.stdout: + async for part in proc.stdout: # pragma: no branch buffer += part if buffer.replace(b"\r\n", b"\n").endswith(b"READY\n") and not killed: os.kill(proc.pid, signal_sent) From 607879946dbf822dbe038ee338d4eb68766ab695 Mon Sep 17 00:00:00 2001 From: A5rocks Date: Tue, 12 Aug 2025 16:00:17 +0900 Subject: [PATCH 12/19] Start checking sysctls for `test_ki_newline_injection` --- src/trio/_tests/test_repl.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index b4270cc315..df006e6592 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import pathlib import signal import subprocess import sys @@ -244,8 +245,18 @@ def test_main_entrypoint() -> None: assert repl.returncode == 0 -# TODO: skip this based on sysctls? Or Linux version? -@pytest.mark.skipif(True, reason="the ioctl we use is disabled in CI") +def should_try_newline_injection() -> bool: + sysctl = pathlib.Path("/proc/sys/dev/tty/legacy_tiocsti") + if not sysctl.exists(): + return True + + else: + return sysctl.read_text() == "1" + + +@pytest.mark.skipif( + not should_try_newline_injection(), reason="the ioctl we use is disabled in CI" +) def test_ki_newline_injection() -> None: # TODO: test this line # TODO: we want to remove this functionality, eg by using vendored # pyrepls. From 86eb25006981ca5259b8adfcd33a2848c47124af Mon Sep 17 00:00:00 2001 From: A5rocks Date: Tue, 12 Aug 2025 16:01:56 +0900 Subject: [PATCH 13/19] Try enabling the sysctl --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f47e7ab07e..df691906a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -285,6 +285,8 @@ jobs: run: | sudo apt update sudo apt install -q python3-apport + - name: Enable TIOCSTI + run: sudo sysctl dev.tty.legacy_tiocsti=1 - name: Run tests if: matrix.check_formatting == '0' run: ./ci.sh From d9b407eb5c7626ab027630f79cf6e173a2eac97d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 07:03:55 +0000 Subject: [PATCH 14/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/trio/_repl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index 677e305fcf..e692695b6c 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -6,7 +6,7 @@ import sys import warnings from code import InteractiveConsole -from types import CodeType, FrameType, FunctionType +from types import FrameType from typing import Callable import outcome From 2d7add22c45dabf36f06b18ee46fb2d10b2dee16 Mon Sep 17 00:00:00 2001 From: A5rocks Date: Tue, 12 Aug 2025 16:05:50 +0900 Subject: [PATCH 15/19] Fix pre-commit --- src/trio/_repl.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index e692695b6c..a80c09dcea 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -6,7 +6,7 @@ import sys import warnings from code import InteractiveConsole -from types import FrameType +from types import CodeType, FrameType, FunctionType from typing import Callable import outcome @@ -41,8 +41,8 @@ def __init__(self, repl_locals: dict[str, object] | None = None) -> None: self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT self.interrupted = False - def runcode(self, code: types.CodeType) -> None: - func = types.FunctionType(code, self.locals) + def runcode(self, code: CodeType) -> None: + func = FunctionType(code, self.locals) if inspect.iscoroutinefunction(func): result = trio.from_thread.run(outcome.acapture, func) else: From b60e1ae2bcb78cd5b93b2b4441456010fbe423b7 Mon Sep 17 00:00:00 2001 From: A5rocks Date: Tue, 12 Aug 2025 16:12:17 +0900 Subject: [PATCH 16/19] Give up on TIOCSTI in CI --- .github/workflows/ci.yml | 2 -- src/trio/_tests/test_repl.py | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7336fee56c..ed09025c3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -282,8 +282,6 @@ jobs: run: | sudo apt update sudo apt install -q python3-apport - - name: Enable TIOCSTI - run: sudo sysctl dev.tty.legacy_tiocsti=1 - name: Run tests if: matrix.check_formatting == '0' run: ./ci.sh diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index df006e6592..3234bd201b 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -255,7 +255,8 @@ def should_try_newline_injection() -> bool: @pytest.mark.skipif( - not should_try_newline_injection(), reason="the ioctl we use is disabled in CI" + not should_try_newline_injection() and sys.platform != "win32", + reason="the ioctl we use is disabled in CI", ) def test_ki_newline_injection() -> None: # TODO: test this line # TODO: we want to remove this functionality, eg by using vendored From 16b65e3c938ea7683c7785031c1a86e5605e758d Mon Sep 17 00:00:00 2001 From: A5rocks Date: Tue, 12 Aug 2025 16:19:01 +0900 Subject: [PATCH 17/19] Actually skip newline injection on Windows --- src/trio/_tests/test_repl.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index 3234bd201b..851ab7d3ff 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -246,6 +246,9 @@ def test_main_entrypoint() -> None: def should_try_newline_injection() -> bool: + if sys.platform == "win32": + return False + sysctl = pathlib.Path("/proc/sys/dev/tty/legacy_tiocsti") if not sysctl.exists(): return True @@ -255,7 +258,7 @@ def should_try_newline_injection() -> bool: @pytest.mark.skipif( - not should_try_newline_injection() and sys.platform != "win32", + not should_try_newline_injection(), reason="the ioctl we use is disabled in CI", ) def test_ki_newline_injection() -> None: # TODO: test this line From d25d9d124b464d000a4b28983e7ae2f8e62bbda9 Mon Sep 17 00:00:00 2001 From: A5rocks Date: Tue, 12 Aug 2025 16:27:20 +0900 Subject: [PATCH 18/19] Actually skip newline injection tests on MacOS --- src/trio/_tests/test_repl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index 851ab7d3ff..796c7de53b 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -246,7 +246,7 @@ def test_main_entrypoint() -> None: def should_try_newline_injection() -> bool: - if sys.platform == "win32": + if sys.platform != "linux": return False sysctl = pathlib.Path("/proc/sys/dev/tty/legacy_tiocsti") From 8a07eb3b4a64a22ddfacd66f8c0f0a345790da07 Mon Sep 17 00:00:00 2001 From: A5rocks Date: Tue, 12 Aug 2025 16:36:08 +0900 Subject: [PATCH 19/19] Codecov annoyances --- src/trio/_repl.py | 12 +++++++----- src/trio/_tests/test_repl.py | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index a80c09dcea..5a96e68789 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -22,7 +22,7 @@ class SuppressDecorator(contextlib.ContextDecorator, contextlib.suppress): @SuppressDecorator(KeyboardInterrupt) @trio.lowlevel.disable_ki_protection -def terminal_newline() -> None: +def terminal_newline() -> None: # TODO: test this line import fcntl import termios @@ -72,7 +72,7 @@ def runcode(self, code: CodeType) -> None: trio.from_thread.run(trio.lowlevel.checkpoint_if_cancelled) # trio.from_thread.check_cancelled() has too long of a memory - if sys.platform == "win32": + if sys.platform == "win32": # TODO: test this line def raw_input(self, prompt: str = "") -> str: try: @@ -92,7 +92,9 @@ def raw_input(self, prompt: str = "") -> str: def install_handler() -> ( Callable[[int, FrameType | None], None] | int | None ): - def handler(sig: int, frame: FrameType | None) -> None: + def handler( + sig: int, frame: FrameType | None + ) -> None: # TODO: test this line self.interrupted = True token.run_sync_soon(terminal_newline, idempotent=True) @@ -105,11 +107,11 @@ def handler(sig: int, frame: FrameType | None) -> None: return input(prompt) finally: trio.from_thread.run_sync(signal, SIGINT, prev_handler) - if self.interrupted: + if self.interrupted: # TODO: test this line raise KeyboardInterrupt def write(self, output: str) -> None: - if self.interrupted: + if self.interrupted: # TODO: test this line assert output == "\nKeyboardInterrupt\n" sys.stderr.write(output[1:]) self.interrupted = False diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index 796c7de53b..ae125d9ab0 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -250,7 +250,7 @@ def should_try_newline_injection() -> bool: return False sysctl = pathlib.Path("/proc/sys/dev/tty/legacy_tiocsti") - if not sysctl.exists(): + if not sysctl.exists(): # pragma: no cover return True else: