The crash you didn't ask for
You write a Rust function that calls a C library. The C library returns an error code, or it segfaults, or it triggers a panic inside a Rust callback. Your program crashes with a stack trace that points to assembly, or worse, it silently corrupts memory and keeps running. The FFI boundary is a hard line. Rust's panic mechanism stops at that line. If a panic crosses into C, or if C triggers a memory violation inside Rust, the compiler's safety guarantees evaporate. You need a strategy to catch failures before they cross the border.
Why the FFI boundary breaks Rust's rules
Rust enforces memory safety through ownership, borrowing, and strict compile-time checks. Those rules apply to Rust code. The moment you call extern "C", you step outside the compiler's jurisdiction. C does not understand lifetimes. It does not know about Drop implementations. It does not care if you hand it a dangling pointer or a slice that points to freed memory. The compiler cannot verify what happens on the other side of the boundary.
Think of the FFI boundary like a customs checkpoint. Rust hands you a passport that guarantees your data is valid and properly owned. The moment you step into the C side, you are in a different jurisdiction. They do not read your passport. They do not care about your borrow checker. If something goes wrong on their side, they will not call your emergency services. They will just drop the package and walk away. Or they will hand you a broken package and expect you to fix it. Your job is to inspect everything before it crosses back into Rust territory.
The compiler cannot protect you here. You must build the inspection layer yourself.
Catching panics before they escape
Rust panics are designed to unwind the stack and clean up resources. That unwinding process relies on Rust's runtime. C functions do not know how to handle Rust unwinding. If a panic crosses an FFI boundary, the behavior is undefined. The program might crash immediately, or it might leak memory, or it might continue with corrupted state. The standard solution is to catch the panic on the Rust side before it touches the C code.
use std::panic::{self, AssertUnwindSafe};
/// Safely wraps an FFI call to catch panics before they cross the boundary.
fn call_ffi_safe() -> Result<i32, String> {
// AssertUnwindSafe tells the compiler we accept the risk of
// catching a panic from a type that doesn't implement UnwindSafe.
let result = panic::catch_unwind(AssertUnwindSafe(|| {
// unsafe { ffi_function() } // Replace with actual FFI call
42
}));
// Map the panic outcome to a standard Rust Result type.
match result {
Ok(val) => Ok(val),
Err(_) => Err("Panic occurred in FFI call".to_string()),
}
}
The catch_unwind function takes a closure and runs it in a protected context. If the closure panics, catch_unwind catches it and returns Err(Box<dyn Any>). If it succeeds, it returns Ok(T). The AssertUnwindSafe wrapper is required because catch_unwind only accepts closures that implement UnwindSafe. Many types, including raw pointers and references to mutable state, deliberately do not implement that trait. The compiler rejects them with E0277 (trait bound not satisfied) to prevent you from accidentally catching a panic while holding a partially updated state. Wrapping the closure in AssertUnwindSafe explicitly acknowledges that risk.
This pattern only catches Rust panics. It does not catch C crashes. A segmentation fault, a stack overflow, or a call to abort() in C will terminate the process immediately. The operating system handles those signals, not Rust's runtime. You cannot catch a segfault with catch_unwind. You must prevent it by validating inputs and checking return codes.
Treat catch_unwind as a safety net for Rust callbacks, not a shield against C bugs.
Translating C error codes into Rust results
Real FFI work rarely relies on panics. C libraries communicate errors through return values and global state. The standard pattern is to wrap the raw extern "C" function in a safe Rust function that returns a Result. You check the return code, map it to a Rust error type, and only expose the safe wrapper to the rest of your codebase.
use std::ffi::CString;
use std::io;
/// Wraps a C-style function that returns 0 on success and -1 on failure.
fn open_file_safe(path: &str) -> Result<i32, io::Error> {
// Convert the Rust string to a C-compatible null-terminated buffer.
let c_path = CString::new(path).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidInput, "Embedded null in path")
})?;
// SAFETY: The C library expects a valid null-terminated string.
// 1. c_path.as_ptr() returns a valid pointer to a null-terminated buffer.
// 2. The buffer remains alive for the duration of the unsafe block.
// 3. We check the return value before using the file descriptor.
let ret = unsafe {
libc::open(c_path.as_ptr(), libc::O_RDONLY)
};
// C libraries use -1 to signal an error and set errno.
if ret == -1 {
Err(io::Error::last_os_error())
} else {
Ok(ret)
}
}
The CString::new call fails if the input contains a null byte. C strings must be null-terminated, so an embedded null would truncate the path and cause undefined behavior. Mapping that failure to io::Error keeps the error handling consistent. The unsafe block is kept to three lines. The // SAFETY: comment lists the exact invariants that make the block sound. The community standard is to write these invariants as a numbered list inside the comment. It forces you to prove safety before the compiler lets you proceed.
The io::Error::last_os_error() function reads the platform-specific errno variable and converts it into a Rust io::Error. This gives you a portable error type that works on Linux, macOS, and Windows without manual mapping. You never read errno directly in Rust code. You always use the standard library wrapper.
Never expose raw extern "C" functions to other Rust code. Wrap them immediately.
Pitfalls and compiler boundaries
FFI error handling trips up developers in predictable ways. The compiler will catch some mistakes, but others require discipline.
If you forget to wrap a closure in AssertUnwindSafe when calling catch_unwind, the compiler rejects you with E0277 (trait bound not satisfied). The error message points to the UnwindSafe trait. The fix is always the same: wrap the closure or refactor the code so it does not capture non-UnwindSafe types.
If you pass a &str directly to a C function expecting a char*, the compiler rejects you with E0308 (mismatched types). Rust strings are not null-terminated. They do not guarantee a trailing zero byte. You must convert them to CString or CStr first. The conversion allocates on the heap. It is not free. You cannot avoid it if you need C compatibility.
If you call a C function that modifies a buffer and forget to mark the buffer as mutable, the compiler rejects you with E0596 (cannot borrow as mutable). C functions expect mutable pointers for output parameters. You must declare the buffer with mut and pass buffer.as_mut_ptr(). The compiler enforces this to prevent accidental writes to read-only memory.
If you try to move a value out of a reference inside an unsafe block, the compiler rejects you with E0507 (cannot move out of borrowed content). Raw pointers do not bypass Rust's move semantics. You must explicitly copy or clone the data, or use ptr::read with a corresponding ptr::write to manage the memory manually.
Convention aside: the community calls this the "minimum unsafe surface" rule. Keep unsafe blocks smaller than a paragraph. If your unsafe block spans twenty lines, you are doing too much work inside it. Extract the logic into safe helper functions first. Only cross the boundary when you absolutely have to.
Trust the borrow checker. It usually has a point.
Choosing your error strategy
FFI error handling requires picking the right tool for the specific boundary you are crossing. Different scenarios demand different patterns.
Use catch_unwind when you are calling Rust code from C through a callback and need to prevent a Rust panic from crashing the host process. Use return code checking when the C library documents success as 0 and errors as negative integers or specific constants. Use errno mapping when the C library follows POSIX conventions and sets a global error variable on failure. Use Result wrapping when you want to integrate the FFI call into standard Rust error handling pipelines like ? propagation. Use explicit validation when the C library accepts raw pointers, buffers, or file descriptors that could easily point to invalid memory. Use CString conversion when passing string data across the boundary and you must guarantee null termination.
Counter-intuitive but true: the more you rely on unsafe to skip error checks, the harder the rest of your code becomes to reason about.