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
| Situation | FFI? |
|---|---|
| You want a native macOS UI in Swift over a Rust core | Yes — this is libghostty's exact case |
| You want your core usable from C++, Go, Python, or Ruby | Yes, via a C ABI |
| You want a browser demo | WASM, not a C ABI — a different mechanism |
| Your consumers are all Rust | No. A C ABI would be pure cost. |
| You have not shipped a stable Rust API yet | No. 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_unwinddoes nothing underabort, so a Rust panic terminates the host application — a Swift app crashing with no stack trace because a terminal parsed a malformed escape sequence. Keeppanic = "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)
| Cost | Detail |
|---|---|
Every function needs catch_unwind | Forget one and a panic is UB in the host |
| Ownership becomes manual and undocumented-by-default | Double frees, leaks, use-after-free — the bugs Rust exists to prevent, reintroduced at the seam |
| The API is frozen | Changing a C function's meaning breaks consumers silently |
| Rich types must be flattened | Option<T>, enums with payloads, and lifetimes have no C equivalent |
| Errors lose structure | An integer plus a thread-local string, instead of a typed error |
| Testing doubles | You must test the Rust API and the C API, from C |
| Build complexity | Multiple targets, universal binaries, module maps, header generation |
| Debugging is worse | Stack 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
- Generate the header with
cbindgenin CI and fail the build if the checked-in header differs. That diff is your ABI review. - Run the C example under ASan and Valgrind to catch ownership mistakes the Rust tests cannot.
- A Swift package wrapping the C ABI with
deinit-based lifetime management, plus a tiny SwiftUI view that renders a snapshot. - WASM in the browser: replay an
asciinemarecording onto a canvas, using onlyterminal-core. - Fuzz the FFI: random pointer/length combinations including nulls, zero lengths, and overlapping buffers. Assert no crashes.
- 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
- Why must the Rust API stabilize before the FFI? Give two concrete costs of the wrong order.
- State the seven C ABI design principles.
- Why must every
extern "C"functioncatch_unwind? What happens without it? - Why is
panic = "abort"a trap for an FFI library? - Explain the two-call size-query idiom and why it beats returning an allocated pointer.
- What Rust types cannot appear in a C signature, and what do you use instead?
- How does Swift make the C ownership rule automatic?
- Why must C++ exceptions not propagate into Rust?
- What does a successful WASM build prove, and what does WASM not give you?
- List five costs of maintaining an FFI. Which would you accept, and for what consumer?
Next: Lab 20 — Extract the Core.