Unsafe & FFI
When you need to call a C library, reach an OS API, or work with raw memory,
you leave the region Sailfin’s analysis covers. That boundary is an extern fn
declaration and a raw pointer.
This page is the practical guide. The normative rules are §13 Foreign Interface, and the interop contract still being built is SFEP-0079.
Current status. extern fn declarations, their C-ABI validation
(E0801–E0805), native lowering, raw-pointer load/store/member/arithmetic,
and function addresses via name as *u8 (guarded by E0808/E0809) all ship.
unsafe { } is meaningful to the ownership checker — it carries the E0906
extern boundary and suppresses ownership analysis of its interior — and to
nothing else. A trailing ... marks an extern as C-variadic (E0851) and
also ships. Layout guarantees, pointer mutability enforcement, typed callback
parameters, and effect-attested externs are designed in SFEP-0079 and are not
shipped.
Overview
Section titled “Overview”FFI enables:
- Calling C libraries — libc, system libraries, third-party native libraries
- OS API access — file descriptors, sockets, signals, platform-specific calls
- Performance-critical code — SIMD intrinsics, hardware interfaces, custom allocators
- Embedding in C/C++ programs — exposing Sailfin functions to a C host
The trade-off is real: across an extern boundary the compiler verifies neither memory safety, nor null safety, nor that your declaration matches the C header. Keep the foreign surface small and wrap it.
extern fn Declarations
Section titled “extern fn Declarations”External functions are declared with extern fn. The declaration is a
signature with no body, resolved at link time.
extern fn malloc(size: usize) -> *u8;extern fn free(ptr: *u8) -> void;extern fn memcpy(dest: *u8, src: *u8, n: usize) -> *u8;extern fn memset(dest: *u8, val: i32, n: usize) -> *u8;extern fn strlen(s: *u8) -> usize;A foreign variable is declared with extern var, validated against the same
accept-list:
extern var environ: **u8;unsafe extern fn is also accepted, and means the same thing. The unsafe
keyword on an extern is consumed by the parser and read back by nothing, so the
two spellings typecheck and lower identically. Prefer plain extern fn; use
unsafe extern fn only if you want the visual marker.
Key properties:
- C ABI by default. Parameters use the platform C calling convention.
Extern declarations and matching calls carry
signextfori8/i16andzeroextforu8/u16/bool, on parameters and returns, matching clang’s narrow-integer C ABI lowering on the governed targets (SFEP-0079 §3.3). - No effects on the declaration.
extern fn f() -> i32 ![io]is rejected withE0804. Declare the effect on the Sailfin wrapper that calls it. Extern calls are invisible to the effect checker, so a wrapper’s effect clause is an author’s claim about the foreign function, not a derived fact. - Ownership boundary. Passing a bare owned value to an extern declared in
the same compilation unit, outside an
unsafeblock, raisesE0906. - Must be linked. The library providing the symbol has to reach the final link. A link input is a build input, not provenance: it says what the linker resolves against, and records nothing about what that library does.
- No safety guarantees. The compiler trusts the declaration. A wrong parameter or return type is undefined behavior.
Extern type table
Section titled “Extern type table”These are the types an extern signature admits today.
| Sailfin | C | LLVM | Notes |
|---|---|---|---|
i8 |
int8_t / char |
i8 |
|
i16 |
int16_t |
i16 |
|
i32 |
int32_t |
i32 |
|
i64 |
int64_t |
i64 |
|
u8 |
uint8_t |
i8 |
|
u16 |
uint16_t |
i16 |
|
u32 |
uint32_t |
i32 |
|
u64 |
uint64_t |
i64 |
|
usize |
size_t |
i64 on a 64-bit target |
pointer-sized |
isize |
ssize_t |
i64 on a 64-bit target |
pointer-sized |
f32 |
float |
float |
|
f64 |
double |
double |
|
f16 |
_Float16 |
half |
accepted; no dedicated ABI handling |
bf16 |
__bf16 |
bfloat |
accepted; no dedicated ABI handling |
bool |
_Bool |
i1 |
boolean is rejected (E0805) — externs spell it bool |
int |
int64_t |
i64 |
Sailfin’s default integer |
float |
double |
double |
Sailfin’s default float |
void |
void |
void |
return position only; a void parameter is E0805 |
*T |
T* |
T* |
pointee must itself be admissible |
*void |
void* |
i8* |
the untyped pointer — *opaque is rejected |
*Handle |
struct Handle* |
i8* |
any pointee with an uppercase initial is taken as an opaque handle — *FILE too |
*const T and *mut T are also accepted — the checker strips the prefix and
checks the pointee — but neither prefix means anything yet. See
Raw pointers.
usize and isize are pointer-sized and lower to i64 on every target
Sailfin supports — the governed set is four 64-bit triples (SFEP-0066 §3.2,
enforced by E0614/E0623), so there is no 32-bit case today. Use usize
for any size or count crossing to a C size_t.
Typed function-pointer parameters do not work in practice. The checker
accepts the tight spelling fn(A) -> B, but sfn fmt rewrites it to
fn (A) -> B, which the same checker then rejects with E0805. A formatted
file cannot carry one. Pass callbacks as raw addresses instead — see
Callbacks into Sailfin. Typed callback parameters
are designed in SFEP-0079 §3.4 and not shipped.
Declaration diagnostics
Section titled “Declaration diagnostics”| Code | Raised when |
|---|---|
E0801 |
The type is string / string?, or a pointer to one |
E0802 |
The type has a top-level [] — arrays carry runtime metadata that does not cross the boundary |
E0803 |
The extern declares type parameters (<...>) |
E0804 |
The extern declares effects (![...]) |
E0805 |
Any other inadmissible or missing type — including boolean, number, *opaque, a missing annotation, and a void parameter |
Sailfin string needs an explicit conversion. A string literal is
NUL-terminated, so literal as *u8 may be passed to a C const char*
directly; anything else must be copied into a NUL-terminated buffer first,
because slices are not NUL-terminated.
Variadic externs
Section titled “Variadic externs”A trailing ... marks an extern as C-variadic. It must be the declaration’s
last parameter, and there must be at least one fixed parameter before it —
... alone is not valid, because C resolves a variadic call against the
fixed prototype:
extern fn ioctl(fd: i32, request: u64, ...) -> i32;
fn set_flag(fd: i32, request: u64, value: i32) -> i32 { return ioctl(fd, request, value);}Misplacing ... is E0851.
Cast narrow arguments explicitly. C default-promotes every variadic
argument — i8/i16/u8/u16/bool widen to int, f32 widens to
f64 — and Sailfin never promotes implicitly. Passing an unpromoted value in
variadic position is E0851 too, but only when the compiler can see the
argument’s type stated outright (an as cast, or an identifier with an
explicit type annotation):
let x: u16 = 1;sum_va(1, x); // E0851 — cast itsum_va(1, x as i32); // finesum_va(1, 10, 20); // fine — untyped literals pass silently either wayThat last case is the check’s known gap: an integer literal, the result of a call, or any argument whose type the checker cannot see passes without comment, promoted or not. State the type at the call site if you want the check to catch a mistake there.
unsafe Blocks
Section titled “unsafe Blocks”An unsafe { ... } block is a lexical region whose contents are
author-asserted for the ownership checker. That is its entire shipped meaning.
extern fn malloc(size: usize) -> *u8;
fn allocate_buffer(bytes: usize) -> *u8 { unsafe { return malloc(bytes); }}No pointer operation requires an unsafe block. Dereference, stores,
member access, arithmetic, and casts all compile outside one — the runtime
itself does raw-pointer work outside unsafe throughout. A rule requiring
unsafe for them would be a restriction without a matching power, and
SFEP-0079 §3.2 explicitly declines to add one.
What the block does do is the boundary it exists for: passing a bare owned
value to an extern declared in the same compilation unit outside a block raises
E0906, and inside one it does not.
Raw Pointer Types
Section titled “Raw Pointer Types”| Type | C equivalent | Description |
|---|---|---|
*T |
T* |
Raw pointer to T. Reads and writes are both permitted. |
*const T |
const T* |
Accepted spelling. Not enforced — writes through it compile. |
*mut T |
T* |
Accepted spelling, identical to *T. |
*void |
void* |
Untyped pointer. Use this where C uses void*. |
*Handle |
struct Handle* |
Opaque foreign handle, by UpperCamelCase convention. |
Raw pointers differ fundamentally from Sailfin references (&T, &mut T):
- No lifetime tracking. The compiler does not know when the pointed-to memory is valid.
- No null safety. A raw pointer may be null; check before dereferencing.
- No borrow checking. Multiple pointers to the same memory are permitted.
- Freely cast.
p as *Ureinterprets with no check.
Retention
Section titled “Retention”A pointer into Sailfin-managed storage is valid only for the duration of the
foreign call it is passed to. Sailfin storage may be arena-backed and
reclaimed at a phase boundary, so a pointer the foreign side keeps can dangle.
Anything C retains — a user_data payload, an epoll data pointer — must live
in memory C owns, typically a malloc allocation.
This rule is documented, not enforced. Nothing rejects handing arena storage to a retaining parameter.
Pointer Operations
Section titled “Pointer Operations”All of these work today, inside an unsafe block or outside one.
extern fn malloc(size: usize) -> *u8;extern fn free(ptr: *u8) -> void;
fn pointer_example() ![io] { let arr = malloc(40) as *i32; // cast *u8 to *i32
for i in 0..10 { let element_ptr = arr + i; // advance by i elements *element_ptr = i * i; // store through the pointer }
let third = *(arr + 2); // load the third element print("${ third }"); // prints 4
free(arr as *u8);}| Operation | Description |
|---|---|
*p |
Load a T. |
*p = v |
Store a T. |
p.f |
Load or store field f of a struct through the pointer, auto-dereferencing. |
p + n, p - n |
Advance or retreat by n elements, scaled by the pointee’s size. On *u8 the step is one byte. |
p as *U |
Reinterpret as a different pointer type. |
p as i64, n as *T |
Convert between an address and an integer. |
s as *S |
Address of a struct binding’s storage. |
s as *u8 |
A string’s data pointer — NUL-terminated only for literals. Bind the literal to a string local first (see below). |
p == null, p != null |
Null tests. 0 as *T is also the null pointer. |
Struct Layout
Section titled “Struct Layout”Until that lands, a struct shared with C is a hazard. The workable pattern is to keep the foreign-facing shape as explicit scalar fields in declaration order, verify the offsets against the C header on each target you ship, and prefer passing scalars over passing structs.
Callbacks into Sailfin
Section titled “Callbacks into Sailfin”C calls back into Sailfin through a raw function address. Cast the function’s name:
extern fn pthread_create(thread: *u8, attr: *u8, start: *u8, arg: *u8) -> i32;
fn worker(arg: *u8) -> *u8 { return arg;}
fn spawn(thread: *u8, arg: *u8) -> i32 { return pthread_create(thread, 0 as *u8, worker as *u8, arg);}worker as *u8 lowers to the function’s code pointer, not a closure pair, so C
can call it directly. Two diagnostics guard the form:
E0808— a function name used as a value without the cast, or cast to something other than* u8or a function-pointer type.E0809— the named function is generic; only a concrete function has one address.
This is the shipped path, and the runtime scheduler depends on it. Two caveats:
a Sailfin throw unwinding across a C frame is undefined, and there is no way
to define a symbol C can call by name — defined functions are module-mangled.
C-ABI definitions are designed in SFEP-0079 §3.4 and not shipped.
Safe Wrapper Pattern
Section titled “Safe Wrapper Pattern”Keep the foreign surface in a small module and export only safe wrappers.
- Declare the
extern fnbindings privately. - Write wrappers that handle the invariants: null checks, size validation, NUL-termination, cleanup.
- Export only the wrappers, carrying the effect clause the foreign call deserves.
- Take a resource that must be released as
Linear<T>, so the ownership checker enforces the release.
// Foreign internals — not exportedextern fn malloc(size: usize) -> *u8;extern fn free(ptr: *u8) -> void;extern fn memset(dest: *u8, val: i32, n: usize) -> *u8;
struct ManagedBuffer { ptr: *u8; capacity: usize;}
// Zero-initialized allocation. A null return means the allocation failed.export fn allocate_buffer(size: usize) -> *u8 { let ptr = malloc(size); if ptr == null { return ptr; } memset(ptr, 0, size); return ptr;}
// Taking the buffer as `Linear<ManagedBuffer>` makes releasing it mandatory:// a linear value must be consumed exactly once. `free(v)` is one of the// consumption forms the ownership checker recognizes — it is that rule, not a// call to libc `free`.export fn release_buffer(buffer: Linear<ManagedBuffer>) -> i32 { free(buffer); return 0;}
// Forwarding a linear value to another function that takes it also consumes it.export fn release_all(buffer: Linear<ManagedBuffer>) -> i32 { return release_buffer(buffer);}Drop the free(buffer) and the compiler rejects the function:
error[E0907]: linear value `buffer` is never consumed at 2:20What
Linear<T>does and does not do today. The ownership checker recognizesLinear<T>andAffine<T>on a binding or parameter and enforces single use: use-after-move and a second binding raiseE0901/E0904, and a linear value still live at scope exit raisesE0907. A linear value is consumed by returning it, by passing it to a function that takes it, or byfree(v).There is no
Linear<T>constructor and noconsume()function. ALinear<T>arrives as a parameter; you cannot wrap a value in one from source. Shared-borrow and view-lifetime checking are in progress.
Error Handling Across FFI
Section titled “Error Handling Across FFI”C signals errors through return codes, a global errno, and out-parameters.
Translate them inside the wrapper:
struct PosixError { errno: i32;}
extern fn c_open(path: *u8, flags: i32) -> i32;extern fn c_errno() -> i32;
// The effect clause is the wrapper's claim about the foreign call; the// compiler derives nothing from the extern itself.fn open_file(path: *u8, flags: i32) -> i32 | PosixError ![io] { let fd = c_open(path, flags); if fd < 0 { return PosixError { errno: c_errno() }; } return fd;}Callers then use ordinary Sailfin pattern matching. A wrapper can also return
the shipped Result<T, E> and let callers propagate with postfix ?; an
explicit union stays valid where it models the native outcomes better.
fn read_config(path: *u8) ![io] { let result = open_file(path, 0); match result { PosixError { errno } => print.err("Failed to open file, errno: ${ errno }"), _ => { /* `result` is the file descriptor */ }, }}When to Use FFI
Section titled “When to Use FFI”Use FFI when:
- A C library provides unique functionality not available in the standard library or registry — hardware drivers, OS-specific APIs, mature C libraries.
- A measured hot path needs SIMD intrinsics, a custom allocator, or zero-copy I/O that safe Sailfin cannot express.
- You are embedding in a C/C++ host that calls into Sailfin.
Do not use FFI when:
- A safe Sailfin implementation exists. Prefer it even if it is slower.
- The motivation is avoiding the effect system. An extern does not remove the capability — it removes the compiler’s record of it.
- You are early in development and the need is not yet concrete.
Given that layout and pointer mutability are not yet contracts, weigh a port that depends on either of them against waiting for the SFEP-0079 leaves that specify them.
Example Reference
Section titled “Example Reference”The examples/advanced/ directory contains:
examples/advanced/unsafe-extern-interop.sfn— extern declarations andunsafeblocksexamples/advanced/pointer-arithmetic.sfn— pointer arithmetic withmalloc/freeexamples/advanced/raw-pointers.sfn— a design sketch: the&rawform is kept in comments, and the runnable body uses shipped grammar
None of them needs an ![unsafe] effect or an "unsafe" capability, because
neither exists.
Summary
Section titled “Summary”| Concept | Quick reference |
|---|---|
| Declare a C function | extern fn name(param: Type) -> ReturnType; |
| Declare a variadic C function | extern fn name(param: Type, ...) -> ReturnType; — ... last, ≥1 fixed parameter (E0851) |
unsafe keyword on an extern |
Accepted, inert — same meaning as plain extern fn |
unsafe block |
Suppresses ownership analysis of its interior; carries the E0906 extern boundary. Not required for any pointer operation |
unsafe fn |
Skips ownership analysis of the whole body — a Linear<T> obligation is not enforced |
| Effects | On the calling wrapper, never on the extern (E0804) |
| Pointer | *T — reads and writes |
*const T / *mut T |
Accepted spellings, no enforcement |
| Untyped pointer | *void (not *opaque) |
| Boolean across the boundary | bool (not boolean) |
| Pointer advance | ptr + n, scaled by element size |
| Null check | ptr == null |
| Address of a struct | s as *S |
| Function address for C | name as *u8 (E0808/E0809) |
| Layout control | None. @repr(C) is ignored — designed in SFEP-0079 §3.1 |
| Raw address operator | None. &raw fails E0818 |
| Unsafe effect / capability / policy | Withdrawn (SFEP-0079 §3.5) |
| Normative reference | §13 Foreign Interface |