From 91c6ffcf98eec762acd3b61133871b97df3c9a88 Mon Sep 17 00:00:00 2001 From: Tim O'Hare Date: Tue, 18 Aug 2026 22:07:25 +1000 Subject: [PATCH] Fix PDF.close() closing the wrong Page instances (memory leak) close() called flush_cache() -- which clears the cached `_pages` list -- before iterating `self.pages` to close each page. With `_pages` gone, that iteration re-triggers the `pages` property's parsing branch, producing a fresh, never-otherwise-referenced set of Page objects, and closes *those* instead of the ones the caller actually used (e.g. via `for page in pdf.pages: page.extract_text()`). The real pages' per-page caches (`_objects`, `_layout`, and the `get_textmap` lru_cache) are therefore never released by close(), so a long-lived process that opens many PDFs sequentially sees memory grow roughly unbounded rather than being freed after each `with pdfplumber.open(...)` block exits. Three independent reports of this same symptom: #1339, #1229, #1189. Fix: close the pages before flushing the cache that they're read from, so close() operates on the actual pages instead of a discarded reparse. Added a regression test (test_issue_1339) that fails against the old ordering and passes with the fix -- confirmed both directions locally. --- CHANGELOG.md | 1 + README.md | 1 + pdfplumber/pdf.py | 9 +++++++-- tests/test_issues.py | 23 +++++++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d161e9..314eb190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] ### Fixed +- Fix `PDF.close()` closing the wrong `Page` instances: it called `flush_cache()` (which clears the cached `_pages` list) before iterating `self.pages`, so that iteration re-parsed and closed a fresh, never-otherwise-referenced set of pages instead of the ones the caller actually used — leaving the real pages' `_objects`/`_layout`/`get_textmap` caches unreleased. This caused unbounded-seeming memory growth in long-lived processes that open many PDFs sequentially. ([#1339](https://github.com/jsvine/pdfplumber/issues/1339), related: [#1229](https://github.com/jsvine/pdfplumber/issues/1229), [#1189](https://github.com/jsvine/pdfplumber/issues/1189)) - Initialize PDFium's form environment in `get_page_image` so that filled AcroForm field content is included when rendering pages via `Page.to_image()`. ([#1367](https://github.com/jsvine/pdfplumber/issues/1367)) - Fix `make venv`, which created the virtual environment at `venv/` but then installed into `${VENV}` (default `.venv/`), causing the target to fail on a fresh checkout (h/t @soodoku). ([ec96f72](https://github.com/jsvine/pdfplumber/commit/ec96f72)) - Include the wrapped exception's class name in `PdfminerException`'s message when `pdfminer.six` raises an exception without one (e.g. `PDFPasswordIncorrect`), which previously surfaced as a blank error message. diff --git a/README.md b/README.md index dc23ac9c..b0638350 100644 --- a/README.md +++ b/README.md @@ -577,6 +577,7 @@ Many thanks to the following users who've contributed ideas, features, and fixes - [Sebastian Cao](https://github.com/cycsmail) - [Kaspar Naraghi](https://github.com/kaninaba94) - [Siddharth Gaur](https://github.com/siddharthgaur1) +- [Timothy O'Hare](https://github.com/timothyohare) ## Contributing diff --git a/pdfplumber/pdf.py b/pdfplumber/pdf.py index f3d2dc69..3628c72d 100644 --- a/pdfplumber/pdf.py +++ b/pdfplumber/pdf.py @@ -122,11 +122,16 @@ def open( raise def close(self) -> None: - self.flush_cache() - + # Must close the pages *before* flush_cache() clears `_pages`; the + # `pages` property re-parses (and returns a fresh, never-otherwise- + # referenced set of pages) if `_pages` is missing, so flushing first + # means the pages actually used by the caller never get their heavy + # caches (`_objects`, `_layout`, `get_textmap`) released. See #1339. for page in self.pages: page.close() + self.flush_cache() + if not self.stream_is_external: self.stream.close() diff --git a/tests/test_issues.py b/tests/test_issues.py index e0823f2b..ae8c7f31 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -338,3 +338,26 @@ def test_pr_1195(self): ): for _ in pdf.annots: pass + + def test_issue_1339(self): + """ + PDF.close() closed the wrong Page instances: flush_cache() ran + first and deleted the cached `_pages` list, so the subsequent + `for page in self.pages: page.close()` re-parsed and closed a + fresh, never-otherwise-referenced set of pages instead of the ones + the caller actually used -- leaving the real pages' `_objects` / + `_layout` / `get_textmap` caches unreleased after close(). + https://github.com/jsvine/pdfplumber/issues/1339 + """ + path = os.path.join(HERE, "pdfs/issue-33-lorem-ipsum.pdf") + pdf = pdfplumber.open(path) + pages = pdf.pages + for page in pages: + page.extract_text() + assert hasattr(page, "_objects") + + pdf.close() + + for page in pages: + assert not hasattr(page, "_objects") + assert page.get_textmap.cache_info().currsize == 0