Runtime ABI
- Performance-first data layouts.
- Stable, versioned ABI — a coarse
i32version counter plus a fine-grainedi64FNV-1a hash over the locked aggregate layouts. The runtime refuses to start if a linked module disagrees with the runtime’s expected version or hash. See ABI Versioning for the link-time contract. - Zero-copy slices.
- Clear interop boundaries for FFI.
Core Data Layouts
Section titled “Core Data Layouts”SfnString — { i8*, i64 }
Section titled “SfnString — { i8*, i64 }”%SfnString = type { i8*, i64 } ; { data, length }| Field | Offset | Size | Alignment | Meaning |
|---|---|---|---|---|
data |
0 | 8 | 8 | Pointer to UTF-8 bytes (not NUL-terminated). |
length |
8 | 8 | 8 | Byte length (i64). |
Total 16 bytes, aggregate alignment 8. Locked at M1.A (issue #392) —
perturbing either field changes @sfn_abi_hash and trips the
runtime’s startup check.
Ownership. data is owned by whichever side allocated it.
String-literal materialization routes through
sfn_alloc_struct; the arena-
aware helpers (sfn_str_concat_arena, sfn_str_append_arena) are
unconditionally arena-backed by design — the SAILFIN_USE_ARENA
opt-out applies only to the legacy _rt_* allocation paths and the
2-arg sailfin_runtime_string_concat trampoline. Callers that need
the bytes past arena rewind must copy them. The seed-built first-
pass binary still emits some legacy i8*-shaped string locals
during the M2.4 cutover; call sites use extractvalue to recover
the legacy i8* operand where the surrounding lowering still
treats strings as raw pointers.
SfnArray — { T*, i64, i64 }
Section titled “SfnArray — { T*, i64, i64 }”%SfnArray = type { i8**, i64, i64 } ; { data, length, capacity }| Field | Offset | Size | Alignment | Meaning |
|---|---|---|---|---|
data |
0 | 8 | 8 | Pointer to contiguous element storage. |
length |
8 | 8 | 8 | Element count (i64). |
capacity |
16 | 8 | 8 | Allocated slot count (i64). |
Total 24 bytes, aggregate alignment 8. Locked at M1.B (issue #393).
Element-type genericity. T is erased to i8* in every runtime
helper signature and in the ABI hash’s layout string (canonical form
SfnArray{f0:i8**@8;f1:i64@8;f2:i64@8}). Specialized element types
are recovered at the call site through the
array_concat_element_type / array_push_element_type parameters
threaded through coerce_and_emit_call;
runtime bodies never index into elements directly.
Growth policy. length and capacity are independent — appends
fitting capacity mutate in place, overflows allocate a fresh page
through the arena (or libc realloc off-arena) and copy existing
slots. The arena’s grow-if-at-tip realloc
(sfn_arena_realloc)
preserves the in-place fast path for typical append loops. Doubling
is not part of the ABI — it is an implementation detail of the
array helpers — but no helper may shrink capacity below length
or leave a slot uninitialized within [0, length).
Slice & Dynamic
Section titled “Slice & Dynamic”%SfnSlice = type { i8**, i64 } ; { data, length }%SfnDynamic = type { i64, [16 x i8] } ; { tag, payload }SfnSlice is a non-resizable view into another aggregate’s storage,
materializable from SfnArray by a no-op extractvalue of the
{data, length} prefix; lifetime is enforced statically by the
compiler, not carried in the aggregate. SfnDynamic is a tagged
union for any/dynamic interop boundaries; its 16-byte payload
inlines an SfnString or a 64-bit scalar, and out-of-line values
store their pointer in the first 8 bytes.
Entry Point
Section titled “Entry Point”Since M5 (#451,
shipped 2026-05-25) the binary’s entry point is the
Sailfin-emitted @main — no C driver participates in startup.
The compiler emits exactly one definition matching the platform
contract:
define i32 @main(i32 %argc, i8** %argv) { ; resolve runtime root (walks up from argv[0] looking for a ; sibling `runtime/` directory, honouring SAILFIN_RUNTIME_ROOT) ; marshal argv into SfnArray<SfnString> ; call into the Sailfin CLI: sailfin_cli_main__cli_main ; return i32 exit status}In LLVM IR the entry symbol is spelled @main; after linking, tools
like nm show it without the @ sigil. nm build/bin/sfn | grep -E ' T main$' shows exactly one T main row, sourced from the
Sailfin-emitted definition in compiler/capsules/codegen-llvm/src/lowering/ (see
the Runtime Migration table in docs/status.md for the pipeline trace).
SAILFIN_TRACE_ARGV prints the argv vector observed by
sailfin_cli_main for entry-point debugging.
The previous C entry point (runtime/native/src/native_driver.c)
and its Windows MinGW cross-compile rules were deleted as part of
M5; the symbols its extern decls referenced
(compile_to_sailfin / compile_to_llvm, _resolve_runtime_root,
SailfinPtrArray construction helpers) are now reached entirely
through Sailfin code paths.
Native Windows compiler and release builds use the MSVC ABI and bootstrap from
the signed MSVC release seed. MinGW is not a release or bootstrap provider; the
x86_64-w64-mingw32 triple remains available as an explicit cross-compilation
target. See the completed
Native Windows Self-Host proposal (SFEP-0021)
for the migration design and acceptance record.
ABI Versioning
Section titled “ABI Versioning”Every emitted Sailfin LLVM module defines two global symbols with
linkonce_odr linkage so duplicates coalesce at link time:
@sfn_abi_version = linkonce_odr constant i32 1@sfn_abi_hash = linkonce_odr constant i64 13458649150685806382Emission lives in
lowering_phase_render.sfn:205-207;
the hash value is computed at compile time by
abi_hash.sfn:sfn_abi_hash_decimal().
Version semantics. @sfn_abi_version is a coarse monotonic
counter (current value 1); it bumps when a breaking shape change
ships that the hash cannot describe — a new aggregate, a renumbered
exception ABI, or a calling-convention change. @sfn_abi_hash is
an FNV-1a 64-bit digest over the order-sensitive layout string
SfnString{f0:i8*@8;f1:i64@8};SfnArray{f0:i8**@8;f1:i64@8;f2:i64@8}Hand-perturbing any field type, offset, alignment, or aggregate name
changes the hash. The FNV-1a routine lives in pure Sailfin (no
extern fn) and carries its state as four 16-bit limbs so earlier
seeds, which compiled int as f64, still produced exact results.
Link-time behavior. The compiler emits @sfn_abi_version and
@sfn_abi_hash as linkonce_odr constants in each Sailfin LLVM module. The
runtime is entirely Sailfin-native under runtime/sfn/; the former weak C
fallbacks and C constructor were deleted with runtime/native/ in #822. A
runtime-only C link mode is therefore no longer part of the supported ABI.
Allocation Helpers
Section titled “Allocation Helpers”The compiler emits calls to two arena-aware helpers for boxed
allocations that the Sailfin-native bump allocator in
runtime/sfn/memory/arena.sfn reclaims in bulk via sfn_arena_mark /
sfn_arena_rewind. Both
fall through to libc calloc / free when the arena is disabled
(SAILFIN_USE_ARENA=0), so off-arena callers retain the original
malloc-based lifetime contract.
| Symbol | Signature | When emitted | Pairs with | Arena | Off-arena |
|---|---|---|---|---|---|
sfn_alloc_struct |
i8* (i64 size_bytes) |
string-literal materialization, struct/array literal boxing, scalar→i8* coercion |
sfn_mem_free (or arena bulk reclaim) |
arena allocation | calloc(1, size) |
sfn_mem_free |
void (i8*) |
await-unboxing of a boxed-struct future return; scope-exit drop of a boxed local | sfn_alloc_struct |
no-op (arena reclaims at rewind / exit) | free(ptr) |
Both helpers are implemented in runtime/sfn/memory/mem.sfn and preserve the
arena-mode no-op guard plus the libc allocation/free fallback.
Alignment. Both helpers guarantee 8-byte alignment — the arena
path passes align=8 to sfn_arena_alloc, and calloc aligns to
at least max_align_t (≥ 8 on every supported target).
Lifetime (arena-backed by default). Per
issue #324,
installed binaries default to SAILFIN_USE_ARENA=1 — unset takes
the default, only an explicit 0/""/"false" opts out. Arena
allocations live until the next sfn_arena_rewind to a prior mark
or until process exit; the compiler does not emit per-allocation
drops for arena-routed pointers (M1.5.5 escape promotion is not yet
on). Off-arena, sfn_mem_free fires only at sites with
unambiguous ownership (today: await unboxing of boxed-struct
future returns and scope-exit drops of boxed locals), must only be
paired with _rt_calloc-routed pointers, and would otherwise leak
or corrupt libc metadata. The
async-context calloc/free pair in
emission_async.sfn
intentionally stays on raw @calloc / @free because both ends
of the allocation are libc-resident regardless of arena state.
In-loop reclamation (#1514 / #1515). Beyond the function-scope
rewind, the emitter now reclaims per-iteration arena allocations:
it wraps a loop body in sfn_arena_sfn_mark at body entry and
sfn_arena_sfn_rewind at iteration close, so a hot allocation loop
stops growing RSS linearly (the arena_alloc benchmark drops from
~691 MB to a flat ~1.7 MB — see
docs/perf/runtime-performance.md).
This is gated on a non-escape proof
(loop_body_rewind_eligible, instructions_helpers.sfn) and fires
only when the body allocates via a real @sfn_alloc_struct, every
body-scope heap local is an all-primitive-scalar struct still routed
to the arena (not promoted to rc, consumed, or stored into an
ancestor-scope binding), and the body has no other call/store that
could grow an ancestor container the rewind would then free. It
covers generic loop, for x in arr, and for i in a..b;
continue / break / return paths skip the rewind (a safe missed
reclamation, never a dangling pointer). Scalar-replacement
(strategy B) and nested-loop mark stacking are post-1.0.
Default arena global — per-thread (SFN-558). The Sailfin-native arena
module lazily initializes a default arena through sfn_arena_global(); that
symbol resolves thread_local storage, so each thread lazily creates and
privately owns its own Arena rather than every thread sharing one
process-wide instance. This closes a data race reachable from
routine/spawn/parallel (concurrent allocations could otherwise alias the
same address). Arena-aware helpers resolve the calling thread’s arena when no
explicit arena is supplied; no C constructor or runtime-global fallback
participates in startup. A thread’s arena is never destroyed — it leaks until
process exit, matching the prior single-arena lifetime, since a value a
worker returns across parallel/await/a channel must outlive the worker
and arena memory has no per-object free. mark/rewind and the
SAILFIN_DUMP_ARENA_STATS telemetry dump likewise bind to the calling
thread’s arena only. Full rationale:
docs/proposals/design-notes/sfn-558-arena-thread-safety.md.
Native Signature Registry
Section titled “Native Signature Registry”Every entry in
runtime_helpers.sfn
carries an optional native_signature field (issue #392). When set,
the LLVM lowering emits calls to the canonical Sailfin-native symbol
(sfn_<domain>_<op>) instead of the legacy C entrypoint
(sailfin_runtime_*). Consumers are
render_runtime_helper_declarations
(emits the declare) and
coerce_and_emit_call
(emits the call site); both prefer native_signature over symbol
when populated, so unflipped entries keep routing through the legacy
C body during the M2.4 / M2.6 migration.
Naming convention. sfn_<domain>_<op> — short, lowercase,
underscore-separated. Domains are allocated in priority order so ABI
hazards surface in the smallest pilot first:
sfn_str_*— string ops. Smallest ABI surface, no effects.sfn_array_*— array/collection ops.sfn_fs_*— filesystem ops (needs![io]capability bridging).sfn_net_*— network ops (needs![net]capability bridging).sfn_clock_*— clock/time ops (mostly already direct-emitted).
Pilot list. Twelve descriptors carry a populated
native_signature today (9 string + 3 array):
Descriptor target |
Native symbol | Wave |
|---|---|---|
substring |
sfn_str_slice_lv |
SFN-632 |
substring_unchecked |
sfn_str_slice_lv |
SFN-632 |
string.length |
sfn_str_len |
M2.4a (#430) |
strings_equal |
sfn_str_eq |
M2.4a (#430) |
string.concat |
sfn_str_concat_arena |
M2.4b (#398) |
string.starts_with |
sfn_str_starts_with |
M1.2 (#461) |
string.ends_with |
sfn_str_ends_with |
M1.2 (#461) |
string.contains |
sfn_str_contains |
M1.2 (#461) |
string.repeat |
sfn_str_repeat |
M1.2 (#461) |
append_string |
sfn_array_push_string |
M2.6 (#466) |
concat (array) |
sfn_array_concat |
M2.6 (#466) |
array_push_slot |
sfn_array_push_slot |
M2.6 (#466) |
Rollout status. Roughly 107 descriptors remain on the legacy
sailfin_runtime_* shape. The per-wave checklist lives as a header
comment at the top of runtime_helpers.sfn; each wave picks a 5–10
helper batch in a single domain, populates native_signature, and
adds C trampolines for any hardcoded LLVM call sites that bypass
the descriptor table.
Typed Closures
Section titled “Typed Closures”A typed closure is a two-word aggregate { fn_ptr, env_ptr } with
both slots erased to i8*, so closure values pass uniformly across
runtime boundaries (spawn handlers, channels, higher-order helpers)
without per-capture-shape ABI variants:
%sfn_closure = type { i8*, i8* } ; { fn_ptr, env_ptr }Hidden-env-first calling convention. The lifted lambda is lowered with the env pointer as a hidden first parameter:
define <ret_ty> @sfn_lambda_<id>(i8* %env, <decl_param_tys>...)Non-capturing lambdas use the same signature — %env is unused but
present, so the call shape is uniform.
Concrete synchronous top-level named functions use that shape when an exact
fn(...) type is expected. The compiler emits one internal adapter per named
function value; the adapter accepts the hidden env, ignores it, and performs a
tail call to the original function. LLVM’s musttail form is not valid here
because the adapter has the extra hidden-env parameter; the tail hint and the
compiler’s -O2 code-quality gate ensure the forwarding frame is eliminated.
The materialized pair contains the adapter pointer and null. A normal direct
call to the same named function is unchanged, and raw as * u8 address-taking
still names the original C-ABI symbol rather than the adapter. Concrete nested
functions use the same materialized form. Generic, async, entry-point, and
non-pointer-width aggregate signatures are not admitted by this v0 path.
A fn-typed struct field stores this pair inline. Calling the field GEP-loads the pair and feeds it to the same hidden-env-first dispatch below; it does not box the pair or introduce a second indirect-call convention.
Env-struct allocation. Captures are packed into a per-lambda env
struct in first-use order (deterministic across runs). The helper
API in closures.sfn
synthesizes the type (%sfn_closure_env_<id>), allocates it via
sfn_alloc_struct, and emits the
GEP+load prologue inside the lifted body. Non-capturing closures
materialize with env_ptr = i8* null.
Call-site dispatch. Closure calls extract the slots, bitcast the
function pointer to <ret_ty> (i8*, <param_tys>)*, and invoke with
the env pointer first:
%fn_ptr = extractvalue {i8*, i8*} %closure, 0%env = extractvalue {i8*, i8*} %closure, 1%typed = bitcast i8* %fn_ptr to <ret_ty> (i8*, <param_tys>)*%result = call <ret_ty> %typed(i8* %env, <args>...)This mirrors the trait-dispatch shape, keeping the IR-shape
vocabulary uniform across closures, trait objects, and any future
indirect-call primitive. The bitcast signature is recovered from
either the lifted lambda’s internal __closure__@sfn_lambda_<N>
sentinel or a user-written fn(<param_tys>) -> <ret_ty> annotation.
Lifetime stance. The env struct routes through
sfn_alloc_struct; the compiler does not emit a
paired free on closure-pair drop because the pair has no
deterministic owner (it can be copied into spawn handlers, channels,
or returned upward). Arena reclaim covers the arena case; the off-
arena case leaks until process exit. Per-capture RC retain/release
and a deterministic env drop are deferred to a post-M1 issue. See
docs/proposals/0025-native-runtime-architecture.md §3.4 “Typed Closures”
for the design rationale.
Exception / Unwind ABI
Section titled “Exception / Unwind ABI”Sailfin’s exception model is structured frames with deterministic
unwind. Today’s implementation is setjmp/longjmp-based and lives in
runtime/sfn/exception.sfn. Compiler-emitted try blocks use stack-allocated
frames and call the Sailfin-native exception primitives directly.
| Symbol | Signature | Role |
|---|---|---|
sfn_exception_push_frame |
void (i8*) |
Push a compiler-allocated frame onto the thread-local frame chain. |
sfn_exception_pop_frame |
void (i8*) |
Pop the matching frame from the chain. |
sfn_throw |
void (i8*) |
Store the message on the current frame and longjmp; abort if no frame exists. |
sfn_take_exception |
i8* (i8*) |
Read and clear the message from the caught frame. |
Landingpad shape. The compiler-emitted IR follows a call+br
shape rather than LLVM’s native invoke/landingpad:
%status = call i32 @setjmp(i8* %jmp_buf)%caught = icmp ne i32 %status, 0br i1 %caught, label %catch.entry, label %try.bodyThe init-sentinel cleanup at catch entry (M1.5.4 /
issue #328)
emits guarded sfn_rc_release calls for owned RC locals initialized
before the throw — the sentinel slot lives in the function frame
and is set at each declaration site by
instructions_let.sfn.
Unwind tables. The setjmp/longjmp path does not require
DWARF unwind tables (.eh_frame) — rewind happens through the thread-local Sailfin frame chain, not the C++
Itanium ABI. This sidesteps the macOS arm64 /
Linux x86_64 unwinder fragmentation that has historically broken
cross-platform release builds. The trade-off is that intermediate
frames between throw and the catching try cannot run cleanup
automatically — the compiler emits explicit scope-boundary drops
instead.
Frame-based catches. Compiler lowering allocates an
SfnExceptionFrame in the function frame, pushes it before setjmp, and pops
it on normal exit. A throw records the message on that frame and jumps back to
the saved point; the catch path reads it with sfn_take_exception.
Out of scope today. Exception propagation across routine /
spawn boundaries is gated on the M4 concurrency ABI; concurrent
throws are not yet defined. longjmp across an extern fn is
undefined — FFI boundaries trap at the C body.
The runtime has migrated from C to Sailfin. See the
Runtime Migration table in docs/status.md
for current status, and Epic
#450 (M1 — ABI lock +
codegen switch) for the lock-in sequencing this doc reflects.