Skip to content

Commit 66c748c

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 This approach reuses existing infrastructure rather than creating a separate test framework, keeping the codebase simpler while enabling signal generation testing. Signed-off-by: Andrew Leech <[email protected]>
1 parent a2b2e9d commit 66c748c

File tree

6 files changed

+114
-14
lines changed

6 files changed

+114
-14
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ exclude = [ # Ruff finds Python SyntaxError in these files
3434
"tests/cmdline/repl_autoindent.py",
3535
"tests/cmdline/repl_basic.py",
3636
"tests/cmdline/repl_cont.py",
37+
"tests/cmdline/repl_ctrl_c_interrupt.py",
38+
"tests/cmdline/repl_ctrl_c_interrupt_execution.py",
3739
"tests/cmdline/repl_emacs_keys.py",
3840
"tests/cmdline/repl_paste.py",
3941
"tests/cmdline/repl_words_move.py",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Test that Ctrl-C interrupts running code
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+
Type "help()" for more information.
3+
>>> # Test that Ctrl-C interrupts running code
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+
>>>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Test Ctrl-C interrupts executing code
2+
import time
3+
time.sleep(30)
4+
{\x03}
5+
print('interrupted')
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
MicroPython \.\+ version
2+
Type "help()" for more information.
3+
>>> # Test Ctrl-C interrupts executing code
4+
>>> import time
5+
>>> time.sleep(30)
6+
^C
7+
Traceback (most recent call last):
8+
File "<stdin>", line 1, in <module>
9+
KeyboardInterrupt:
10+
>>> print('interrupted')
11+
interrupted
12+
>>>

tests/run-tests.py

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,8 @@ def run_micropython(pyb, args, test_file, test_file_abspath, is_special=False):
619619
# Need to use a PTY to test command line editing
620620
try:
621621
import pty
622+
import termios
623+
import fcntl
622624
except ImportError:
623625
# in case pty module is not available, like on Windows
624626
return b"SKIP\n"
@@ -629,14 +631,8 @@ def run_micropython(pyb, args, test_file, test_file_abspath, is_special=False):
629631
return b"SKIP\n"
630632

631633
def get(required=False):
632-
rv = b""
633-
while True:
634-
ready = select.select([master], [], [], 0.02)
635-
if ready[0] == [master]:
636-
rv += os.read(master, 1024)
637-
else:
638-
if not required or rv:
639-
return rv
634+
# Use the unified pty_read_until_quiet function
635+
return pty_read_until_quiet(master, timeout=0.02, required=required)
640636

641637
def send_get(what):
642638
# Detect {\x00} pattern and convert to ctrl-key codes.
@@ -649,8 +645,27 @@ def send_get(what):
649645
with open(test_file, "rb") as f:
650646
# instead of: output_mupy = subprocess.check_output(args, stdin=f)
651647
master, slave = pty.openpty()
648+
649+
# Configure terminal attributes to enable Ctrl-C signal generation
650+
attrs = termios.tcgetattr(slave)
651+
attrs[3] |= termios.ISIG # Enable signal generation (ISIG flag)
652+
attrs[6][termios.VINTR] = 3 # Set Ctrl-C (0x03) as interrupt character
653+
termios.tcsetattr(slave, termios.TCSANOW, attrs)
654+
655+
def setup_controlling_terminal():
656+
"""Set up the child process with the PTY as controlling terminal."""
657+
os.setsid() # Create a new session
658+
fcntl.ioctl(
659+
0, termios.TIOCSCTTY, 0
660+
) # Make PTY the controlling terminal
661+
652662
p = subprocess.Popen(
653-
args, stdin=slave, stdout=slave, stderr=subprocess.STDOUT, bufsize=0
663+
args,
664+
stdin=slave,
665+
stdout=slave,
666+
stderr=subprocess.STDOUT,
667+
bufsize=0,
668+
preexec_fn=setup_controlling_terminal,
654669
)
655670
banner = get(True)
656671
output_mupy = banner + b"".join(send_get(line) for line in f)
@@ -808,6 +823,42 @@ def value(self):
808823
return self._value
809824

810825

826+
# Global lock to protect stdout operations when running tests in parallel
827+
stdout_lock = threading.Lock()
828+
829+
830+
def pty_read_until_quiet(master, timeout=0.05, required=False):
831+
"""Read from PTY until no data for timeout seconds.
832+
833+
This is a unified utility function used by repl_ tests.
834+
835+
Args:
836+
master: PTY master file descriptor
837+
timeout: Seconds to wait for data before considering the stream quiet (default 0.05)
838+
required: If True, keep trying until at least some data is read (default False)
839+
840+
Returns:
841+
bytes: Data read from PTY
842+
"""
843+
import select
844+
import os
845+
846+
output = b""
847+
while True:
848+
ready = select.select([master], [], [], timeout)
849+
if ready[0]:
850+
data = os.read(master, 1024)
851+
if not data:
852+
# EOF reached
853+
break
854+
output += data
855+
else:
856+
# Timeout with no data available
857+
if not required or output:
858+
break
859+
return output
860+
861+
811862
class PyboardNodeRunner:
812863
def __init__(self):
813864
mjs = os.getenv("MICROPY_MICROPYTHON_MJS")
@@ -1098,7 +1149,8 @@ def run_one_test(test_file):
10981149
skip_it |= skip_inlineasm and is_inlineasm
10991150

11001151
if skip_it:
1101-
print("skip ", test_file)
1152+
with stdout_lock:
1153+
print("skip ", test_file)
11021154
test_results.append((test_file, "skip", ""))
11031155
return
11041156

@@ -1113,11 +1165,13 @@ def run_one_test(test_file):
11131165
# reset. Wait for the soft reset to finish, so we don't interrupt the
11141166
# start-up code (eg boot.py) when preparing to run the next test.
11151167
pyb.read_until(1, b"raw REPL; CTRL-B to exit\r\n")
1116-
print("skip ", test_file)
1168+
with stdout_lock:
1169+
print("skip ", test_file)
11171170
test_results.append((test_file, "skip", ""))
11181171
return
11191172
elif output_mupy == b"SKIP-TOO-LARGE\n":
1120-
print("lrge ", test_file)
1173+
with stdout_lock:
1174+
print("lrge ", test_file)
11211175
test_results.append((test_file, "skip", "too large"))
11221176
return
11231177

@@ -1204,12 +1258,14 @@ def run_one_test(test_file):
12041258

12051259
# Print test summary, update counters, and save .exp/.out files if needed.
12061260
if test_passed:
1207-
print("pass ", test_file, extra_info)
1261+
with stdout_lock:
1262+
print("pass ", test_file, extra_info)
12081263
test_results.append((test_file, "pass", ""))
12091264
rm_f(filename_expected)
12101265
rm_f(filename_mupy)
12111266
else:
1212-
print("FAIL ", test_file, extra_info)
1267+
with stdout_lock:
1268+
print("FAIL ", test_file, extra_info)
12131269
if output_expected is not None:
12141270
with open(filename_expected, "wb") as f:
12151271
f.write(output_expected)

0 commit comments

Comments
 (0)