Skip to content

fix: close PIL Image handles to prevent FD / memory leaks#362

Open
Chen17-sq wants to merge 1 commit intovolcengine:mainfrom
Chen17-sq:fix/image-open-resource-leaks
Open

fix: close PIL Image handles to prevent FD / memory leaks#362
Chen17-sq wants to merge 1 commit intovolcengine:mainfrom
Chen17-sq:fix/image-open-resource-leaks

Conversation

@Chen17-sq
Copy link
Copy Markdown
Contributor

Summary

Six call sites use PIL.Image.open() without a with block. PIL's Image.open is 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_phash is called from ScreenshotProcessor._is_duplicate for 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.py return the image to their callers, 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 img is independent of the underlying file/BytesIO, and the original lazy handle is reliably closed on with exit.

Sites

File Line Caller pattern
opencontext/utils/image.py calculate_bytes2phash image consumed inline
opencontext/utils/image.py calculate_phash image consumed inline (per-screenshot hot path)
opencontext/context_processing/processor/document_converter.py _load_image image returned to caller
opencontext/context_processing/processor/document_converter.py _extract_paragraph_images image appended to caller's list
opencontext/context_processing/processor/document_converter.py _extract_markdown_images (remote branch) image appended to caller's list
opencontext/context_processing/processor/document_converter.py _extract_markdown_images (local branch) image appended to caller's list

The existing resize_image (same file, utils/image.py) already uses with 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 propagate format, so for inputs that were already RGB the returned image now has format=None (previously it carried 'JPEG'/'PNG'/etc.). This matches the behavior already present for non-RGB inputs in the original code, and grep -rn '\\.format' shows nothing downstream reads .format on these returned images — the only .format read in the package is inside resize_image on a freshly-opened Image, which is unrelated.

Test plan

  • python -m black --check passes for both files
  • python -m isort --check-only passes for both files
  • grep -rn 'Image.open' opencontext/ | grep -v 'with Image.open' is now empty
  • In-process PIL smoke test verifying that:
    • the image returned from with Image.open(...) as fp: img = fp.convert(\"RGB\") is fully usable after the with exits (getpixel, save, putpixel),
    • fp.fp is None after the with (handle released),
    • already-RGB inputs still produce an independent copy (mutating it does not affect the source).
  • Maintainer to verify on a real recording session: monitor lsof -p $(pgrep -f opencontext) over time and confirm screenshot-related FD count no longer grows monotonically.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant