Skip to content

Commit 35ba6d4

Browse files
ambvdura0okjschwinger233picnixz
authored
[3.13] pythongh-135329: prevent infinite traceback loop on Ctrl-C for strace (python#138974)
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]> (cherry picked from commit b9dbf6a)
1 parent 2b8ff3c commit 35ba6d4

File tree

4 files changed

+172
-3
lines changed

4 files changed

+172
-3
lines changed

Lib/_pyrepl/unix_console.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,14 @@ def prepare(self):
349349
raw.lflag |= termios.ISIG
350350
raw.cc[termios.VMIN] = 1
351351
raw.cc[termios.VTIME] = 0
352-
tcsetattr(self.input_fd, termios.TCSADRAIN, raw)
352+
try:
353+
tcsetattr(self.input_fd, termios.TCSADRAIN, raw)
354+
except termios.error as e:
355+
if e.args[0] != errno.EIO:
356+
# gh-135329: when running under external programs (like strace),
357+
# tcsetattr may fail with EIO. We can safely ignore this
358+
# and continue with default terminal settings.
359+
raise
353360

354361
# In macOS terminal we need to deactivate line wrap via ANSI escape code
355362
if self.is_apple_terminal:
@@ -381,7 +388,11 @@ def restore(self):
381388
self.__disable_bracketed_paste()
382389
self.__maybe_write_code(self._rmkx)
383390
self.flushoutput()
384-
tcsetattr(self.input_fd, termios.TCSADRAIN, self.__svtermstate)
391+
try:
392+
tcsetattr(self.input_fd, termios.TCSADRAIN, self.__svtermstate)
393+
except termios.error as e:
394+
if e.args[0] != errno.EIO:
395+
raise
385396

386397
if self.is_apple_terminal:
387398
os.write(self.output_fd, b"\033[?7h")
@@ -420,6 +431,8 @@ def get_event(self, block: bool = True) -> Event | None:
420431
return self.event_queue.get()
421432
else:
422433
continue
434+
elif err.errno == errno.EIO:
435+
raise SystemExit(errno.EIO)
423436
else:
424437
raise
425438
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: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +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
10+
from test.support import script_helper
11+
712
from unittest import TestCase
8-
from unittest.mock import MagicMock, call, patch, ANY
13+
from unittest.mock import MagicMock, call, patch, ANY, Mock
914

1015
from .support import handle_all_events, code_to_events, reader_no_colors
1116

@@ -336,3 +341,59 @@ def test_restore_with_invalid_environ_on_macos(self, _os_write):
336341
os.environ = []
337342
console.prepare() # needed to call restore()
338343
console.restore() # this should succeed
344+
345+
346+
@unittest.skipIf(sys.platform == "win32", "No Unix console on Windows")
347+
class TestUnixConsoleEIOHandling(TestCase):
348+
349+
@patch('_pyrepl.unix_console.tcsetattr')
350+
@patch('_pyrepl.unix_console.tcgetattr')
351+
def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr):
352+
353+
import termios
354+
mock_termios = Mock()
355+
mock_termios.iflag = 0
356+
mock_termios.oflag = 0
357+
mock_termios.cflag = 0
358+
mock_termios.lflag = 0
359+
mock_termios.cc = [0] * 32
360+
mock_termios.copy.return_value = mock_termios
361+
mock_tcgetattr.return_value = mock_termios
362+
363+
console = UnixConsole(term="xterm")
364+
console.prepare()
365+
366+
mock_tcsetattr.side_effect = termios.error(errno.EIO, "Input/output error")
367+
368+
# EIO error should be handled gracefully in restore()
369+
console.restore()
370+
371+
@unittest.skipUnless(sys.platform == "linux", "Only valid on Linux")
372+
def test_repl_eio(self):
373+
# Use the pty-based approach to simulate EIO error
374+
script_path = os.path.join(os.path.dirname(__file__), "eio_test_script.py")
375+
376+
proc = script_helper.spawn_python(
377+
"-S", script_path,
378+
stderr=subprocess.PIPE,
379+
text=True
380+
)
381+
382+
ready_line = proc.stdout.readline().strip()
383+
if ready_line != "READY" or proc.poll() is not None:
384+
self.fail("Child process failed to start properly")
385+
386+
os.kill(proc.pid, signal.SIGUSR1)
387+
_, err = proc.communicate(timeout=5) # sleep for pty to settle
388+
self.assertEqual(
389+
proc.returncode,
390+
1,
391+
f"Expected EIO/ENXIO error, got return code {proc.returncode}",
392+
)
393+
self.assertTrue(
394+
(
395+
"Got EIO:" in err
396+
or "Got ENXIO:" in err
397+
),
398+
f"Expected EIO/ENXIO error message in stderr: {err}",
399+
)
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)