§13 Foreign Interface
Sailfin reaches foreign code through extern fn declarations, raw pointers,
and a validated @repr(C) struct layout. This chapter is normative for the
surface that ships today. The broader interop contract — a read-only
*const T, variadic externs, typed callback parameters, and effect-attested
externs — is designed in SFEP-0079 and is
called out as designed, not shipped wherever it appears below.
For the practical guide, see Unsafe & FFI.
13.1 Extern declarations
Section titled “13.1 Extern declarations”An extern declaration names a symbol resolved at link time:
extern fn malloc(size: usize) -> *u8;extern fn free(ptr: *u8) -> void;extern fn strlen(s: *u8) -> usize;The declaration is a signature only; it has no body. Parameters use the
ordinary name: Type form and the return type follows ->.
A foreign variable is declared with extern var, which is the one extern
form that may also define storage:
extern var environ: **u8;Its type is validated against the same accept-list, in parameter position — so
a bare void is rejected there too.
unsafe extern fn is also accepted. The unsafe keyword on an extern is a
parsed marker: it is consumed by the parser and no analysis pass reads it back,
so extern fn and unsafe extern fn typecheck and lower identically. unsafe
is not an effect — see §13.6.
An extern declaring effects is rejected with E0804; effects belong on the
Sailfin wrapper that calls the extern, not on the extern itself.
13.2 The C-ABI accept-list
Section titled “13.2 The C-ABI accept-list”A type is admissible in extern parameter or return position when it is one of:
Primitives
| Sailfin | C | Notes |
|---|---|---|
i8, i16, i32, i64 |
int8_t … int64_t |
|
u8, u16, u32, u64 |
uint8_t … uint64_t |
|
usize, isize |
size_t, ssize_t |
pointer-sized |
f32, f64 |
float, double |
|
f16, bf16 |
_Float16, __bf16 |
accepted by the checker; no dedicated ABI handling |
bool |
_Bool |
the extern spelling is bool, not boolean |
int |
int64_t |
Sailfin’s default integer, lowers to i64 |
float |
double |
Sailfin’s default float |
void |
void |
return position only — bare void as a parameter is E0805 |
Pointers. *T where T is itself admissible, void, or an identifier
with an uppercase initial, treated as an opaque foreign handle (*File,
*FILE, *PthreadMutex). The rule checks only the first character and that
the rest are identifier characters; it does not verify that the name denotes a
declared type. **T follows by recursion. *void is the untyped byte
pointer (C’s void*); *u8 is the conventional spelling for byte buffers and
C strings.
*const T and *mut T are accepted: the checker strips the const or mut
prefix and applies the same rule to the pointee. Neither prefix carries meaning
today — see §13.4.
Not admissible:
*opaqueis rejected withE0805. The opaque-pointee rule requires an uppercase initial, so the lowercaseopaquematches nothing. Write*void.
Function pointers. fn(A, B) -> C is accepted, but only in the tight
spelling with no space before (. sfn fmt normalizes that to fn (A, B) -> C,
which the checker then rejects with E0805, so a formatted source file cannot
carry a typed function-pointer extern parameter. Typed callback parameters are
designed, not shipped (SFEP-0079 §3.4, leaf L5). Pass a callback as a raw
address instead — §13.5.
Declaration diagnostics
Section titled “Declaration diagnostics”| Code | Raised when |
|---|---|
E0801 |
The type is string or string?, or a pointer whose pointee is (*string, *const string). Use *u8 plus a NUL-terminated copy. |
E0802 |
The type has a [] at the top level. Sailfin arrays carry runtime metadata; use *T plus a length parameter. |
E0803 |
The extern declares type parameters (<...>). Only concrete C-ABI types cross the boundary. |
E0804 |
The extern declares effects (![...]). Move the clause onto the calling wrapper. |
E0805 |
Any other inadmissible or missing type: a missing parameter or extern var annotation, bare void in parameter position, an unrecognized name such as number or boolean, and any string/array shape the two rules above do not reach (a nested Foo<int[]> lands here, not on E0802). |
13.3 The @repr(C) layout contract
Section titled “13.3 The @repr(C) layout contract”@repr(C) on a struct guarantees a C-compatible layout: fields are placed in
declaration order at each field’s natural alignment, the struct’s alignment is
the maximum field alignment, and the size is rounded up to that alignment
(tail padding). For a valid unpacked struct this changes no generated IR —
the LLVM lowering for a struct already does this — what @repr(C) adds is the
guarantee: a future Sailfin-native layout optimization (reordering, niche
packing, field elision) must skip a @repr(C) struct.
// linux/input.h — 24 bytes on LP64 (aarch64 and x86_64 Linux).@repr(C, size = 24)struct InputEvent { sec: i64; usec: i64; kind: u16; code: u16; value: i32;}InputEvent lays out at offsets 0, 8, 16, 18, 20, for a size of 24 and an
alignment of 8, mirroring struct input_event from linux/input.h on LP64.
Admissible field types. A @repr(C) struct field must be one of:
| Sailfin | Size / align (bytes) |
|---|---|
i8, u8 |
1 / 1 |
i16, u16 |
2 / 2 |
i32, u32 |
4 / 4 |
i64, u64, isize, usize |
8 / 8 |
f32 |
4 / 4 |
f64 |
8 / 8 |
f16, bf16 |
2 / 2 |
*T, *const T, *mut T |
8 / 8 |
another @repr(C) struct, by value |
that struct’s computed size / align |
Every other field type is E0847: string, T[], closures, enums,
optionals (T?), generics, a non-@repr(C) struct by value, and bool.
bool is rejected because C’s _Bool is a byte while Sailfin’s bool
storage is i1; a loaded byte other than 0/1 would be undefined
behavior. Use u8 instead. Admitting bool later with i8 storage is a
compatible widening. An inline fixed-size array field ([T; N]) is not
admissible yet — designed, not shipped (SFEP-0079 §3.1, leaf L9, SFN-1299).
A nested @repr(C) struct field must be declared in the same module. A
struct imported from another module is reconstructed from that module’s
compiled artifact, which records no decorators, so the compiler cannot tell
whether it carries @repr(C) and conservatively rejects the field with
E0847. Declare the mirror alongside the struct that embeds it, or hold it
behind a pointer (*Timespec), which is admissible across modules.
packed. @repr(C, packed) lowers to an LLVM packed struct
(<{ ... }>): alignment 1, no padding between or after fields — the same
guarantee as __attribute__((packed)). Field loads and stores against a
packed struct use align 1.
@repr(C, packed)struct EpollEvent { events: u32; data: u64;}Unpacked, EpollEvent would lay out as { i32, [4 x i8], i64 } — 16 bytes,
with 4 bytes of padding before data so it lands at its natural 8-byte
alignment. Packed, it lowers to <{ i32, i64 }> and is 12 bytes: data
follows events immediately, at offset 4.
Assertions. size = N and align = N are optional named arguments,
checked against the computed layout. align = N must be a power of two. A
mismatch is E0848, and the diagnostic prints the computed per-field offsets
so a C header transcription error is caught at compile time instead of
becoming a silent ABI mismatch. InputEvent above uses size = 24 this way.
Target invariance. Every governed target (SFEP-0066 §3.2: x86_64 and
aarch64 Linux, arm64 macOS, x86_64 Windows) is 64-bit with identical natural
alignment for every admissible field type, so a valid @repr(C) layout is
target-invariant — with one exception, packed, which reproduces
whatever ABI the platform’s C compiler assigns a packed struct. A C type
whose layout genuinely differs per target (long, struct stat) must still
be modeled per target by the binding library; @repr(C) does not make a
target-varying C type target-invariant, it only guarantees that Sailfin lays
out the fields you wrote exactly as specified everywhere.
Misuse. E0846 covers:
- an unknown or malformed decorator argument (
@repr(c),@repr(C, pack), a non-literal or non-power-of-twoalign) @repr(C)on an enum@repr(C)on a generic struct — a type parameter has no single C layout
Methods are allowed on a @repr(C) struct; the decorator constrains data
layout only.
Not shipped. The layout builtins size_of, align_of, and offset_of
are designed, not shipped (SFEP-0079 §3.1, leaf L2b, SFN-1295). There is
no way yet to query a @repr(C) struct’s layout from Sailfin source; the
guarantee is enforced at compile time by the checker and the LLVM lowering,
not exposed as a value.
Layout diagnostics
Section titled “Layout diagnostics”| Code | Raised when |
|---|---|
E0846 |
An unrecognized or malformed @repr argument, or @repr(C) applied to an enum or a generic struct. |
E0847 |
A @repr(C) struct field’s type has no C representation: string, T[], a closure, an enum, T?, a generic, a non-@repr(C) struct by value, or bool. |
E0848 |
A size = or align = assertion disagrees with the computed layout. The message prints the computed per-field offsets. |
13.4 Raw pointer operations
Section titled “13.4 Raw pointer operations”The following operate on any raw pointer and are specified here as shipped
behavior. None of them requires an unsafe block.
- Load.
*preads aT. - Store.
*p = vwrites aT. - Member access.
p.f, wherep: *SandSis a struct, loads or stores fieldfat its offset, auto-dereferencing. - Arithmetic.
p + nandp - nadvance and retreat bynelements, scaled by the pointee’s size, matching C. On*u8the step is one byte. - Casts.
p as *Ureinterprets.p as i64andn as *Tconvert between an address and an integer.0 as *Tandnullare the null pointer, andp == null/p != nullare the null tests. - Address of a struct binding.
s as *Syields the address of that struct’s storage. - String data pointer.
s as *u8yields a string’s data pointer. It is NUL-terminated only for string literals; any other string handed to a Cconst char*must first be copied with an explicit NUL, because slices are not NUL-terminated.
Dereferencing a non-pointer is not rejected by
sfn check. It producesE1006, awarning-severity diagnostic raised during lowering, and the expression lowers to no operand.sfn checkmodels no codegen, so it reports nothing at all.
Bind a literal before casting it. Write the string to a
stringlocal and cast the local. The inline form —getenv("PATH" as *u8)— miscompiles: the call is elided and its result lowered to a null store, so the value reads as unset. The runtime records this atruntime/sfn/memory/arena.sfn:441.
Mutability is not enforced. *T, *const T, and *mut T produce the same
pointer type and permit the same reads and writes. A read-only *const T, with
stores through it rejected as E0852, is designed, not shipped
(SFEP-0079 §3.2, leaf L3).
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. Anything the foreign side
retains beyond the call must live in memory it owns, such as a malloc
allocation. This rule is documented, not enforced.
Layout is unspecified except under @repr(C). A struct without @repr(C)
lowers to an LLVM identified type in field declaration order, which coincides
with the C ABI for scalar fields on the supported targets, but no part of that
is a contract: nothing prevents a future layout optimization from reordering
its fields. @repr(C) is the guarantee — see §13.3.
13.5 Function addresses
Section titled “13.5 Function addresses”A named Sailfin function’s address is taken with an explicit cast:
extern fn pthread_create(t: *u8, attr: *u8, start: *u8, arg: *u8) -> i32;
fn worker(arg: *u8) -> *u8 { return arg; }
// `worker as *u8` is the code pointer C receives.The cast lowers to a single code pointer, not a closure pair, so foreign code can call the function directly. Two diagnostics guard the form:
| Code | Raised when |
|---|---|
E0808 |
A function name is used as a value without the cast, or with a target other than * u8 or a function-pointer type. |
E0809 |
The named function is generic. Only concrete functions have a single address. |
This is the shipped C→Sailfin callback path. Defining a C-callable symbol under
an unmangled name (extern fn … { body }) is designed, not shipped
(SFEP-0079 §3.4, leaf L6). A Sailfin throw unwinding across a foreign frame
is undefined.
13.6 Effects and unsafe
Section titled “13.6 Effects and unsafe”Extern calls are invisible to the effect checker: E0804 forbids effects on
the declaration, and no analysis pass attributes an effect to an extern call.
The capability surface of foreign code is therefore not derived — declare
the effects on the Sailfin wrapper that calls the extern, so that the wrapper’s
callers propagate them normally.
unsafe is meaningful to the ownership checker and to nothing else. No pointer
operation requires it. What it does is suppress ownership analysis, and the
two forms suppress different amounts:
unsafe { }— the checker does not walk the block’s interior, so statement-level findings inside it are not raised: a double consume that would beE0901outside the block is not reported inside it. The routine-level obligation survives, but the checker cannot see a consumption that happened inside the block, so aLinear<T>consumed only there is reported asE0907— the diagnostic moves rather than disappearing.unsafe fn— the entire body is skipped. ALinear<T>parameter of anunsafe fncarries no enforced obligation; the same function writtenfnraisesE0907.
Passing a bare owned value to an extern declared in the same compilation unit,
outside an unsafe block, raises E0906; inside one it does not. That is the
boundary the block exists for.
Because unsafe fn disables the checks rather than narrowing them, prefer a
plain fn with a narrow unsafe { } block around the foreign call.
![unsafe] is not an effect and never was. The canonical effects are
clock, gpu, io, model, net, and rand; a function declaring
![unsafe] is rejected with E0404. The ![unsafe] effect,
[capabilities] required = ["unsafe"], and [policies.unsafe] are
withdrawn by SFEP-0079 §3.5: each was a restriction with no matching power,
and a derived record of the program’s foreign edges supersedes the audit
purpose they were meant to serve. That record is designed, not shipped
(SFEP-0079 §3.5, leaf L8).
13.7 Linking
Section titled “13.7 Linking”The foreign library providing an extern symbol must be linked into the final binary. Name it in the capsule’s manifest:
[build]link-libs = ["m", "pthread"]link-libs is honored only for the capsule being built. A dependency
capsule that declares it has the key ignored, reported as E0627 at warning
severity, so an application linking a foreign library must declare it itself.
Link inputs are build inputs, not provenance: naming a library tells the linker what to resolve against, and records nothing about what that library does.