Skip to content

Commit 594e4f5

Browse files
RonnyPfannschmidtCursor AIclaudecursoragent
committed
fix: guard FDCapture.snap() against prematurely closed tmpfile (pytest-dev#14528)
Under Python 3.14's incremental garbage collector (present in 3.14.0–3.14.4, reverted in 3.14.5), the TemporaryFile backing FDCapture can be finalized before pytest's own capture teardown runs. This caused a ValueError crash in stop_global_capturing and corrupted the exit code. 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 lost anyway, so crashing on teardown only makes things worse. Upstream references: - Incremental GC tracking issue: python/cpython#108362 - Memory pressure reports: python/cpython#142516 - Revert (forward-port generational GC to 3.14): python/cpython#148720 - Revert discussion: https://discuss.python.org/t/107014 Closes pytest-dev#14528 Co-authored-by: Cursor AI <ai@cursor.sh> Co-authored-by: Claude Opus 4 <claude@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2f26a02 commit 594e4f5

3 files changed

Lines changed: 59 additions & 0 deletions

File tree

‎changelog/14528.bugfix.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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).

‎src/_pytest/capture.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from typing import NamedTuple
2727
from typing import TextIO
2828
from typing import TYPE_CHECKING
29+
import warnings
2930

3031

3132
if TYPE_CHECKING:
@@ -41,6 +42,7 @@
4142
from _pytest.nodes import File
4243
from _pytest.nodes import Item
4344
from _pytest.reports import CollectReport
45+
from _pytest.warning_types import PytestWarning
4446

4547

4648
_CaptureMethod = Literal["fd", "sys", "no", "tee-sys"]
@@ -566,6 +568,17 @@ class FDCaptureBinary(FDCaptureBase[bytes]):
566568

567569
def snap(self) -> bytes:
568570
self._assert_state("snap", ("started", "suspended"))
571+
if self.tmpfile.closed:
572+
warnings.warn(
573+
PytestWarning(
574+
"capture tmpfile was closed before snap() -- "
575+
"captured output may be lost "
576+
"(likely caused by Python 3.14.0-3.14.4 incremental GC; "
577+
"upgrade to 3.14.5+)"
578+
),
579+
stacklevel=1,
580+
)
581+
return self.EMPTY_BUFFER
569582
self.tmpfile.seek(0)
570583
res = self.tmpfile.buffer.read()
571584
self.tmpfile.seek(0)
@@ -588,6 +601,17 @@ class FDCapture(FDCaptureBase[str]):
588601

589602
def snap(self) -> str:
590603
self._assert_state("snap", ("started", "suspended"))
604+
if self.tmpfile.closed:
605+
warnings.warn(
606+
PytestWarning(
607+
"capture tmpfile was closed before snap() -- "
608+
"captured output may be lost "
609+
"(likely caused by Python 3.14.0-3.14.4 incremental GC; "
610+
"upgrade to 3.14.5+)"
611+
),
612+
stacklevel=1,
613+
)
614+
return self.EMPTY_BUFFER
591615
self.tmpfile.seek(0)
592616
res = self.tmpfile.read()
593617
self.tmpfile.seek(0)

‎testing/test_capture.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,40 @@ def test_capfd_sys_stdout_mode(self, capfd) -> None:
11291129
assert "b" not in sys.stdout.mode
11301130

11311131

1132+
class TestFDCaptureClosedTmpfile:
1133+
"""Regression tests for #14528: snap() on a prematurely closed tmpfile."""
1134+
1135+
@pytest.fixture
1136+
def pipe_fd(self) -> Generator[int]:
1137+
"""Create a throwaway FD via os.pipe() so we don't touch real stdio."""
1138+
r, w = os.pipe()
1139+
os.close(r)
1140+
yield w
1141+
try:
1142+
os.close(w)
1143+
except OSError:
1144+
pass
1145+
1146+
@pytest.mark.parametrize(
1147+
"cls, empty",
1148+
[
1149+
(capture.FDCapture, ""),
1150+
(capture.FDCaptureBinary, b""),
1151+
],
1152+
ids=["text", "binary"],
1153+
)
1154+
def test_snap_returns_empty_on_closed_tmpfile(
1155+
self, pipe_fd: int, cls: type, empty: str | bytes
1156+
) -> None:
1157+
cap = cls(pipe_fd)
1158+
cap.start()
1159+
cap.tmpfile.close()
1160+
with pytest.warns(pytest.PytestWarning, match="capture tmpfile was closed"):
1161+
result = cap.snap()
1162+
assert result == empty
1163+
cap.done()
1164+
1165+
11321166
@contextlib.contextmanager
11331167
def saved_fd(fd):
11341168
new_fd = os.dup(fd)

0 commit comments

Comments
 (0)