🎓 A1Learner
🔍

Rust FFI Error Codes

An FFI error code is a number that reports how a call went. Zero means fine, and any other number is a code the caller looks up.

The answer rides an out pointer

The return value says how it went. The pointer carries the answer.

pub extern "C" fn calc_divide(a: i32, b: i32, out: *mut i32) -> i32 {
    // 0 = fine, 1 = bad pointer, 2 = division by zero
}

Null earns a code

The first check guards the out pointer. Trouble becomes a number, never a crash.

if out.is_null() {
    set_error("nowhere to write the answer");
    return 1;
}

Only the happy path writes

The checks run first. The single write happens last.

unsafe { *out = a / b };
0

Catch the panic at the border

catch_unwind turns a panic into a value you can grade. The C caller only ever sees the code.

let result = panic::catch_unwind(|| risky_work());
match result {
    Ok(v) => v,
    Err(_) => -1,
}

A message waits on the side

The caller asks for the text after a failure. The message lives in thread-local storage until the next call on that thread.

pub extern "C" fn calc_last_error() -> *const c_char {
    LAST_ERROR.with(|cell| cell.borrow().as_ptr())
}

Example

use std::cell::RefCell;
use std::ffi::{c_char, CStr, CString};
use std::panic;

thread_local! {
    static LAST_ERROR: RefCell<CString> = RefCell::new(CString::new("").unwrap());
}

fn set_error(message: &str) {
    LAST_ERROR.with(|cell| {
        *cell.borrow_mut() = CString::new(message).unwrap_or_default();
    });
}

pub extern "C" fn calc_divide(a: i32, b: i32, out: *mut i32) -> i32 {
    if out.is_null() {
        set_error("nowhere to write the answer");
        return 1;
    }
    if b == 0 {
        set_error("division by zero");
        return 2;
    }
    unsafe { *out = a / b };
    0
}

pub extern "C" fn calc_risky() -> i32 {
    let result = panic::catch_unwind(|| -> i32 {
        let values = [1, 2];
        let i = values.len() + 3;
        values[i]
    });
    match result {
        Ok(v) => v,
        Err(_) => {
            set_error("index out of bounds, caught at the border");
            -1
        }
    }
}

pub extern "C" fn calc_last_error() -> *const c_char {
    LAST_ERROR.with(|cell| cell.borrow().as_ptr())
}

fn last_error_text() -> String {
    let ptr = calc_last_error();
    let cstr = unsafe { CStr::from_ptr(ptr) };
    cstr.to_str().unwrap().to_string()
}

fn main() {
    let mut out = 0;
    println!("divide code = {}", calc_divide(10, 2, &mut out));
    println!("answer = {out}");
    println!("zero code = {}", calc_divide(10, 0, &mut out));
    println!("error: {}", last_error_text());
    println!("risky code = {}", calc_risky());
    println!("error: {}", last_error_text());
    println!("still alive at the end");
}

Key points

  • Return a status code from every exported function.
  • Zero means success.
  • Each failure gets its own code
  • Send the real answer through an out pointer.
  • The return value is busy carrying the code
  • Check pointer arguments first.
  • A null out pointer earns a code, not a crash
  • Wrap the body in catch_unwind when it might panic.
  • A panic that unwinds into C is undefined behaviour
  • Keep a last-error message on the side.
  • A second exported function hands it to the caller as a C string
  • An error code turns a failure into a number the C caller can check.
  • Codes travel.
  • Meanings do not.

In this example, main plays the C caller. calc_divide writes 5 through the out pointer and returns 0. The division by zero returns 2. calc_last_error then explains the failure in words. calc_risky indexes past the end of an array on purpose. catch_unwind catches that panic inside the exported function. The function stores a message and returns -1. You will see the panic report on stderr. The caller still receives a calm number. The program reaches its last line alive. A real C program calling this same code as a library sees exactly the same codes.

Note

Write down what each code means in the header file, or the C side will guess

Try it Yourself

Edit the code, then press Run.

Running is simulated · Rust Playground

Exercise

An exported Rust function might panic deep inside. What must the exported function do?