Skip to content

Commit de2e1d9

Browse files
committed
docs(sdk): codify FFI panic-safety contract at the napi/PyO3 boundary (#32)
Audit outcome for #32 (FFI panic-unwind safety, napi 2.16 / pyo3 0.23): the boundary is panic-safe today, but the invariant holds by *convention*, not structure, so it can silently regress. Documents the contract in each SDK's module doc where future FFI edits will see it. Key finding (verified against the cargo cache, not memory): napi 2.x does NOT wrap sync #[napi] bodies in catch_unwind by default — a panic there aborts the Node process on Rust >= 1.81, it is not a catchable JS error. Only #[napi] async fns (-> rejected Promise) and #[napi(catch_unwind)] are safe; ThreadsafeFunction callbacks, spawned tasks, Drop, and module init are not. PyO3 0.23 does catch #[pyfunction]/#[pymethods]/module-init bodies (-> PanicException) but not worker-thread with_gil bridges or spawned tasks. No code change: independent verification confirmed the only production panic site in each SDK is the lazy Tokio-runtime .expect(), reached from caught contexts; the genuinely-uncaught paths (spawned tasks, the Python with_gil callback bridges, no Drop impls exist) are panic-free by construction via ? / unwrap_or_else / fail-closed defaults. Converting get_runtime() to a Result would thread through ~75 call sites to defend an OS-exhaustion panic that is already caught — not worth it.
1 parent 8e709c5 commit de2e1d9

2 files changed

Lines changed: 38 additions & 0 deletions

File tree

sdk/node/src/lib.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,27 @@
1313
//! const result = await session.send('What files handle auth?');
1414
//! console.log(result.text);
1515
//! ```
16+
//!
17+
//! ## Panic safety at the FFI boundary
18+
//!
19+
//! napi 2.x does **not** wrap exported bodies in `catch_unwind` by default. A
20+
//! Rust panic that reaches the `extern "C"` boundary aborts the whole Node
21+
//! process (Rust ≥ 1.81) — it does *not* become a catchable JS error. Only two
22+
//! contexts are panic-safe: a `#[napi]` **async** fn / `impl Future` (panic →
23+
//! rejected Promise) and a sync fn explicitly tagged `#[napi(catch_unwind)]`.
24+
//! Everything else aborts (or silently loses the panic): default **sync**
25+
//! `#[napi]` fns, `ThreadsafeFunction` callbacks (a panic there — or a
26+
//! return-value conversion `Err` — aborts via `napi_fatal_error` under *both*
27+
//! `ErrorStrategy` variants), `tokio::spawn`'d task bodies (panic swallowed,
28+
//! never surfaced), `Drop`/finalizers, and module init.
29+
//!
30+
//! Convention this crate follows so the boundary stays safe: never
31+
//! `.unwrap()` / `.expect()` / `panic!` in those contexts. Propagate with `?`
32+
//! into a `napi::Error`, or fail closed with `unwrap_or_else` inside
33+
//! threadsafe callbacks. (Audited 2026-05: the only production panic site is
34+
//! the lazy Tokio-runtime build in `fallback_runtime()`, reached from within
35+
//! `#[napi]` bodies; the spawned-task and threadsafe-callback paths are
36+
//! panic-free by construction.)
1637
1738
#[macro_use]
1839
extern crate napi_derive;

sdk/python/src/lib.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,23 @@
1313
//! result = session.send("What files handle auth?")
1414
//! print(result.text)
1515
//! ```
16+
//!
17+
//! ## Panic safety at the FFI boundary
18+
//!
19+
//! PyO3 0.23 wraps `#[pyfunction]` / `#[pymethods]` / `#[pymodule]`-init bodies
20+
//! in `catch_unwind`, so a panic there surfaces as a Python `PanicException`
21+
//! (a `BaseException` subclass) rather than UB. It does **not** cover panics
22+
//! inside `std::thread` / `tokio::spawn` task bodies, or `Python::with_gil`
23+
//! closures invoked from a worker thread *outside* a pyfunction frame — those
24+
//! are silently lost, and a panicking `Drop` during an unwind aborts the
25+
//! process.
26+
//!
27+
//! Convention this crate follows so the boundary stays safe: the Rust→Python
28+
//! bridges that run on tokio worker threads (`PythonCallbackHandler`,
29+
//! `PyBudgetGuard`, `PySlashCommand`) never `.unwrap()` / `panic!`; they use
30+
//! `.ok()` / `unwrap_or_else` and fail closed. (Audited 2026-05: the only
31+
//! production panic site is the lazy Tokio-runtime build in `get_runtime()`,
32+
//! reached only from caught pyfunction frames.)
1633
1734
use a3s_code_core::commands::{
1835
CommandContext as RustCommandContext, CommandOutput as RustCommandOutput,

0 commit comments

Comments
 (0)