FFI Boundary Semantics

FFI Boundary Semantics

This chapter defines the Foreign Function Interface (FFI) boundary between Clef code and external C libraries on a target that links a host C runtime (Lane 1, hosted). It establishes the null-safety contract, the opaque-handle representation of a C binding’s pointer, and the normative requirements for binding generation tools like Farscape. It is the C instance of the foreign-boundary family; the JavaScript instance is JavaScript Boundary Semantics, and the two share the family invariant stated in §1.1. The FFI boundary exists whenever a C runtime is linked, and its presence is independent of whether the target is freestanding:

  • Hosted — libc is linked dynamically; the FFI boundary of this chapter applies as written.
  • Freestanding with static libc — libc is linked statically and its resources are accessed directly, with no dynamic linker in the image. The FFI boundary still exists and this chapter still applies; static coupling removes the dynamic-binding step (a supply-chain consideration developed in Getting to the Heart of Unikernels, out of scope here).
  • Bare (no C runtime) — no libc is linked at all, so there is no FFI boundary and nothing in this chapter applies. Interior memory follows the lifetime lattice defined in closure-representation.md §3.3.

Interior Clef has no raw pointer type. nativeptr<'T>, voidptr, and nativeint-as-pointer are not denotable in Clef source, at the FFI boundary or anywhere else. A C binding that returns a pointer marshals that pointer through an opaque handle, CHandle<'T>: the handle is non-arithmetic and non-dereferenceable in Clef source, and its only use is to be passed back across the boundary to another C binding. The interior pointer mechanism is the flat closure; a register is the width-typed Mmio handle.

1. Null Safety Principle

1.1 Core Invariant

Null exists ONLY at the FFI boundary. Within Clef code, CHandle<'T> and FnPtr<'F> are NEVER null.

This invariant is fundamental to Clef’s memory safety guarantees. Unlike C where any pointer may be null, Clef enforces non-nullability at the type level. A C binding hands back an opaque handle rather than a raw pointer, so interior code never holds a dereferenceable address.

The statement above is the C instance of a family invariant that holds at every foreign boundary: a boundary’s absence sentinels exist only in its boundary conversions, and no foreign sentinel is representable in interior Clef. The C boundary’s sentinel is NULL, converted through Option as this chapter specifies. The JavaScript boundary’s absence alphabet has three states, converted as JavaScript Boundary Semantics §5 specifies. Each boundary chapter confines its own sentinels; interior code is identical under both.

1.2 Rationale

Null pointer dereferences are a leading cause of crashes and security vulnerabilities in native code. By eliminating null from the type system’s interior, Clef provides:

  1. Compile-time safety: The type checker ensures handles handed back from C are always valid
  2. Explicit optionality: Option<CHandle<'T>> makes nullability visible in the type signature
  3. Clean FFI boundary: Null handling is isolated to the interface with C code
  4. No runtime null checks: Interior code needs no defensive null checks

1.3 The FFI Boundary

The FFI boundary is the interface between Clef code and external C functions. At this boundary:

  • Outgoing (F# → C): Option<CHandle<'T>> converts to nullable C pointer

    • NoneNULL
    • Some handle → the pointer the handle carries
  • Incoming (C → F#): Nullable C pointer converts to Option<CHandle<'T>>

    • NULLNone
    • Non-null → Some handle
┌─────────────────────────────────────────────────────────┐
│  Clef World                                        │
│                                                         │
│  CHandle<'T>            - NEVER null, opaque           │
│  FnPtr<'F>              - NEVER null                   │
│  Option<CHandle<'T>>    - explicit nullability         │
│  Option<FnPtr<'F>>      - explicit nullability         │
└─────────────────────────────────────────────────────────┘
                         ↕ FFI Boundary
┌─────────────────────────────────────────────────────────┐
│  C World                                                │
│                                                         │
│  T*                    - may be NULL                   │
│  void (*f)(...)        - may be NULL                   │
└─────────────────────────────────────────────────────────┘

2. Pointer Types at FFI Boundary

2.1 Non-Nullable Handles

Clef TypeC EquivalentSemantics
CHandle<'T>T* (non-null)Opaque typed handle, guaranteed valid; non-arithmetic, non-dereferenceable in Clef source
CHandle<unit>void* (non-null)Opaque untyped handle, guaranteed valid
FnPtr<'F>Function pointer (non-null)Function pointer, guaranteed valid

A CHandle<'T> carries a C pointer across the boundary but exposes no pointer operations in Clef source: no arithmetic, no dereference, no conversion to an integer. Its only role is to be handed back to another C binding. These types have no null representation, and attempting to construct a null value is a compile-time error.

2.2 Nullable Handles (FFI Only)

Clef TypeC EquivalentSemantics
Option<CHandle<'T>>T* (nullable)May be null, explicit handling required
Option<CHandle<unit>>void* (nullable)Untyped nullable handle
Option<FnPtr<'F>>Function pointer (nullable)May be null callback

Option wrapping is used ONLY at FFI boundaries where C semantics require nullable pointers.

2.3 Memory Layout

Option<CHandle<'T>> has the same memory layout as CHandle<'T> (a single platform word). The compiler uses the null pointer optimization:

  • None is represented as the bit pattern 0 (null)
  • Some handle is represented as the carried pointer value itself

This ensures zero overhead for Option-wrapped handles at the FFI boundary.

3. FnPtr Intrinsics

3.1 FnPtr Type

FnPtr<'F> is a function pointer type where 'F is the full function signature:

FnPtr<unit -> unit>                              // void (*)(void)
FnPtr<int -> int>                                // int (*)(int)
FnPtr<CHandle<byte> -> int -> int>               // int (*)(char*, int)
FnPtr<Option<CHandle<int>> -> unit>              // void (*)(int*)  -- nullable param
 

The type parameter 'F MUST be a function type ('a -> 'b). Using a non-function type is a compile-time error.

3.2 FnPtr.fromSymbol

Declares an external symbol to be resolved by the linker.

Signature:

FnPtr.fromSymbol<'F> : string -> FnPtr<'F>

Semantics:

  • The string argument MUST be a compile-time constant (string literal)
  • Returns a non-null function pointer (linker guarantees symbol exists)
  • Symbol resolution occurs at link time, not runtime

Example:

// Declare external C functions
let private strlen_ptr = FnPtr.fromSymbol<CHandle<byte> -> int> "strlen"
let private gtk_init_ptr =
    FnPtr.fromSymbol<Option<CHandle<int>> -> Option<CHandle<CHandle<byte>>> -> unit> "gtk_init"

Code Generation: The middle end emits portable dialects only: an external func.func declaration for the symbol, and the symbol as a func.constant referencing that declaration — a first-class function value in the portable dialect, with no cast. Each target pathway realizes it through its standard lowerings: the LLVM pathway (CPU/MCU) lowers the declaration to an llvm.func and the constant to an llvm.mlir.addressof, which is the address the C side receives; other pathways realize it in their own terms. The conversion of a function value to an address is therefore a pathway commitment made at the extern boundary, never a middle-end operation.

3.3 FnPtr.invoke

Calls a function through a function pointer.

Signature:

FnPtr.invoke : FnPtr<'F> -> 'F

Semantics:

  • Invokes the function with the provided arguments
  • Arguments matching Option<CHandle<'T>> are marshalled (None → NULL)
  • Return values matching Option<CHandle<'T>> are marshalled (NULL → None)

Example:

// Call external function
let len = FnPtr.invoke strlen_ptr myStringPtr

// Call with nullable arguments (None → NULL)
FnPtr.invoke gtk_init_ptr None None

3.4 FnPtr.ofFunction

Converts a top-level F# function to a function pointer (for callbacks).

Signature:

FnPtr.ofFunction : 'F -> FnPtr<'F>

Constraints:

  • The argument MUST be a reference to a module-level let binding
  • Lambdas and closures are REJECTED at compile time
  • The function must not capture any environment

Rationale: C callbacks expect stable function addresses. Closures capture environment with unpredictable lifetime. Compile-time enforcement prevents subtle bugs.

Example:

// OK - top-level function
let myCallback (x: int) : int = x + 1
let callbackPtr = FnPtr.ofFunction myCallback

// ERROR - lambda (even without captures)
let ptr = FnPtr.ofFunction (fun x -> x + 1)  // Compile error

// ERROR - closure with captures
let multiplier = 2
let ptr = FnPtr.ofFunction (fun x -> x * multiplier)  // Compile error
 

3.5 Removed Intrinsics

The following intrinsics are NOT available in Clef:

  • FnPtr.null: Use Option<FnPtr<'F>> with None instead
  • FnPtr.isNull: Use pattern matching on Option<FnPtr<'F>> instead

Migration:

// Old (NOT SUPPORTED):
let maybeCallback = FnPtr.null<int -> unit> ()
if not (FnPtr.isNull maybeCallback) then
    FnPtr.invoke maybeCallback 42

// New (CORRECT):
let maybeCallback : Option<FnPtr<int -> unit>> = None
match maybeCallback with
| Some cb -> FnPtr.invoke cb 42
| None -> ()

4. Option↔NULL Marshalling

4.1 Parameter Marshalling (F# → C)

When a function parameter has type Option<CHandle<'T>> or Option<FnPtr<'F>>:

F# ValueC Value
NoneNULL (0)
Some handlethe pointer the handle carries

Optimization: When the argument is a compile-time None literal, the compiler directly emits null without runtime checks.

4.2 Return Value Marshalling (C → F#)

When a function return type is Option<CHandle<'T>> or Option<FnPtr<'F>>:

C ValueF# Value
NULL (0)None
Non-nullSome handle

Code Generation (portable dialects): The compare-against-null and the select are expressed in portable ops. The returned pointer is a platform word, typed index; the null check is arith.cmpi; the choice between None and Some is scf.select (or arith.select).

// C function returns nullable pointer (platform word, carried as index)
%result = func.call @may_return_null() : () -> index

// Marshal to Option
%zero = arith.constant 0 : index
%is_null = arith.cmpi eq, %result, %zero : index
%option = arith.select %is_null, %none_value, %some_result : ...

LLVM-pathway lowering example: on the CPU/MCU pathway the same marshalling lowers to llvm.call ... -> !llvm.ptr, llvm.icmp "eq", and llvm.select. That form is one target pathway, not what the middle end emits.

4.3 Non-Marshalled Types

Types NOT wrapped in Option are passed directly without marshalling:

  • CHandle<'T>: passed as-is (must be non-null)
  • FnPtr<'F>: passed as-is (must be non-null)
  • int, float, etc.: passed as-is (value types)

5. Farscape Binding Generation Contract

This section defines normative requirements for Farscape and other binding generation tools.

Informative. The C++ Binding via Farscape guide describes how these requirements are applied in practice.

5.1 C Nullability Annotation Mapping

Farscape MUST interpret C nullability annotations as follows:

C AnnotationPlatformClef Output
_NonnullClang/AppleCHandle<'T>
_NullableClang/AppleOption<CHandle<'T>>
_Null_unspecifiedClang/AppleSee default policy
__attribute__((nonnull))GCCCHandle<'T>
_In_Windows SALCHandle<'T>
_In_opt_Windows SALOption<CHandle<'T>>
_Out_Windows SALCHandle<'T>
_Out_opt_Windows SALOption<CHandle<'T>>

5.2 Default Policy (Unannotated Pointers)

When C code lacks nullability annotations, Farscape MUST apply these defaults:

Function Parameters:

  • Default: CHandle<'T> (assume non-null)
  • Override to Option<CHandle<'T>> when documentation indicates nullable

Function Return Values:

  • Default: CHandle<'T> (assume non-null)
  • Override to Option<CHandle<'T>> for functions documented to return NULL on error

Rationale: Most C APIs expect non-null parameters and return non-null on success. Defaulting to non-null reduces Option ceremony while the override mechanism handles exceptions.

5.3 Generated Binding Structure

Farscape-generated bindings MUST follow this pattern:

module LibraryName.Bindings

// External function declarations (private)
let private function_name_ptr =
    FnPtr.fromSymbol<param_types -> return_type> "c_function_name"

// High-level F# API (public)
let functionName (param1: type1) (param2: type2) : returnType =
    FnPtr.invoke function_name_ptr param1 param2

5.4 Ambiguity Handling

When nullability is ambiguous, Farscape SHOULD:

  1. Emit a warning indicating the assumption made
  2. Support a hints file for manual override
  3. Document the default in generated binding comments

Hints File Format (example):

[gtk_window_new]
return = "nonnull"  # Override: gtk_window_new never returns NULL

[g_object_get_data]
return = "nullable"  # Override: may return NULL if key not found
 

5.5 Callback Function Types

For C functions accepting callbacks, Farscape MUST:

  1. Generate FnPtr<'F> for non-null callback parameters
  2. Generate Option<FnPtr<'F>> for nullable callback parameters
  3. Document that callbacks must be top-level functions (no closures)

6. Examples

6.1 GTK Bindings

module Platform.GTK

// External declarations
let private gtk_init_ptr =
    FnPtr.fromSymbol<Option<CHandle<int>> -> Option<CHandle<CHandle<byte>>> -> unit> "gtk_init"

let private gtk_window_new_ptr =
    FnPtr.fromSymbol<int -> CHandle<GtkWindow>> "gtk_window_new"

let private gtk_widget_show_all_ptr =
    FnPtr.fromSymbol<CHandle<GtkWidget> -> unit> "gtk_widget_show_all"

let private gtk_main_ptr =
    FnPtr.fromSymbol<unit -> unit> "gtk_main"

// High-level API
let gtkInit () =
    FnPtr.invoke gtk_init_ptr None None

let gtkWindowNew (windowType: GtkWindowType) : CHandle<GtkWindow> =
    FnPtr.invoke gtk_window_new_ptr (int windowType)

let gtkWidgetShowAll (widget: CHandle<GtkWidget>) : unit =
    FnPtr.invoke gtk_widget_show_all_ptr widget

let gtkMain () =
    FnPtr.invoke gtk_main_ptr ()

6.2 libc Bindings

This example is hosted-libc (Lane 1). malloc/free exist only where a C runtime with a heap is linked. On a no-heap target these particular bindings do not exist, and interior memory follows the lifetime lattice of closure-representation.md §3.3: a value that classifies as genuinely-dynamic on a no-heap target is a compile-time lifetime error rather than a call to malloc. A bare target with no C runtime at all has no FFI boundary of any kind.

module Platform.Libc

// strlen: never returns null, never accepts null
let private strlen_ptr =
    FnPtr.fromSymbol<CHandle<byte> -> int> "strlen"

// malloc: may return NULL on failure
let private malloc_ptr =
    FnPtr.fromSymbol<int -> Option<CHandle<unit>>> "malloc"

// free: accepts NULL (no-op)
let private free_ptr =
    FnPtr.fromSymbol<Option<CHandle<unit>> -> unit> "free"

// High-level API
let strlen (s: CHandle<byte>) : int =
    FnPtr.invoke strlen_ptr s

let malloc (size: int) : Option<CHandle<unit>> =
    FnPtr.invoke malloc_ptr size

let free (handle: Option<CHandle<unit>>) : unit =
    FnPtr.invoke free_ptr handle

6.3 Callback Pattern

module Platform.GLib

// Type alias for GLib callback
type GSourceFunc = int -> int  // gboolean (*)(gpointer) simplified

// g_idle_add accepts non-null callback; user_data is a nullable gpointer (void*)
let private g_idle_add_ptr =
    FnPtr.fromSymbol<FnPtr<GSourceFunc> -> Option<CHandle<unit>> -> uint32> "g_idle_add"

// User's callback (must be top-level)
let myIdleCallback (userData: int) : int =
    // Do work...
    0  // Return FALSE to remove source

// Register callback (no user_data -> None -> NULL)
let sourceId =
    let callbackPtr = FnPtr.ofFunction myIdleCallback
    FnPtr.invoke g_idle_add_ptr callbackPtr None

7. Normative Summary

  1. A C binding’s pointer marshals through the opaque CHandle<'T>; interior Clef has no raw pointer type, and nativeptr<'T>/voidptr/nativeint-as-pointer are not denotable anywhere. CHandle<'T> and FnPtr<'F> are NEVER null within Clef code
  2. Option<CHandle<'T>> and Option<FnPtr<'F>> represent nullable pointers at the FFI boundary
  3. FnPtr.fromSymbol declares linker-resolved external symbols
  4. FnPtr.invoke calls through function pointers with automatic Option↔NULL marshalling
  5. FnPtr.ofFunction converts top-level functions only (no closures)
  6. Farscape MUST follow the nullability annotation mapping and default policies defined herein
  7. This chapter applies wherever a host C runtime is linked, whether dynamically (hosted) or statically (freestanding with static libc); a bare target with no C runtime has no FFI boundary, and its interior memory follows the lifetime lattice of closure-representation.md §3.3