From 30781dce0a13b8e10d76bf02eb683fc0b2f38560 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Fri, 17 Apr 2026 22:31:03 +1000 Subject: [PATCH] tests/cmdline: Add Ctrl-C interrupt test for the repl_ test framework. Adds a "# sigint:" directive for repl_ tests that need Ctrl-C to generate SIGINT via the PTY terminal driver. When present, the child process is set up with the PTY as its controlling terminal (via setsid/TIOCSCTTY/tcsetpgrp) so that \x03 written to the PTY master generates SIGINT for the child's process group. This works because MicroPython's REPL restores original terminal settings (with ISIG enabled) before executing user code, allowing the terminal driver to convert \x03 into SIGINT during blocking operations. Test added: - repl_ctrl_c_interrupt_execution.py: Verifies Ctrl-C interrupts a blocking time.sleep() call and the REPL remains functional afterward. Also wraps PTY fd handling in try/finally for all repl_ tests. Signed-off-by: Andrew Leech --- pyproject.toml | 1 + .../repl_ctrl_c_interrupt_execution.py | 8 ++ .../repl_ctrl_c_interrupt_execution.py.exp | 15 +++ tests/run-tests.py | 96 +++++++++++++++---- 4 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 tests/cmdline/repl_ctrl_c_interrupt_execution.py create mode 100644 tests/cmdline/repl_ctrl_c_interrupt_execution.py.exp diff --git a/pyproject.toml b/pyproject.toml index 55763552fa..f528961b2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ exclude = [ # Ruff finds Python SyntaxError in these files "tests/cmdline/repl_autoindent.py", "tests/cmdline/repl_basic.py", "tests/cmdline/repl_cont.py", + "tests/cmdline/repl_ctrl_c_interrupt_execution.py", "tests/cmdline/repl_emacs_keys.py", "tests/cmdline/repl_paste.py", "tests/cmdline/repl_words_move.py", diff --git a/tests/cmdline/repl_ctrl_c_interrupt_execution.py b/tests/cmdline/repl_ctrl_c_interrupt_execution.py new file mode 100644 index 0000000000..b125e2c16b --- /dev/null +++ b/tests/cmdline/repl_ctrl_c_interrupt_execution.py @@ -0,0 +1,8 @@ +# sigint: deliver via controlling terminal +# Test that Ctrl-C (SIGINT) interrupts blocking code execution. +# MicroPython restores original terminal mode (ISIG on) during +# execution, so the PTY terminal driver generates SIGINT from \x03. +import time +time.sleep(10) +{\x03} +print('repl still responds') diff --git a/tests/cmdline/repl_ctrl_c_interrupt_execution.py.exp b/tests/cmdline/repl_ctrl_c_interrupt_execution.py.exp new file mode 100644 index 0000000000..48baad0193 --- /dev/null +++ b/tests/cmdline/repl_ctrl_c_interrupt_execution.py.exp @@ -0,0 +1,15 @@ +MicroPython \.\+ version +Type "help()" for more information. +>>> # sigint: deliver via controlling terminal +>>> # Test that Ctrl-C (SIGINT) interrupts blocking code execution. +>>> # MicroPython restores original terminal mode (ISIG on) during +>>> # execution, so the PTY terminal driver generates SIGINT from \x03. +>>> import time +>>> time.sleep(10) +######## +\.\*Traceback (most recent call last): + File "", line 1, in +KeyboardInterrupt: \$ +>>> print('repl still responds') +repl still responds +>>> \$ diff --git a/tests/run-tests.py b/tests/run-tests.py index ba6197acf3..eb6f9d6f9c 100755 --- a/tests/run-tests.py +++ b/tests/run-tests.py @@ -460,11 +460,16 @@ def run_micropython(pyb, args, test_file, test_file_abspath, is_special=False): if is_special: # check for any cmdline options needed for this test cmdlist = [MICROPYTHON] + send_sigint = False with open(test_file, "rb") as f: - line = f.readline() - if line.startswith(b"# cmdline:"): - # subprocess.check_output on Windows only accepts strings, not bytes - cmdlist += [str(c, "utf-8") for c in line[10:].strip().split()] + for line in f: + if line.startswith(b"# cmdline:"): + # subprocess.check_output on Windows only accepts strings, not bytes + cmdlist += [str(c, "utf-8") for c in line[10:].strip().split()] + elif line.startswith(b"# sigint:"): + send_sigint = True + elif not line.startswith(b"#"): + break # run the test, possibly with redirected input try: @@ -499,27 +504,76 @@ def run_micropython(pyb, args, test_file, test_file_abspath, is_special=False): os.write(master, what) return get() + def send_ctrl_c(): + # Send \x03 without trailing newline and wait for + # the full response (traceback + new prompt). + os.write(master, b"\x03") + return get(True) + with open(test_file, "rb") as f: # instead of: output_mupy = subprocess.check_output(cmdlist, stdin=f) master, slave = pty.openpty() - p = subprocess.Popen( - cmdlist, stdin=slave, stdout=slave, stderr=subprocess.STDOUT, bufsize=0 - ) - banner = get(True) - output_mupy = banner + b"".join(send_get(line) for line in f) - send_get(b"\x04") # exit the REPL, so coverage info is saved - # At this point the process might have exited already, but trying to - # kill it 'again' normally doesn't result in exceptions as Python and/or - # the OS seem to try to handle this nicely. When running Linux on WSL - # though, the situation differs and calling Popen.kill after the process - # terminated results in a ProcessLookupError. Just catch that one here - # since we just want the process to be gone and that's the case. try: - p.kill() - except ProcessLookupError: - pass - os.close(master) - os.close(slave) + preexec_fn = None + use_sigint_kill = False + # Tests with "# sigint:" need Ctrl-C (\x03) to + # generate SIGINT. MicroPython restores original + # terminal mode (ISIG on) during code execution, + # so on Linux we set up the PTY as a controlling + # terminal for proper signal delivery. On macOS, + # setsid/TIOCSCTTY breaks PTY I/O, so we fall + # back to os.kill(). + if send_sigint: + if sys.platform == "darwin": + use_sigint_kill = True + else: + import fcntl + import termios + + def preexec_fn(): + os.setsid() + fcntl.ioctl(0, termios.TIOCSCTTY, 0) + os.tcsetpgrp(0, os.getpid()) + + p = subprocess.Popen( + cmdlist, + stdin=slave, + stdout=slave, + stderr=subprocess.STDOUT, + bufsize=0, + preexec_fn=preexec_fn, + ) + banner = get(True) + if send_sigint: + import signal + + parts = [] + for line in f: + if b"{\\x03}" in line: + if use_sigint_kill: + os.kill(p.pid, signal.SIGINT) + parts.append(get(True)) + else: + parts.append(send_ctrl_c()) + else: + parts.append(send_get(line)) + output_mupy = banner + b"".join(parts) + else: + output_mupy = banner + b"".join(send_get(line) for line in f) + send_get(b"\x04") # exit the REPL, so coverage info is saved + # At this point the process might have exited already, but trying to + # kill it 'again' normally doesn't result in exceptions as Python and/or + # the OS seem to try to handle this nicely. When running Linux on WSL + # though, the situation differs and calling Popen.kill after the process + # terminated results in a ProcessLookupError. Just catch that one here + # since we just want the process to be gone and that's the case. + try: + p.kill() + except ProcessLookupError: + pass + finally: + os.close(master) + os.close(slave) else: output_mupy = subprocess.check_output( cmdlist + [test_file], stderr=subprocess.STDOUT