The Loup Native Book — contents
  1. Chapter 1: Getting started
  2. Chapter 2: Native mode overview
  3. Chapter 3: Variables and numerics
  4. Chapter 4: Functions
  5. Chapter 5: Control flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and pattern matching
  9. Chapter 9: Classes and ARC
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error handling
  13. Chapter 13: Modules and packages
  14. Chapter 14: Template fences
  15. Chapter 15: Ownership verbs
  16. Chapter 16: borrow, excl borrow and noescape
  17. Chapter 17: unique ownership
  18. Chapter 18: Lifetimes and regions
  19. Chapter 19: @unsafe and calling C
  20. Chapter 20: Concurrency with OS threads
  21. Chapter 21: Async on native
  22. Chapter 22: Desktop UI
  23. Chapter 23: SIMD and GPU
  24. Chapter 24: Building and distributing
  25. Chapter 25: Memory safety patterns

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 run type-checks extern native declarations and calls, but its interpreter cannot call into C. Build with loup 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) and native.mem are 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 an extern native func, a class or a func;
  • a statement region: unsafe("reason") { … } around statements in any function body;
  • an annotation on one statement: @unsafe("…") on the line before a call or an asm block, 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

This code does not compile
@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:

This code does not compile
@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:

This code does not compile
@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:

This code throws at run time
@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:

This code does not compile
@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 T and the &mut x address-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 a NativeHandle parameter, and the specified native.ffi helpers 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:

This code does not compile
@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:

Fragment — not a complete program
@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 asm block in host.native without 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 is E_UNSAFE_NO_REASON, and the retired @threadSafe/@escapingBorrow are E_RETIRED_ANNOTATION. unsafe("reason") { … } is the statement form.
  • @unsafe("sendable: …") lets a class claim Sendable despite unprotected var fields (E_SENDABLE_UNPROTECTED_VAR otherwise).
  • extern native "lib" func … declares C functions; strings are passed as NUL-terminated copies, pointers as opaque NativeHandles, and structs must be @repr(C) (E_EXTERN_STRUCT_NOT_REPR_C).
  • C calls and asm blocks run only in native builds; loup run type-checks them and stops at the call.
  • Wrap C resources in a class (or unique class) whose finalize() 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, @cexport and native library output are ⚠ planned; the header tools exist on Linux x86-64 only.
  • Chapter 20 covers OS threads and the Sendable rules in full.