Appendix L — FFI Boundary Checklist
Review foreign-function boundaries across ABI, layout, ownership, data, callbacks, failure, packaging, and cross-language evidence.
Begin an FFI review with the header that foreign engineers will compile, not with the Rust implementation they cannot see:
typedef struct wireview_handle wireview_handle;
wireview_status wireview_new(wireview_handle **out_handle);
void wireview_free(wireview_handle *handle);
wireview_status wireview_parse(
wireview_handle *handle,
const uint8_t *data,
size_t len,
uint32_t *out_value
);
This surface already makes several consequential promises. The handle is opaque. Creation transfers a token to C; destruction transfers it back exactly once. Input bytes are borrowed. The result is a fixed-width integer written through an out parameter. A status code, not Rust’s Result, crosses the boundary.
Those promises must agree in five places: header, Rust declarations, implementation, language-specific wrapper, and packaged binary. FFI review is the work of proving that agreement over time.
An internal unsafe ledger can rely on Rust types and module privacy to assign obligations. A foreign caller sees none of that structure. The boundary must restate each relevant fact as a language-neutral lifecycle rule and preserve it across separately compiled artifacts.
Draw the call as a lifecycle
For every exported function, record ownership before the call, valid activity during the call, and ownership after every return status.
The lifecycle prevents a common documentation defect: describing pointer types without saying when the pointee may be accessed. Turn it into a boundary ledger:
| Item | Direction | Ownership/lifetime | Null rule | Thread rule | Failure state |
|---|---|---|---|---|---|
wireview_handle * |
C → Rust | C holds one token; Rust borrows per call; free consumes it | null accepted only where documented | one call at a time; no concurrent mutation | invalid after free |
data,len |
C → Rust | borrowed initialized bytes for call; never retained | null rejected, including current zero-length policy | caller prevents mutation during call | no state change on bad input |
out_value |
Rust → C | caller-owned writable uint32_t; written only on success |
non-null | exclusive for call | preserved on error |
| callback + userdata | both | borrowed synchronously; not retained | callback required | invoked on calling thread; same-handle mutation is rejected during callback | callback must not unwind |
Do not leave “thread-safe,” “caller-owned,” or “nullable” unqualified. Name the operation, interval, and consequence.
ABI and symbol gate
Choose the ABI deliberately. extern "C" selects the platform’s C calling convention for the supported target; it does not create one universal binary format. Rust’s native ABI is not a stable cross-version contract. Rust 2024 requires unsafe extern blocks because the declaration author must verify foreign signatures.
For every imported or exported symbol, compare:
- exact symbol name and visibility;
- calling convention, including platform-specific
systemcases; - parameter count, order, widths, signedness, and return convention;
- variadic use, if unavoidable;
- linker name and native library selection;
- target architecture, C runtime, and minimum operating-system policy.
#[unsafe(no_mangle)] asserts that exposing the chosen symbol will not collide with another global symbol. Prefer a project prefix such as wireview_, export only the supported surface, and inspect the produced library’s symbol table in release validation.
Publish an ABI version query or versioned constructor when callers need runtime compatibility checks. A returned 1 is only useful if the package documents which additions remain compatible, how incompatible changes receive new symbols or library names, and how an older caller rejects a newer incompatible binary.
Layout and representation gate
List every type crossing by value or through shared memory. Prefer C integer types with explicit widths, size_t for object sizes where appropriate, pointer-sized opaque handles, and #[repr(C)] structs/enums only when their C representation is intentionally public.
repr(C) gives the annotated Rust type C-oriented layout rules; it does not make arbitrary fields FFI-safe, freeze every nested Rust type, solve ownership, or guarantee the same C ABI on every target. Do not expose String, Vec<T>, Rust references, trait objects, closures, Rust enums with data, or unspecified-layout structs directly.
Check:
- field order, offsets, size, alignment, padding, and target endianness;
- enum discriminant representation and unknown values;
bool,char, and platform integer differences;- packing and unaligned access hazards;
- nested types and function-pointer signatures;
- compile-time assertions on both sides where the layout is shared.
Opaque handles often age better because Rust retains layout freedom. Shared structs are appropriate when callers need direct data access and the compatibility policy can support them. A byte protocol or process boundary is often safer when parties evolve independently.
Ownership and allocator gate
Every allocation needs one allocating domain and a matching release function. Do not allocate with Rust’s allocator and ask C to call free, or accept C allocation and reconstruct a Box without a documented matching allocator contract.
For an opaque handle, document:
- which constructor creates it;
- whether null indicates absence or error;
- whether functions borrow, mutate, retain, or consume it;
- whether cloning creates a new owner or shared reference;
- whether destruction accepts null;
- whether destruction must occur on a particular thread;
- what happens to child resources and callbacks.
If an operation fails after allocating, say whether it returns ownership, destroys partial state, or leaves a cleanup token. Out parameters should remain unchanged on failure unless partial output is an explicit contract. The fixture tests that wireview_parse preserves out_value for BadTag.
Nullability, strings, and buffers
Write null rules per pointer. C cannot infer Rust’s reference invariants, and Rust must not create a reference before checking the foreign pointer. Use Option<extern "C" fn(...)> for nullable function pointers where that representation is supported; use raw pointers and explicit checks for data.
Strings require an encoding and terminator policy:
- C string: pointer to a NUL-terminated byte sequence; embedded NUL is not representable;
- pointer plus length: arbitrary bytes; specify UTF-8 validation if producing
str; - output buffer: specify required capacity, terminator inclusion, truncation, and length reporting;
- returned pointer: specify owner, lifetime, mutability, and release function.
Avoid this ambiguous API:
const char *wireview_error_message(wireview_handle *handle);
The signature does not reveal whether the pointer is static, handle-owned until the next call, thread-local, newly allocated, UTF-8, or nullable. A status-to-static-message function with documented lifetime can work; so can a caller buffer with a size-query protocol; so can an owned error object with a matching release. Choose one and test it from the foreign language.
For buffers, pair pointer with element count, define whether zero length permits null, cap lengths before arithmetic, and distinguish capacity from initialized length. State whether Rust retains the buffer, writes it, or only reads it. If retained, a call-scoped pointer is insufficient; use owned transfer, reference counting, pinning by the foreign runtime, or a copying boundary.
Callback and thread gate
A callback contract is a reverse FFI. Verify its ABI, arguments, userdata, nullability, lifetime, thread, reentrancy, and failure behavior.
The fixture’s callback is synchronous: callback and userdata live for wireview_visit, the call invokes them on its current thread, and neither is retained. That is materially simpler than an asynchronous callback. If retained, provide registration and deregistration, define the last possible invocation, coordinate teardown, and ensure the foreign runtime keeps userdata alive.
Ask whether a callback may:
- call the library reentrantly with the same handle;
- block or acquire foreign runtime locks;
- trigger handle destruction;
- arrive concurrently or in order;
- run after cancellation or shutdown;
- throw an exception or initiate a Rust panic.
Thread affinity belongs on each handle and callback, not in a vague platform note. If a handle may cross threads, prove synchronization and foreign runtime compatibility. If it is thread-bound, make wrappers reject or prevent transfer where the host language permits.
Errors and unwinding gate
Translate Rust failures into a C-shaped protocol: status enum, nullable result, out parameter, error object, or a documented combination. Preserve distinctions callers need—invalid input, allocation failure, version mismatch, busy/reentrant access, and internal panic—without requiring message parsing.
Define whether outputs and handle state change for every status. Keep human messages separate from stable machine codes. Reserve values or provide an unknown-code fallback so a newer library does not make an older wrapper unsound.
Do not allow an ordinary Rust panic to cross an extern "C" boundary. Catch at a narrow boundary when recovery is valid, or abort according to explicit policy. catch_unwind catches unwinding panics, not aborting panics, and not every payload or foreign exception. A caught panic also does not prove internal state remains usable; the function needs an unwind-safety argument and should often poison or retire the affected handle.
If cross-language unwinding is intentionally required, use the applicable *-unwind ABI only after specialist review of both runtimes and targets. The safer default is explicit error translation on each side.
Versioning and packaging gate
Review the release envelope, not only source code:
- public header and generated language bindings;
- static/shared library format and target triple;
- debug versus release runtime dependencies;
- exported symbol allowlist and ABI version;
- header generator and compiler versions;
- library name, soname/install-name, search path, and rpath policy;
- license notices and native dependencies;
- checksums, provenance, and supported platform matrix;
- examples showing compile and link commands.
Keep headers and binaries from the same build identifiable. A stale system header paired with a new shared object can be as dangerous as a Rust bug. Package CI should install into a clean prefix and compile a consumer from the installed artifacts, not from source-tree paths.
Binding generators reduce transcription errors but do not decide ownership or threading. Pin generator versions, review generated diffs, and preserve hand-written wrapper tests. For C++, Python, JVM/.NET, JavaScript, and other runtimes, add tests for their exception, garbage-collection, thread-affinity, and unload behavior rather than assuming the C smoke test covers them.
Cross-language evidence matrix
The companion fixture provides Rust tests, a checked-in C11 header, and a C smoke client:
cd examples/rust-engineering-handbook/part-11/boundary-contracts-lab
cargo test --locked
cargo build --locked
cc -std=c11 -Wall -Wextra -Werror c-smoke/main.c -Iinclude \
-Ltarget/debug -lboundary_contracts_lab \
-Wl,-rpath,"$PWD/target/debug" -o target/c-smoke
./target/c-smoke
Extend evidence by risk:
| Risk | Required test |
|---|---|
| declaration drift | compile header as C and C++; compare generated/checked declaration |
| layout drift | size/alignment/offset assertions on every supported target |
| ownership | create/use/free, null policy, double-free rejection strategy, leak checks |
| buffer rules | empty, null, short, maximum, malformed, and aliasing cases |
| callbacks | userdata round trip, reentrancy, thread identity, unregister/teardown |
| errors | each status and unchanged-output guarantee |
| unwinding | injected Rust panic becomes documented status or abort behavior |
| packaging | install artifact, link clean consumer, inspect dependencies and symbols |
Run native sanitizers where supported because Miri generally cannot execute arbitrary foreign code. Test each claimed architecture and operating system; compilation alone is not execution evidence.
Boundary review exercise
Integrate. Prepare wireview ABI version 2 with an optional retained progress callback and UTF-8 diagnostic output.
- Context: C and Python consumers must coexist with v1 during migration.
- Constraints: existing v1 binaries keep working; Python callbacks must obey its runtime/thread rules; no exception or panic crosses the C ABI; allocation domains cannot mix.
- Deliverable: revised header, ownership/thread ledger, symbol/version plan, error-message representation, registration/teardown sequence, packaging matrix, and cross-language test plan.
- Evaluation: old callers reject or ignore additions safely; callback lifetime has a final-invocation rule; strings have encoding/ownership; every allocation has a matching release; failure and unwind paths preserve a stated handle condition.
- Allowed assumptions: C11 is the lowest common denominator and process isolation is a valid alternative.
Compare the in-process design with a process boundary. IPC adds serialization, deployment, latency, and lifecycle costs, but isolates crashes, allocators, runtimes, and upgrade cadence. Choose FFI only when its performance or embedding value exceeds the permanent compatibility burden.
FFI boundary card
- Are ABI, symbol name, types, widths, target, and calling convention exact?
- Are all shared layouts explicit, nested-field safe, and asserted on each target?
- Does every pointer state owner, lifetime, mutability, null rule, and thread rule?
- Do strings define encoding, termination, length, allocation, and release?
- Do buffers distinguish initialized length from capacity and constrain size arithmetic?
- Do callbacks define ABI, userdata, retention, reentrancy, last invocation, and thread?
- Are status values stable, outputs defined on failure, and messages non-protocol?
- Is unwinding contained or deliberately handled by a reviewed unwind ABI?
- Are ABI evolution, symbol versioning, and old/new coexistence documented?
- Does the installed package contain matching headers, binaries, dependencies, and metadata?
- Do real foreign consumers test lifecycle, boundaries, failure, callbacks, and teardown?
An FFI boundary is a small protocol with a long maintenance life. Review it as a temporal ownership system and a release artifact, not merely as matching declarations.
Sources and version notes
- The Rust Reference: external blocks
- The Rust Reference: application binary interface
- The Rust Reference: type layout
- Rust 2024 Edition Guide: unsafe external blocks
- The Rustonomicon: foreign-function interface
catch_unwinddocumentation
Core declarations target Rust 2024, C11, and stable Rust; the manuscript snapshot is Rust 1.97.0. ABI availability, unwinding behavior, symbol tooling, library packaging, and platform layout remain target-sensitive. Recompile and execute the complete consumer matrix with the publication artifacts.
Continue reading
Full table of contents