Skip to content

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 (E0801E0805), 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.

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.

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 signext for i8/i16 and zeroext for u8/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 with E0804. 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 unsafe block, raises E0906.
  • 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.

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.

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.

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 it
sum_va(1, x as i32); // fine
sum_va(1, 10, 20); // fine — untyped literals pass silently either way

That 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.

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.

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 *U reinterprets with no check.

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.

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.

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.

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 * u8 or 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.

Keep the foreign surface in a small module and export only safe wrappers.

  1. Declare the extern fn bindings privately.
  2. Write wrappers that handle the invariants: null checks, size validation, NUL-termination, cleanup.
  3. Export only the wrappers, carrying the effect clause the foreign call deserves.
  4. Take a resource that must be released as Linear<T>, so the ownership checker enforces the release.
// Foreign internals — not exported
extern 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:20

What Linear<T> does and does not do today. The ownership checker recognizes Linear<T> and Affine<T> on a binding or parameter and enforces single use: use-after-move and a second binding raise E0901/E0904, and a linear value still live at scope exit raises E0907. A linear value is consumed by returning it, by passing it to a function that takes it, or by free(v).

There is no Linear<T> constructor and no consume() function. A Linear<T> arrives as a parameter; you cannot wrap a value in one from source. Shared-borrow and view-lifetime checking are in progress.

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 */ },
}
}

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.

The examples/advanced/ directory contains:

  • examples/advanced/unsafe-extern-interop.sfn — extern declarations and unsafe blocks
  • examples/advanced/pointer-arithmetic.sfn — pointer arithmetic with malloc/free
  • examples/advanced/raw-pointers.sfn — a design sketch: the &raw form 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.

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