Panic and Unwind Boundaries
Unwinding a Rust panic across a C stack frame that was not compiled with compatible unwind tables corrupts the call stack and is Undefined Behavior. To protect FFI boundaries, Rust enforces a strict unwind boundary on extern "C" functions and provides std::panic::catch_unwind to convert panics into C-compatible error codes before returning across the boundary.
#[unsafe(no_mangle)]
pub extern "C" fn ffi_entry(...) -> c_int {
match std::panic::catch_unwind(|| { ... }) {
Ok(result) => result, // Normal return to C caller
Err(_panic) => -1, // Caught panic! Return error code to C safely
}
}
Automatic Process Abort in extern "C" vs "C-unwind"
In modern Rust, extern "C" and extern "C-unwind" have well-defined, distinct unwind semantics:
| ABI Specification | Behavior If a Rust Panic Reaches the Function Boundary | When to Use |
|---|---|---|
extern "C" |
Guaranteed Process Abort: If a panic is not caught by catch_unwind before leaving an extern "C" function, the Rust runtime catches it at the boundary and calls std::process::abort() to prevent UB! |
Standard C libraries and host languages (Python, C, Go, Node.js) that cannot unwind Rust panics. |
extern "C-unwind" |
Unwinds Across Boundary: Allows a Rust panic (or a foreign C++ exception / longjmp) to unwind through the ABI boundary into the caller's stack frame. |
Mixed Rust/C++ codebases or when a Rust caller passes a callback through a C library that supports DWARF/SEH unwinding back into a Rust catch_unwind. |
Catching Panics and Translating Them into C Error Codes
Even though an uncaught panic in extern "C" safely aborts the process rather than causing Undefined Behavior, aborting the entire host application (such as a Python interpreter or PostgreSQL server loading your Rust extension) is undesirable.
Wrap the body of every non-trivial extern "C" entrypoint in std::panic::catch_unwind, and map any caught panic to a documented negative status code (or thread-local last_error message):
use core::ffi::c_int;
use std::cell::RefCell;
use std::panic;
thread_local! {
static LAST_FFI_ERROR: RefCell<String> = const { RefCell::new(String::new()) };
}
fn set_last_error(msg: impl Into<String>) {
LAST_FFI_ERROR.with(|slot| {
*slot.borrow_mut() = msg.into();
});
}
/// Divides `a` by `b` across FFI, catching any panic and returning `-1` on failure.
#[unsafe(no_mangle)]
pub extern "C" fn rust_checked_divide(
a: c_int,
b: c_int,
out_quotient: Option<&mut c_int>,
) -> c_int {
let Some(out) = out_quotient else {
set_last_error("out_quotient pointer was NULL");
return -1;
};
let result = panic::catch_unwind(|| {
if b == 0 {
panic!("division by zero in rust_checked_divide");
}
a / b
});
match result {
Ok(quotient) => {
*out = quotient;
0
}
Err(panic_payload) => {
let msg = if let Some(s) = panic_payload.downcast_ref::<&'static str>() {
(*s).to_string()
} else if let Some(s) = panic_payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic in FFI call".to_string()
};
set_last_error(msg);
-2
}
}
}
fn main() {
let mut out = 0;
let ok_rc = rust_checked_divide(100, 4, Some(&mut out));
println!("100 / 4 -> rc={ok_rc}, out={out}");
// Triggering a panic inside the FFI function returns `-2` cleanly instead of aborting!
let err_rc = rust_checked_divide(100, 0, Some(&mut out));
LAST_FFI_ERROR.with(|slot| {
println!("100 / 0 -> rc={err_rc}, last_error={:?}", *slot.borrow());
});
}
100 / 4 -> rc=0, out=25
100 / 0 -> rc=-2, last_error="division by zero in rust_checked_divide"