Skip to content

fix(android): defer PdfFile dispose to the rendering thread to fix SIGSEGV in libpdfium - #1045

Open
GaelCO wants to merge 2 commits into
wonday:masterfrom
GaelCO:fix-Native-SIGSEGV
Open

GaelCO wants to merge 2 commits into
wonday:masterfrom
GaelCO:fix-Native-SIGSEGV

Conversation

@GaelCO

@GaelCO GaelCO commented Oct 1, 2026 •

Copy link
Copy Markdown

Fixes #882
Fixes #976
Fixes #987
Fixes #989
Fixes #1024
Fixes #1041

Likely fixes #847

Summary

On Android, closing a <Pdf> viewer (or changing its source) while a page is still being rendered can crash the whole app with a native SIGSEGV in libpdfium.so. This PR makes the native document close wait until any in-flight render has finished.

Root cause

PDFView (com.github.zacharee:AndroidPdfViewer:4.0.1) renders pages on a dedicated HandlerThread ("PDF renderer", RenderingHandler). PDFView.recycle() runs on the main thread and does, in this order:

  1. renderingHandler.stop() + removeMessages(MSG_RENDER_TASK). This drops queued render tasks, but cannot interrupt the one currently running.
  2. pdfFile.dispose(), which synchronously closes the native pdfium document.

Neither PdfFile.dispose() nor PdfFile.renderPageBitmap() take a lock (checked in the 4.0.1 bytecode: no monitorenter). So if a render is in progress, the document is freed under the renderer → use-after-free in pdfium:

recycle() is reached from two paths, both affected:

  • unmount: onDetachedFromWindow() → recycle()
  • source change on a mounted view: PDFView.Configurator.load() → recycle()

AlreadyClosedBehavior.IGNORE (#989) only covers the case where pdfiumandroid sees the document is already closed before entering native code. It cannot protect a native call that is already running when the document is freed.

This also explains why #989 was still reported on 7.0.4 after #999: IGNORE hides the Java-side IllegalStateException, but the document can still be closed while a render is in progress

Fix

PdfView.recycle() is overridden to:

  1. detach pdfFile from the view (set the field to null), so super.recycle() skips its synchronous dispose(),
  2. call super.recycle() as before (stop rendering, clear the cache, reset state…),
  3. post pdfFile.dispose() on the view's RenderingHandler.

RenderingHandler is a single-threaded looper, so the posted dispose can only run after the render currently in progress (queued render tasks have already been removed by recycle()). Close and render can no longer overlap.

The helper PdfFileDisposer lives in the com.github.barteksc.pdfviewer package because PDFView.pdfFile, PDFView.renderingHandler and the PdfFile class are package-private.

Why it is safe

  • The dispose always runs, no leak. On unmount, onDetachedFromWindow() calls recycle() before renderingHandlerThread.quitSafely(), and quitSafely() still processes messages already queued. If the looper has already quit, post() returns false: nothing can be rendering any more, so the document is disposed directly on the calling thread.
  • Nothing reads the detached pdfFile. The steps of super.recycle() that run before its original dispose (animation stop, gesture disable, cache recycle, scroll handle) don't touch pdfFile, and recycle() runs atomically on the main thread, so no gesture callback can interleave.
  • No deadlock / no overlap on reload. On a source change, the old document may now close on the render thread while DecodingAsyncTask opens the new one. In pdfiumandroid 1.0.32, both PdfiumCore.newDocument() and PdfDocument.close() synchronize on the same global PdfiumCore.lock, with no nested lock. The new document gets a new RenderingHandler / PdfFile, so the two never mix.
  • Thread-agnostic dispose. PdfFile.dispose() only calls PdfiumCore.closeDocument() and clears fields; nothing in it needs the main thread. As a side benefit, the main thread no longer blocks on the native close.
  • Fails loudly on dependency changes. If a future AndroidPdfViewer renames these fields, the build breaks at compile time rather than misbehaving at runtime. The version stays pinned in android/build.gradle.

Why not upgrade io.legere:pdfiumandroid?

pdfiumandroid ≥ 2.0.0 adds a configurable LockManager that addresses this kind of race. But its bundled libpdfium.so needs API 26 at the native-link level, even though the module declares minSdk 24. On API < 26 the library fails to load:

java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "strtof_l"

This is the crash reported in #979 when this project moved to 1.0.34, and why it was reverted to 1.0.32 (c1879d0). I reproduced it again with 2.0.3 on an API 25 emulator and reported it upstream: johngray1965/PdfiumAndroidKt#55.

react-native-pdf supports minSdkVersion 21, so upgrading would mean either raising that to 26 (breaking for apps that still support Android 5–7) or shipping a library that crashes at load on those devices. This fix stays on 1.0.32, needs no native rebuild and changes no public API. If upstream ships an API-21-compatible binary, the dependency can be upgraded later, and this workaround stays harmless.

Testing

Stress test on a physical device: Samsung Galaxy Tab Active5 (SM-X306B), Android 16 / API 36, arm64, debug build of FabricExample. A test screen mounts a local file:// PDF and tears the viewer down 0–150 ms after onLoadComplete (20 % of iterations before load completes), in two modes:

Mode Without this PR With this PR
unmount (onDetachedFromWindow → recycle()) crash after ~114 loads: RuntimeException: Get page pdf document null on "PDF renderer" 500 iterations, 0 crash
source switch (Configurator.load() → recycle()) SIGSEGV in libpdfium.so, nativeRenderPageBitmap+668 (same as #1041) after ~89 loads 500 iterations, 0 crash

Memory: same native heap growth per mount/unmount with and without this PR (≈ 3.0 MB per load in both cases, measured outside the race window). The PR doesn't introduce a leak, and every load has a matching PdfDocument.close. That growth already exists on master and is unrelated to this change. I'll report it separately.

, wonday#1041)

PDFView.recycle() disposed the PdfFile synchronously on the main thread while
RenderingHandler could still be rendering a page on the "PDF renderer" thread,
freeing the native pdfium document under the renderer (SIGSEGV in
libpdfium.so).

PdfView.recycle() now detaches the PdfFile before super.recycle() and posts
its dispose on the rendering handler, so it runs after any in-flight render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@GaelCO GaelCO changed the title fix(android): defer PdfFile dispose to the rendering thread to fix SIG fix(android): defer PdfFile dispose to the rendering thread to fix SIGSEGV in libpdfium Oct 1, 2026
RenderingHandler.proceed() reads PDFView.pdfFile when it dequeues a render
task. PdfFileDisposer cleared the field before super.recycle() had stopped
the handler and removed the queued tasks, so a task dequeued in between hit
a NullPointerException on the "PDF renderer" thread.

Stop the handler and drop the queued tasks before clearing the field, as
PDFView.recycle() itself does before its own dispose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@leonam-paula-fp

Copy link
Copy Markdown

Thanks for this PR. Your root-cause analysis matches what we see in production. We applied it (head 2941304) on top of react-native-pdf 7.0.4 with AndroidPdfViewer 4.0.1, pdfiumandroid 1.0.34 and RN 0.79 (New Architecture), and found one window it opens.

NPE at RenderingHandler.proceed:91

PdfFileDisposer.detach() sets view.pdfFile = null on the UI thread before super.recycle(). removeMessages(MSG_RENDER_TASK) only drops tasks still waiting in the queue. A task the Looper has already dequeued still runs RenderingHandler.proceed(), which starts with PdfFile pdfFile = pdfView.pdfFile; and has no null check:

FATAL EXCEPTION: PDF renderer
java.lang.NullPointerException: Attempt to invoke virtual method 'boolean com.github.barteksc.pdfviewer.PdfFile.openPage(int)' on a null object reference
    at com.github.barteksc.pdfviewer.RenderingHandler.proceed(RenderingHandler.java:91)
    at com.github.barteksc.pdfviewer.RenderingHandler.handleMessage(RenderingHandler.java:66)

Repro

Redmi Note 11 Pro, Android 13, debug build. The setup is a mounted <Pdf> showing a local one-page PDF. From the JS debugger, change a native prop every 120 ms:

setInterval(() => pdf.setNativeProps({ spacing: n++ % 2 ? 11 : 10 }), 120);

Each prop transaction runs onAfterUpdateTransaction → drawPdf() → Configurator.load() → recycle() while the previous document may still be rendering. At 500 ms intervals neither crash showed up.

Build Reloads Fatal crashes Renderer thread logs D/PdfiumCore: Already closed
7.0.4 1,113 (3 runs) 1: IllegalStateException: Already closed at PdfDocument.openPage ← RenderingHandler.proceed:109 on 60–82% of reloads
7.0.4 + this PR 109 (2 runs) 2: the NPE above, after 45 and 64 reloads 0
7.0.4 + drain (below) 2,280 (3 runs) 0 0

The deferred dispose does remove the overlap (0 "Already closed" lines). But clearing pdfFile ahead of it turns the dequeued-task window into the dominant crash at this reload rate.

What we're using instead: drain the renderer before recycle()

It uses the same package trick, but waits for the in-flight task instead of deferring the close:

package com.github.barteksc.pdfviewer;

import android.os.Looper;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

public final class RenderingDrain {
    private static final long TIMEOUT_MS = 1000;

    private RenderingDrain() {}

    public static void drain(PDFView view) {
        final RenderingHandler renderingHandler = view.renderingHandler;
        if (renderingHandler == null) {
            return;
        }
        renderingHandler.stop();
        renderingHandler.removeMessages(RenderingHandler.MSG_RENDER_TASK);
        if (Looper.myLooper() == renderingHandler.getLooper()) {
            return;
        }
        final CountDownLatch idle = new CountDownLatch(1);
        if (!renderingHandler.postAtFrontOfQueue(idle::countDown)) {
            return;
        }
        try {
            idle.await(TIMEOUT_MS, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
// org/wonday/pdf/PdfView.java
@Override
public void recycle() {
    RenderingDrain.drain(this);
    super.recycle();
}
  • Why it's safe: once the latch fires, the renderer is idle and its queue is empty. So super.recycle()'s synchronous dispose() can't overlap a render, and no task can start after pdfFile is cleared.
  • UI-thread cost: the UI thread waits for at most the in-flight tile. That's roughly what PdfDocument.close() already does today, since it blocks on PdfiumCore.lock during a native render.
  • The 1 s timeout only matters if the renderer is stuck. In that case the in-flight task is already past openPage, so the close just waits for the lock as before.
  • Unmount: onDetachedFromWindow() drains in recycle() before quitSafely(). On unmount, pdfium's open-document count returned to 0 every time in our tests.

Native memory: on 7.0.4, every renderer-side "Already closed" also leaked a pdfium page, because PdfPage.close() returns early under IGNORE once the document is closed. That's roughly 0.3 MB each, about +187 MB of native heap over 760 reloads. With the drain, native heap stayed flat across all three runs.

Happy to open this as a PR, or contribute it as a commit to this branch, if that's useful.

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