Skip to content

fix: guard FDCapture.snap() against prematurely closed tmpfile#14531

Draft
RonnyPfannschmidt wants to merge 1 commit into
pytest-dev:mainfrom
RonnyPfannschmidt:fix/14528-capture-closed-tmpfile
Draft

fix: guard FDCapture.snap() against prematurely closed tmpfile#14531
RonnyPfannschmidt wants to merge 1 commit into
pytest-dev:mainfrom
RonnyPfannschmidt:fix/14528-capture-closed-tmpfile

Conversation

@RonnyPfannschmidt
Copy link
Copy Markdown
Member

@RonnyPfannschmidt RonnyPfannschmidt commented May 28, 2026

Summary

Fixes #14528ValueError: I/O operation on closed file during capture teardown on Windows + Python 3.14.3.

Guards FDCapture.snap() and FDCaptureBinary.snap() against a prematurely closed tmpfile, returning EMPTY_BUFFER and emitting a PytestWarning instead of crashing. The captured data is unrecoverable regardless, so crashing only makes things worse.

Reproduction attempts

The issue was reported on Windows 11 + Python 3.14.3 + pytest 9.0.3 with pytest-asyncio, anyio, and pytest-httpx. Collection short-circuits inside a dot-prefix path (.claude/skills/actionmail/tests/) with import errors, and the capture tmpfile is already closed by the time snap() runs.

We attempted to reproduce on Windows CI with:

  • Python 3.14.3 (incremental GC active, gc.get_threshold() = (2000, 10, 0))
  • The reporter's exact plugin set (pytest-asyncio==1.3.0, anyio==4.13.0, pytest-httpx==0.36.2)
  • An identical dot-prefix tree layout with __init__.py, two test files with sys.path.insert + failed import run, and a fixtures/ sibling

The issue did not reproduce. All EncodedFile.close() calls traced to explicit teardown paths (pytest_keyboard_interrupt → stop_global_capturing → done → tmpfile.close()). No GC-triggered file closure was observed. The trigger appears to depend on additional environmental factors specific to the reporter's machine.

Notably, Python 3.14.5 (which reverts the incremental GC) also showed no issue, consistent with the reporter's observation that the problem is 3.14-specific.

Changes

  • src/_pytest/capture.py: Guard snap() in both FDCapture and FDCaptureBinary — check self.tmpfile.closed before seeking, emit PytestWarning, return EMPTY_BUFFER
  • testing/test_capture.py: Parametrized regression test using os.pipe() for isolation
  • changelog/14528.bugfix.rst: Changelog entry

Test plan

  • Regression test TestFDCaptureClosedTmpfile — deterministically closes tmpfile before snap(), asserts EMPTY_BUFFER and PytestWarning
  • Existing capture tests pass
  • Windows CI with Python 3.14.3 + reporter's plugins — no crash (exit code 2)
  • Pre-commit clean

Copilot AI review requested due to automatic review settings May 28, 2026 04:26
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label May 28, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Guards FDCapture.snap() and FDCaptureBinary.snap() against the case where the underlying tmpfile has been closed externally (notably by Python 3.14.0–3.14.4's incremental GC finalizing the TemporaryFile before pytest's capture teardown runs). Instead of raising ValueError: I/O operation on closed file during _ensure_unconfigure → stop_global_capturing, snap() now returns EMPTY_BUFFER, preserving the intended exit code (e.g., 5 for no-tests-collected) and avoiding confusing tracebacks in test output.

Changes:

  • Add early-return on self.tmpfile.closed in both FDCaptureBinary.snap() and FDCapture.snap().
  • Add changelog entry for issue #14528.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/_pytest/capture.py Returns empty buffer from snap() when tmpfile is already closed, avoiding the seek(0) crash.
changelog/14528.bugfix.rst New changelog entry describing the fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/_pytest/capture.py
Comment thread src/_pytest/capture.py
Comment on lines 569 to +594
@@ -588,6 +590,8 @@ class FDCapture(FDCaptureBase[str]):

def snap(self) -> str:
self._assert_state("snap", ("started", "suspended"))
if self.tmpfile.closed:
return self.EMPTY_BUFFER
@RonnyPfannschmidt RonnyPfannschmidt marked this pull request as draft May 28, 2026 04:29
@RonnyPfannschmidt RonnyPfannschmidt force-pushed the fix/14528-capture-closed-tmpfile branch 5 times, most recently from 9e43244 to 594e4f5 Compare May 28, 2026 06:13
@RonnyPfannschmidt
Copy link
Copy Markdown
Member Author

running another expeirment first - im of the impression the initial explanation doesnt fit and only coincides

@RonnyPfannschmidt RonnyPfannschmidt force-pushed the fix/14528-capture-closed-tmpfile branch 3 times, most recently from adf46dd to be36867 Compare May 28, 2026 08:35
…t-dev#14528)

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 pytest-dev#14528

Co-authored-by: Cursor AI <ai@cursor.sh>
Co-authored-by: Claude Opus 4 <claude@anthropic.com>
@RonnyPfannschmidt RonnyPfannschmidt force-pushed the fix/14528-capture-closed-tmpfile branch from be36867 to 77940ef Compare May 28, 2026 09:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

2 participants