Foreign Function Interface (FFI)

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"