Skip to content

Commit b9dbf6a

Browse files
yihong0618dura0okjschwinger233picnixzambv
authored
gh-135329: prevent infinite traceback loop on Ctrl-C for strace (GH-138133)
Signed-off-by: yihong0618 <[email protected]> Co-authored-by: dura0ok <[email protected]> Co-authored-by: graymon <[email protected]> Co-authored-by: Bénédikt Tran <[email protected]> Co-authored-by: Łukasz Langa <[email protected]>
1 parent 55e29a6 commit b9dbf6a

File tree

4 files changed

+171
-3
lines changed

4 files changed

+171
-3
lines changed

Lib/_pyrepl/unix_console.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,14 @@ def prepare(self):
340340
raw.lflag |= termios.ISIG
341341
raw.cc[termios.VMIN] = 1
342342
raw.cc[termios.VTIME] = 0
343-
tcsetattr(self.input_fd, termios.TCSADRAIN, raw)
343+
try:
344+
tcsetattr(self.input_fd, termios.TCSADRAIN, raw)
345+
except termios.error as e:
346+
if e.args[0] != errno.EIO:
347+
# gh-135329: when running under external programs (like strace),
348+
# tcsetattr may fail with EIO. We can safely ignore this
349+
# and continue with default terminal settings.
350+
raise
344351

345352
# In macOS terminal we need to deactivate line wrap via ANSI escape code
346353
if self.is_apple_terminal:
@@ -372,7 +379,11 @@ def restore(self):
372379
self.__disable_bracketed_paste()
373380
self.__maybe_write_code(self._rmkx)
374381
self.flushoutput()
375-
tcsetattr(self.input_fd, termios.TCSADRAIN, self.__svtermstate)
382+
try:
383+
tcsetattr(self.input_fd, termios.TCSADRAIN, self.__svtermstate)
384+
except termios.error as e:
385+
if e.args[0] != errno.EIO:
386+
raise
376387

377388
if self.is_apple_terminal:
378389
os.write(self.output_fd, b"\033[?7h")
@@ -411,6 +422,8 @@ def get_event(self, block: bool = True) -> Event | None:
411422
return self.event_queue.get()
412423
else:
413424
continue
425+
elif err.errno == errno.EIO:
426+
raise SystemExit(errno.EIO)
414427
else:
415428
raise
416429
else:
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import errno
2+
import fcntl
3+
import os
4+
import pty
5+
import signal
6+
import sys
7+
import termios
8+
9+
10+
def handler(sig, f):
11+
pass
12+
13+
14+
def create_eio_condition():
15+
# SIGINT handler used to produce an EIO.
16+
# See https://github.com/python/cpython/issues/135329.
17+
try:
18+
master_fd, slave_fd = pty.openpty()
19+
child_pid = os.fork()
20+
if child_pid == 0:
21+
try:
22+
os.setsid()
23+
fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
24+
child_process_group_id = os.getpgrp()
25+
grandchild_pid = os.fork()
26+
if grandchild_pid == 0:
27+
os.setpgid(0, 0) # set process group for grandchild
28+
os.dup2(slave_fd, 0) # redirect stdin
29+
if slave_fd > 2:
30+
os.close(slave_fd)
31+
# Fork grandchild for terminal control manipulation
32+
if os.fork() == 0:
33+
sys.exit(0) # exit the child process that was just obtained
34+
else:
35+
try:
36+
os.tcsetpgrp(0, child_process_group_id)
37+
except OSError:
38+
pass
39+
sys.exit(0)
40+
else:
41+
# Back to child
42+
try:
43+
os.setpgid(grandchild_pid, grandchild_pid)
44+
except ProcessLookupError:
45+
pass
46+
os.tcsetpgrp(slave_fd, grandchild_pid)
47+
if slave_fd > 2:
48+
os.close(slave_fd)
49+
os.waitpid(grandchild_pid, 0)
50+
# Manipulate terminal control to create EIO condition
51+
os.tcsetpgrp(master_fd, child_process_group_id)
52+
# Now try to read from master - this might cause EIO
53+
try:
54+
os.read(master_fd, 1)
55+
except OSError as e:
56+
if e.errno == errno.EIO:
57+
print(f"Setup created EIO condition: {e}", file=sys.stderr)
58+
sys.exit(0)
59+
except Exception as setup_e:
60+
print(f"Setup error: {setup_e}", file=sys.stderr)
61+
sys.exit(1)
62+
else:
63+
# Parent process
64+
os.close(slave_fd)
65+
os.waitpid(child_pid, 0)
66+
# Now replace stdin with master_fd and try to read
67+
os.dup2(master_fd, 0)
68+
os.close(master_fd)
69+
# This should now trigger EIO
70+
print(f"Unexpectedly got input: {input()!r}", file=sys.stderr)
71+
sys.exit(0)
72+
except OSError as e:
73+
if e.errno == errno.EIO:
74+
print(f"Got EIO: {e}", file=sys.stderr)
75+
sys.exit(1)
76+
elif e.errno == errno.ENXIO:
77+
print(f"Got ENXIO (no such device): {e}", file=sys.stderr)
78+
sys.exit(1) # Treat ENXIO as success too
79+
else:
80+
print(f"Got other OSError: errno={e.errno} {e}", file=sys.stderr)
81+
sys.exit(2)
82+
except EOFError as e:
83+
print(f"Got EOFError: {e}", file=sys.stderr)
84+
sys.exit(3)
85+
except Exception as e:
86+
print(f"Got unexpected error: {type(e).__name__}: {e}", file=sys.stderr)
87+
sys.exit(4)
88+
89+
90+
if __name__ == "__main__":
91+
# Set up signal handler for coordination
92+
signal.signal(signal.SIGUSR1, lambda *a: create_eio_condition())
93+
print("READY", flush=True)
94+
signal.pause()

Lib/test/test_pyrepl/test_unix_console.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
import errno
12
import itertools
23
import os
4+
import signal
5+
import subprocess
36
import sys
47
import unittest
58
from functools import partial
69
from test.support import os_helper, force_not_colorized_test_class
10+
from test.support import script_helper
711

812
from unittest import TestCase
9-
from unittest.mock import MagicMock, call, patch, ANY
13+
from unittest.mock import MagicMock, call, patch, ANY, Mock
1014

1115
from .support import handle_all_events, code_to_events
1216

@@ -312,3 +316,59 @@ def test_restore_with_invalid_environ_on_macos(self, _os_write):
312316
os.environ = []
313317
console.prepare() # needed to call restore()
314318
console.restore() # this should succeed
319+
320+
321+
@unittest.skipIf(sys.platform == "win32", "No Unix console on Windows")
322+
class TestUnixConsoleEIOHandling(TestCase):
323+
324+
@patch('_pyrepl.unix_console.tcsetattr')
325+
@patch('_pyrepl.unix_console.tcgetattr')
326+
def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr):
327+
328+
import termios
329+
mock_termios = Mock()
330+
mock_termios.iflag = 0
331+
mock_termios.oflag = 0
332+
mock_termios.cflag = 0
333+
mock_termios.lflag = 0
334+
mock_termios.cc = [0] * 32
335+
mock_termios.copy.return_value = mock_termios
336+
mock_tcgetattr.return_value = mock_termios
337+
338+
console = UnixConsole(term="xterm")
339+
console.prepare()
340+
341+
mock_tcsetattr.side_effect = termios.error(errno.EIO, "Input/output error")
342+
343+
# EIO error should be handled gracefully in restore()
344+
console.restore()
345+
346+
@unittest.skipUnless(sys.platform == "linux", "Only valid on Linux")
347+
def test_repl_eio(self):
348+
# Use the pty-based approach to simulate EIO error
349+
script_path = os.path.join(os.path.dirname(__file__), "eio_test_script.py")
350+
351+
proc = script_helper.spawn_python(
352+
"-S", script_path,
353+
stderr=subprocess.PIPE,
354+
text=True
355+
)
356+
357+
ready_line = proc.stdout.readline().strip()
358+
if ready_line != "READY" or proc.poll() is not None:
359+
self.fail("Child process failed to start properly")
360+
361+
os.kill(proc.pid, signal.SIGUSR1)
362+
_, err = proc.communicate(timeout=5) # sleep for pty to settle
363+
self.assertEqual(
364+
proc.returncode,
365+
1,
366+
f"Expected EIO/ENXIO error, got return code {proc.returncode}",
367+
)
368+
self.assertTrue(
369+
(
370+
"Got EIO:" in err
371+
or "Got ENXIO:" in err
372+
),
373+
f"Expected EIO/ENXIO error message in stderr: {err}",
374+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Prevent infinite traceback loop when sending CTRL^C to Python through ``strace``.

0 commit comments

Comments
 (0)