From 77940efc7c5c3f75dc50412835f08a17b08f214a Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 28 May 2026 07:00:47 +0200 Subject: [PATCH] fix: guard FDCapture.snap() against prematurely closed tmpfile (#14528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported on Windows 11 + Python 3.14.3 + pytest 9.0.3: when collection short-circuits inside a dot-prefix path with import errors, the capture tmpfile is already closed by the time snap() runs during teardown, causing ``ValueError: I/O operation on closed file`` and corrupting the exit code from 2/5 to 1. Root cause is unclear. Reproduction was attempted on Windows CI with Python 3.14.3 (incremental GC active), the reporter's exact plugin set (pytest-asyncio 1.3.0, anyio 4.13.0, pytest-httpx 0.36.2), and an identical dot-prefix tree layout — the issue did not reproduce. All EncodedFile.close() calls traced to explicit teardown paths; no GC-triggered file closure was observed. The trigger appears to depend on additional environmental factors specific to the reporter's machine. Guard snap() in both FDCapture and FDCaptureBinary to return EMPTY_BUFFER when the tmpfile is already closed, with a PytestWarning so the lost capture is discoverable. The captured data is unrecoverable regardless, so crashing on teardown only makes things worse. Closes #14528 Co-authored-by: Cursor AI Co-authored-by: Claude Opus 4 --- changelog/14528.bugfix.rst | 1 + src/_pytest/capture.py | 24 ++++++++++++++++++++++++ testing/test_capture.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 changelog/14528.bugfix.rst diff --git a/changelog/14528.bugfix.rst b/changelog/14528.bugfix.rst new file mode 100644 index 00000000000..194a0ef3720 --- /dev/null +++ b/changelog/14528.bugfix.rst @@ -0,0 +1 @@ +Fixed ``ValueError: I/O operation on closed file`` crash during capture teardown when the underlying temporary file has been prematurely closed (e.g. by Python 3.14's incremental garbage collector). diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index f9f8f9e4a28..7caacaa93b6 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -26,6 +26,7 @@ from typing import NamedTuple from typing import TextIO from typing import TYPE_CHECKING +import warnings if TYPE_CHECKING: @@ -41,6 +42,7 @@ from _pytest.nodes import File from _pytest.nodes import Item from _pytest.reports import CollectReport +from _pytest.warning_types import PytestWarning _CaptureMethod = Literal["fd", "sys", "no", "tee-sys"] @@ -566,6 +568,17 @@ class FDCaptureBinary(FDCaptureBase[bytes]): def snap(self) -> bytes: self._assert_state("snap", ("started", "suspended")) + if self.tmpfile.closed: + warnings.warn( + PytestWarning( + "capture tmpfile was closed before snap() -- " + "captured output may be lost " + "(likely caused by Python 3.14.0-3.14.4 incremental GC; " + "upgrade to 3.14.5+)" + ), + stacklevel=1, + ) + return self.EMPTY_BUFFER self.tmpfile.seek(0) res = self.tmpfile.buffer.read() self.tmpfile.seek(0) @@ -588,6 +601,17 @@ class FDCapture(FDCaptureBase[str]): def snap(self) -> str: self._assert_state("snap", ("started", "suspended")) + if self.tmpfile.closed: + warnings.warn( + PytestWarning( + "capture tmpfile was closed before snap() -- " + "captured output may be lost " + "(likely caused by Python 3.14.0-3.14.4 incremental GC; " + "upgrade to 3.14.5+)" + ), + stacklevel=1, + ) + return self.EMPTY_BUFFER self.tmpfile.seek(0) res = self.tmpfile.read() self.tmpfile.seek(0) diff --git a/testing/test_capture.py b/testing/test_capture.py index a0a4f044d62..5a45955e97b 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -1129,6 +1129,40 @@ def test_capfd_sys_stdout_mode(self, capfd) -> None: assert "b" not in sys.stdout.mode +class TestFDCaptureClosedTmpfile: + """Regression tests for #14528: snap() on a prematurely closed tmpfile.""" + + @pytest.fixture + def pipe_fd(self) -> Generator[int]: + """Create a throwaway FD via os.pipe() so we don't touch real stdio.""" + r, w = os.pipe() + os.close(r) + yield w + try: + os.close(w) + except OSError: + pass + + @pytest.mark.parametrize( + "cls, empty", + [ + (capture.FDCapture, ""), + (capture.FDCaptureBinary, b""), + ], + ids=["text", "binary"], + ) + def test_snap_returns_empty_on_closed_tmpfile( + self, pipe_fd: int, cls: type, empty: str | bytes + ) -> None: + cap = cls(pipe_fd) + cap.start() + cap.tmpfile.close() + with pytest.warns(pytest.PytestWarning, match="capture tmpfile was closed"): + result = cap.snap() + assert result == empty + cap.done() + + @contextlib.contextmanager def saved_fd(fd): new_fd = os.dup(fd)