wrap all NAPI functions in catch_unwind to prevent process abort on panic

Rust panics in NAPI modules abort the Node.js process with no chance
to report errors. This wraps every exported function in catch_unwind,
converting panics into JS Error exceptions that can be caught and
reported to Sentry.

Buffer data is extracted to Vec<u8> before the catch_unwind boundary
to satisfy UnwindSafe requirements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-04-02 15:10:15 -07:00
co-authored by Claude Opus 4.6
parent 6dfa370fd4
commit 79e53f779d
+51 -8
View File
@@ -3,6 +3,7 @@
use napi::bindgen_prelude::*; use napi::bindgen_prelude::*;
use napi_derive::napi; use napi_derive::napi;
use std::collections::HashSet; use std::collections::HashSet;
use std::panic;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Result types // Result types
@@ -116,6 +117,30 @@ fn to_napi_err(e: impl std::fmt::Display, ctx: &str) -> Error {
Error::new(Status::GenericFailure, format!("{ctx}: {e}")) Error::new(Status::GenericFailure, format!("{ctx}: {e}"))
} }
/// Run a closure, catching any Rust panic and converting it to a NAPI error.
/// Prevents process abort from unwind panics in the native module.
fn catch_panic<F, T>(ctx: &str, f: F) -> Result<T>
where
F: FnOnce() -> Result<T> + panic::UnwindSafe,
{
match panic::catch_unwind(f) {
Ok(result) => result,
Err(payload) => {
let msg = if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
};
Err(Error::new(
Status::GenericFailure,
format!("{ctx}: Rust panic: {msg}"),
))
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public NAPI API // Public NAPI API
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -123,21 +148,27 @@ fn to_napi_err(e: impl std::fmt::Display, ctx: &str) -> Error {
/// Process a PDF from a Buffer: detect type, extract text, and convert to Markdown. /// Process a PDF from a Buffer: detect type, extract text, and convert to Markdown.
#[napi] #[napi]
pub fn process_pdf(buffer: Buffer, pages: Option<Vec<u32>>) -> Result<PdfResult> { pub fn process_pdf(buffer: Buffer, pages: Option<Vec<u32>>) -> Result<PdfResult> {
let bytes: Vec<u8> = buffer.to_vec();
catch_panic("process_pdf", move || {
let mut opts = pdf_inspector::PdfOptions::new(); let mut opts = pdf_inspector::PdfOptions::new();
if let Some(p) = pages { if let Some(p) = pages {
opts = opts.pages(p); opts = opts.pages(p);
} }
let result = pdf_inspector::process_pdf_mem_with_options(&buffer, opts) let result = pdf_inspector::process_pdf_mem_with_options(&bytes, opts)
.map_err(|e| to_napi_err(e, "process_pdf"))?; .map_err(|e| to_napi_err(e, "process_pdf"))?;
Ok(to_napi_result(result)) Ok(to_napi_result(result))
})
} }
/// Fast detection only — no text extraction or markdown. /// Fast detection only — no text extraction or markdown.
#[napi] #[napi]
pub fn detect_pdf(buffer: Buffer) -> Result<PdfResult> { pub fn detect_pdf(buffer: Buffer) -> Result<PdfResult> {
let bytes: Vec<u8> = buffer.to_vec();
catch_panic("detect_pdf", move || {
let result = let result =
pdf_inspector::detect_pdf_mem(&buffer).map_err(|e| to_napi_err(e, "detect_pdf"))?; pdf_inspector::detect_pdf_mem(&bytes).map_err(|e| to_napi_err(e, "detect_pdf"))?;
Ok(to_napi_result(result)) Ok(to_napi_result(result))
})
} }
/// Lightweight PDF classification — returns type, page count, and OCR pages. /// Lightweight PDF classification — returns type, page count, and OCR pages.
@@ -145,21 +176,27 @@ pub fn detect_pdf(buffer: Buffer) -> Result<PdfResult> {
/// Pages in pagesNeedingOcr are 0-indexed. /// Pages in pagesNeedingOcr are 0-indexed.
#[napi] #[napi]
pub fn classify_pdf(buffer: Buffer) -> Result<PdfClassification> { pub fn classify_pdf(buffer: Buffer) -> Result<PdfClassification> {
let bytes: Vec<u8> = buffer.to_vec();
catch_panic("classify_pdf", move || {
let result = let result =
pdf_inspector::classify_pdf_mem(&buffer).map_err(|e| to_napi_err(e, "classify_pdf"))?; pdf_inspector::classify_pdf_mem(&bytes).map_err(|e| to_napi_err(e, "classify_pdf"))?;
Ok(PdfClassification { Ok(PdfClassification {
pdf_type: pdf_type_string(result.pdf_type), pdf_type: pdf_type_string(result.pdf_type),
page_count: result.page_count, page_count: result.page_count,
pages_needing_ocr: result.pages_needing_ocr, pages_needing_ocr: result.pages_needing_ocr,
confidence: result.confidence as f64, confidence: result.confidence as f64,
}) })
})
} }
/// Extract plain text from a PDF Buffer. /// Extract plain text from a PDF Buffer.
#[napi] #[napi]
pub fn extract_text(buffer: Buffer) -> Result<String> { pub fn extract_text(buffer: Buffer) -> Result<String> {
pdf_inspector::extractor::extract_text_mem(&buffer).map_err(|e| to_napi_err(e, "extract_text")) let bytes: Vec<u8> = buffer.to_vec();
catch_panic("extract_text", move || {
pdf_inspector::extractor::extract_text_mem(&bytes)
.map_err(|e| to_napi_err(e, "extract_text"))
})
} }
/// Extract text with position information from a PDF Buffer. /// Extract text with position information from a PDF Buffer.
@@ -168,16 +205,18 @@ pub fn extract_text_with_positions(
buffer: Buffer, buffer: Buffer,
pages: Option<Vec<u32>>, pages: Option<Vec<u32>>,
) -> Result<Vec<TextItem>> { ) -> Result<Vec<TextItem>> {
let bytes: Vec<u8> = buffer.to_vec();
catch_panic("extract_text_with_positions", move || {
let items = match pages { let items = match pages {
Some(p) => { Some(p) => {
let page_set: HashSet<u32> = p.into_iter().collect(); let page_set: HashSet<u32> = p.into_iter().collect();
pdf_inspector::extractor::extract_text_with_positions_mem_pages( pdf_inspector::extractor::extract_text_with_positions_mem_pages(
&buffer, &bytes,
Some(&page_set), Some(&page_set),
) )
.map_err(|e| to_napi_err(e, "extract_text_with_positions"))? .map_err(|e| to_napi_err(e, "extract_text_with_positions"))?
} }
None => pdf_inspector::extractor::extract_text_with_positions_mem(&buffer) None => pdf_inspector::extractor::extract_text_with_positions_mem(&bytes)
.map_err(|e| to_napi_err(e, "extract_text_with_positions"))?, .map_err(|e| to_napi_err(e, "extract_text_with_positions"))?,
}; };
@@ -197,6 +236,7 @@ pub fn extract_text_with_positions(
item_type: item_type_string(&item.item_type), item_type: item_type_string(&item.item_type),
}) })
.collect()) .collect())
})
} }
/// Extract text within bounding-box regions from a PDF. /// Extract text within bounding-box regions from a PDF.
@@ -214,6 +254,7 @@ pub fn extract_text_in_regions(
buffer: Buffer, buffer: Buffer,
page_regions: Vec<PageRegions>, page_regions: Vec<PageRegions>,
) -> Result<Vec<PageRegionTexts>> { ) -> Result<Vec<PageRegionTexts>> {
let bytes: Vec<u8> = buffer.to_vec();
let regions: Vec<(u32, Vec<[f32; 4]>)> = page_regions let regions: Vec<(u32, Vec<[f32; 4]>)> = page_regions
.iter() .iter()
.map(|pr| { .map(|pr| {
@@ -232,7 +273,8 @@ pub fn extract_text_in_regions(
}) })
.collect(); .collect();
let results = pdf_inspector::extract_text_in_regions_mem(&buffer, &regions) catch_panic("extract_text_in_regions", move || {
let results = pdf_inspector::extract_text_in_regions_mem(&bytes, &regions)
.map_err(|e| to_napi_err(e, "extract_text_in_regions"))?; .map_err(|e| to_napi_err(e, "extract_text_in_regions"))?;
Ok(results Ok(results
@@ -249,4 +291,5 @@ pub fn extract_text_in_regions(
.collect(), .collect(),
}) })
.collect()) .collect())
})
} }