Skip to content

Commit d316010

Browse files
committed
tests: Add Ctrl-C interrupt tests using extended repl_ framework.
Extends the repl_ test framework to support signal generation by configuring terminal attributes (ISIG, VINTR) and setting up the PTY as the controlling terminal for the MicroPython subprocess. This allows testing Ctrl-C interrupt behavior in both paste mode and during code execution using the standard repl_ test syntax with {\x03} for Ctrl-C control codes. Tests added: - repl_ctrl_c_interrupt.py: Tests Ctrl-C cancels paste mode - repl_ctrl_c_interrupt_execution.py: Tests Ctrl-C interrupts running code Changes to run-tests.py: - Added terminal configuration to enable ISIG flag and VINTR mapping - Added setup_controlling_terminal() to make PTY the controlling terminal - Modified repl_ Popen call to use preexec_fn for proper signal delivery Signed-off-by: Andrew Leech <[email protected]>
1 parent 27544a2 commit d316010

File tree

6 files changed

+105
-18
lines changed

6 files changed

+105
-18
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ exclude = [ # Ruff finds Python SyntaxError in these files
3535
"tests/cmdline/repl_autoindent.py",
3636
"tests/cmdline/repl_basic.py",
3737
"tests/cmdline/repl_cont.py",
38+
"tests/cmdline/repl_ctrl_c_paste_cancel.py",
39+
"tests/cmdline/repl_ctrl_c_interrupt_execution.py",
3840
"tests/cmdline/repl_emacs_keys.py",
3941
"tests/cmdline/repl_paste.py",
4042
"tests/cmdline/repl_words_move.py",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Test Ctrl-C interrupts executing code
2+
# The test framework sends lines sequentially without waiting for completion,
3+
# so Ctrl-C is sent while sleep(30) is still running, triggering SIGINT.
4+
import time
5+
time.sleep(30)
6+
{\x03}
7+
print('repl still responds')
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
MicroPython \.\+ version
2+
Use Ctrl-D to exit, Ctrl-E for paste mode
3+
>>> # Test Ctrl-C interrupts executing code
4+
>>> # The test framework sends lines sequentially without waiting for completion,
5+
>>> # so Ctrl-C is sent while sleep(30) is still running, triggering SIGINT.
6+
>>> import time
7+
>>> time.sleep(30)
8+
^C
9+
Traceback (most recent call last):
10+
File "<stdin>", line 1, in <module>
11+
KeyboardInterrupt:
12+
>>> print('repl still responds')
13+
repl still responds
14+
>>>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Test that Ctrl-C cancels paste mode
2+
3+
# Test paste mode Ctrl-C cancel
4+
{\x05}
5+
print('paste mode')
6+
{\x03}
7+
8+
# Verify REPL works
9+
print('REPL works')
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
MicroPython \.\+ version
2+
Use Ctrl-D to exit, Ctrl-E for paste mode
3+
>>> # Test that Ctrl-C cancels paste mode
4+
>>>
5+
>>> # Test paste mode Ctrl-C cancel
6+
>>>
7+
paste mode; Ctrl-C to cancel, Ctrl-D to finish
8+
===
9+
=== print('paste mode')
10+
===
11+
>>>
12+
>>>
13+
>>> # Verify REPL works
14+
>>> print('REPL works')
15+
REPL works
16+
>>>

tests/run-tests.py

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,23 @@ def base_path(*p):
6060
# Set PYTHONIOENCODING so that CPython will use utf-8 on systems which set another encoding in the locale
6161
os.environ["PYTHONIOENCODING"] = "utf-8"
6262

63+
64+
def normalize_newlines(data):
65+
"""Normalize newline variations to \\n.
66+
67+
Only normalizes actual line endings, not literal \\r characters in strings.
68+
Handles \\r\\r\\n and \\r\\n cases to ensure consistent comparison
69+
across different platforms and terminals.
70+
"""
71+
if isinstance(data, bytes):
72+
# Handle PTY double-newline issue first
73+
data = data.replace(b"\r\r\n", b"\n")
74+
# Then handle standard Windows line endings
75+
data = data.replace(b"\r\n", b"\n")
76+
# Don't convert standalone \r as it might be literal content
77+
return data
78+
79+
6380
# Code to allow a target MicroPython to import an .mpy from RAM
6481
# Note: the module is named `__injected_test` but it needs to have `__name__` set to
6582
# `__main__` so that the test sees itself as the main module, eg so unittest works.
@@ -602,6 +619,8 @@ def run_micropython(pyb, args, test_file, test_file_abspath, is_special=False):
602619
# Need to use a PTY to test command line editing
603620
try:
604621
import pty
622+
import termios
623+
import fcntl
605624
except ImportError:
606625
# in case pty module is not available, like on Windows
607626
return b"SKIP\n"
@@ -632,24 +651,44 @@ def send_get(what):
632651
with open(test_file, "rb") as f:
633652
# instead of: output_mupy = subprocess.check_output(args, stdin=f)
634653
master, slave = pty.openpty()
635-
p = subprocess.Popen(
636-
args, stdin=slave, stdout=slave, stderr=subprocess.STDOUT, bufsize=0
637-
)
638-
banner = get(True)
639-
output_mupy = banner + b"".join(send_get(line) for line in f)
640-
send_get(b"\x04") # exit the REPL, so coverage info is saved
641-
# At this point the process might have exited already, but trying to
642-
# kill it 'again' normally doesn't result in exceptions as Python and/or
643-
# the OS seem to try to handle this nicely. When running Linux on WSL
644-
# though, the situation differs and calling Popen.kill after the process
645-
# terminated results in a ProcessLookupError. Just catch that one here
646-
# since we just want the process to be gone and that's the case.
647654
try:
648-
p.kill()
649-
except ProcessLookupError:
650-
pass
651-
os.close(master)
652-
os.close(slave)
655+
# Configure terminal attributes to enable Ctrl-C signal generation
656+
attrs = termios.tcgetattr(slave)
657+
attrs[3] |= termios.ISIG # Enable signal generation (ISIG flag)
658+
attrs[6][termios.VINTR] = 3 # Set Ctrl-C (0x03) as interrupt character
659+
termios.tcsetattr(slave, termios.TCSANOW, attrs)
660+
661+
def setup_controlling_terminal():
662+
"""Set up the child process with the PTY as controlling terminal."""
663+
os.setsid() # Create a new session
664+
fcntl.ioctl(
665+
0, termios.TIOCSCTTY, 0
666+
) # Make PTY the controlling terminal
667+
668+
p = subprocess.Popen(
669+
args,
670+
stdin=slave,
671+
stdout=slave,
672+
stderr=subprocess.STDOUT,
673+
bufsize=0,
674+
preexec_fn=setup_controlling_terminal,
675+
)
676+
banner = get(True)
677+
output_mupy = banner + b"".join(send_get(line) for line in f)
678+
send_get(b"\x04") # exit the REPL, so coverage info is saved
679+
# At this point the process might have exited already, but trying to
680+
# kill it 'again' normally doesn't result in exceptions as Python and/or
681+
# the OS seem to try to handle this nicely. When running Linux on WSL
682+
# though, the situation differs and calling Popen.kill after the process
683+
# terminated results in a ProcessLookupError. Just catch that one here
684+
# since we just want the process to be gone and that's the case.
685+
try:
686+
p.kill()
687+
except ProcessLookupError:
688+
pass
689+
finally:
690+
os.close(master)
691+
os.close(slave)
653692
else:
654693
output_mupy = subprocess.check_output(
655694
args + [test_file], stderr=subprocess.STDOUT
@@ -705,7 +744,7 @@ def send_get(what):
705744
)
706745

707746
# canonical form for all ports/platforms is to use \n for end-of-line
708-
output_mupy = output_mupy.replace(b"\r\n", b"\n")
747+
output_mupy = normalize_newlines(output_mupy)
709748

710749
# don't try to convert the output if we should skip this test
711750
if had_crash or output_mupy in (b"SKIP\n", b"SKIP-TOO-LARGE\n", b"CRASH"):

0 commit comments

Comments
 (0)