Skip to content

gh-132962: _pyrepl: Prevent crash on Windows when stdout is redirected #135456

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

shuimu5418
Copy link

@shuimu5418 shuimu5418 commented Jun 13, 2025

The interactive interpreter (-i) on Windows no longer crashes when standard output is redirected. The check for CAN_USE_PYREPL now also checks if stdout is a TTY on Windows, falling back to the basic REPL if it is not.


Main branch:

PS F:\github\cpython001> .\python.bat -i .\build\testpys\print_helloworld.py > .\build\file.txt
Traceback (most recent call last):
  File "F:\github\cpython001\Lib\_pyrepl\readline.py", line 394, in multiline_input
    return reader.readline()
           ~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\reader.py", line 742, in readline
    self.prepare()
    ~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\historical_reader.py", line 306, in prepare
    super().prepare()
    ~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\reader.py", line 585, in prepare
    self.console.prepare()
    ~~~~~~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\windows_console.py", line 358, in prepare
    self.height, self.width = self.getheightwidth()
                              ~~~~~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\windows_console.py", line 409, in getheightwidth
    raise WinError(GetLastError())
OSError: [WinError 0] The operation completed successfully.
Traceback (most recent call last):
  File "F:\github\cpython001\Lib\_pyrepl\readline.py", line 394, in multiline_input
    return reader.readline()
           ~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\reader.py", line 742, in readline
    self.prepare()
    ~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\historical_reader.py", line 306, in prepare
    super().prepare()
    ~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\reader.py", line 585, in prepare
    self.console.prepare()
    ~~~~~~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\windows_console.py", line 358, in prepare
    self.height, self.width = self.getheightwidth()
                              ~~~~~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\windows_console.py", line 409, in getheightwidth
    raise WinError(GetLastError())
OSError: [WinError 123] The filename, directory name, or volume label syntax is incorrect.
Traceback (most recent call last):
  File "F:\github\cpython001\Lib\_pyrepl\readline.py", line 394, in multiline_input
    return reader.readline()
           ~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\reader.py", line 742, in readline
    self.prepare()
    ~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\historical_reader.py", line 306, in prepare
    super().prepare()
    ~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\reader.py", line 585, in prepare
    self.console.prepare()
    ~~~~~~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\windows_console.py", line 358, in prepare
    self.height, self.width = self.getheightwidth()
                              ~~~~~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\windows_console.py", line 409, in getheightwidth
    raise WinError(GetLastError())
OSError: [WinError 123] The filename, directory name, or volume label syntax is incorrect.
Traceback (most recent call last):
...... Infinite Tracebacks......

With fix:

PS F:\github\cpython001> .\python.bat -i .\build\testpys\print_helloworld.py > .\build\file.txt
warning: can't use pyrepl: [Errno 25] tty required: 'stdout'
>>> 

…irected

The interactive interpreter (-i) on Windows no longer crashes when
standard output is redirected. The check for `CAN_USE_PYREPL` now
also checks if stdout is a TTY on Windows, falling back to the
basic REPL if it is not.
Copy link
Member

@ZeroIntensity ZeroIntensity left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a test case. You can refer to the devguide or tag me if you're unsure what that entails.

@shuimu5418
Copy link
Author

@ZeroIntensity Thanks for your guidance on adding a test.

I've drafted a test case below that correctly fails on the main branch and passes with my fix. I haven't committed and pushed this yet, because I wanted to get your feedback first.

I feel the approach is very very ugly, especially the need to use time.sleep and then kill the process. Is this an acceptable way to test this kind of hanging/crashing bug, or do you have any suggestions for a cleaner approach?

My rationale for this test:
The bug only manifests in a specific I/O environment on Windows (stdin is a TTY, but stdout is redirected to a file). This test launches a subprocess in that state, waits briefly to see if it produces the looping crash traceback, then terminates it and inspects stderr.

diff --git a/Lib/test/test_pyrepl/test_windows_console.py b/Lib/test/test_pyrepl/test_windows_console.py
index f9607e02c60..963f4db26ba 100644
--- a/Lib/test/test_pyrepl/test_windows_console.py
+++ b/Lib/test/test_pyrepl/test_windows_console.py
@@ -576,6 +576,46 @@ def test_up_vt(self):
                          Event(evt='key', data='up', raw=bytearray(b'\x1b[A')))
         self.assertEqual(self.mock.call_count, 3)
 
+import subprocess
+from tempfile import TemporaryDirectory
+import os
+import time
+
+class WindowsCommandLineTests(unittest.TestCase):
+    def test_for_crash_traceback_with_redirected_stdout(self):
+        script_command = "print('script has run')"
+        stderr_output = ""  # Define in an outer scope
+
+        with TemporaryDirectory() as temp_dir:
+            stdout_path = os.path.join(temp_dir, "WindowsCommandLineTests_stdout.txt")
+
+            with open(stdout_path, "w", encoding="utf-8") as stdout_file:
+                with subprocess.Popen(
+                    [sys.executable, '-i', '-c', script_command],
+                    stdin=None,
+                    stdout=stdout_file,
+                    stderr=subprocess.PIPE,
+                    text=True, encoding='utf-8', errors='replace'
+                ) as process:
+                    time.sleep(3)
+                    if process.poll() is None:
+                        process.kill()
+                    
+                    stderr_output = process.stderr.read()
+
+            with open(stdout_path, "r", encoding="utf-8") as f:
+                stdout_from_file = f.read()
+
+        has_crash_traceback = (
+            "OSError" in stderr_output
+            and "getheightwidth" in stderr_output
+            and len(stderr_output) > 500  # brilliant check for looping traceback
+        )
+
+        if has_crash_traceback:
+            self.fail(
+                "Detected the endless OSError traceback.\n"
+            )
 
 if __name__ == "__main__":
     unittest.main()

main branch:

PS F:\github\cpython001> ./python.bat -m test test_pyrepl
Running Debug|x64 interpreter...
Using random seed: 2091289539
0:00:00 Run 1 test sequentially in a single process
0:00:00 [1/1] test_pyrepl
test test_pyrepl failed -- Traceback (most recent call last):
  File "F:\github\cpython001\Lib\test\test_pyrepl\test_windows_console.py", line 616, in test_for_crash_traceback_with_redirected_stdout
    self.fail(
    ~~~~~~~~~^
        "Detected the endless OSError traceback.\n"
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
AssertionError: Detected the endless OSError traceback.


0:00:20 load avg: 0.32 [1/1/1] test_pyrepl failed (1 failure)

== Tests result: FAILURE ==

1 test failed:
    test_pyrepl

Total duration: 20.1 sec
Total tests: run=236 failures=1 skipped=46
Total test files: run=1/1 failed=1
Result: FAILURE

this branch:

PS F:\github\cpython001> ./python.bat -m test -j2 test_pyrepl test_pyrepl
Running Debug|x64 interpreter...
Using random seed: 1651374118
0:00:00 Run 2 tests in parallel using 2 worker processes
0:00:19 load avg: 1.43 [1/2] test_pyrepl passed
0:00:19 load avg: 1.43 [2/2] test_pyrepl passed

== Tests result: SUCCESS ==

All 2 tests OK.

Total duration: 19.5 sec
Total tests: run=472 skipped=94
Total test files: run=2/2
Result: SUCCESS

@ZeroIntensity
Copy link
Member

I think using subprocess makes sense, but I suspect that time.sleep(3) won't be very portable, because different systems might just take more time to start processes. It's a race condition.

It would be best to design a test that just exercises the correct behavior; there's no need for the extra checking of the infinite traceback. If the test hangs on the current main, that's OK--our test suite would time it out and fail. Hanging is close enough to a test failure.

@ZeroIntensity ZeroIntensity added OS-windows needs backport to 3.13 bugs and security fixes topic-repl Related to the interactive shell needs backport to 3.14 bugs and security fixes labels Jun 14, 2025
@shuimu5418
Copy link
Author

shuimu5418 commented Jun 14, 2025

Hi @ZeroIntensity ,

Thank you again for your valuable feedback on the pyrepl test case. I tried to implement your suggestion of testing for correct behavior (a fast exit), but I've run into some very specific constraints and would be grateful for your advice on a better approach.

My goal is to test a bug where the Python interpreter, in interactive mode (-i) with stdout redirected to a file, enters an infinite traceback loop. This test is meant to simulate a real-world scenario.

Through testing, I've found several strict environmental requirements to trigger the bug:

stdout must be a file handle: Redirecting to subprocess.PIPE does not trigger the bug.
stdin must not be a pipe: For pyrepl to activate its advanced mode, stdin cannot be a simple pipe. If I set stdin=subprocess.PIPE (e.g., to send an exit() command), pyrepl detects it, falls back to a basic mode, and the buggy code path is never reached. stdin must therefore be inherited (stdin=None).
The biggest challenge I discovered is a critical timing issue:

With the -i flag, the buggy REPL initialization (which calls getheightwidth) happens after the script from the -c argument has finished.
This means my attempt to use -c "import os; ...; os._exit(0)" failed as a test. The process terminated immediately before the buggy code could be reached, leading to a false-positive pass on the buggy branch.
This leaves me in a difficult position. To trigger the bug, the process must not exit. The bug itself is the process hanging. The only way I've been able to reliably detect this is with a time.sleep pattern, which I know is not ideal due to the race condition you pointed out.

My main question is: could you suggest a more robust, non-racy pattern to solve this?

How can I reliably test for a process that is expected to hang (due to this specific bug), given that I cannot programmatically cause it to exit and must adhere to the strict stdin/stdout constraints? Any guidance or alternative strategy you could offer would be incredibly helpful.

Thanks again for your time and expertise.


edit to attach:

Now I am trying to use timeout.

import subprocess
from tempfile import TemporaryDirectory
import os
class WindowsCommandLineTests(unittest.TestCase):
    def test_for_crash_traceback_with_redirected_stdout(self):
        """python.bat -i -c "print('hlwd')" > file.txt"""
        script_command = "print('script has run')"
        
        with TemporaryDirectory() as tmp_dir:
            stdout_path = os.path.join(tmp_dir, "WinCMDLineTests.txt")

            with open(stdout_path, "w") as stdout_file, \
                subprocess.Popen(
                    [sys.executable, '-i', '-c', script_command],
                    stdin=None,
                    stdout=stdout_file,
                    stderr=subprocess.PIPE,
                    text=True, errors='replace'
                ) as process:
                
                stderr_output = ""

                try:
                    process.wait(timeout=3)
                    self.fail("Process exited unexpectedly within the timeout.")
                except subprocess.TimeoutExpired:
                    process.kill()
                    _, stderr_output = process.communicate()

                has_crash_traceback = (
                    "OSError" in stderr_output and
                    len(stderr_output) > 1200
                )
            
                if has_crash_traceback:
                    self.fail("Detected endless OSError traceback."
                          f"\n--- stderr ---\n{stderr_output[:1200]}")

main branch:

PS F:\github\cpython001> ./python.bat -m test test_pyrepl
Running Debug|x64 interpreter...
Using random seed: 2225918861
0:00:00 Run 1 test sequentially in a single process
0:00:00 [1/1] test_pyrepl
test test_pyrepl failed -- Traceback (most recent call last):
  File "F:\github\cpython001\Lib\test\test_pyrepl\test_windows_console.py", line 615, in test_for_crash_traceback_with_redirected_stdout
    self.fail("Detected endless OSError traceback."
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
          f"\n--- stderr ---\n{stderr_output[:1200]}")
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: Detected endless OSError traceback.
--- stderr ---
Traceback (most recent call last):
  File "F:\github\cpython001\Lib\_pyrepl\readline.py", line 394, in multiline_input
    return reader.readline()
           ~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\reader.py", line 742, in readline
    self.prepare()
    ~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\historical_reader.py", line 306, in prepare
    super().prepare()
    ~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\reader.py", line 585, in prepare
    self.console.prepare()
    ~~~~~~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\windows_console.py", line 358, in prepare
    self.height, self.width = self.getheightwidth()
                              ~~~~~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\windows_console.py", line 409, in getheightwidth       
    raise WinError(GetLastError())
OSError: [WinError 6] The handle is invalid.
Traceback (most recent call last):
  File "F:\github\cpython001\Lib\_pyrepl\readline.py", line 394, in multiline_input
    return reader.readline()
           ~~~~~~~~~~~~~~~^^
  File "F:\github\cpython001\Lib\_pyrepl\reader.py", line 742, in readline
    self.prepare()
    ~~~~~~~~~~~~^^
  File "F:\github\cpyth

0:00:19 load avg: 0.18 [1/1/1] test_pyrepl failed (1 failure)

== Tests result: FAILURE ==

1 test failed:
    test_pyrepl

Total duration: 20.0 sec
Total tests: run=236 failures=1 skipped=46
Total test files: run=1/1 failed=1
Result: FAILURE

this branch

PS F:\github\cpython001> ./python.bat -m test -j2 test_pyrepl test_pyrepl
Running Debug|x64 interpreter...
Using random seed: 1927743178
0:00:00 Run 2 tests in parallel using 2 worker processes
0:00:19 load avg: 0.02 [1/2] test_pyrepl passed
0:00:19 load avg: 0.02 [2/2] test_pyrepl passed

== Tests result: SUCCESS ==

All 2 tests OK.

Total duration: 19.7 sec
Total tests: run=472 skipped=94
Total test files: run=2/2
Result: SUCCESS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
awaiting review needs backport to 3.13 bugs and security fixes needs backport to 3.14 bugs and security fixes OS-windows topic-repl Related to the interactive shell
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants