In plain words
Every compiled Turf program carries a small support library, called the runtime (libturfrt), inside it. It looks after jobs the program cannot do alone, such as managing the garbage-collected heap and reporting panics. This page explains what it is for and how the compiler connects to it.
Internals reference for libturfrt, the small C static library every compiled
Turf binary links. It is grounded in the current source: runtime/turfrt.h’s
doc comments are the line-level reference, and this document explains what the
library is for and how it fits into the compiler.
Related docs:
docs/architecture/compiler_pipeline.mdfor the pass order the two compiler-side hooks (themainwrapper, panic lowering) attach to.docs/architecture/memory_model.mdfor the storage model, the two heaps and the collector’s root discovery (§2.6). This document covers the library around the collector, not the collector’s algorithm.docs/architecture/boundary.mdfor the compiler / standard library / runtime boundary rules and the closed list of intrinsics.src/sema/Intrinsics.h, the closed list of bare intrinsics; every runtime symbol Turf source can reach is named there (F-STD-2).
1. What the runtime owns
Rule B2 (docs/development/FREEZE.md §6.8): the runtime owns anything that
must be true across the whole process and cannot be re-derived per translation
unit. Turf never compiles modules separately (every import is parsed and
code-generated into the same LLVM module), so “per translation unit” is not a
useful place to keep process-wide state. The runtime therefore owns:
- the GC heap and its collector,
- the root set and the stack bounds the collector scans,
- panic formatting and the process-wide fault handlers (stack overflow, segmentation fault),
- the out-of-memory policy for the GC heap,
- a few platform primitives whose C spelling differs per OS (§2).
It is C, not Turf, and it is not a place to move standard-library functionality
into (F-STD-3, Rule B3). lib/std/mem/mem.tr’s malloc/calloc/realloc
bindings stay what they are: the manual heap is libc’s heap (F-MEM-3).
2. What is in it
The library is built from plain C11 (CMakeLists.txt, target turfrt), has no
dependency on turfcore or LLVM, and is not linked into turfc. It is linked
into every compiled Turf program, the same relationship -lm has.
| File | Role |
|---|---|
runtime/turfrt.h |
The public C ABI: the entry points below and TurfTypeDesc. The compiler never includes it; Codegen builds the matching LLVM types by hand (getOrCreateTurfTypeDescType() in src/codegen/CodegenStruct.cpp). |
runtime/turfrt_internal.h |
Glue between the runtime’s own .c files. Not part of the ABI. |
runtime/turfrt_alloc.c |
turf_rt_init()/turf_rt_shutdown(), the three allocation entry points, turf_rt_checked_free(), turf_rt_stats(). |
runtime/turfrt_gc.c |
The collector: block table, root discovery, mark, sweep, pins, the testing aids. |
runtime/turfrt_panic.c |
turf_rt_panic(), and turfrt_abort() / turfrt_flush_stdout(), bound by lib/std/core/sys.tr. |
runtime/turfrt_convert.c |
turf_rt_str_to_int() and turf_rt_str_to_double(), the string-to-number casts. |
runtime/turfrt_types.c |
turf_rt_typedesc_is_pointer_word(), the one reader of a descriptor’s bitmap. |
runtime/turfrt_abi.c |
The ABI stamp (§7.3). |
runtime/turfrt_signal.c |
The fault handlers (§7.1). |
runtime/turfrt_sys.c |
turfrt_clock_us() (CPU time in microseconds) and the process-wide random generator (turfrt_random_u31(), turfrt_random_reseed()), bound by lib/std/time/time.tr and lib/std/random/random.tr. |
runtime/turfrt_net.c |
Socket helpers whose behaviour differs per platform (turfrt_net_startup, _socket, _close, _reuseaddr, _sockaddr), bound by lib/std/io/net.tr. |
runtime/turfrt_win32.c |
On Windows only: posix_memalign, malloc_usable_size, setenv, defined under their POSIX names so lib/std/mem/mem.tr and lib/std/os/os.tr link unchanged, plus turf_win32_free_aligned(). |
Naming rule for a runtime function that Turf source binds with extern fn: use
the prefix turfrt_, never turf_<module>_<name>. The compiler mangles a
stdlib function random::reseed to turf_random_reseed, and an extern fn
keeps its C name verbatim, so the two spellings would collide.
The entry points in runtime/turfrt.h:
| Symbol | What it does |
|---|---|
turf_rt_init() |
Records the stack base (turf_gc_record_stack_base()), then installs the fault handlers (turf_rt_install_segv_handler()). |
turf_rt_shutdown() |
Prints the allocation statistics when TURF_RT_STATS_DUMP is set; otherwise nothing. There is no final collection: process exit reclaims everything. |
turf_rt_alloc(bytes, desc), turf_rt_alloc_zeroed(bytes, desc), turf_rt_realloc(p, bytes, desc) |
Collected GC-heap allocation. Each panics (out of memory) when the system allocator fails, so a caller never sees NULL. Each registers the block with the collector before the allocation-pressure check can run. |
turf_rt_collect() |
One stop-the-world mark-sweep cycle. |
turf_rt_add_global_root(slot), turf_rt_remove_global_root(slot) |
Register a void** storage location as a precise root. Turf has no mutable global storage, so Codegen has no caller for these; they exist as tested runtime API (scripts/smoke/gc_smoke.sh). |
turf_rt_pin(p), turf_rt_unpin(p) |
Pin the GC object containing p; pins nest. Reached from mem::root()/mem::unroot(). |
turf_rt_is_gc_pointer(p) |
Exact block-start test. Reached from the gc_is_managed intrinsic. |
turf_rt_checked_free(p) |
libc free() for the manual heap, after panicking if p is a live GC block (cross-heap free). Reached from mem::free() through the gc_checked_free intrinsic. |
turf_rt_stats() |
Allocation counters plus the collector’s point-in-time numbers; read from C by the smoke scripts. |
turf_rt_string_capacity(p) |
The bytes from p to the end of the GC block holding it, or -1 outside the GC heap. Bounds every string byte write s[i] = c (EmitStringBoundsCheck(), src/codegen/CodegenArray.cpp); declared to the optimizer as reading only runtime-private memory, so a loop that writes without allocating calls it once. |
turf_rt_str_to_int(s, out), turf_rt_str_to_double(s, out) |
s to int / s to double: 1 and the value when the whole of s is a number by the rules of strings::parse_int/parse_double, else 0, on which Codegen panics at the cast. |
turf_rt_panic(kind, msg, file, line) |
Flushes stdout, formats one line to stderr and calls abort(). |
turf_rt_typedesc_is_pointer_word(desc, i) |
Bit accessor for a descriptor’s pointer bitmap. |
turf_rt_abi_2_0() |
The ABI stamp. |
TurfTypeDesc is the one struct shared with the compiler (§4).
3. The two compiler-side hooks
3.1 Panic lowering: CodegenPanic(), src/sema/Builtins.cpp
Every one of the compiler’s own runtime safety checks (division and modulo by
zero, checked-arithmetic overflow, array and slice bounds, a bad string to int,
double or enum cast) goes through CodegenPanic(Value *MsgVal, SourceLocation Loc).
It declares turf_rt_panic once per module and calls it with the fixed kind
"runtime check", the message, and the Turf source file and line. The kind is
not distinct per call site; the message carries the distinction (INV-RT-2).
This is not the path lib/std/core/sys.tr’s panic() uses. That is an
ordinary Turf function that flushes stdout (turfrt_flush_stdout()), writes to
stderr with write(2) and ends through turfrt_abort(), which flushes again
and calls abort(); sys::abort() is turfrt_abort() too. abort() itself
flushes no stdio buffer, so without the flush, output a program printed before
a panic was lost whenever stdout was a pipe. CodegenPanic() is reachable only from the compiler’s own inline
checks, never from Turf source.
3.2 The main wrapper: WrapMainWithRuntimeBootstrap(), src/codegen/CodegenFunc.cpp
The user’s main is compiled as the C-ABI main, including the
argc/argv-stashing machinery (ArgcStorageGV/ArgvStorageGV and the
TurfRuntimeGetArgc/TurfRuntimeGetArgv accessors os.tr binds to).
WrapMainWithRuntimeBootstrap() runs on the finished module, so none of that
is touched:
- Find the function named
main. - Rename it
__turf_user_mainand give it internal linkage. - Declare (or reuse)
turf_rt_init,turf_rt_shutdownand the ABI stamp symbol (RuntimeAbiSymbol,src/codegen/Codegen.h). - Create a new
mainwith the original function type whose body calls the ABI stamp,turf_rt_init(), then__turf_user_main(argc, argv), thenturf_rt_shutdown(), and returns the user’s result.fn main(): voidis accepted, so for a voidmainthe wrapper isi32 main(...)returning 0.
The driver (src/driver/main.cpp) calls it after the entry-point check, after
every function is generated, and before runOptimizationPipeline(). Order
matters: the optimizer’s Internalize pass treats {"main"} as the whole
must-preserve set, so the symbol named main at that point has to be the
wrapper; __turf_user_main stays alive through the wrapper’s ordinary call.
At -O0 the two functions stay distinct (scripts/smoke/runtime_smoke.sh
checks the call sequence in the emitted IR); at higher levels the inliner
usually folds the wrapper away. The call sequence, not the function boundary,
is the contract, and scripts/smoke/opt_levels_smoke.sh shows optimization
changes no observable behaviour (INV-OPT-1). INV-RT-1 states the contract:
every binary links libturfrt and runs turf_rt_init()/turf_rt_shutdown()
around main.
4. Type descriptors
For every fully built LLVM struct type in the module the compiler emits a constant global mirroring the runtime’s struct:
typedef struct TurfTypeDesc {
uint64_t size; /* allocation size in bytes */
const char *name; /* for diagnostics only */
uint64_t word_count; /* ceil(size / 8) */
const uint64_t *pointer_words; /* ceil(word_count / 64) words of bitmap */
} TurfTypeDesc;
Bit i % 64 of pointer_words[i / 64] set means “bytes [8i, 8i+8) may hold
a pointer”. The bitmap covers the type’s entire layout at any size (INV-MEM-2);
turf_rt_typedesc_is_pointer_word() bounds-checks against word_count.
It is a sound over-approximation: every pointer-shaped word is marked, whether
it points into the GC heap or the manual heap, because the type system does not
tag a pointer field with its heap.
The collector reuses one descriptor for an array of elements: it walks a block
in desc->size-byte strides and re-applies the bitmap at each stride. A
single-struct allocation is the case where the block is exactly one stride.
desc == NULL means opaque (no pointers inside), which is correct for a string
buffer. Collection backing buffers get an element descriptor from
getOrCreateElementTurfTypeDesc() (src/codegen/CodegenStruct.cpp), injected
by CastExprAST::codegen() at a gc_alloc(...) to T* site.
buildTurfTypeDescInit() (src/codegen/CodegenStruct.cpp, shared by
emitTurfTypeDesc() and getOrCreateElementTurfTypeDesc()) builds the
initializer: word_count, a private [N x i64] bitmap global
(N = ceil(word_count / 64)) and a private name-string global. The name
globals are @__turf_typedesc_name.<llvm struct name> and
@__turf_typedesc_bitmap.<llvm struct name>; an element descriptor is
@__turf_elemdesc, with its own _name/_bitmap companions. An assertion at
emission (word_count * 8 >= size, bitmap length equals N) makes a
reintroduced fixed cap fail loudly in a debug build. For example, a struct with
one pointer in its third word:
@__turf_typedesc_bitmap.WithPointer = private constant [1 x i64] [i64 4]
@__turf_typedesc_WithPointer = internal constant %TurfTypeDesc
{ i64 24, ptr @__turf_typedesc_name.WithPointer, i64 3,
ptr @__turf_typedesc_bitmap.WithPointer }
A type with 70 int fields and then a string has word_count 71, so its
bitmap is [2 x i64] [i64 0, i64 64]: word 70 lands in the second i64 at bit
6. scripts/smoke/runtime_smoke.sh checks both shapes with --emit-llvm, and
exercises a hand-built descriptor with word_count 130 directly in C.
4.1 Why emission is a late pass: EmitAllTurfTypeDescs()
EmitAllTurfTypeDescs() (src/codegen/CodegenStruct.cpp, declared in
src/codegen/Codegen.h) iterates TheModule->getIdentifiedStructTypes() once,
from the driver, immediately before verifyModule(). It cannot run inline in
StructDeclExprAST::codegen(). A monomorphized clone (List_gen1_int) is
appended to the pre-pass list, so it is generated after an ordinary struct
declared earlier that embeds it by value (struct Holder { items: List<int> }).
While generating Holder, asking LLVM’s DataLayout for its size also computes
a StructLayout for the still-opaque List_gen1_int, and DataLayout caches
that zero-element layout against the StructType* with no invalidation hook.
When the clone later gets its real body, getStructLayout() returns the stale
object and getElementOffset(0) asserts. Emitting every descriptor after all
struct-building sub-passes (the struct pre-pass, the import-triggered struct
builds, the main pass, and the driver’s scan for a lingering opaque
monomorphized stub) means getStructLayout() is never called on a struct
before its own setBody(). INV-RT-3 records the ordering guarantee.
A function body that needs a named struct’s descriptor before that pass (to
pass to turf_rt_alloc) calls getOrForwardDeclareTurfTypeDescGlobal(), which
creates a forward declaration that emitTurfTypeDesc() later fills in, so both
orders produce the same single global.
4.2 Surviving optimization
Nothing in the module references a descriptor global unless a call site passes
it, and at any level above -O0 Internalize plus GlobalDCE would delete the
unreferenced ones. emitTurfTypeDesc() and getOrCreateElementTurfTypeDesc()
append each descriptor to @llvm.compiler.used (llvm::appendToCompilerUsed(),
llvm/Transforms/Utils/ModuleUtils.h), the standard way to keep a global alive
through the optimizer without external linkage.
4.3 Descriptor names carry the owning module
A descriptor is named after its LLVM struct, and a module-owned type’s LLVM
struct is named module.Name (TypeIdentity::llvmName(), src/sema/Types.h).
list’s List<int> is @__turf_typedesc_list.List_gen1_int and its name
string is "list.List_gen1_int". A type declared in the main program keeps its
bare name. Two modules may each declare a Node without their descriptors
colliding. Nothing in the runtime parses the name.
5. Build and distribution
CMakeLists.txt points the turfrt archive’s output at <repo-root>/lib/,
next to lib/std/. TurfInstallRoot() (src/frontend/ParserModule.cpp,
declared in src/frontend/Parser.h) is the single function both import and
linkObject() (src/driver/main.cpp) use to find the install root:
TURF_HOME if set, else <compiler-binary-dir>/... A dev build therefore
finds lib/libturfrt.a with no setup. *.a is git-ignored, and the release
packaging copies lib/ wholesale, which picks the archive up.
linkObject() links in one of two ways:
- With the system toolchain:
cc(gccon Windows) with-L<root>/lib -lturfrt -lm, ahead of any library the program names withextern fromor--link. On Windows it adds-Wl,--stack,8388608(an 8 MB main stack, as on Linux) and-lws2_32, since an archive member’s own library needs are not recorded in it. - With the linker built into
turfc, when the program names no system library beyond the C runtime, the release archive shipslib/rt/<platform>/, andTURF_LINKER=systemis not set. It links statically against that bundled C runtime andlibturfrt.a.
Either way the archive must exist: if <root>/lib/libturfrt.a is missing,
linkObject() reports TurfLang runtime library not found at '<path>' and a
TURF_HOME hint before invoking any linker (INV-RT-7). turfc --version
prints the runtime ABI stamp (runtime turf_rt_abi_2_0), the install root and
which linker this install uses.
A turfc built with -DTURF_RT_SANITIZE=ON links every program it compiles
against libturfrt_asan.a, an ASan+UBSan build of the same sources (target
turfrt_asan), and always uses the system linker. Under ASan the runtime
installs no fault handler (§7.1) and the conservative scan skips ASan-poisoned
words (memory_model.md §2.6.3).
6. What the runtime deliberately does not do
- It does not manage the manual heap.
mem::allocand friends are libc, and nothing inlib/stdcallsturf_rt_allocexcept through thegc_*intrinsics (F-MEM-3). - It does not trace into manual-heap memory. A GC pointer stored only in a
mem::allocblock is not a root unless the program pins it withmem::root()(memory_model.md§1.7). - It is single-threaded and stop-the-world, with no finalizers (F-MEM-2) and no final collection at shutdown.
7. The runtime contract
What a user sees when something goes wrong, and what stops a binary from running against the wrong runtime.
7.1 Stack overflow and other faults
turf_rt_install_segv_handler() (runtime/turfrt_signal.c) is called from
turf_rt_init() right after the stack base is recorded.
POSIX (Linux, macOS). It installs a sigaltstack-backed handler for
SIGSEGV and SIGBUS (macOS reports many protection faults, the stack guard
among them, as SIGBUS). The alternate stack is a fixed 64 KiB static buffer:
the handler must run after the thread’s own stack is exhausted, and delivering
the signal on it would fault again. If sigaltstack() fails, the handler is not
installed and the process keeps its default disposition.
The handler classifies the fault. getrlimit(RLIMIT_STACK) and
turf_gc_get_stack_base() (the stack’s high end, shared with the collector, not
a second copy) give a computed low limit. A fault address within a fixed
256 KiB window on either side of it is a stack overflow; anything else, and any
case where RLIMIT_STACK is unlimited or unqueryable, is a generic fault. The
window errs toward “generic”: the handler never calls a real memory-safety bug
“just recursion”.
A classified overflow goes through turf_rt_panic("stack overflow", ...), so it
ends exactly like every other panic. Any other fault prints
panic: segmentation fault (this is a bug in unsafe code or in TurfLang) at address <addr>
(bus error for SIGBUS) and calls abort().
The handler calls fprintf and fflush, which are not async-signal-safe
(only abort() is). That is accepted deliberately, since the process is terminating either way.
The one hazard is a fault that arrives while the crashing thread holds stdio’s
lock: the message can be lost, and the process still terminates.
Under ASan turf_rt_install_segv_handler() installs nothing. sigaction
replaces rather than chains, and ASan’s own handler, installed from its startup
constructor, reports far more. INV-RT-5 therefore holds for non-ASan builds
only, which is why the ASan CI job skips
tests/41_gc/fail_stack_overflow_deep_recursion.tr.
Windows. A stack overflow is a structured exception, not a signal.
turf_rt_install_segv_handler() reserves 128 KiB of guaranteed stack with
SetThreadStackGuarantee() (the counterpart of sigaltstack), installs a
vectored exception handler (turf_rt_veh) that prints the same two messages as
the POSIX handler, and sets the error mode and abort behaviour so a crash does
not open the error-reporting dialog. It also hooks SIGABRT to
TerminateProcess with status 0xE0545254, because the C runtime’s own
abort() exits with 3, which an ordinary return 3 also produces. The test
runner (src/tooling/TestRunner.cpp) and turfc itself spell the same number;
the three places change together. The runner reports that status as 134.
7.2 Out of memory
turf_rt_alloc, turf_rt_alloc_zeroed and turf_rt_realloc
(runtime/turfrt_alloc.c) call turf_rt_panic("out of memory", ...) the moment
the system allocator returns NULL, with the size asked for and the
descriptor’s name, if any, as the message (could not allocate 64 bytes for 'Node'). The collector’s own bookkeeping arrays do the same. No caller ever
sees a silent NULL from the GC heap. The manual heap has its own, separately
implemented policy: mem::alloc and friends call sys::panic() on NULL
(lib/std/mem/mem.tr). Both fail loudly, but they are different code (F-MEM-3).
tests/41_gc/fail_oom_huge_direct_allocation.tr and
scripts/smoke/runtime_smoke.sh exercise the path with one gc_alloc() call
sized at the largest int, which no allocator can satisfy.
7.3 The compiler/runtime ABI stamp
runtime/turfrt_abi.c defines turf_rt_abi_2_0(void), which does nothing.
WrapMainWithRuntimeBootstrap() calls it from every binary’s main wrapper
(the name is held in RuntimeAbiSymbol, src/codegen/Codegen.h). The effect is
entirely at link time: an object compiled by this compiler linked against a
libturfrt.a that lacks the symbol fails with
undefined reference to 'turf_rt_abi_2_0', a name that says what is wrong,
instead of linking and misbehaving against a runtime whose TurfTypeDesc
layout or entry-point signatures it no longer agrees with. A change to that ABI
bumps the suffix (_2_0 to _2_1, …) in both places together (INV-RT-6).
7.4 Panic message shapes and exit codes
| Termination | Message | Exit code |
|---|---|---|
| A compiler-inserted runtime check (division by zero, overflow, array bounds, …) | panic: runtime check: <msg> (<file>:<line>) |
134 (SIGABRT) |
| Stack overflow | panic: stack overflow: <msg> |
134 |
Any other SIGSEGV (and SIGBUS on POSIX) |
panic: segmentation fault (this is a bug in unsafe code or in TurfLang) at address <addr> |
134 |
| Out of memory, GC heap | panic: out of memory: could not allocate <n> bytes[ for '<type>'] |
134 |
| Out of memory, manual heap | sys::panic()’s own format |
134 |
A cross-heap mem::free(), an unbalanced mem::unroot(), an undiscoverable stack |
panic: <kind>: <msg> |
134 |
TURF_GC_VALIDATE finding a missed root |
panic: GC root missed: ... |
134 |
lib/std/core/sys.tr’s panic()/assert/fail |
that function’s own message | 134 |
sys::exit(n) |
none | n |
Every panic path ends in abort(). On Windows the same termination surfaces as
status 0xE0545254 (see §7.1), which the test runner reports as 134. Only
sys::exit(n) is a deliberate, non-panic exit with a caller-chosen code.