Skip to content

Clean up encoders deterministically on success and failure - #10043

Open
danking wants to merge 4 commits into
python-pillow:mainfrom
danking:fix-tiff-encoder-cleanup
Open

danking wants to merge 4 commits into
python-pillow:mainfrom
danking:fix-tiff-encoder-cleanup

Conversation

@danking

@danking danking commented Sep 22, 2026

Copy link
Copy Markdown

An LLM wrote the code.

It seems that Pillow doesn’t close the encoder even though the file is closed when a failure occurs. If we happen to reuse the same file descriptor number later, I think the encoder can later accidentally corrupt some unrelated file that happened to have the same fd.

@akx

akx commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

🧐 There might be something to this. I saw two different flaky failures (only on PyPy, which probably handles fds and cleanup differently to CPython) on #10042, both related to file reading:

Tests/test_file_pcx.py::test_large_count - OSError: buffer overrun when reading image file

Tests/test_file_apng.py::test_apng_save_split_fdat - PIL.UnidentifiedImageError: cannot identify image file '/tmp/pytest-of-runner/pytest-0/popen-gw1/test_apng_save_split_fdat0/temp.png'

@danking

danking commented Sep 22, 2026

Copy link
Copy Markdown
Author

I’m trying to validate it resolves the issue on my other PR as well. If it does then I think this might be a reasonable fix.

@danking

danking commented Sep 22, 2026

Copy link
Copy Markdown
Author

Also as you can see I’ve been nerdsnipped during my lunch break.

@akx

akx commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

There's a possibly similar PyPy failure here some weeks ago in #9944:

FAILED Tests/test_file_pcx.py::test_odd[511-RGB] - OSError: buffer overrun when reading image file

with pytest.raises(ValueError, match="cannot write empty image"):
im.save(out, compression=compression)

def test_save_error_cleanup(self, tmp_path: Path) -> None:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

OK, I can confirm that this test fails 100/100 runs before the fixes in this PR. The output file position is moved during the cleanup. It succeeds 100/100 runs with these changes.

I'm not sure if the bugs in the test suite are due to literally sharing a Python File object or if it was unlucky file descriptor reuse, but either way, I this test should pass, IMO.

@akx akx Sep 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As far as I can tell, it's the latter.

The mechanism seems to be:

  1. Start a TIFF save into a disk file using the libtiff encoder. Libtiff gets an fd.
  2. The save raises before finishing, e.g. with "ValueError: cannot write empty image". Before this PR, TiffImagePlugin._save didn't do finally:, so the encoder isn't explicitly cleaned up.
  3. Something keeps the encoder object alive, so its cleanup isn't yet called.
  4. The output file is closed, freeing its fd number. The libtiff encoder still has that fd though.
  5. Unrelated code opens another file, and we get the lowest free fd, which unluckily is also known by the stray TIFF encoder.
  6. Something triggers GC and the encoder is collected and its cleanup is run. ImagingLibTiffEncodeCleanup calls TIFFClose/TIFFCleanup, which flush pending output into the now-unrelated file, clobbering it. 😞

Apparently PyPy's GC is different enough (no refcounting, I understand?) that step 6 happens way further off "in other tests" in PyPy land, and with #9945 having landed, the test order is shuffled enough that this happens more obviously...

@danking
danking marked this pull request as ready for review September 22, 2026 17:54
@akx

akx commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Yep, I found the same reproducer :) Other places seem to clean up encoders properly, but TIFF was the odd one out.

The TIFF encoder is probably the only one that flushes data out when it's destroyed...

// that is fine, as it does not close the file
TIFFClose(tiff);
}
clientstate->tiff = NULL;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good catch. 👍

@danking

danking commented Sep 22, 2026

Copy link
Copy Markdown
Author

fwiw, I think you commented rather than approved. Not sure if that was intentional

@akx akx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM (from the sidelines).

I'm not sure test_encoder_cleanup needs to test those variations of things that could go awry within the try: finally: block, though. (You could just have the patched _getencoder return a thing that only has a cleanup mock, no setimage(), and see that encode raises and cleanup got called. But that's a matter of taste, I think.)

@radarhere

Copy link
Copy Markdown
Member

This would be only instance in our test suite of using unittest. I've created danking#1 to suggest removing it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🤖-assisted AI-assisted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants