fix: close PIL Image handles to prevent FD / memory leaks#362
Open
Chen17-sq wants to merge 1 commit intovolcengine:mainfrom
Open
fix: close PIL Image handles to prevent FD / memory leaks#362Chen17-sq wants to merge 1 commit intovolcengine:mainfrom
Chen17-sq wants to merge 1 commit intovolcengine:mainfrom
Conversation
Six call sites used `Image.open()` without a `with` block. The most
impactful is `calculate_phash` in `opencontext/utils/image.py`, which
runs on every captured screenshot via `ScreenshotProcessor._is_duplicate`,
so each screenshot left an OS file handle (and the lazy-decoded image
buffer) attached until the next garbage-collection pass. On a long-lived
recording session this trends toward the file-descriptor ulimit and
inflates resident memory.
The four call sites in `document_converter.py` return the loaded image to
their caller, so a plain `with` would close the data prematurely. The fix
wraps the open call in `with Image.open(...) as fp:` and rebinds `img =
fp.convert("RGB")` — `convert()` produces a new in-memory image (it
returns `self.copy()` even when the source mode already matches), so the
returned image is independent of the underlying file/BytesIO and the
original lazy handle is closed on `with` exit.
Behavior is preserved: every site previously ended with an RGB image, and
that is still the case (the prior `if mode != "RGB": convert("RGB")`
guard was a micro-optimization for the already-RGB case; we accept the
extra copy in that branch in exchange for the resource-hygiene fix). The
`.format` attribute on the returned image is now consistently `None`,
matching the existing behavior on the non-RGB path; nothing downstream
reads `.format` on these images.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Six call sites use
PIL.Image.open()without awithblock. PIL'sImage.openis lazy and the returned object holds the underlying file (or BytesIO) until the image is garbage-collected. On CPython that is usually "soon", but it isn't deterministic, and at high call frequencies the leaked handles add up.The hottest of the six sites is in the screenshot dedup path:
opencontext/utils/image.py::calculate_phashis called fromScreenshotProcessor._is_duplicatefor every captured screenshot (screenshot_processor.py L125). On a long recording session this trends toward the per-process FD ulimit and inflates resident memory by holding decoded image buffers.The four sites in
document_converter.pyreturn the image to their callers, so a plainwithwould close the data prematurely. The fix wraps the open call inwith Image.open(...) as fp:and rebindsimg = fp.convert(\"RGB\").convert()produces a new in-memory image — it returnsself.copy()even when the source mode already matches — so the returnedimgis independent of the underlying file/BytesIO, and the original lazy handle is reliably closed onwithexit.Sites
opencontext/utils/image.pycalculate_bytes2phashopencontext/utils/image.pycalculate_phashopencontext/context_processing/processor/document_converter.py_load_imageopencontext/context_processing/processor/document_converter.py_extract_paragraph_imagesopencontext/context_processing/processor/document_converter.py_extract_markdown_images(remote branch)opencontext/context_processing/processor/document_converter.py_extract_markdown_images(local branch)The existing
resize_image(same file,utils/image.py) already useswith Image.open(...)correctly — this PR brings the rest of the codebase in line.Behavior compatibility
Each fixed site previously ended with an RGB image and still does. The prior
if mode != \"RGB\": convert(\"RGB\")guard was a micro-optimization that avoided a copy in the already-RGB case; we accept the extra copy in that branch in exchange for the resource-hygiene fix.The only observable change is
.format:convert()does not propagateformat, so for inputs that were already RGB the returned image now hasformat=None(previously it carried'JPEG'/'PNG'/etc.). This matches the behavior already present for non-RGB inputs in the original code, andgrep -rn '\\.format'shows nothing downstream reads.formaton these returned images — the only.formatread in the package is insideresize_imageon a freshly-opened Image, which is unrelated.Test plan
python -m black --checkpasses for both filespython -m isort --check-onlypasses for both filesgrep -rn 'Image.open' opencontext/ | grep -v 'with Image.open'is now emptywith Image.open(...) as fp: img = fp.convert(\"RGB\")is fully usable after thewithexits (getpixel,save,putpixel),fp.fpisNoneafter thewith(handle released),lsof -p $(pgrep -f opencontext)over time and confirm screenshot-related FD count no longer grows monotonically.