The Trampoline Pattern: Safe FFI Callbacks in Rust

Wrapping a C library in Rust is a notoriously complex endeavor as it mixes two different paradigms, and trying to pass Rust callbacks over the FFI boundary is no exception.

In the process of stabilizing AudioNimbus and improving its safety, I needed to redesign the callback system interfacing with Steam Audio’s C API. The previous version passed raw function pointers without lifetime guarantees, leading to potential segfaults. I also wanted to improve ergonomics, so that users could simply write idiomatic Rust closures without ever having to worry about FFI internals.

Enter the trampoline pattern.

The Problem: Closures Aren’t Function Pointers

A C callback typically has a signature like:

typedef void (*ProgressCallback)(float progress, void *user_data);

The user_data parameter is C’s escape hatch for statefulness. The caller stores whatever pointer it wants, and the library passes it back on every invocation. This is how C compensates for having no closures.

To call this function from Rust requires an extern "C" fn binding:

type FfiProgressCallback = unsafe extern "C" fn(progress: f32, user_data: *mut c_void);

Now, we could stop here and call it a day. And in fact, that’s what I did for AudioNimbus up until this update, as Steam Audio only requires callbacks for advanced use cases and customization. I deemed it nice-to-have and wanted to focus on more important components first.

It works, but it is unergonomic, error-prone, and it entirely forgoes the expressiveness of Rust closures. In particular, if the data pointed to by user_data was freed before Steam Audio called the callback, the result was a use-after-free segfault. Nothing in the type system prevented it.

Instead, we want to accept a closure like:

|progress: f32| {
  println!("{progress:.0}%");
}

But Rust closures are not extern "C" function pointers. They are structs generated by the compiler, potentially capturing state from their environment. There is no direct way to pass one to C.

The Trampoline Pattern

The trampoline pattern solves this cleanly. The idea is to:

  1. Box the closure onto the heap, giving it a stable address.
  2. Pass a static extern "C" function (the trampoline) as the function pointer to C.
  3. Pass a pointer to the boxed closure as user_data.
  4. Inside the trampoline, reconstruct a reference to the closure from user_data and call it.

The trampoline acts as the bridge: it has the exact C-compatible signature that the library expects, but its only job is to recover and invoke the Rust closure, using user_data as a stash.

Execution “bounces” from C into the Rust trampoline, which forwards it to the closure and then returns the result back to C, hence the name.

Here’s how it translates into code:

pub struct ProgressCallback {
    callback: Box<dyn FnMut(f32)>,
}

impl ProgressCallback {
    pub fn new<F>(f: F) -> Self
    where
        F: FnMut(f32),
    {
        Self {
            callback: Box::new(f),
        }
    }

    unsafe extern "C" fn trampoline(progress: f32, user_data: *mut c_void) {
        let callback = &mut *(user_data as *mut Box<dyn FnMut(f32)>);
        callback(progress);
    }

    pub(crate) fn as_raw_parts(&self) -> (unsafe extern "C" fn(f32, *mut c_void), *mut c_void) {
        (Self::trampoline, &self.callback as *const _ as *mut c_void)
    }
}

The key invariant that makes this safe is that the ProgressCallback struct owns the Box. As a result, the closure lives exactly as long as the wrapper does, preventing dangling pointers.

Users just pass a closure and never think about user_data:

let callback = ProgressCallback::new(|progress: f32| {
    println!("{progress:.0}%");
});

Note that in this example the callback doesn’t return a result, but the pattern supports it; for instance:

unsafe extern "C" fn trampoline(foo: f32, user_data: *mut c_void) -> f32 {
    let callback = &mut *(user_data as *mut Box<dyn FnMut(f32) -> f32>);
    callback(foo)
}

Bonus: FFI Arguments

C libraries often have types more involved than just primitive types like u32 or f32.

In the case of AudioNimbus, there are many FFI structs generated by bindgen, each wrapped by a more ergonomic Rusty type. For example, audionimbus_sys::IPLVector3 is abstracted away by the simpler, more ergonomic Vector3.

The problem is that the extern "C" signatures look something like:

type PathingCallback = unsafe extern "C" fn(
    from: audionimbus_sys::IPLVector3,
    to: audionimbus_sys::IPLVector3,
    occluded: audionimbus_sys::IPLbool,
    user_data: *mut c_void,
);

Using the trampoline pattern we described earlier, we can design the following callback:

|
    from: audionimbus_sys::IPLVector3,
    to: audionimbus_sys::IPLVector3,
    occluded: audionimbus_sys::IPLbool,
| {
  // ...
}

But this defeats the purpose of using closures for better ergonomics, as users still have to deal with FFI types.

For this reason, it’s a good idea to convert the trampoline arguments into Rusty types before passing them to the callback, and, if applicable, converting the result of the callback back to the expected FFI type:

unsafe extern "C" fn trampoline(
    ffi_from: audionimbus_sys::IPLVector3,
    ffi_to: audionimbus_sys::IPLVector3,
    ffi_occluded: audionimbus_sys::IPLbool,
    user_data: *mut c_void,
) {
    let callback = &mut *(user_data as *mut Box<dyn FnMut(Vector3, Vector3, bool)>);
    let from = Vector3::from(ffi_from);
    let to = Vector3::from(ffi_to);
    let occluded = bool::from(ffi_occluded);
    callback(from, to, occluded);
}

Now users can pass:

|from: Vector3, to: Vector3, occluded: bool| {
    // ...
}

Bonus: Making It Ergonomic with a Macro

Implementing numerous callbacks can be tedious, and AudioNimbus has many: progress, pathing visualization, occlusion, and more.

I went one step further by adding a callback! macro that handles this automatically. It also takes care of converting between FFI types and idiomatic Rust types via a FfiConvert trait, so that users never encounter raw C types in their closure arguments:

trait FfiConvert {
    type FfiType;

    fn to_ffi(self) -> Self::FfiType;
    fn from_ffi(ffi: Self::FfiType) -> Self;
}

The macro expands to a trampoline like:

unsafe extern "C" fn trampoline(
    from: <Vector3 as FfiConvert>::FfiType,
    to: <Vector3 as FfiConvert>::FfiType,
    occluded: <bool as FfiConvert>::FfiType,
    user_data: *mut c_void,
) {
    let callback = &mut *(user_data
        as *mut Box<dyn FnMut(Vector3, Vector3, bool)>);
    let from = <Vector3 as FfiConvert>::from_ffi(from);
    let to = <Vector3 as FfiConvert>::from_ffi(to);
    let occluded = <bool as FfiConvert>::from_ffi(occluded);
    callback(from, to, occluded);
}

Better yet, Copy types can automatically implement FfiConvert using this additional trait:

trait FfiPassthrough: Copy + 'static {}

impl<T: FfiPassthrough> FfiConvert for T {
    type FfiType = T;

    fn to_ffi(self) -> Self::FfiType { self }
    fn from_ffi(ffi: Self::FfiType) -> Self { ffi }
}

impl FfiPassthrough for u32 {}
impl FfiPassthrough for f32 {}
// ... other Copy types

Analyzing the entirety of the macro would make this post longer than necessary; readers willing to dive deeper can take a look at this AudioNimbus PR.

When user_data Isn’t Available

The trampoline pattern relies entirely on user_data being passed back to the callback. Most C APIs provide one. But what if that’s not the case?

AudioNimbus faced this challenge because one of the callbacks turned out to have a corrupted user_data, which caused a segfault when trying to reconstruct the closure. This made using user_data impossible.

To be clear, I don’t think there’s a single silver bullet here, and the solution will vary on a case-by-case basis.

The workaround I found in this particular case was to sidestep user_data entirely. Because only one calculation can run at a time per thread, a thread-local pointer is a safe substitute for user_data:

thread_local! {
    static CALLBACK_PTR: Cell<*mut c_void>
       = const { Cell::new(std::ptr::null_mut()) };
}

Before passing the callback to C, we store the pointer in the thread-local cell. Inside the trampoline, we read it back directly instead of trusting user_data. It is not as clean as the standard pattern, but it is safe given the single-execution-per-thread constraint.

Whether this is applicable to your scenario depends on the constraints at stake. The static pointer storage could be extended to store a map to multiple callbacks, for example.

Closing Notes

The trampoline pattern is a robust and idiomatic solution to the closure-to-FFI problem. Its safety guarantee is simple: box the closure so it has a stable address, keep the wrapper alive as long as C might call the callback, and let the trampoline do the ugly casting in one isolated place.

Not every situation is so clean. When the library controls neither user_data nor the callback signature, there is no pure solution, and pragmatic compromises must be made.


If you’re interested in the development of AudioNimbus, you can follow the project on Github. For the specific changes discussed here, see the pull request that introduced these callback wrappers.