Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/14528.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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).
24 changes: 24 additions & 0 deletions src/_pytest/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from typing import NamedTuple
from typing import TextIO
from typing import TYPE_CHECKING
import warnings


if TYPE_CHECKING:
Expand All @@ -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"]
Expand Down Expand Up @@ -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)
Expand All @@ -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+)"
Comment on lines +607 to +610
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Move this message to a constant and reuse in both places?

),
stacklevel=1,
)
return self.EMPTY_BUFFER
Comment thread
RonnyPfannschmidt marked this conversation as resolved.
Comment thread
nicoddemus marked this conversation as resolved.
self.tmpfile.seek(0)
res = self.tmpfile.read()
self.tmpfile.seek(0)
Expand Down
34 changes: 34 additions & 0 deletions testing/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is this necessary? Perhaps leave a comment.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
self, pipe_fd: int, cls: type, empty: str | bytes
self, pipe_fd: int, cls: type[capture.FDCapture|capture.FDCaptureBinary], 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)
Expand Down
Loading