From a1804a26f633e885b15245a016e35e6f55223ff4 Mon Sep 17 00:00:00 2001 From: Mike Penz Date: Mon, 3 Aug 2026 23:13:27 +0200 Subject: [PATCH 1/2] fix(pdfium): don't double-free the buffer when PDFium refuses a document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `openPdfDocument` frees the native buffer in the `h == 0L` branch and again in the enclosing `catch`, which the `error(...)` on the next line always reaches. Any bytes PDFium refuses — a 401/404 HTML body, a JPEG served in place of the PDF, a corrupt file — therefore abort the host process instead of throwing: signal 6 (SIGABRT), code -1 (SI_QUEUE) Abort message: 'Scudo ERROR: invalid chunk state when deallocating address ...' name: pdfium-shared >>> com.example.app <<< #05 scudo::Allocator<...>::deallocate(void*, ...) #08 dev.nucleusframework.pdfium.PdfDocument_androidKt$openPdfDocument$2.invokeSuspend On the JVM the same path exits 134. Callers can't defend against it — `PdfReaderState.open` already catches `Throwable`, but the free happens below the JNI boundary. Drop the inner `nFreeBuffer`; the `catch` owns it. The document handles opened before the failure are still closed in the branch, as before. androidMain and jvmMain only — iosMain pins/unpins the ByteArray and webMain transfers an ArrayBuffer, so neither has a second free. --- .../kotlin/dev/nucleusframework/pdfium/PdfDocument.android.kt | 3 ++- .../kotlin/dev/nucleusframework/pdfium/PdfDocument.jvm.kt | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pdfium/src/androidMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.android.kt b/pdfium/src/androidMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.android.kt index 1e5e3e4..8530bd3 100644 --- a/pdfium/src/androidMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.android.kt +++ b/pdfium/src/androidMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.android.kt @@ -199,8 +199,9 @@ internal actual suspend fun openPdfDocument(bytes: ByteArray, password: String?) for (i in 0 until POOL_SIZE) { val h = PdfiumBridge.nOpenDocumentFromMemory(bufferAddr, bytes.size.toLong(), password) if (h == 0L) { + // NOTE: the buffer is freed by the catch below — freeing it here too + // double-frees it (native abort, not an exception). for (j in 0 until i) PdfiumBridge.nCloseDocument(handles[j]) - PdfiumBridge.nFreeBuffer(bufferAddr) error("PDFium refused to open document (err=${PdfiumBridge.nGetLastError()})") } handles[i] = h diff --git a/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.jvm.kt b/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.jvm.kt index d17425f..e570657 100644 --- a/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.jvm.kt +++ b/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.jvm.kt @@ -224,9 +224,9 @@ internal actual suspend fun openPdfDocument(bytes: ByteArray, password: String?) for (i in 0 until POOL_SIZE) { val h = PdfiumBridge.nOpenDocumentFromMemory(bufferAddr, bytes.size.toLong(), password) if (h == 0L) { - // Cleanup previously opened handles + buffer on failure. + // Cleanup previously opened handles. The buffer is freed by the catch + // below — freeing it here too double-frees it (native abort). for (j in 0 until i) PdfiumBridge.nCloseDocument(handles[j]) - PdfiumBridge.nFreeBuffer(bufferAddr) error("PDFium refused to open document (err=${PdfiumBridge.nGetLastError()})") } handles[i] = h From bf8dc9f72da6490bb72798312e837b4e9697998d Mon Sep 17 00:00:00 2001 From: Mike Penz Date: Mon, 3 Aug 2026 23:13:40 +0200 Subject: [PATCH 2/2] test(pdfium): cover openPdfDocument's refuse + success paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First test source set in the repo, so `:pdfium:check` (already run by Pre Merge Checks) now exercises the JNI open path on the JVM. `refusedBytesThrowInsteadOfAbortingTheProcess` is a regression test for the double free: on the parent commit it does not fail, it takes the whole test JVM down — Process 'Gradle Test Executor 2' finished with non-zero exit value 134 (this value may indicate that the process was terminated with the SIGABRT signal) Reaching the assertion at all is the signal. `aValidDocumentStillOpens` pairs with it so the buffer's remaining single free stays exercised on the success path too. --- pdfium/build.gradle.kts | 1 + .../pdfium/OpenPdfDocumentTest.kt | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 pdfium/src/jvmTest/kotlin/dev/nucleusframework/pdfium/OpenPdfDocumentTest.kt diff --git a/pdfium/build.gradle.kts b/pdfium/build.gradle.kts index aef24bb..26f9993 100644 --- a/pdfium/build.gradle.kts +++ b/pdfium/build.gradle.kts @@ -152,6 +152,7 @@ kotlin { // kotlinx-browser exposes the org.khronos.webgl.* typed arrays as a shared API // across js + wasmJs, so they resolve in the webMain metadata compilation. webMain.dependencies { implementation(libs.kotlinx.browser) } + jvmTest.dependencies { implementation(libs.kotlin.test) } } } diff --git a/pdfium/src/jvmTest/kotlin/dev/nucleusframework/pdfium/OpenPdfDocumentTest.kt b/pdfium/src/jvmTest/kotlin/dev/nucleusframework/pdfium/OpenPdfDocumentTest.kt new file mode 100644 index 0000000..c9f40b6 --- /dev/null +++ b/pdfium/src/jvmTest/kotlin/dev/nucleusframework/pdfium/OpenPdfDocumentTest.kt @@ -0,0 +1,52 @@ +package dev.nucleusframework.pdfium + +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlinx.coroutines.runBlocking + +class OpenPdfDocumentTest { + /** + * Regression: bytes PDFium refuses must surface as an exception, not kill the + * process. The open-failure branch used to free the native buffer that the + * enclosing `catch` frees as well, so a non-PDF body double-freed it and the + * host aborted — SIGABRT on the JVM, "Scudo ERROR: invalid chunk state when + * deallocating" on Android. Reaching the assertion at all IS the test. + */ + @Test + fun refusedBytesThrowInsteadOfAbortingTheProcess() = runBlocking { + assertFailsWith { + openPdfDocument("definitely not a pdf".encodeToByteArray(), null) + } + Unit + } + + @Test + fun aValidDocumentStillOpens() = runBlocking { + val doc = openPdfDocument(minimalPdf(), null) + try { + kotlin.test.assertEquals(1, doc.pageCount) + } finally { + doc.close() + } + } +} + +/** A minimal, valid one-page PDF (built-in /Helvetica, no embedded font program). */ +private fun minimalPdf(): ByteArray = java.util.Base64.getDecoder().decode( + "JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2Jq" + + "CjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2Jq" + + "CjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIg" + + "NzkyXSAvQ29udGVudHMgNCAwIFIgL1Jlc291cmNlcyA8PCAvRm9udCA8PCAvRjEgNSAwIFIgPj4g" + + "Pj4gPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCAzMzEgPj4Kc3RyZWFtCkJUIC9GMSAyNCBU" + + "ZiA3MiA3MDAgVGQgMjggVEwgKFRoZSBxdWljayBicm93biBmb3gganVtcHMgb3ZlciB0aGUgbGF6" + + "eSBkb2cuKSBUaiBUKiAoSW52b2ljZSAjMjAyNi0wNzE0ICBBbW91bnQgZHVlOiAkMSwyMzQuNTYp" + + "IFRqIFQqIChUaGlzIGlzIGEgdGV4dC1vbmx5IFBERiB0byBleGVyY2lzZSBwZGZpdW0gYXN5bmMg" + + "Z2x5cGggcGFpbnQuKSBUaiBUKiAoTGluZSBmb3VyIHdpdGggbW9yZSB3b3JkcyB0byBmaWxsIHRo" + + "ZSBwYWdlIGJvZHkgYXJlYSBuaWNlbHkuKSBUaiBUKiAoQ29udGFjdDogY2xhdWRlQG1pa2VwZW56" + + "LmRldiAgIFJlZjogUFJFVklFVy1SRVBSTykgVGogVCogRVQKZW5kc3RyZWFtCmVuZG9iago1IDAg" + + "b2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+" + + "PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4g" + + "CjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAw" + + "IG4gCjAwMDAwMDA2MjMgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+" + + "CnN0YXJ0eHJlZgo2OTMKJSVFT0Y=" +)