Chapter 19: @unsafe and calling C
Every check in chapters 15 to 18 stops at the edge of what the compiler can see. C libraries, inline assembly and claims about thread safety that rest on a protocol the compiler cannot read are on the other side. Loup marks that edge with one annotation, @unsafe("reason"), whose reason string is mandatory, so an audit of every unchecked assertion in a code base is one grep.
This chapter covers @unsafe and its statement form, declaring C functions with extern native, passing structs across the boundary, wrapping C resources in classes, inline assembly in host.native, and the header tools. The normative text is in the Language Reference §3-2.4.6 (@unsafe), §2-3.4 (extern native) and §3-2.7 (headers).
Two facts shape everything below:
- C calls need a native build.
loup runtype-checksextern nativedeclarations and calls, but its interpreter cannot call into C. Build withloup build --target native(chapter 24) to run FFI code. - The FFI library is planned. The specified helpers in
native.ffi(OwnedHandle,BorrowedHandle,PinnedRef, pointer arithmetic) andnative.memare not in the 1.25.1 standard library. What ships is the language part:extern native,NativeHandle,@repr(C)and@unsafe.
@unsafe and its reason string
@unsafe takes one string: a category, a colon, and the assertion being made. The category tells the compiler which check the annotation answers for, and tells a reviewer what kind of claim it is:
| Category | Asserts | Section |
|---|---|---|
threadSafe: |
a C function may be called from any thread | §2-3.5 |
escapingBorrow: |
a pointer given to C outlives the call | §3-2.4.6 |
sendable: |
a class is safe to share across threads although its fields do not prove it | §2-1.8.1 |
asm: |
an inline assembly block is correct | §3-3.7 |
distinct: |
arguments do not overlap (for requires distinct, planned in chapter 15) |
§3-2.4.10 |
overlay:, mmio:, init:, transmute: |
raw memory claims, mostly used in freestanding code | §3-3 |
It comes in three forms:
- an annotation on a declaration:
@unsafe("threadSafe: …")before anextern native func, aclassor afunc; - a statement region:
unsafe("reason") { … }around statements in any function body; - an annotation on one statement:
@unsafe("…")on the line before a call or anasmblock, which is shorthand for a one-statement region.
The audit is a single search that finds all three:
grep -rnE '@?unsafe\(' src/
Mistake: an @unsafe without a reason
@mode(host.native)
@unsafe
extern native "c" func getpid(): int32;
func main(): void {
println("declared");
}
main.lphc:3:1: error[E_UNSAFE_NO_REASON]: E_UNSAFE_NO_REASON: `@unsafe` requires a non-empty reason string — `@unsafe("<category>: <reason>")`
An empty string, @unsafe(""), is rejected the same way, and so is a statement region unsafe("") { … }, although its message is less polished (… has empty reason string (E_UNSAFE_NO_REASON)). Write what you are asserting: @unsafe("threadSafe: getpid only reads process state").
The single-purpose annotations that @unsafe replaced are hard errors that name their replacement:
@mode(host.native)
@threadSafe
extern native "c" func getpid(): int32;
func main(): void {
}
main.lphc:3:1: error[E_RETIRED_ANNOTATION]: E_RETIRED_ANNOTATION: `@threadSafe` is retired — use `@unsafe("threadSafe: <reason>")` instead (§3-2.4.6 consolidates the ad-hoc safety annotations into one auditable surface)
@escapingBorrow and @unsafeEscapingBorrow are reported the same way. Note the spelling of the replacement: @unsafe("…") takes the string directly, not @unsafe(reason: "…").
Opting a class out of the Sendable check
A class that declares implements Sendable may only have var fields of type Mutex[T], RwLock[T] or Atomic[T] (chapter 20). That rule rejects a class that is safe for a reason the compiler cannot see, such as a configuration that is filled before any thread starts and never written again:
@mode(host.native)
class Settings implements Sendable {
var entries: list[string] = [];
pub modifier func add(e: string): void { this.entries.push(e); }
pub func count(): int { return this.entries.length(); }
}
func main(): void {
let s = Settings();
safe { s.add("verbose"); }
let t = Thread.spawn(func(): void { println(s.count()); });
t.join();
}
main.lphc:4:5: error[E_SENDABLE_UNPROTECTED_VAR]: var field 'entries' on Sendable class 'Settings' must be of type Mutex[T], RwLock[T], or Atomic[T], not 'list'
If the field really is only written before the object is shared, say so, and the compiler accepts the claim:
@unsafe("sendable: entries is filled before the object is shared and never written afterwards")
class Settings implements Sendable {
var entries: list[string] = [];
pub modifier func add(e: string): void { this.entries.push(e); }
pub func count(): int { return this.entries.length(); }
}
Listing 19-1: A sendable: assertion on a class
1
The compiler now trusts you. If something does write entries from a thread later, the result is a data race that no check will report, which is why the reason has to be specific enough for a reviewer to verify. Prefer a synchronized class or Mutex/Atomic fields (chapters 15 and 20) whenever they fit; @unsafe is for the cases they do not cover.
Declaring C functions
extern native declares a function that lives in a C library. This program calls two of them, and running it with loup run shows the first thing to know about FFI code:
@mode(host.native)
extern native "c" func getpid(): int32;
extern native "c" func strlen(s: string): int;
func main(): void {
println(i"pid is positive: {$ getpid() > 0 $}");
println(strlen("hello"));
}
Listing 19-2: Calling two C library functions
Uncaught RuntimeError: internal interpreter limitation: unsupported instruction: CallExternNative
The program compiled: the declarations and calls were type-checked. Then the interpreter reached the first C call, which it cannot make. Build the program instead. On Linux x86-64, or on macOS with the LLVM backend:
loup build --target native -o pid
./pid
pid is positive: true
5
Use loup run to check FFI code for type errors while you write it, and a native build to run it.
The string after native names the library ("c" for the C library; "sqlite3" for libsqlite3.so / .dylib). The parameter and return types are Loup types that the compiler maps to the C ABI:
| Loup | C |
|---|---|
int8 … int64, uint8 … uint64 |
the integer type of the same width |
int |
int64_t (long on 64-bit Unix) |
float32, float |
float, double |
bool |
bool |
string (parameter) |
const char * to a NUL-terminated UTF-8 copy that lives for the duration of the call |
NativeHandle |
any pointer, treated as opaque |
a @repr(C) struct |
the C struct of the same layout |
extern native declarations are accepted in host.native, host.dual and freestanding files. In a script they are E_EXTERN_NATIVE_WASM: 'extern native' is not available in 'web.scripted', 'web.compiled', or 'host.scripted' modes; use 'host.native', 'host.dual', or 'freestanding'.
Structs across the boundary
A struct passed to or returned from a C function must have C layout, which @repr(C) guarantees: fields in declaration order, each at its C alignment. The compiler refuses a struct without it:
@mode(host.native)
struct Point {
var x: float;
var y: float;
}
extern native "geo" func geo_distance(a: Point, b: Point): float;
func main(): void {
println("declared");
}
main.lphc:8:1: error[E_EXTERN_STRUCT_NOT_REPR_C]: E_EXTERN_STRUCT_NOT_REPR_C: struct `Point` crosses the `extern native` boundary of `geo_distance` in host.native mode without `@repr(C)` — a struct passed to or returned from an `extern native` function must carry `@repr(C)` so its layout matches the C ABI (§2-3.4)
main.lphc:8:1: error[E_EXTERN_STRUCT_NOT_REPR_C]: E_EXTERN_STRUCT_NOT_REPR_C: struct `Point` crosses the `extern native` boundary of `geo_distance` in host.native mode without `@repr(C)` — a struct passed to or returned from an `extern native` function must carry `@repr(C)` so its layout matches the C ABI (§2-3.4)
The error appears once for each parameter of type Point. Without the annotation the compiler is free to lay the struct out as it likes, and the C side would read the wrong bytes. Add it:
@repr(C)
struct Point {
var x: float;
var y: float;
}
extern native "geo" func geo_distance(a: Point, b: Point): float;
Listing 19-3: A @repr(C) struct passed by value to C
⚠ Planned. Pointer types such as
*mut Tand the&mut xaddress-of operator that earlier editions used for C out-parameters are not part of the language. Today a C function that writes through a pointer is declared with aNativeHandleparameter, and the specifiednative.ffihelpers for obtaining such pointers safely are not in 1.25.1.
Wrapping a C resource in a class
C hands back resources as pointers that must be released exactly once. The Loup way is a class that owns the NativeHandle and releases it in finalize(), so the reference count decides when the C resource is freed. A wrapper for a small C library that keeps a running total:
extern native "tally" func tally_new(): NativeHandle;
extern native "tally" func tally_add(t: NativeHandle, n: int): void;
extern native "tally" func tally_total(t: NativeHandle): int;
extern native "tally" func tally_free(t: NativeHandle): void;
class Tally {
let handle: NativeHandle;
new() {
this.handle = tally_new();
}
pub func add(n: int): void {
tally_add(this.handle, n);
}
pub func total(): int {
return tally_total(this.handle);
}
func finalize(): void {
tally_free(this.handle);
}
}
Listing 19-4: A class that owns a C handle
Users of Tally never see the handle, cannot free it twice, and cannot use it after it is freed: the object holds the only copy, and finalize() runs once, when the last reference goes away (or at del, chapter 9). Making the class unique (chapter 17) goes further and rules out sharing the handle at all.
NativeHandle is deliberately opaque. It has no members, no literal and no operators, not even ==; values come only from extern native returns and go only into extern native parameters, fields and bindings. One consequence is that there is no tidy way yet to test a returned pointer for NULL. Prefer C APIs that report failure through a return code, and keep such checks inside small wrappers like this one until the native.ffi helpers land.
Pointers that outlive the call
A C function that stores a pointer it is given (a callback registry, a stream buffer) must say so at its declaration with an escapingBorrow: assertion. Every call site must then acknowledge the escape:
@mode(host.native)
@unsafe("escapingBorrow: setvbuf keeps using buf until the stream is closed")
extern native "c" func setvbuf(stream: NativeHandle, buf: NativeHandle, mode: int32, size: uint64): int32;
func useBuffer(stream: NativeHandle, buf: NativeHandle): void {
setvbuf(stream, buf, 0, 4096);
}
func main(): void {
}
main.lphc:7:5: error[E_ESCAPING_BORROW_UNACKNOWLEDGED]: E_ESCAPING_BORROW_UNACKNOWLEDGED: call to 'setvbuf' requires a call-site @unsafe("escapingBorrow: ...") acknowledgment — the declaration asserts the borrowed argument escapes the native call
The acknowledgement records, at that call, why the pointer lives long enough:
func useBuffer(stream: NativeHandle, buf: NativeHandle): void {
@unsafe("escapingBorrow: buf is a module-level buffer that lives as long as the process")
setvbuf(stream, buf, 0, 4096);
}
Listing 19-5: Acknowledging an escaping borrow at the call site
An enclosing unsafe("escapingBorrow: …") { … } region also counts, but only with the matching category: a region that says threadSafe: does not acknowledge an escaping borrow.
Inline assembly in host.native
asm { } embeds machine instructions. It is allowed in host.native and freestanding files; in host.dual it is E_ASM_NOT_COMPILED (chapter 2). The specification requires an asm: assertion in host.native. A timestamp-counter read on x86-64:
@mode(host.native)
func cycles(): uint64 {
var lo: uint32 = 0;
var hi: uint32 = 0;
@unsafe("asm: rdtsc only reads the time-stamp counter")
asm {
"rdtsc"
out: lo = "=a", hi = "=d"
}
return ((hi as uint64) << 32) | (lo as uint64);
}
func main(): void {
println(cycles() > 0);
}
Listing 19-6: Reading the x86-64 time-stamp counter (x86-64 native builds only)
out: binds output registers to local variables using GCC/LLVM constraint strings; in: passes values in, and clobbers: [...] lists what the instructions destroy (§3-3.7). unsafe("asm: …") asm { … } is the same thing written as a statement region.
This listing is not checked by the book's tooling: it is specific to x86-64, and the interpreter cannot run assembly (loup run stops with internal interpreter limitation: unsupported instruction: InlineAsm). Prefer library code where it exists: native.atomic ships the x86-64 barriers hwFence(), hwFenceAcquire(), hwFenceRelease() and compilerFence().
Known gap. Stage0 1.25.1 accepts an
asmblock inhost.nativewithout the@unsafe("asm: …")assertion. Write it anyway; the audit depends on it.
Generating bindings and headers
The specification describes two tools (§3-2.7). loup import-headers reads a C header and writes extern native declarations; loup export-headers reads a Loup module and writes a C header for its pub func declarations:
loup import-headers /usr/include/zlib.h --lib z -o src/zlib.lphc
loup export-headers src/checksum.lphc -o include/checksum.h
They ship only in the Linux x86-64 toolchain. Elsewhere loup prints `loup import-headers` is not yet available in this release and exits. The generated header describes Loup functions, but --lib shared does not yet produce a native library to link them from (chapter 24), so calling Loup from C is ⚠ planned. The @cexport annotation that the specification uses to control exported names is not recognized by 1.25.1 (warning: Unknown annotation '@cexport').
Summary
@unsafe("category: reason")marks every assertion the compiler cannot check; a missing reason isE_UNSAFE_NO_REASON, and the retired@threadSafe/@escapingBorrowareE_RETIRED_ANNOTATION.unsafe("reason") { … }is the statement form.@unsafe("sendable: …")lets a class claimSendabledespite unprotectedvarfields (E_SENDABLE_UNPROTECTED_VARotherwise).extern native "lib" func …declares C functions; strings are passed as NUL-terminated copies, pointers as opaqueNativeHandles, and structs must be@repr(C)(E_EXTERN_STRUCT_NOT_REPR_C).- C calls and
asmblocks run only in native builds;loup runtype-checks them and stops at the call. - Wrap C resources in a class (or
unique class) whosefinalize()releases them. - A C function that keeps a pointer is declared
escapingBorrow:, and each call site acknowledges it (E_ESCAPING_BORROW_UNACKNOWLEDGED). native.ffi,native.mem, pointer types,@cexportand native library output are ⚠ planned; the header tools exist on Linux x86-64 only.- Chapter 20 covers OS threads and the
Sendablerules in full.