Skip to content

Use a private duplicate of stdout/stderr fd in CliRunner#3387

Closed
charlieleith wants to merge 1 commit intopallets:mainfrom
charlieleith:fix-cli-runner-dup-fd
Closed

Use a private duplicate of stdout/stderr fd in CliRunner#3387
charlieleith wants to merge 1 commit intopallets:mainfrom
charlieleith:fix-cli-runner-dup-fd

Conversation

@charlieleith
Copy link
Copy Markdown

fixes #3384

Summary

Click 8.3.3 (#3244) started exposing the original stream's file descriptor via _NamedTextIOWrapper.fileno(), so that C-level consumers like faulthandler and subprocess could obtain a real fd from sys.stdout / sys.stderr inside CliRunner isolation.

That regressed any command that performs os.dup2 against the value returned by sys.stdout.fileno() — logging tees, build tools, subprocess wrappers, etc. The redirect happens on the host process's real stdout fd, which clobbers any outer fd-based capture (most notably pytest's capfd/capsys capture pipe). The reporter's repro fails during pytest's own teardown with OSError: [Errno 29] Illegal seek because pytest tries to seek(0) on the tmpfile that has just been overwritten with a non-seekable pipe.

This mirrors how pytest itself handles the same problem in capfd: os.dup the original fd inside CliRunner and expose the duplicate via fileno(). os.dup2 against the duplicate redirects only this private copy, so the host's stdout/stderr — and any capture installed on top of it — stays intact. The duplicates are closed when the isolation context exits.

The semantics from #3244 are preserved: faulthandler.enable(), subprocess, signal handlers, and similar C-level consumers still get a valid, terminal-bound fd.

Repro

The repro from the issue passes after this change:

import io, os, sys
import click
from click.testing import CliRunner

@click.command()
def cmd():
    stdout_fd = sys.stdout.fileno()
    r, w = os.pipe()
    os.dup2(w, stdout_fd)
    os.close(w); os.close(r)
    click.echo("hello")

def test_invoke():
    runner = CliRunner()
    result = runner.invoke(cmd)
    assert result.exit_code == 0

Tests

  • Adds test_dup2_over_stdout_fd_does_not_leak in tests/test_testing.py. Fails on main with OSError: Illegal seek during pytest teardown (same backtrace as the issue), passes with this change.
  • Existing test_faulthandler_enable still passes — duped fd remains valid for faulthandler.
  • Full suite: 1437 passed, 23 skipped, 1 xfailed.

Checklist

  • Add tests that demonstrate the correct behavior of the change. Tests fail without the change.
  • Add or update relevant docs, in the docs folder and in code (_NamedTextIOWrapper docstring + versionchanged).
  • Add an entry in CHANGES.rst summarizing the change and linking to the issue.
  • Add versionchanged entries in any relevant code docs.

Since 8.3.3, ``_NamedTextIOWrapper.fileno()`` returns the
file descriptor of the stream being replaced. Code under test
that calls ``os.dup2`` over that value (logging tees, build
tools, subprocess wrappers, ...) ends up redirecting the host
process's real stdout/stderr fd, breaking outer harnesses such
as pytest's fd-based capture. Reproduce with the snippet from
issue pallets#3384: pytest exits with ``OSError: Illegal seek`` from
``_pytest.capture`` while tearing down its own capture.

Mirror pytest's own ``capfd`` model: ``os.dup`` the original
fd inside ``CliRunner`` and expose the duplicate via
``fileno()``. ``os.dup2`` against the duplicate now only
redirects this private copy, leaving the host's stdout/stderr
intact. Close the duplicates when isolation exits.
@davidism davidism closed this Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Click 8.3.3 breaks pytest when stream fd is duplicated

2 participants