Repository navigation
fix: refuse handles whose owner is closed, and close every handle before destroying PDFium (ADR-028, ADR-029) - #71
Merged
Conversation
cpp_destroy_library() called FPDF_DestroyLibrary() with documents and other handles still open. Closing, using or collecting any of them afterwards, also after the library was initialised again by opening a document or by pdf_system_fonts_install_default(), crashed R: in CPDF_FontGlobals::Clear(), FT_Done_Face() and CPDF_ColorSpace::GetStockCS(). .onUnload and two tests destroy the library; no exported function does. The handle registry gains the library as an owner. Documents, clip paths from pdf_clip_path_new(), bitmaps and the buffers that memory documents read from are registered under it and released, each document after its own handles, before FPDF_DestroyLibrary(). make_handle() is now the only code that attaches a finalizer, and document_handle.h folds into handle_registry.h. ADR-028 records the decision and the .onUnload shared-library hazard it leaves to ADR-031. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A handle derived from a page-object passed validation while what it pinned, the page or the annotation, was still open, even after the object itself was destroyed. Under valgrind seven paths through exported functions read freed memory: a clip path after pdf_annot_delete() of its annotation (pdf_clip_path_count() segfaults without valgrind), after pdf_obj_delete(), pdf_form_obj_remove_object() or pdf_annot_remove_object() of its object, or after a move into an annotation that was then deleted; and the objects of a form after pdf_obj_delete() of the form, or after a move into a deleted annotation. In R, is_open() ignored the annotation and form owners, so such objects printed as open and failed with an internal message. Each handle now pins its immediate owner (a clip path its page-object, a nested object its form), validate_handle() walks the whole prot chain, capped at 64 links above PDFium's 40 levels of form nesting, and is_open() follows the same chain. The closed-handle messages name the outermost closed owner. pdf_annot_update_object() and pdf_form_obj_remove_object() check their page-object like the other page-object functions. pdf_annot_remove_object() still cannot reach handles made earlier, as documented. See ADR-029. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The @return described a tibble, but pdf_form_fields() returns a pdfium_form_field_list of pdfium_form_field handles; the tibble is what as_tibble() and summary() build from it. The column list moves to as_tibble.pdfium_form_field_list(), whose @return only pointed back to "the previous pdf_form_fields() shape". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The memory model still said the finalizer is the only path that closes a PDFium handle. It now covers the registry: which kinds are registered under a document or the library, the single mint and release path per kind, that owners release their handles first, the weak entries, and the prot chain validate_handle() and is_open() walk (ADR-024, ADR-025, ADR-028, ADR-029). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-leftovers # Conflicts: # dev/decisions/README.md
dev/architecture.md still said the finalizer is the only path that closes a handle and that children pin their parent, and its "GC order" pitfall relied on finalizers running before .onUnload destroys the library. It now describes the single mint and release path, the immediate-owner prot chain and the library-level registry, and names the shared-library unload hazard ADR-031 will fix. The architecture vignette says what unloading the package does (it named a nonexistent library.unload() and claimed quitting R runs .onUnload) and that deleting an annotation or form object closes the objects and clip paths read from it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR 66 (claude/page-lifetime-registry, this branch's base) has merged, so the branch now targets main. main's tree is the base tip merged here before (3159713), so the merge changes no file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
This PR handles the leftovers from ADR-025 (PR 66, now merged). Base:
main.Two use-after-free families remained after ADR-025:
cpp_destroy_library()calledFPDF_DestroyLibrary()while documents and other handles were open. Closing, using or collecting any of them afterwards crashed R. That included doing so after the library was re-initialised by opening a document or bypdf_system_fonts_install_default(), and it included handles that were merely waiting for collection..onUnloadand two tests reach this path; no exported function does.pdf_annot_delete(), segfaults without valgrind.Two ADRs record the fixes:
pdf_clip_path_new()clip paths, bitmaps and memory-document buffers are registered under it.cpp_destroy_library()releases them beforeFPDF_DestroyLibrary(), each document together with its own handles.make_handle()is now the only code that attaches a finalizer, anddocument_handle.his folded intohandle_registry.h.validate_handle()walks the wholeprotchain, capped at 64 links; PDFium parses Form XObjects up to 40 levels deep, so the longest real chain is 44.is_open()follows the same chain in R. The closed-handle messages name the outermost owner that is closed.Changes, by item
is_open()on an annotation's page-objects afterpdf_annot_delete(). Confirmed at the R level only: the C++ layer already refused these handles, and valgrind showed 0 errors. Such objects now print[closed]. Page-object functions refuse them with "Parent annotation has been closed: it was deleted with pdf_annot_delete(). The object handle is no longer valid." The same applies to the children of forms among them.pdf_annot_update_object()andpdf_form_obj_remove_object()now check their page-object argument like every other page-object function.pdf_obj_clip_path()on an annotation's object. Confirmed as a real use-after-free. An/AP /Nstream with a clip before painting (q 10 10 50 50 re W n 1 0 0 rg 0 0 100 100 re f Q) gives the object a one-sub-path clip. Afterpdf_annot_delete(),pdf_clip_path_count()segfaulted. The fix pins the clip path to its page-object, and the chain walk refuses it. The six sibling paths (S1–S6 below) were swept here with the planner's approval.cpp_destroy_library()with handles open. Fixed by releasing everything first (ADR-028), not by refusing. Refusing would depend on GC timing, and.onUnloadcannot refuse anyway:runHook()turns the error into a warning. No exported function reachescpp_destroy_library(). Opening a document,pdf_system_fonts_install_default(), and creating a clip path or bitmap now re-initialise the library.pdf_form_fields()@return. It now says the function returns apdfium_form_field_listof form-field handles. The tibble's columns are documented onas_tibble.pdfium_form_field_list(), which builds that tibble.ADR-020 listed twice in
dev/decisions/README.md. Not reproduced. Every commit that touched the index, every branch and every worktree lists ADR-020 exactly once, so the index is left alone. The duplicate most likely came from a transient merge state. Whoever resolves an ADR-index conflict should keep the 020 row only once. Its status cell differs across branches ("Accepted" vs "Accepted (§7 superseded by ADR-022)"), so a keep-both resolution duplicates it.CLAUDE.mdmemory model. The section now covers:protchain.It points at ADR-024, ADR-025, ADR-028 and ADR-029. No other section changed.
dev/architecture.mdand the architecture vignette are updated to match. The vignette also named a nonexistentlibrary.unload()and claimed that quitting R runs.onUnload; both are corrected.NEWS.mdhas a bullet under "Bug fixes" for each user-visible change.Verification (picasso, Linux, R 4.6.1, valgrind 3.22, PDFium chromium/8066)
Each reproducer runs in its own process. "Before" is PR 66's branch at 2dfdb84; "after" is this branch. Numbers are valgrind invalid reads / writes / frees. "SIGSEGV" means the process died even under valgrind. The "Mismatched free()" reports inside libpdfium are the known noise and are not counted.
pdf_annot_delete()is_open()TRUE,[open], internal C message[closed], "Parent annotation has been closed: …"pdf_annot_delete()pdf_obj_delete(obj)pdf_obj_delete(form)pdf_form_obj_remove_object()pdf_annot_remove_object()pdf_annot_delete()pdf_annot_delete()pdf_doc_close()after destroyCPDF_FontGlobals::Clear)FT_Done_Face)pdf_system_fonts_install_default(), thengc()CPDF_ColorSpace::GetStockCS)gc()Other checks on the final tree:
tests/:TESTTHAT_PARALLEL=false R -d "valgrind --tool=memcheck --leak-check=full --num-callers=40 --error-exitcode=0 --log-file=<log>" --no-echo --vanilla -f testthat.Rgives[ FAIL 0 | WARN 0 | SKIP 3 | PASS 3568 ]in 558 s. Definitely lost: 0 bytes; indirectly lost: 0 bytes; possibly lost: 0 bytes, so the CI gate passes. Invalid read / write / free: 0 / 0 / 0. There are 521 "Mismatched free()" contexts, all PDFium freeing its own allocations. One of them appears undercpp_destroy_library()only becauseFPDF_FreeDefaultSystemFontInfo()tail-callsoperator deleteon the blockFPDF_GetDefaultSystemFontInfo()calloc'd.MALLOC_CHECK_=3withLD_PRELOAD=libc_malloc_debug.so.0: exit 0,[ FAIL 0 | WARN 0 | SKIP 3 | PASS 3568 ].MALLOC_CHECK_=3): 1040 of 1040 runs exit 0 and reach teardown. Every call has the same OK/ERR outcome and message as on the base branch, so no call is newly refused by these scenarios.covr, CI gate): R 100.00%. Every touched C++ file (handle_registry.cpp,init.cpp,clip.cpp,forms.cpp,api_completion.cpp,mutation.cpp,handle_validation.h) is also at 100%, counting its existingnocovexclusions.lintrreports 0 lints on the touched files. styler 1.10.3, the version the pre-commit hook pins, changes none of the lines this branch wrote, apart from eight lines inhelper-inline-pdf.R; those use the same hanging indent as the existing builders in that file.R CMD check --as-cran: tests, examples, vignettes and "can be unloaded cleanly" are OK. The only findings come from the environment: 1 WARNING, because picasso lacks thecheckbashismsscript, and 1 NOTE, because Ubuntu's R compiles with-mno-omit-leaf-frame-pointer.Rcpp::compileAttributes()and roxygen show no drift.Known limitations, not fixed here
.onUnloadcallslibrary.dynam.unload(). A handle that survivesunloadNamespace()ordetach(unload = TRUE)keeps a C finalizer pointing into the unmapped shared library. That holds even for a closed handle or one waiting for collection. R then crashes inR_RunWeakRefFinalizer()at the nextgc()or at exit: exit 139 before and after this PR.pkgload::load_all()reloads hit it too. Per the user's decision the unload path is unchanged. A separate task (ADR-031) replaces the C finalizers with R-level finalizers that check the shared library is still loaded. ADR-028 keeps every finalizer inmake_handle()so that change happens in one place.pdf_annot_remove_object()cannot reach handles made earlier (S4). It destroys an object by position, so those handles, and the clip paths and nested objects read through them, stay stale, as documented. The same holds for a second handle to one object, from a separatepdf_page_objects()orpdf_form_objects()call, after the object is deleted through the first. A possible fix, proposed and not implemented: register an annotation's page-object handles under the annotation in the ADR-025 registry, sopdf_annot_remove_object()can clear them.pdf_annot_append_object(annot, form), objects read through the form's (now closed) handle are refused, although they still exist inside the annotation. Read them again throughpdf_annot_objects()and thenpdf_form_objects().🤖 Generated with Claude Code