From 61ca66558ea90b646a2c67b37e2c0dabf41bb5f1 Mon Sep 17 00:00:00 2001 From: NIK-TIGER-BILL Date: Thu, 28 May 2026 00:04:42 +0000 Subject: [PATCH 1/2] fix(capture): guard snap() against closed tmpfile (#14528) When collection short-circuits (e.g. no tests found in a dot-prefix path), the capture teardown can call snap() on a capture whose tmpfile has already been closed by done(). This produces: ValueError: I/O operation on closed file. Add an early closed-file guard to all snap() implementations so they gracefully return their EMPTY_BUFFER instead of raising. Fixes #14528 Signed-off-by: NIK-TIGER-BILL --- src/_pytest/capture.py | 6 ++++++ testing/test_capture.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 6d98676be5f..48b2f153de7 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -449,6 +449,8 @@ class SysCapture(SysCaptureBase[str]): def snap(self) -> str: self._assert_state("snap", ("started", "suspended")) + if getattr(self.tmpfile, "closed", False): + return self.EMPTY_BUFFER assert isinstance(self.tmpfile, CaptureIO) res = self.tmpfile.getvalue() self.tmpfile.seek(0) @@ -566,6 +568,8 @@ class FDCaptureBinary(FDCaptureBase[bytes]): def snap(self) -> bytes: self._assert_state("snap", ("started", "suspended")) + if getattr(self.tmpfile, "closed", False): + return self.EMPTY_BUFFER self.tmpfile.seek(0) res = self.tmpfile.buffer.read() self.tmpfile.seek(0) @@ -588,6 +592,8 @@ class FDCapture(FDCaptureBase[str]): def snap(self) -> str: self._assert_state("snap", ("started", "suspended")) + if getattr(self.tmpfile, "closed", False): + 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 7aaba99fe43..34f62115e46 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -1750,3 +1750,31 @@ def pytest_terminal_summary(config): match = re.search(r"^value: '(.*)'\r?$", rest, re.MULTILINE) assert match is not None assert match.group(1) == "hi" + + + +def test_snap_on_closed_tmpfile() -> None: + """Regression test for #14528: snap() should not crash when tmpfile is closed.""" + # FDCapture + cap = capture.FDCapture(1) + cap.start() + cap.done() + assert cap.snap() == '' + + # FDCaptureBinary + capb = capture.FDCaptureBinary(1) + capb.start() + capb.done() + assert capb.snap() == b'' + + # SysCapture + caps = capture.SysCapture(1) + caps.start() + caps.done() + assert caps.snap() == '' + + # SysCaptureBinary + capsb = capture.SysCaptureBinary(1) + capsb.start() + capsb.done() + assert capsb.snap() == b'' From 41db7a3f5e93a227954ca9ef7eb261a1fa2ead1d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 00:07:10 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_capture.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/testing/test_capture.py b/testing/test_capture.py index 34f62115e46..dc1edafd5d5 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -1752,29 +1752,28 @@ def pytest_terminal_summary(config): assert match.group(1) == "hi" - def test_snap_on_closed_tmpfile() -> None: """Regression test for #14528: snap() should not crash when tmpfile is closed.""" # FDCapture cap = capture.FDCapture(1) cap.start() cap.done() - assert cap.snap() == '' + assert cap.snap() == "" # FDCaptureBinary capb = capture.FDCaptureBinary(1) capb.start() capb.done() - assert capb.snap() == b'' + assert capb.snap() == b"" # SysCapture caps = capture.SysCapture(1) caps.start() caps.done() - assert caps.snap() == '' + assert caps.snap() == "" # SysCaptureBinary capsb = capture.SysCaptureBinary(1) capsb.start() capsb.done() - assert capsb.snap() == b'' + assert capsb.snap() == b""