Skip to content

Reopen sys.__std*__ on Windows consoles so they follow fd capture - #14736

Closed
Abhishek-kroy wants to merge 1 commit into
pytest-dev:mainfrom
Abhishek-kroy:fix-12349-windows-dunder-streams
Closed

Reopen sys.__std*__ on Windows consoles so they follow fd capture#14736
Abhishek-kroy wants to merge 1 commit into
pytest-dev:mainfrom
Abhishek-kroy:fix-12349-windows-dunder-streams

Conversation

@Abhishek-kroy

Copy link
Copy Markdown

Closes #12349

The problem

On Windows, when attached to a console, sys.__stdin__/sys.__stdout__/sys.__stderr__ are backed by _io._WindowsConsoleIO, which writes through the console handle rather than the file descriptor, and whose isatty() unconditionally returns True.

So while FDCapture has dup2-ed the fds away (--capture=fd, the default):

  • sys.__stdout__.isatty() keeps returning True, while os.isatty(1) correctly returns False — unlike POSIX, where both return False (this is the exact assertion failure reported in sys.__stdout__.isatty() returns incorrect value for Windows 11 #12349, reproduced on Windows 11);
  • writes to sys.__stdout__ either escape capturing and are printed straight to the terminal, or fail with OSError: [WinError 6] The handle is invalid once dup2 has closed the console handle (the very handle instability already described in _windowsconsoleio_workaround's docstring).

The fix

Extend _windowsconsoleio_workaround to also reopen the dunder streams, backed by their original file descriptors (unlike sys.std*, which keep being reopened on duplicated fds for handle isolation). The raw stream is constructed explicitly with io.FileIO, because open() on a console fd produces another _WindowsConsoleIO and would change nothing.

After the fix the dunder streams follow fd redirection exactly like on POSIX: isatty() reflects the actual state of the fd, writes during capture land in the capture file, and writes outside capture reach the console. sys.__stdin__.encoding and sys.__stderr__.fileno() (used by the faulthandler plugin) are preserved.

Verification on Windows 11

Running the reproducer from #12349 under a real console (ConPTY), before the fix:

FAILED - AssertionError: assert not True   (sys.__stdout__.isatty())

and a write probe showed OSError(9, 'The handle is invalid') plus output leaking to the terminal despite capture being active.

After the fix, both asserts from the issue pass, and writes to sys.__stdout__ are captured. testing/test_capture.py (131 tests), testing/test_terminal.py and testing/test_faulthandler.py all pass both piped and under a real console on Windows 11.

The new regression test fakes the console environment (monkeypatched sys.platform and io._WindowsConsoleIO), so it exercises this code path on all platforms/CI runners, where no real console is available.


  • Include documentation when adding new features.
  • Include new tests or update existing tests when applicable.
  • Allow maintainers to push and squash when merging my commits. Please uncheck this if you prefer to squash the commits yourself.
  • Add text like closes #XYZW to the PR description and/or commits (where XYZW is the issue number).
  • If AI agents were used, they are credited in Co-authored-by commit trailers.
  • Create a new changelog file in the changelog directory (changelog/12349.bugfix.rst).
  • Add yourself to AUTHORS in alphabetical order.

Disclosure per the AI/LLM-Assisted Contributions Policy: this change was developed with AI assistance (Claude Code), credited in the commit trailer. I take responsibility for the change and will respond to review feedback myself.

On Windows, when attached to a console, the sys.__std{in,out,err}__
streams are backed by _WindowsConsoleIO, which writes through the
console handle rather than the file descriptor and whose isatty()
unconditionally returns True. As a result, while FDCapture had
redirected the fds, sys.__stdout__.isatty() kept returning True (unlike
on POSIX, where it returns False), and writes to sys.__stdout__ either
escaped capturing and appeared on the terminal or failed with
"OSError: [WinError 6] The handle is invalid" once dup2 had closed the
console handle.

Extend _windowsconsoleio_workaround to also reopen the dunder streams,
backed by their original file descriptors. The raw stream is built
explicitly with io.FileIO because open() on a console fd would produce
another _WindowsConsoleIO.

Closes pytest-dev#12349

Co-authored-by: Claude <noreply@anthropic.com>
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Jul 20, 2026
@RonnyPfannschmidt

Copy link
Copy Markdown
Member

closing as unattended ai contribution - the errors are clear and easy to fix yet nothing was touched after initial drop in

@RonnyPfannschmidt

Copy link
Copy Markdown
Member

Thanks for the detailed writeup — the analysis of _WindowsConsoleIO is largely right, but this isn't a change we want to make, so I've closed it.

We deliberately do not touch the dunder streams. _windowsconsoleio_workaround rebinds sys.stdin/sys.stdout/sys.stderr and stops there. sys.__stdin__/__stdout__/__stderr__ are the interpreter's saved originals; pytest overwriting them would persist for the rest of the process, with no restore path — which matters for in-process pytest.main() and for xdist workers, and it would destroy the one thing those attributes exist to preserve. Making that conditional on --capture=fd would also mean sys.__stdout__ denotes different things depending on capture mode.

The motivating breakage no longer applies. The failing writes and OSError: [WinError 6] described here are the pre-3.10 behaviour, when _WindowsConsoleIO cached the console handle at construction and dup2 invalidated it. That is python/cpython#74740 (bpo-30555) — filed in 2017 explicitly citing pytest — and it was fixed in CPython 3.10 by re-deriving the handle from the descriptor on every call. pytest requires Python >= 3.10, so no supported version still has it.

What remains is only isatty() being hardcoded to return True, which is python/cpython#72840 (bpo-28654), still open upstream. That's a CPython bug, and not one we want to work around by rewriting interpreter state.

What would change our minds is a concrete example. Real code that breaks under pytest on a supported Python — not a synthetic assert not sys.__stdout__.isatty() — would give us something to weigh the cost against. As it stands the added test monkeypatches sys.platform to "win32" and substitutes a fake io._WindowsConsoleIO, so it asserts against the mock rather than against Windows behaviour and can't validate the premise either way; it was also red on every CI job, including Linux and macOS, on an EncodingWarning from open("w+") without an encoding.

If you hit an actual failure in real code, please do open an issue with it and we'll take another look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sys.__stdout__.isatty() returns incorrect value for Windows 11

2 participants