CO3: The Language of FFI

We will look back at times when FFI was written raw as barbaric.

On this page

When I recently presented CO3 to the world, to my surprise, the reception was rather mild. Actually, I seem to have attracted only one early adopter (Altair kudos to you) and a handful of GitHub stars. I did not think I would have to get into the business of evangelizing, but here I am.

As to why the reception was mild, I suspect it was the use of strong claims that might have put some people off. I understand bold claims, especially in the age of LLMs, can raise eyebrows. However, my ambition in building CO3 over the past three years was no less than to make any other approach to writing FFI in Rust obsolete. In this article, after addressing some of the early criticism, I’m going to double down on this vision and try to better explain how CO3 moves us towards it.

Before we begin with the topic of this article, I want to take a moment to briefly summarize what the design goals of CO3 are and why:

1. Native Rust API

CO3 has been criticized for obscuring actual types at the boundary, especially when importing C libs into Rust. While I sympathize with this concern, it was my supposition that whenever Rust interacts with the FFI boundary, you always want to abstract the boundary so as not to deal with transitional types at the boundary and exit directly into native Rust types.

In that sense, CO3 automates and compresses the conversion layer that one would have to write manually anyways. The best way to think about co3::ffi! is that it is a special Rust module (like syn::ItemForeignMod is) where all types that you see are replaced with their C-companion. That’s the gist of it; it’s as simple as that.

Having said that, there is no reason why a tool couldn’t be made that would compare the API generated by CO3 and the actual C header file. This should assuage most concerns regarding the obscuring nature of abstraction at the boundary.

2. Complete Type Support

CO3 aims to perfectly describe every Rust type (and I literally mean every type expressible in the type system) with a corresponding C-companion type. This is the hard part of any FFI and is of immense importance because, if we can define the language of the boundary, we can completely separate concerns of encoding and decoding. Suddenly, it becomes possible for Rust to define lowering into C and for any other language (including Rust) to generate the bindings and type conversions optimally just by reading a C header file. This is possible because CO3 reduces all of the Rust type system information into custom C types and type aliases.

Without going into a complete specification of types at the boundary (which should be machine readable anyways), let’s consider how &mut str and &UnsafeCell<str> are represented in a C header file. How the header generator emits these types of stable representation into a C header file is a concern separate from CO3, but I imagine it would look a lot like this:

  1. &mut str => CWideRefMut<Utf8Str, EXCLUSIVE = true>
typedef uint8_t Utf8Str;

typedef struct CWideRefMut_Utf8Str_true {
    Utf8Str *data;
    size_t len;
} CWideRefMut_Utf8Str_true;
  1. &UnsafeCell<str> => CWideRefMut<UnsafeCell<Utf8Str>, EXCLUSIVE = false>
typedef uint8_t Utf8Str;
typedef Utf8Str UnsafeCell_Utf8Str;

typedef struct CWideRefMut_UnsafeCell_Utf8Str_false {
    UnsafeCell_Utf8Str *data;
    size_t len;
} CWideRefMut_UnsafeCell_Utf8Str_false;

As you can see, through a convention of type names and aliases, a C header file tries to capture the full semantics of any Rust type barring the lifetime relationships. Notice that CWideRefMut<Utf8Str, EXCLUSIVE = true> and CWideRefMut<UnsafeCell<Utf8Str>, EXCLUSIVE = false> are still Rust types, but now with a stable representation.

3. Linking-Agnostic Library Use

When you write your library in Rust and make its FFI doppelganger, I bet you would love for the API to look the same regardless of whether a Rust consumer links it as an rlib or a cdylib. Why this is not a broader concern in the ecosystem, I’m not quite sure.

It always looked to me as if Rust crates exporting an FFI have been neglecting the very same language they were written in. A likely reason for this is that this is hard to reason about without proper abstractions. While it is inherently not possible to close this gap entirely, CO3 gets you as far as feasible without obfuscating the truth behind the abstraction.

As my LLM instructed me (try to absorb this ontological shock) I must always substantiate a claim with an immediate example. The stronger the claim, the more important an example is. Therefore, suppose a crate named checksum, which depends on CO3 optionally, provides an ordinary Rust function and also declares its FFI export:

[features]
default = []
ffi-export = ["co3"]

[dependencies]
co3 = { version = "0.8.1", optional = true }
#[cfg(any(not(feature = "co3"), feature = "ffi-export"))]
pub fn checksum(bytes: &[u8]) -> u32 {
    bytes.iter().fold(0_u32, |sum, &byte| sum.wrapping_add(byte.into()))
}

#[cfg(feature = "co3")]
co3::ffi! {
    #![cfg_attr(feature = "ffi-export", unsafe(export("C")))]
    #![cfg_attr(not(feature = "ffi-export"), unsafe(extern("C")))]

    #[symbol_name = "example_checksum"]
    pub fn checksum(bytes: &[u8]) -> u32;
}

The consumer code is identical in both cases:

fn main() {
    let value = checksum::checksum(&[1, 2, 3]);
    println!("checksum: {value}");
}
[dependencies]
# Use the native Rust implementation through an rlib:
checksum = { path = "../checksum" }

# Or replace the line above to use bindings to the cdylib:
# checksum = { path = "../checksum", features = ["co3"] }

The Rust consumer depends on a small subset of the original rlib that contains only the bindings, but the implementation lives in the separately built cdylib.

“The real secret of magic is that the world is made of words”

— Terence McKenna, Alien Dreamtime

You may not be aware of this, but as I came to discover (or invent?), fully describing FFI depends on a set of unique language constructs. I designed these as intuitive extensions around the existing Rust syntax (which is why ffi! had to be a function-like macro). To see what I mean, I find it best to smack you with a full-blown example that I’ll then dissect:

use std::marker::PhantomData;
use co3::{ffi, ReprC, rust_spec::RustSpec};

#[derive(RustSpec, ReprC)]
// Using a concrete repr optimizes conversion but is not required
// for the C-companion and conversions to be derived for a type
// #[repr(C)]
struct Image<Format: ?Sized> {
    bytes: Vec<u8>,
    format: PhantomData<Format>,
}

ffi! {
    #![unsafe(extern("C"))] // Here we're importing an existing C library
 // #![unsafe(export("C"))] // We can also export it without changing the syntax

    // Tells us how to interpolate types into symbols of static bindings
    #![symbol_fragments {
        Png = "png",
        Jpeg = "jpeg"
    }]

    // Opaque/Extern types
    type Transcoder;

    // Opaque types usable in tagged-dispatch
    #[tag(u8, unsafe(1))]
    type Png;
    #[tag(u8, unsafe(2))]
    type Jpeg;

    impl Transcoder {
        #[symbol_name = "convert_to_{To}"]
        // `From` is dispatched dynamically via tagged-dispatch, while
        // `To` is dispatched statically and must name a distinct binding
        fn convert<dyn(u8) From, To>(
            &self,
            // Contrary to mainstream Rust, this type is not moved across FFI.
            // Without `move`, it is borrowed when encoded and cloned when decoded.
            image: Image<From>,
            // Callbacks must be functions with a stable ABI which are called `raw` functions
            on_progress: raw fn(&str, f32),
        // With the explicit `move` type qualifier, the type is moved to the caller
        ) -> move Image<To>
        where
            // Since there are no generics at the FFI boundary, every
            // generic parameter must be bound to a concrete selection
            use<From, To> @ (<Png, Jpeg> | <Jpeg, Png>);
    }
}

// Native Rust callback function
fn on_progress(stage: &str, fraction: f32) {
    println!("{stage}: {:.0}%", fraction * 100.0);
}

co3::raw! {
    // Generates the `on_progress_raw` C-companion that looks like:
    //    `unsafe extern "C" fn on_progress_raw(stage: CWideRef<Utf8Str>, fraction: f32)`
    fn on_progress(stage: &str, fraction: f32);
}

// Showcase of how the imported function can be called from Rust
fn png_to_jpeg(transcoder: &Transcoder, image: Image<Png>) -> Image<Jpeg> {
    transcoder.convert::<Png, Jpeg>(image, on_progress_raw)
}

First of all, let me get you into the right mindset.

It is an invariant of this library that each Rust type has a corresponding C type. As mentioned previously, you should best think of ffi! as a special Rust module inside which you write regular good old Rust code which gets exported/imported to/from an FFI boundary. Not to overwhelm the reader, I’m only focusing on import but take notice that export declarations would look pretty much the same.

Just keep in mind that every type that you see inside ffi! is projected/lowered to its robust C-companion (for example, if you see &u8, the actual type is reminiscent of *const u8). It goes without saying that all value conversions are checked at runtime by default (although you can opt out of runtime checks), with a lot of compile-time glue upholding it all. This gives us a strong, stable ABI with low cognitive overhead.

Ownership Policy and move

Transferring ownership over an FFI boundary is quite problematic. I would even potentially single it out as one of the biggest sources of memory leaks and use-after-free bugs in FFI code. The contract of who gets to deallocate memory is too messy and error-prone, which is why many libraries either choose not to transfer it or hide it through the use of opaque types. The syntax of CO3 is built with this reality in mind and makes ownership transfer an explicit opt-in behavior per type.

When we usually think of ownership in Rust, the type is either moved into a function or borrowed by the function. With FFI, there is a third case where a type is handed out as a reference and implicitly cloned inside the function; i.e., one side of the FFI sends a reference and the other clones it into its own allocator space.

This is exactly what CO3 does by default for an owned type like Vec<T>. Unless you write down move Vec<T> explicitly, the type will be sent as a borrow and cloned on the other side. If you intended to borrow the type across the boundary, just use regular borrowing syntax such as &[T] (you can use move &[T], but it will have no effect).

Closed-Set Dispatch With use Predicates

As we all know, there are no generic parameters at the FFI boundary. The first and main reason for this is that parameters form an open set of all types bounded by some trait(s). What is needed, if parametrization is to be supported, is a form of closed-set dispatch where all concrete instantiations of a generic parameter are known AOT.

Therefore, CO3 provides the following syntax: where use<T1, T2> @ (<ConcreteTy1, ConcreteTy2> | ...);, which is called a use where-predicate. You can write one or several use selections as long as you constrain every generic parameter (that affects the ABI) to a set of concrete instantiations. The code will not compile otherwise.

Static Monomorphization

By closing the set of concrete types, you have effectively closed the set of functions that will appear in the FFI. There is now a finite number of functions with concrete layouts that are being exported/imported. However, this is not enough because monomorphization will produce functions with different ABIs, and we can’t refer to them through a single symbol at the FFI boundary. We have to choose a different symbol name for each of them.

Conveniently, this is done through type name interpolation in the #[symbol_name = "fn_name_{T}_{U}"] function-level attribute. When ffi! monomorphizes your function, it will look up what concrete type each generic parameter resolves to and find its string literal defined in the #![symbol_fragments { ConcreteTy1 = "N1", ... }] (primitive types resolve to predefined string literals).

If you don’t care about the names of monomorphized functions, which may be the case if you’re exporting an FFI, you don’t have to choose symbol names. The ffi! macro will infer them.

Runtime Tagged-Dispatch

Static monomorphization is nice and all, but it can easily blow up the surface of your API. Or you may find that you have a parametrized function where all concrete instantiations of generic parameters share the same ABI. Whatever your reason, you may want to reach out for some kind of dynamic dispatch.

In C APIs, there is a common kind of runtime dispatch known as tagged-dispatch. CO3 allows you to opt into this dispatch by declaring your generic parameters as dynamic with <dyn(TagTy) T = ErasedTy>. This means that concrete instantiations of T, when lowered into their C-companions, must all share the same ABI, and that ABI must be equivalent to ErasedTy. At compile time, the machinery of ffi! will ensure this holds true. The generated function will not become monomorphized on dynamic generic parameters and will get exported under one symbol name. You can still choose to combine static and dynamic generic parameter dispatch in a single function as you see fit.

ErasedTy can be omitted, in which case CO3 will infer the erased type to have the ABI of an opaque pointer *mut c_void.

raw Function Qualifier and Callbacks

The situation around using function pointers as arguments to FFI functions (i.e., callbacks) has proven to be somewhat problematic.

On the one hand, unless they are already in stable ABI form, converting function pointer types to their stable C form generally necessitates that they be converted into closures. While the design around native support for Rust closures hasn’t yet been settled on in CO3, it would nonetheless be quite surprising to the programmer if function pointers were silently getting converted into closures.

On the other hand, the most important premise of CO3 is that the programmer must write exclusively idiomatic Rust code. The programmer must never concern themselves with thinking in terms of the FFI boundary.

Considering the requirements, CO3 invents a new function qualifier, raw. A raw fn is a regular function, but with all types replaced with their stable ABI form. For example, if you have raw fn(move Box<bool>), it is roughly equivalent to unsafe extern "C" fn(*mut u8) (move semantics apply normally). Check out the raw! macro, which is used to produce a stable version of your existing Rust function.

Plugging Holes

This was the last of the language features presented in the article. If you have read up to here, try going back to the full example to see if you can now understand it or if you have any suggestions on what can be improved. There are 3 big gaps that I can identify right now:

  1. As was previously mentioned, CO3 lacks native support for closures. You can write your own, of course, but it would be much nicer to take care of that bloat inside ffi!.
  2. CO3 doesn’t verify that your import declarations match some C header. If you’re importing an FFI that was not produced by CO3, ffi! trusts that you have correctly described the boundary. This should be a separate tool.
  3. There is a lack of support for C header generation. Notice that this is only important if you’re exporting Rust to another language; if you’re consuming the foreign API only from Rust, it is not required to build the C header files. However, CO3 is already ahead of that curve because it produces an incredibly rich landscape of stable types that the header generator could use to choose what to emit as a corresponding C type. cbindgen is not far from fully supporting CO3 (I vibecoded an extension myself but cannot recommend it).

Are You Convinced by Now?

This was an introductory course on some of the capabilities of CO3 in which I used simplified examples to make the whole article more readable. I’m not saying it’s all perfect yet, but I’m confident that with CO3 we’re at least putting the right bet on the table. If you aren’t convinced by now that CO3 will lower the cost, financial or cognitive, and increase the safety of maintaining your FFI code, I would like to hear why.

How much of your FFI code is doing a compiler’s job?