From ede576f8dbdcf9678f720819845d64d322f8684c Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Mon, 25 Aug 2025 16:33:30 +0800 Subject: [PATCH 01/16] fix: strace ./python will forever loop Signed-off-by: yihong0618 Co-authored-by: dura0ok --- Lib/_pyrepl/unix_console.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index a7e49923191c07..62e9b5ce3e9167 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -336,7 +336,17 @@ def prepare(self): raw.lflag |= termios.ISIG raw.cc[termios.VMIN] = 1 raw.cc[termios.VTIME] = 0 - tcsetattr(self.input_fd, termios.TCSADRAIN, raw) + try: + tcsetattr(self.input_fd, termios.TCSADRAIN, raw) + except termios.error as e: + if e.args[0] == errno.EIO: + # gh-135329 + # When running under external programs (like strace), + # tcsetattr may fail with EIO. We can safely ignore this + # and continue with default terminal settings. + pass + else: + raise # In macOS terminal we need to deactivate line wrap via ANSI escape code if platform.system() == "Darwin" and os.getenv("TERM_PROGRAM") == "Apple_Terminal": @@ -368,7 +378,17 @@ def restore(self): self.__disable_bracketed_paste() self.__maybe_write_code(self._rmkx) self.flushoutput() - tcsetattr(self.input_fd, termios.TCSADRAIN, self.__svtermstate) + try: + tcsetattr(self.input_fd, termios.TCSADRAIN, self.__svtermstate) + except termios.error as e: + if e.args[0] == errno.EIO: + # gh-135329 + # When running under external programs (like strace), + # tcsetattr may fail with EIO. We can safely ignore this + # as the terminal state will be restored by the external program. + pass + else: + raise if platform.system() == "Darwin" and os.getenv("TERM_PROGRAM") == "Apple_Terminal": os.write(self.output_fd, b"\033[?7h") @@ -407,6 +427,13 @@ def get_event(self, block: bool = True) -> Event | None: return self.event_queue.get() else: continue + elif err.errno == errno.EIO: + # gh-135329 + # When running under external programs (like strace), + # os.read may fail with EIO. In this case, we should + # exit gracefully to avoid infinite error loops. + import sys + sys.exit(errno.EIO) else: raise else: From 92355224e607e5174d144b9a2816a083bf916a68 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Mon, 25 Aug 2025 18:06:32 +0800 Subject: [PATCH 02/16] fix: add news Signed-off-by: yihong0618 --- .../next/Library/2025-08-25-18-06-04.gh-issue-138133.Zh9rGo.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2025-08-25-18-06-04.gh-issue-138133.Zh9rGo.rst diff --git a/Misc/NEWS.d/next/Library/2025-08-25-18-06-04.gh-issue-138133.Zh9rGo.rst b/Misc/NEWS.d/next/Library/2025-08-25-18-06-04.gh-issue-138133.Zh9rGo.rst new file mode 100644 index 00000000000000..4bb197a99d6d1f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-08-25-18-06-04.gh-issue-138133.Zh9rGo.rst @@ -0,0 +1 @@ +prevent infinite traceback loop on Ctrl-C for strace From 5ff6b9bb9c06fa2c33a58d16e1c6b644884e6019 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Mon, 25 Aug 2025 19:49:05 +0800 Subject: [PATCH 03/16] fix: address comments Signed-off-by: yihong0618 --- Lib/_pyrepl/unix_console.py | 10 +++------- .../2025-08-25-18-06-04.gh-issue-138133.Zh9rGo.rst | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index 62e9b5ce3e9167..3e02cceac47b21 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -339,13 +339,11 @@ def prepare(self): try: tcsetattr(self.input_fd, termios.TCSADRAIN, raw) except termios.error as e: - if e.args[0] == errno.EIO: + if e.args[0] != errno.EIO: # gh-135329 # When running under external programs (like strace), # tcsetattr may fail with EIO. We can safely ignore this # and continue with default terminal settings. - pass - else: raise # In macOS terminal we need to deactivate line wrap via ANSI escape code @@ -381,13 +379,11 @@ def restore(self): try: tcsetattr(self.input_fd, termios.TCSADRAIN, self.__svtermstate) except termios.error as e: - if e.args[0] == errno.EIO: + if e.args[0] != errno.EIO: # gh-135329 # When running under external programs (like strace), # tcsetattr may fail with EIO. We can safely ignore this - # as the terminal state will be restored by the external program. - pass - else: + # and continue with default terminal settings. raise if platform.system() == "Darwin" and os.getenv("TERM_PROGRAM") == "Apple_Terminal": diff --git a/Misc/NEWS.d/next/Library/2025-08-25-18-06-04.gh-issue-138133.Zh9rGo.rst b/Misc/NEWS.d/next/Library/2025-08-25-18-06-04.gh-issue-138133.Zh9rGo.rst index 4bb197a99d6d1f..f9045ef3b37fd7 100644 --- a/Misc/NEWS.d/next/Library/2025-08-25-18-06-04.gh-issue-138133.Zh9rGo.rst +++ b/Misc/NEWS.d/next/Library/2025-08-25-18-06-04.gh-issue-138133.Zh9rGo.rst @@ -1 +1 @@ -prevent infinite traceback loop on Ctrl-C for strace +Prevent infinite traceback loop when sending CTRL^C to Python through ``strace``. From d37d3182d1c3d7891a1a2793e62b49f8628f4734 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 26 Aug 2025 05:47:47 +0800 Subject: [PATCH 04/16] fix: address comments and add tests Signed-off-by: yihong0618 --- Lib/_pyrepl/unix_console.py | 11 +---- Lib/test/test_pyrepl/test_unix_console.py | 55 ++++++++++++++++++++++- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index 3e02cceac47b21..e4ef5ec64b2a1a 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -340,10 +340,6 @@ def prepare(self): tcsetattr(self.input_fd, termios.TCSADRAIN, raw) except termios.error as e: if e.args[0] != errno.EIO: - # gh-135329 - # When running under external programs (like strace), - # tcsetattr may fail with EIO. We can safely ignore this - # and continue with default terminal settings. raise # In macOS terminal we need to deactivate line wrap via ANSI escape code @@ -380,8 +376,7 @@ def restore(self): tcsetattr(self.input_fd, termios.TCSADRAIN, self.__svtermstate) except termios.error as e: if e.args[0] != errno.EIO: - # gh-135329 - # When running under external programs (like strace), + # gh-135329: when running under external programs (like strace), # tcsetattr may fail with EIO. We can safely ignore this # and continue with default terminal settings. raise @@ -424,10 +419,6 @@ def get_event(self, block: bool = True) -> Event | None: else: continue elif err.errno == errno.EIO: - # gh-135329 - # When running under external programs (like strace), - # os.read may fail with EIO. In this case, we should - # exit gracefully to avoid infinite error loops. import sys sys.exit(errno.EIO) else: diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index ab1236768cfb3e..863e573d306869 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -2,11 +2,13 @@ import os import sys import unittest +import errno +import termios from functools import partial from test.support import os_helper, force_not_colorized_test_class from unittest import TestCase -from unittest.mock import MagicMock, call, patch, ANY +from unittest.mock import MagicMock, call, patch, ANY, Mock from .support import handle_all_events, code_to_events @@ -303,3 +305,54 @@ def test_getheightwidth_with_invalid_environ(self, _os_write): self.assertIsInstance(console.getheightwidth(), tuple) os.environ = [] self.assertIsInstance(console.getheightwidth(), tuple) + + +class TestUnixConsoleEIOHandling(TestCase): + + @patch('_pyrepl.unix_console.tcsetattr') + @patch('_pyrepl.unix_console.tcgetattr') + def test_eio_error_handling_in_prepare(self, mock_tcgetattr, mock_tcsetattr): + mock_termios = Mock() + mock_termios.iflag = 0 + mock_termios.oflag = 0 + mock_termios.cflag = 0 + mock_termios.lflag = 0 + mock_termios.cc = [0] * 32 + mock_termios.copy.return_value = mock_termios + mock_tcgetattr.return_value = mock_termios + + mock_tcsetattr.side_effect = termios.error(errno.EIO, "Input/output error") + + console = UnixConsole(term="xterm") + + try: + console.prepare() + except termios.error as e: + if e.args[0] == errno.EIO: + self.fail("EIO error should have been handled gracefully in prepare()") + raise + + @patch('_pyrepl.unix_console.tcsetattr') + @patch('_pyrepl.unix_console.tcgetattr') + def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr): + + mock_termios = Mock() + mock_termios.iflag = 0 + mock_termios.oflag = 0 + mock_termios.cflag = 0 + mock_termios.lflag = 0 + mock_termios.cc = [0] * 32 + mock_termios.copy.return_value = mock_termios + mock_tcgetattr.return_value = mock_termios + + console = UnixConsole(term="xterm") + console.prepare() + + mock_tcsetattr.side_effect = termios.error(errno.EIO, "Input/output error") + + try: + console.restore() + except termios.error as e: + if e.args[0] == errno.EIO: + self.fail("EIO error should have been handled gracefully in restore()") + raise From f33f4ad171e86ba48e02ef38f02556cebd361f8d Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 26 Aug 2025 19:49:56 +0800 Subject: [PATCH 05/16] fix: address comments Signed-off-by: yihong0618 --- Lib/_pyrepl/unix_console.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index e4ef5ec64b2a1a..32897eaa05d319 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -340,6 +340,9 @@ def prepare(self): tcsetattr(self.input_fd, termios.TCSADRAIN, raw) except termios.error as e: if e.args[0] != errno.EIO: + # gh-135329: when running under external programs (like strace), + # tcsetattr may fail with EIO. We can safely ignore this + # and continue with default terminal settings. raise # In macOS terminal we need to deactivate line wrap via ANSI escape code @@ -376,9 +379,6 @@ def restore(self): tcsetattr(self.input_fd, termios.TCSADRAIN, self.__svtermstate) except termios.error as e: if e.args[0] != errno.EIO: - # gh-135329: when running under external programs (like strace), - # tcsetattr may fail with EIO. We can safely ignore this - # and continue with default terminal settings. raise if platform.system() == "Darwin" and os.getenv("TERM_PROGRAM") == "Apple_Terminal": From 851476bb0e9b44e989c6a3f1c51eda4195868842 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Mon, 1 Sep 2025 15:30:18 +0800 Subject: [PATCH 06/16] fix: add EIO erorr with strace test Signed-off-by: yihong0618 --- Lib/test/test_pyrepl/test_unix_console.py | 59 +++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index 863e573d306869..dfd498f5fcb691 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -6,6 +6,10 @@ import termios from functools import partial from test.support import os_helper, force_not_colorized_test_class +import subprocess +import time +import shutil +import signal from unittest import TestCase from unittest.mock import MagicMock, call, patch, ANY, Mock @@ -356,3 +360,58 @@ def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr): if e.args[0] == errno.EIO: self.fail("EIO error should have been handled gracefully in restore()") raise + +class TestEIOWithStrace(unittest.TestCase): + def setUp(self): + self.strace = shutil.which("strace") + if not self.strace: + self.skipTest("strace") + + def _attach_strace(self, pid): + cmd = [self.strace, "-qq", "-p", str(pid), "-e", "inject=read:error=EIO:when=1", "-o", "/dev/null"] + try: + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + time.sleep(0.15) + if p.poll() is None: + return p + except Exception: + pass + + cmd = [self.strace, "-qq", "-p", str(pid), "-e", "fault=read:error=EIO:when=1", "-o", "/dev/null"] + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + time.sleep(0.15) + return p + + def test_repl_eio(self): + pybin = sys.executable + + child_code = r""" +import signal, sys +signal.signal(signal.SIGUSR1, lambda *a: None) +print("READY", flush=True) +signal.pause() +input() +""" + + proc = subprocess.Popen( + [pybin, "-S", "-c", child_code], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + line = proc.stdout.readline().strip() + self.assertEqual(line, "READY") + + tracer = self._attach_strace(proc.pid) + + try: + os.kill(proc.pid, signal.SIGUSR1) + _, err = proc.communicate(timeout=5) + finally: + if tracer and tracer.poll() is None: + tracer.terminate() + + self.assertNotEqual(proc.returncode, 0) + self.assertTrue("Errno 5" in err or "Input/output error" in err or "EOFError" in err, err) From d54f1378416d825aecbccacfd8577c1a6becc4ef Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 2 Sep 2025 11:50:44 +0800 Subject: [PATCH 07/16] fix: address comments Signed-off-by: yihong0618 --- Lib/test/test_pyrepl/test_unix_console.py | 81 +++++++++-------------- 1 file changed, 33 insertions(+), 48 deletions(-) diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index dfd498f5fcb691..23ae36bb1d31ea 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -6,10 +6,11 @@ import termios from functools import partial from test.support import os_helper, force_not_colorized_test_class +from test.support.strace_helper import requires_strace import subprocess -import time import shutil import signal +import textwrap from unittest import TestCase from unittest.mock import MagicMock, call, patch, ANY, Mock @@ -313,29 +314,6 @@ def test_getheightwidth_with_invalid_environ(self, _os_write): class TestUnixConsoleEIOHandling(TestCase): - @patch('_pyrepl.unix_console.tcsetattr') - @patch('_pyrepl.unix_console.tcgetattr') - def test_eio_error_handling_in_prepare(self, mock_tcgetattr, mock_tcsetattr): - mock_termios = Mock() - mock_termios.iflag = 0 - mock_termios.oflag = 0 - mock_termios.cflag = 0 - mock_termios.lflag = 0 - mock_termios.cc = [0] * 32 - mock_termios.copy.return_value = mock_termios - mock_tcgetattr.return_value = mock_termios - - mock_tcsetattr.side_effect = termios.error(errno.EIO, "Input/output error") - - console = UnixConsole(term="xterm") - - try: - console.prepare() - except termios.error as e: - if e.args[0] == errno.EIO: - self.fail("EIO error should have been handled gracefully in prepare()") - raise - @patch('_pyrepl.unix_console.tcsetattr') @patch('_pyrepl.unix_console.tcgetattr') def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr): @@ -361,48 +339,56 @@ def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr): self.fail("EIO error should have been handled gracefully in restore()") raise +@requires_strace() class TestEIOWithStrace(unittest.TestCase): - def setUp(self): - self.strace = shutil.which("strace") - if not self.strace: - self.skipTest("strace") - def _attach_strace(self, pid): - cmd = [self.strace, "-qq", "-p", str(pid), "-e", "inject=read:error=EIO:when=1", "-o", "/dev/null"] + strace = shutil.which("strace") + cmd = [strace, "-qq", "-p", str(pid), "-e", "inject=read:error=EIO:when=1", "-o", "/dev/null"] try: p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - time.sleep(0.15) - if p.poll() is None: + try: + p.communicate(timeout=0.15) + except subprocess.TimeoutExpired: return p - except Exception: + except (OSError, subprocess.SubprocessError): + pass + except (OSError, subprocess.SubprocessError): pass - cmd = [self.strace, "-qq", "-p", str(pid), "-e", "fault=read:error=EIO:when=1", "-o", "/dev/null"] + # Fallback command + cmd = [strace, "-qq", "-p", str(pid), "-e", "fault=read:error=EIO:when=1", "-o", "/dev/null"] p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - time.sleep(0.15) + try: + p.communicate(timeout=0.15) + except subprocess.TimeoutExpired: + pass return p def test_repl_eio(self): - pybin = sys.executable - - child_code = r""" -import signal, sys -signal.signal(signal.SIGUSR1, lambda *a: None) -print("READY", flush=True) -signal.pause() -input() -""" + child_code = textwrap.dedent(""" + import signal, sys + signal.signal(signal.SIGUSR1, lambda *a: None) + print("READY", flush=True) + signal.pause() + input() + """).strip() proc = subprocess.Popen( - [pybin, "-S", "-c", child_code], + [sys.executable, "-S", "-c", child_code], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) - line = proc.stdout.readline().strip() - self.assertEqual(line, "READY") + ready = False + while not ready: + line = proc.stdout.readline().strip() + if line == "READY": + ready = True + break + if proc.poll() is not None: + self.fail("Child process exited unexpectedly") tracer = self._attach_strace(proc.pid) @@ -412,6 +398,5 @@ def test_repl_eio(self): finally: if tracer and tracer.poll() is None: tracer.terminate() - self.assertNotEqual(proc.returncode, 0) self.assertTrue("Errno 5" in err or "Input/output error" in err or "EOFError" in err, err) From f441dca21ec96b1d71a95123a65d098b94e406d0 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 2 Sep 2025 15:26:59 +0800 Subject: [PATCH 08/16] fix: raise EIO without strace Signed-off-by: yihong0618 Co-authored-by: graymon --- Lib/test/test_pyrepl/test_unix_console.py | 149 +++++++++++++++------- 1 file changed, 101 insertions(+), 48 deletions(-) diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index 23ae36bb1d31ea..6600c72dbfe8e6 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -6,9 +6,7 @@ import termios from functools import partial from test.support import os_helper, force_not_colorized_test_class -from test.support.strace_helper import requires_strace import subprocess -import shutil import signal import textwrap @@ -339,38 +337,104 @@ def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr): self.fail("EIO error should have been handled gracefully in restore()") raise -@requires_strace() -class TestEIOWithStrace(unittest.TestCase): - def _attach_strace(self, pid): - strace = shutil.which("strace") - cmd = [strace, "-qq", "-p", str(pid), "-e", "inject=read:error=EIO:when=1", "-o", "/dev/null"] - try: - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - try: - p.communicate(timeout=0.15) - except subprocess.TimeoutExpired: - return p - except (OSError, subprocess.SubprocessError): - pass - except (OSError, subprocess.SubprocessError): - pass - - # Fallback command - cmd = [strace, "-qq", "-p", str(pid), "-e", "fault=read:error=EIO:when=1", "-o", "/dev/null"] - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - try: - p.communicate(timeout=0.15) - except subprocess.TimeoutExpired: - pass - return p - + @unittest.skipUnless(sys.platform == "linux", "Only valid on Linux") def test_repl_eio(self): + # Use the pty-based approach to simulate EIO error child_code = textwrap.dedent(""" - import signal, sys - signal.signal(signal.SIGUSR1, lambda *a: None) + import os, sys, pty, fcntl, termios, signal, time, errno + + def handler(sig, f): + pass + + def create_eio_condition(): + try: + # Try to create a condition that will actually produce EIO + # Method: Use your original script's approach with modifications + master_fd, slave_fd = pty.openpty() + # Fork a child that will manipulate the pty + child_pid = os.fork() + if child_pid == 0: + # Child process + try: + # Set up session and control terminal like your script + os.setsid() + fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0) + # Get process group + p2_pgid = os.getpgrp() + # Fork grandchild + grandchild_pid = os.fork() + if grandchild_pid == 0: + # Grandchild - set up process group + os.setpgid(0, 0) + # Redirect stdin to slave + os.dup2(slave_fd, 0) + if slave_fd > 2: + os.close(slave_fd) + # Fork great-grandchild for terminal control manipulation + ggc_pid = os.fork() + if ggc_pid == 0: + # Great-grandchild - just exit quickly + sys.exit(0) + else: + # Back to grandchild + try: + os.tcsetpgrp(0, p2_pgid) + except: + pass + sys.exit(0) + else: + # Back to child + try: + os.setpgid(grandchild_pid, grandchild_pid) + except ProcessLookupError: + pass + os.tcsetpgrp(slave_fd, grandchild_pid) + if slave_fd > 2: + os.close(slave_fd) + os.waitpid(grandchild_pid, 0) + # Manipulate terminal control to create EIO condition + os.tcsetpgrp(master_fd, p2_pgid) + # Now try to read from master - this might cause EIO + try: + os.read(master_fd, 1) + except OSError as e: + if e.errno == errno.EIO: + print(f"Setup created EIO condition: {e}", file=sys.stderr) + sys.exit(0) + except Exception as setup_e: + print(f"Setup error: {setup_e}", file=sys.stderr) + sys.exit(1) + else: + # Parent process + os.close(slave_fd) + os.waitpid(child_pid, 0) + # Now replace stdin with master_fd and try to read + os.dup2(master_fd, 0) + os.close(master_fd) + # This should now trigger EIO + result = input() + print(f"Unexpectedly got input: {repr(result)}", file=sys.stderr) + sys.exit(0) + except OSError as e: + if e.errno == errno.EIO: + print(f"Got EIO: {e}", file=sys.stderr) + sys.exit(1) + elif e.errno == errno.ENXIO: + print(f"Got ENXIO (no such device): {e}", file=sys.stderr) + sys.exit(1) # Treat ENXIO as success too + else: + print(f"Got other OSError: errno={e.errno} {e}", file=sys.stderr) + sys.exit(2) + except EOFError as e: + print(f"Got EOFError: {e}", file=sys.stderr) + sys.exit(3) + except Exception as e: + print(f"Got unexpected error: {type(e).__name__}: {e}", file=sys.stderr) + sys.exit(4) + # Set up signal handler for coordination + signal.signal(signal.SIGUSR1, lambda *a: create_eio_condition()) print("READY", flush=True) signal.pause() - input() """).strip() proc = subprocess.Popen( @@ -381,22 +445,11 @@ def test_repl_eio(self): text=True ) - ready = False - while not ready: - line = proc.stdout.readline().strip() - if line == "READY": - ready = True - break - if proc.poll() is not None: - self.fail("Child process exited unexpectedly") + ready_line = proc.stdout.readline().strip() + if ready_line != "READY" or proc.poll() is not None: + self.fail("Child process failed to start properly") - tracer = self._attach_strace(proc.pid) - - try: - os.kill(proc.pid, signal.SIGUSR1) - _, err = proc.communicate(timeout=5) - finally: - if tracer and tracer.poll() is None: - tracer.terminate() - self.assertNotEqual(proc.returncode, 0) - self.assertTrue("Errno 5" in err or "Input/output error" in err or "EOFError" in err, err) + os.kill(proc.pid, signal.SIGUSR1) + _, err = proc.communicate(timeout=5) # sleep for pty to settle + self.assertEqual(proc.returncode, 1, f"Expected EIO error, got return code {proc.returncode}") + self.assertIn("Got EIO:", err, f"Expected EIO error message in stderr: {err}") From 0fc6c4aac439e7b942f3a7f8edef206bc96508ca Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Tue, 2 Sep 2025 15:31:35 +0800 Subject: [PATCH 09/16] fix: import sort Signed-off-by: yihong0618 --- Lib/test/test_pyrepl/test_unix_console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index 6600c72dbfe8e6..fbf2a57d3a67b1 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -1,8 +1,8 @@ +import errno import itertools import os import sys import unittest -import errno import termios from functools import partial from test.support import os_helper, force_not_colorized_test_class From ac140617dad260d3e1afc77fb64d30e9497de5bd Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Wed, 3 Sep 2025 15:12:06 +0800 Subject: [PATCH 10/16] fix: use spawn_python Signed-off-by: yihong0618 --- Lib/test/test_pyrepl/test_unix_console.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index fbf2a57d3a67b1..306d5ab3dbe236 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -6,6 +6,7 @@ import termios from functools import partial from test.support import os_helper, force_not_colorized_test_class +from test.support import script_helper import subprocess import signal import textwrap @@ -437,10 +438,8 @@ def create_eio_condition(): signal.pause() """).strip() - proc = subprocess.Popen( - [sys.executable, "-S", "-c", child_code], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, + proc = script_helper.spawn_python( + "-S", "-c", child_code, stderr=subprocess.PIPE, text=True ) From 41e191eb1d0ae6a4a403c78961f0f9e7aba829ce Mon Sep 17 00:00:00 2001 From: yihong Date: Fri, 12 Sep 2025 17:06:26 +0800 Subject: [PATCH 11/16] Update Lib/_pyrepl/unix_console.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/_pyrepl/unix_console.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index 32897eaa05d319..d790d054038158 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -419,8 +419,7 @@ def get_event(self, block: bool = True) -> Event | None: else: continue elif err.errno == errno.EIO: - import sys - sys.exit(errno.EIO) + raise SystemExit(errno.EIO) else: raise else: From e3ae9a21c9787ac28f34a1f94772a04838be7719 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Fri, 12 Sep 2025 17:23:50 +0800 Subject: [PATCH 12/16] fix: apply comments Signed-off-by: yihong0618 --- Lib/test/test_pyrepl/eio_test_script.py | 99 +++++++++++++++++++ Lib/test/test_pyrepl/test_unix_console.py | 114 ++-------------------- 2 files changed, 106 insertions(+), 107 deletions(-) create mode 100644 Lib/test/test_pyrepl/eio_test_script.py diff --git a/Lib/test/test_pyrepl/eio_test_script.py b/Lib/test/test_pyrepl/eio_test_script.py new file mode 100644 index 00000000000000..db48a6a8e8187c --- /dev/null +++ b/Lib/test/test_pyrepl/eio_test_script.py @@ -0,0 +1,99 @@ +import os +import sys +import pty +import fcntl +import termios +import signal +import errno + + +def handler(sig, f): + pass + + +def create_eio_condition(): + try: + # gh-135329: try to create a condition that will actually produce EIO + master_fd, slave_fd = pty.openpty() + child_pid = os.fork() + if child_pid == 0: + try: + os.setsid() + fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0) + p2_pgid = os.getpgrp() + grandchild_pid = os.fork() + if grandchild_pid == 0: + # Grandchild - set up process group + os.setpgid(0, 0) + # Redirect stdin to slave + os.dup2(slave_fd, 0) + if slave_fd > 2: + os.close(slave_fd) + # Fork great-grandchild for terminal control manipulation + ggc_pid = os.fork() + if ggc_pid == 0: + # Great-grandchild - just exit quickly + sys.exit(0) + else: + # Back to grandchild + try: + os.tcsetpgrp(0, p2_pgid) + except OSError: + pass + sys.exit(0) + else: + # Back to child + try: + os.setpgid(grandchild_pid, grandchild_pid) + except ProcessLookupError: + pass + os.tcsetpgrp(slave_fd, grandchild_pid) + if slave_fd > 2: + os.close(slave_fd) + os.waitpid(grandchild_pid, 0) + # Manipulate terminal control to create EIO condition + os.tcsetpgrp(master_fd, p2_pgid) + # Now try to read from master - this might cause EIO + try: + os.read(master_fd, 1) + except OSError as e: + if e.errno == errno.EIO: + print(f"Setup created EIO condition: {e}", file=sys.stderr) + sys.exit(0) + except Exception as setup_e: + print(f"Setup error: {setup_e}", file=sys.stderr) + sys.exit(1) + else: + # Parent process + os.close(slave_fd) + os.waitpid(child_pid, 0) + # Now replace stdin with master_fd and try to read + os.dup2(master_fd, 0) + os.close(master_fd) + # This should now trigger EIO + result = input() + print(f"Unexpectedly got input: {repr(result)}", file=sys.stderr) + sys.exit(0) + except OSError as e: + if e.errno == errno.EIO: + print(f"Got EIO: {e}", file=sys.stderr) + sys.exit(1) + elif e.errno == errno.ENXIO: + print(f"Got ENXIO (no such device): {e}", file=sys.stderr) + sys.exit(1) # Treat ENXIO as success too + else: + print(f"Got other OSError: errno={e.errno} {e}", file=sys.stderr) + sys.exit(2) + except EOFError as e: + print(f"Got EOFError: {e}", file=sys.stderr) + sys.exit(3) + except Exception as e: + print(f"Got unexpected error: {type(e).__name__}: {e}", file=sys.stderr) + sys.exit(4) + + +if __name__ == "__main__": + # Set up signal handler for coordination + signal.signal(signal.SIGUSR1, lambda *a: create_eio_condition()) + print("READY", flush=True) + signal.pause() diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index 306d5ab3dbe236..bb1c156ccba66b 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -1,15 +1,14 @@ import errno import itertools import os +import signal +import subprocess import sys -import unittest import termios +import unittest from functools import partial from test.support import os_helper, force_not_colorized_test_class from test.support import script_helper -import subprocess -import signal -import textwrap from unittest import TestCase from unittest.mock import MagicMock, call, patch, ANY, Mock @@ -331,115 +330,16 @@ def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr): mock_tcsetattr.side_effect = termios.error(errno.EIO, "Input/output error") - try: - console.restore() - except termios.error as e: - if e.args[0] == errno.EIO: - self.fail("EIO error should have been handled gracefully in restore()") - raise + # EIO error should be handled gracefully in restore() + console.restore() @unittest.skipUnless(sys.platform == "linux", "Only valid on Linux") def test_repl_eio(self): # Use the pty-based approach to simulate EIO error - child_code = textwrap.dedent(""" - import os, sys, pty, fcntl, termios, signal, time, errno - - def handler(sig, f): - pass - - def create_eio_condition(): - try: - # Try to create a condition that will actually produce EIO - # Method: Use your original script's approach with modifications - master_fd, slave_fd = pty.openpty() - # Fork a child that will manipulate the pty - child_pid = os.fork() - if child_pid == 0: - # Child process - try: - # Set up session and control terminal like your script - os.setsid() - fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0) - # Get process group - p2_pgid = os.getpgrp() - # Fork grandchild - grandchild_pid = os.fork() - if grandchild_pid == 0: - # Grandchild - set up process group - os.setpgid(0, 0) - # Redirect stdin to slave - os.dup2(slave_fd, 0) - if slave_fd > 2: - os.close(slave_fd) - # Fork great-grandchild for terminal control manipulation - ggc_pid = os.fork() - if ggc_pid == 0: - # Great-grandchild - just exit quickly - sys.exit(0) - else: - # Back to grandchild - try: - os.tcsetpgrp(0, p2_pgid) - except: - pass - sys.exit(0) - else: - # Back to child - try: - os.setpgid(grandchild_pid, grandchild_pid) - except ProcessLookupError: - pass - os.tcsetpgrp(slave_fd, grandchild_pid) - if slave_fd > 2: - os.close(slave_fd) - os.waitpid(grandchild_pid, 0) - # Manipulate terminal control to create EIO condition - os.tcsetpgrp(master_fd, p2_pgid) - # Now try to read from master - this might cause EIO - try: - os.read(master_fd, 1) - except OSError as e: - if e.errno == errno.EIO: - print(f"Setup created EIO condition: {e}", file=sys.stderr) - sys.exit(0) - except Exception as setup_e: - print(f"Setup error: {setup_e}", file=sys.stderr) - sys.exit(1) - else: - # Parent process - os.close(slave_fd) - os.waitpid(child_pid, 0) - # Now replace stdin with master_fd and try to read - os.dup2(master_fd, 0) - os.close(master_fd) - # This should now trigger EIO - result = input() - print(f"Unexpectedly got input: {repr(result)}", file=sys.stderr) - sys.exit(0) - except OSError as e: - if e.errno == errno.EIO: - print(f"Got EIO: {e}", file=sys.stderr) - sys.exit(1) - elif e.errno == errno.ENXIO: - print(f"Got ENXIO (no such device): {e}", file=sys.stderr) - sys.exit(1) # Treat ENXIO as success too - else: - print(f"Got other OSError: errno={e.errno} {e}", file=sys.stderr) - sys.exit(2) - except EOFError as e: - print(f"Got EOFError: {e}", file=sys.stderr) - sys.exit(3) - except Exception as e: - print(f"Got unexpected error: {type(e).__name__}: {e}", file=sys.stderr) - sys.exit(4) - # Set up signal handler for coordination - signal.signal(signal.SIGUSR1, lambda *a: create_eio_condition()) - print("READY", flush=True) - signal.pause() - """).strip() + script_path = os.path.join(os.path.dirname(__file__), "eio_test_script.py") proc = script_helper.spawn_python( - "-S", "-c", child_code, + "-S", script_path, stderr=subprocess.PIPE, text=True ) From f46384938a95e44f190b03ed929f2db9e5934063 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Fri, 12 Sep 2025 18:07:04 +0800 Subject: [PATCH 13/16] fix: test on windows Signed-off-by: yihong0618 --- Lib/test/test_pyrepl/test_unix_console.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index bb1c156ccba66b..ceeced1eb0e1cc 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -4,7 +4,6 @@ import signal import subprocess import sys -import termios import unittest from functools import partial from test.support import os_helper, force_not_colorized_test_class @@ -314,8 +313,10 @@ class TestUnixConsoleEIOHandling(TestCase): @patch('_pyrepl.unix_console.tcsetattr') @patch('_pyrepl.unix_console.tcgetattr') + @unittest.skipUnless(sys.platform == "linux", "Only valid on Linux") def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr): + import termios mock_termios = Mock() mock_termios.iflag = 0 mock_termios.oflag = 0 From 1801806d8b8dddd0e506feb4651751c06721bce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Tue, 16 Sep 2025 11:18:04 +0200 Subject: [PATCH 14/16] Run the internal EIO test on any non-win32 plat --- Lib/test/test_pyrepl/test_unix_console.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index ceeced1eb0e1cc..f36a382d5a48bf 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -313,7 +313,6 @@ class TestUnixConsoleEIOHandling(TestCase): @patch('_pyrepl.unix_console.tcsetattr') @patch('_pyrepl.unix_console.tcgetattr') - @unittest.skipUnless(sys.platform == "linux", "Only valid on Linux") def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr): import termios From e4a9a67660d80ad26e5ae35e60827eebfe9ba110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Tue, 16 Sep 2025 12:04:55 +0200 Subject: [PATCH 15/16] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/test/test_pyrepl/eio_test_script.py | 29 ++++++++++------------- Lib/test/test_pyrepl/test_unix_console.py | 2 +- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/Lib/test/test_pyrepl/eio_test_script.py b/Lib/test/test_pyrepl/eio_test_script.py index db48a6a8e8187c..747521e2f1f31e 100644 --- a/Lib/test/test_pyrepl/eio_test_script.py +++ b/Lib/test/test_pyrepl/eio_test_script.py @@ -1,10 +1,10 @@ +import errno +import fcntl import os -import sys import pty -import fcntl -import termios import signal -import errno +import sys +import termios def handler(sig, f): @@ -12,8 +12,9 @@ def handler(sig, f): def create_eio_condition(): + # SIGINT handler used to produce an EIO. + # See https://github.com/python/cpython/issues/135329. try: - # gh-135329: try to create a condition that will actually produce EIO master_fd, slave_fd = pty.openpty() child_pid = os.fork() if child_pid == 0: @@ -23,19 +24,14 @@ def create_eio_condition(): p2_pgid = os.getpgrp() grandchild_pid = os.fork() if grandchild_pid == 0: - # Grandchild - set up process group - os.setpgid(0, 0) - # Redirect stdin to slave - os.dup2(slave_fd, 0) + os.setpgid(0, 0) # set process group for grandchild + os.dup2(slave_fd, 0) # redirect stdin if slave_fd > 2: os.close(slave_fd) - # Fork great-grandchild for terminal control manipulation - ggc_pid = os.fork() - if ggc_pid == 0: - # Great-grandchild - just exit quickly - sys.exit(0) + # Fork grandchild for terminal control manipulation + if os.fork() == 0: + sys.exit(0) # exit the child process that was just obtained else: - # Back to grandchild try: os.tcsetpgrp(0, p2_pgid) except OSError: @@ -71,8 +67,7 @@ def create_eio_condition(): os.dup2(master_fd, 0) os.close(master_fd) # This should now trigger EIO - result = input() - print(f"Unexpectedly got input: {repr(result)}", file=sys.stderr) + print(f"Unexpectedly got input: {input()!r}", file=sys.stderr) sys.exit(0) except OSError as e: if e.errno == errno.EIO: diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index a293bc65b5d805..9ef90d4116800b 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -358,6 +358,6 @@ def test_repl_eio(self): self.fail("Child process failed to start properly") os.kill(proc.pid, signal.SIGUSR1) - _, err = proc.communicate(timeout=5) # sleep for pty to settle + _, err = proc.communicate(timeout=5) # sleep for pty to settle self.assertEqual(proc.returncode, 1, f"Expected EIO error, got return code {proc.returncode}") self.assertIn("Got EIO:", err, f"Expected EIO error message in stderr: {err}") From 643f36839b3d7788602a4cbcb85f722356770441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Tue, 16 Sep 2025 12:09:00 +0200 Subject: [PATCH 16/16] Treat ENXIO as success --- Lib/test/test_pyrepl/eio_test_script.py | 6 +++--- Lib/test/test_pyrepl/test_unix_console.py | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_pyrepl/eio_test_script.py b/Lib/test/test_pyrepl/eio_test_script.py index 747521e2f1f31e..e3ea6caef58e80 100644 --- a/Lib/test/test_pyrepl/eio_test_script.py +++ b/Lib/test/test_pyrepl/eio_test_script.py @@ -21,7 +21,7 @@ def create_eio_condition(): try: os.setsid() fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0) - p2_pgid = os.getpgrp() + child_process_group_id = os.getpgrp() grandchild_pid = os.fork() if grandchild_pid == 0: os.setpgid(0, 0) # set process group for grandchild @@ -33,7 +33,7 @@ def create_eio_condition(): sys.exit(0) # exit the child process that was just obtained else: try: - os.tcsetpgrp(0, p2_pgid) + os.tcsetpgrp(0, child_process_group_id) except OSError: pass sys.exit(0) @@ -48,7 +48,7 @@ def create_eio_condition(): os.close(slave_fd) os.waitpid(grandchild_pid, 0) # Manipulate terminal control to create EIO condition - os.tcsetpgrp(master_fd, p2_pgid) + os.tcsetpgrp(master_fd, child_process_group_id) # Now try to read from master - this might cause EIO try: os.read(master_fd, 1) diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index 9ef90d4116800b..3b0d2637dab9cb 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -318,6 +318,7 @@ def test_restore_with_invalid_environ_on_macos(self, _os_write): console.restore() # this should succeed +@unittest.skipIf(sys.platform == "win32", "No Unix console on Windows") class TestUnixConsoleEIOHandling(TestCase): @patch('_pyrepl.unix_console.tcsetattr') @@ -359,5 +360,15 @@ def test_repl_eio(self): os.kill(proc.pid, signal.SIGUSR1) _, err = proc.communicate(timeout=5) # sleep for pty to settle - self.assertEqual(proc.returncode, 1, f"Expected EIO error, got return code {proc.returncode}") - self.assertIn("Got EIO:", err, f"Expected EIO error message in stderr: {err}") + self.assertEqual( + proc.returncode, + 1, + f"Expected EIO/ENXIO error, got return code {proc.returncode}", + ) + self.assertTrue( + ( + "Got EIO:" in err + or "Got ENXIO:" in err + ), + f"Expected EIO/ENXIO error message in stderr: {err}", + )