FFI and Bindings: C ABI, Swift, C++, WebAssembly

This is advanced material, and it comes last for a reason.

Discuss foreign-function interfaces only after the Rust-native API is stable.

An FFI is a commitment you cannot take back cheaply. A C header, once published, is depended on by code you cannot see or fix. Designing one against an unstable API bakes in accidents: it exposes internals that were about to change, and it forces the Rust API to preserve shapes that only ever existed to serve the bridge.

Read this chapter to understand the shape of the problem. Implement it only if you have a consumer that genuinely needs it.


When an FFI Is Justified

SituationFFI?
You want a native macOS UI in Swift over a Rust coreYes — this is libghostty's exact case
You want your core usable from C++, Go, Python, or RubyYes, via a C ABI
You want a browser demoWASM, not a C ABI — a different mechanism
Your consumers are all RustNo. A C ABI would be pure cost.
You have not shipped a stable Rust API yetNo. Not yet.
You think it would be "more reusable"No. Reusability is proven by consumers, not by header files.

The C ABI: Design Principles

   1. OPAQUE HANDLES. Never expose a Rust struct's layout. C sees a pointer.
   2. NO PANICS ACROSS THE BOUNDARY. A Rust panic unwinding into C is UNDEFINED
      BEHAVIOR. Every extern "C" function must catch_unwind.
   3. EXPLICIT OWNERSHIP. For every _new there is a _free, documented, on the
      same side of the boundary.
   4. NO RUST TYPES IN SIGNATURES. No String, no Vec, no &str, no Option<T>,
      no enums with payloads. Pointers, lengths, and integers only.
   5. STABLE ERROR REPORTING. An integer code, plus a way to fetch a message.
   6. THREAD RULES DOCUMENTED. Which functions may be called from which threads,
      and whether a handle may cross threads.
   7. VERSIONED. A version function, and never change a function's meaning —
      add a new one.

The header

/* terminal.h — the stable C ABI for the mini-terminal engine. */
#ifndef MINI_TERMINAL_H
#define MINI_TERMINAL_H

#include <stdint.h>
#include <stddef.h>

#ifdef __cplusplus
extern "C" {
#endif

/* Opaque. C never sees the layout, so the Rust side may change freely. */
typedef struct mt_terminal mt_terminal;

typedef enum {
    MT_OK = 0,
    MT_ERR_NULL_POINTER = 1,
    MT_ERR_INVALID_SIZE = 2,
    MT_ERR_BUFFER_TOO_SMALL = 3,
    MT_ERR_PANIC = 4,          /* a Rust panic was caught; the handle may be poisoned */
    MT_ERR_UTF8 = 5,
} mt_status;

/* ABI version. Bump the MAJOR on any breaking change. */
uint32_t mt_abi_version_major(void);
uint32_t mt_abi_version_minor(void);

/* ---- Lifecycle. Every _new has exactly one _free. ---- */
mt_terminal *mt_terminal_new(uint16_t rows, uint16_t cols);
void         mt_terminal_free(mt_terminal *term);

/* ---- Feeding bytes ---- */
mt_status mt_terminal_advance(mt_terminal *term, const uint8_t *bytes, size_t len);

/* ---- State ---- */
mt_status mt_terminal_resize(mt_terminal *term, uint16_t rows, uint16_t cols);
mt_status mt_terminal_cursor(const mt_terminal *term, uint16_t *out_row, uint16_t *out_col);

/* ---- Reading the screen ----
 * Two-call idiom: pass NULL to learn the required size, then call again with a
 * buffer. The alternative — returning a Rust-allocated pointer — requires the
 * caller to free it with OUR function, which is a common source of crashes when
 * a caller uses free() instead. */
mt_status mt_terminal_snapshot_utf8(const mt_terminal *term,
                                    uint8_t *out, size_t out_cap, size_t *out_len);

/* ---- Effects the caller must perform ---- */
mt_status mt_terminal_take_replies(mt_terminal *term,
                                   uint8_t *out, size_t out_cap, size_t *out_len);

/* ---- Errors ---- */
/* Returns a NUL-terminated, thread-local message for the last error on this
 * thread. Valid until the next call on the same thread. Never NULL. */
const char *mt_last_error_message(void);

#ifdef __cplusplus
}
#endif
#endif /* MINI_TERMINAL_H */

The implementation

#![allow(unused)]
fn main() {
// crates/terminal-ffi/src/lib.rs
use std::panic::{catch_unwind, AssertUnwindSafe};

pub struct mt_terminal { inner: terminal_core::Terminal }

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

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

/// # Safety
/// Returns a pointer the caller MUST free with `mt_terminal_free`, exactly once.
#[no_mangle]
pub extern "C" fn mt_terminal_new(rows: u16, cols: u16) -> *mut mt_terminal {
    // A panic unwinding into C is UNDEFINED BEHAVIOR. Every extern "C" function
    // needs this, without exception.
    let result = catch_unwind(|| {
        if rows == 0 || cols == 0 { return std::ptr::null_mut(); }
        Box::into_raw(Box::new(mt_terminal {
            inner: terminal_core::Terminal::new(rows as usize, cols as usize,
                                                Default::default()),
        }))
    });
    match result {
        Ok(p) => p,
        Err(_) => { set_error("panic in mt_terminal_new"); std::ptr::null_mut() }
    }
}

/// # Safety
/// `term` must come from `mt_terminal_new` and must not be used afterwards.
/// Calling this twice on the same pointer is a double free.
#[no_mangle]
pub unsafe extern "C" fn mt_terminal_free(term: *mut mt_terminal) {
    if term.is_null() { return; }
    let _ = catch_unwind(AssertUnwindSafe(|| { drop(Box::from_raw(term)); }));
}

/// # Safety
/// `bytes` must point to at least `len` readable bytes, or be NULL when len==0.
#[no_mangle]
pub unsafe extern "C" fn mt_terminal_advance(term: *mut mt_terminal,
                                             bytes: *const u8, len: usize) -> mt_status {
    if term.is_null() { set_error("null terminal"); return MT_ERR_NULL_POINTER; }
    if bytes.is_null() && len > 0 { set_error("null bytes"); return MT_ERR_NULL_POINTER; }
    let r = catch_unwind(AssertUnwindSafe(|| {
        let t = &mut *term;
        let slice = if len == 0 { &[][..] } else { std::slice::from_raw_parts(bytes, len) };
        t.inner.advance(slice);
    }));
    match r { Ok(()) => MT_OK, Err(_) => { set_error("panic in advance"); MT_ERR_PANIC } }
}

/// The two-call idiom: NULL `out` reports the required size in `out_len`.
#[no_mangle]
pub unsafe extern "C" fn mt_terminal_snapshot_utf8(term: *const mt_terminal,
                                                   out: *mut u8, out_cap: usize,
                                                   out_len: *mut usize) -> mt_status {
    if term.is_null() || out_len.is_null() { return MT_ERR_NULL_POINTER; }
    let r = catch_unwind(AssertUnwindSafe(|| {
        let s = (&*term).inner.snapshot_text();
        *out_len = s.len();
        if out.is_null() { return MT_OK; }              // size query
        if out_cap < s.len() { return MT_ERR_BUFFER_TOO_SMALL; }
        std::ptr::copy_nonoverlapping(s.as_ptr(), out, s.len());
        MT_OK
    }));
    match r { Ok(s) => s, Err(_) => MT_ERR_PANIC }
}
}
# crates/terminal-ffi/Cargo.toml
[lib]
# cdylib for dynamic linking; staticlib so a Swift/C++ app can link statically.
crate-type = ["cdylib", "staticlib"]

[profile.release]
# panic = "abort" would make catch_unwind useless — and a panic would then abort
# the CALLER's process. Keep unwinding so the boundary can catch it.
panic = "unwind"

Warning: panic = "abort" in a library exposed over FFI is a trap. catch_unwind does nothing under abort, so a Rust panic terminates the host application — a Swift app crashing with no stack trace because a terminal parsed a malformed escape sequence. Keep panic = "unwind" and catch at every boundary function.

Generating the header

cargo install cbindgen
cbindgen --crate terminal-ffi --output include/terminal.h
# Check the generated header into version control and DIFF IT ON EVERY CHANGE.
# The diff is your ABI change review, and it is the only one you get.

Swift

Swift can call C directly. The work is wrapping the C API in something that feels native — and making memory safety automatic.

import Foundation

public final class Terminal {
    private let handle: OpaquePointer

    public init?(rows: UInt16, cols: UInt16) {
        guard let h = mt_terminal_new(rows, cols) else { return nil }
        self.handle = OpaquePointer(h)
    }

    // deinit is what makes the C ownership rule automatic on the Swift side.
    // Without it, every caller must remember mt_terminal_free — and will not.
    deinit { mt_terminal_free(UnsafeMutablePointer(handle)) }

    public func advance(_ data: Data) throws {
        let status = data.withUnsafeBytes { buf in
            mt_terminal_advance(UnsafeMutablePointer(handle),
                                buf.bindMemory(to: UInt8.self).baseAddress, buf.count)
        }
        guard status == MT_OK else { throw TerminalError(status: status) }
    }

    public func snapshot() throws -> String {
        // The two-call idiom, wrapped so Swift callers never see it.
        var needed = 0
        _ = mt_terminal_snapshot_utf8(UnsafePointer(handle), nil, 0, &needed)
        var buf = [UInt8](repeating: 0, count: needed)
        let status = mt_terminal_snapshot_utf8(UnsafePointer(handle), &buf, needed, &needed)
        guard status == MT_OK else { throw TerminalError(status: status) }
        return String(decoding: buf[0..<needed], as: UTF8.self)
    }
}

Build integration, briefly:

   1. Build the Rust staticlib for each Apple architecture:
        cargo build --release --target aarch64-apple-darwin
        cargo build --release --target x86_64-apple-darwin
   2. lipo them into a universal library, or build an .xcframework.
   3. Add a module map so Swift can import the C header:
        module MiniTerminal { header "terminal.h" export * }
   4. Link the staticlib in the Xcode target.

This is roughly the shape of Ghostty's macOS integration: a Zig core with a C ABI, a Swift app on top.


C++

C++ can call the C ABI directly. The value you add is RAII.

#include "terminal.h"
#include <memory>
#include <stdexcept>
#include <string>

namespace mini_terminal {

class Terminal {
public:
    Terminal(uint16_t rows, uint16_t cols)
        : handle_(mt_terminal_new(rows, cols), &mt_terminal_free) {
        if (!handle_) throw std::runtime_error("failed to create terminal");
    }

    void advance(const uint8_t* bytes, size_t len) {
        // Errors become exceptions at the boundary, so C++ callers never see
        // a status code they might forget to check.
        if (mt_terminal_advance(handle_.get(), bytes, len) != MT_OK)
            throw std::runtime_error(mt_last_error_message());
    }

    std::string snapshot() const {
        size_t needed = 0;
        mt_terminal_snapshot_utf8(handle_.get(), nullptr, 0, &needed);
        std::string out(needed, '\0');
        if (mt_terminal_snapshot_utf8(handle_.get(),
                                      reinterpret_cast<uint8_t*>(out.data()),
                                      needed, &needed) != MT_OK)
            throw std::runtime_error(mt_last_error_message());
        out.resize(needed);
        return out;
    }

private:
    // unique_ptr with a custom deleter: the C ownership rule, enforced by the
    // type system.
    std::unique_ptr<mt_terminal, decltype(&mt_terminal_free)> handle_;
};

}  // namespace mini_terminal

Warning: Exceptions must not propagate into Rust. If you ever pass a C++ callback into the Rust side, wrap it so it catches everything — the mirror image of catch_unwind. Unwinding across an FFI boundary in either direction is undefined behavior.


WebAssembly

WASM is a different mechanism, not a C ABI, and it is much easier — because wasm-bindgen generates the glue.

#![allow(unused)]
fn main() {
// crates/terminal-wasm/src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct WasmTerminal { inner: terminal_core::Terminal }

#[wasm_bindgen]
impl WasmTerminal {
    #[wasm_bindgen(constructor)]
    pub fn new(rows: usize, cols: usize) -> WasmTerminal {
        // Panics become JS exceptions with a stack trace, rather than an
        // unhelpful "unreachable executed".
        console_error_panic_hook::set_once();
        WasmTerminal { inner: terminal_core::Terminal::new(rows, cols, Default::default()) }
    }

    /// Feed bytes. wasm-bindgen copies the JS Uint8Array into linear memory.
    pub fn advance(&mut self, bytes: &[u8]) { self.inner.advance(bytes); }

    /// A structured snapshot the JS side can render to a canvas or the DOM.
    pub fn snapshot_json(&self) -> String { self.inner.snapshot_json() }

    pub fn take_replies(&mut self) -> Vec<u8> { self.inner.take_replies() }
}
}
<script type="module">
  import init, { WasmTerminal } from './pkg/terminal_wasm.js';
  await init();
  const term = new WasmTerminal(24, 80);
  term.advance(new TextEncoder().encode("\x1b[31mhello\x1b[0m\n"));
  document.body.textContent = term.snapshot_json();
</script>

What WASM proves: terminal-core has no OS dependency. It is the strictest possible check, it takes one command, and it should be in CI:

cargo build --target wasm32-unknown-unknown -p terminal-core

What it does not give you: a PTY. There are no processes in the browser. A WASM terminal is a viewer — for recordings, for a remote session over a WebSocket, or for a demo. That limitation is clarifying rather than annoying: it is exactly the boundary between terminal-core and terminal-pty, enforced by the platform.


The Costs of an FFI (Be Honest About These)

CostDetail
Every function needs catch_unwindForget one and a panic is UB in the host
Ownership becomes manual and undocumented-by-defaultDouble frees, leaks, use-after-free — the bugs Rust exists to prevent, reintroduced at the seam
The API is frozenChanging a C function's meaning breaks consumers silently
Rich types must be flattenedOption<T>, enums with payloads, and lifetimes have no C equivalent
Errors lose structureAn integer plus a thread-local string, instead of a typed error
Testing doublesYou must test the Rust API and the C API, from C
Build complexityMultiple targets, universal binaries, module maps, header generation
Debugging is worseStack traces stop at the boundary

None of this is a reason not to do it when you have a real consumer. All of it is a reason not to do it speculatively.


Experiment

CLAIM. A Rust panic crossing an FFI boundary without catch_unwind is undefined behavior, and you can observe it.

METHOD.

#![allow(unused)]
fn main() {
// A deliberately unsafe FFI function, for demonstration ONLY.
#[no_mangle]
pub unsafe extern "C" fn mt_panic_demo(term: *mut mt_terminal) {
    let t = &mut *term;
    t.inner.definitely_panics();      // no catch_unwind
}
}
/* demo.c */
#include "terminal.h"
#include <stdio.h>
int main(void) {
    mt_terminal *t = mt_terminal_new(24, 80);
    printf("before\n");
    mt_panic_demo(t);
    printf("after\n");     /* is this reached? */
    return 0;
}
cargo build --release -p terminal-ffi
cc demo.c -Ltarget/release -lterminal_ffi -o demo && ./demo
# Then add catch_unwind and repeat.

PREDICTION. Before running: does the C program print "after"? Does it abort? Does the behavior differ between debug and release? Between panic=unwind and panic=abort?

RESULT. Record what you saw on your platform — and note that "it seemed to work" is the worst possible outcome for undefined behavior, because it means the bug is waiting for a different compiler version.


Test

#![allow(unused)]
fn main() {
#[test]
fn ffi_handles_null_pointers_without_crashing() {
    unsafe {
        assert_eq!(mt_terminal_advance(std::ptr::null_mut(), b"x".as_ptr(), 1),
                   MT_ERR_NULL_POINTER);
        mt_terminal_free(std::ptr::null_mut());        // must be a safe no-op
    }
}

#[test]
fn ffi_catches_panics() {
    // If this test aborts instead of passing, catch_unwind is missing or
    // panic=abort is set.
    unsafe {
        let t = mt_terminal_new(24, 80);
        let status = mt_terminal_advance_that_might_panic(t, b"\xff".as_ptr(), 1);
        assert!(status == MT_OK || status == MT_ERR_PANIC);
        mt_terminal_free(t);
    }
}

#[test]
fn two_call_size_query_works() {
    unsafe {
        let t = mt_terminal_new(3, 10);
        mt_terminal_advance(t, b"hi".as_ptr(), 2);
        let mut needed = 0usize;
        assert_eq!(mt_terminal_snapshot_utf8(t, std::ptr::null_mut(), 0, &mut needed), MT_OK);
        assert!(needed > 0);
        let mut buf = vec![0u8; needed];
        let mut written = needed;
        assert_eq!(mt_terminal_snapshot_utf8(t, buf.as_mut_ptr(), needed, &mut written), MT_OK);
        assert!(String::from_utf8_lossy(&buf[..written]).starts_with("hi"));
        mt_terminal_free(t);
    }
}

#[test]
fn buffer_too_small_is_reported_not_overflowed() {
    unsafe {
        let t = mt_terminal_new(24, 80);
        mt_terminal_advance(t, b"a long line of text".as_ptr(), 19);
        let mut buf = [0u8; 2];
        let mut written = 0usize;
        assert_eq!(mt_terminal_snapshot_utf8(t, buf.as_mut_ptr(), 2, &mut written),
                   MT_ERR_BUFFER_TOO_SMALL);
        mt_terminal_free(t);
    }
}
}

Plus a C test program, compiled and run in CI — because the Rust tests exercise the Rust side of the boundary, not the C side.


Challenge Extensions

  1. Generate the header with cbindgen in CI and fail the build if the checked-in header differs. That diff is your ABI review.
  2. Run the C example under ASan and Valgrind to catch ownership mistakes the Rust tests cannot.
  3. A Swift package wrapping the C ABI with deinit-based lifetime management, plus a tiny SwiftUI view that renders a snapshot.
  4. WASM in the browser: replay an asciinema recording onto a canvas, using only terminal-core.
  5. Fuzz the FFI: random pointer/length combinations including nulls, zero lengths, and overlapping buffers. Assert no crashes.
  6. An ABI stability test: keep a copy of the previous header and assert that every old symbol still exists with the same signature.

Validation / Self-check

  1. Why must the Rust API stabilize before the FFI? Give two concrete costs of the wrong order.
  2. State the seven C ABI design principles.
  3. Why must every extern "C" function catch_unwind? What happens without it?
  4. Why is panic = "abort" a trap for an FFI library?
  5. Explain the two-call size-query idiom and why it beats returning an allocated pointer.
  6. What Rust types cannot appear in a C signature, and what do you use instead?
  7. How does Swift make the C ownership rule automatic?
  8. Why must C++ exceptions not propagate into Rust?
  9. What does a successful WASM build prove, and what does WASM not give you?
  10. List five costs of maintaining an FFI. Which would you accept, and for what consumer?

Next: Lab 20 — Extract the Core.