Memory, Unsafe, And FFI
This chapter documents Zynx memory safety, references, raw pointers, unsafe code, volatile operations, thread-send safety, layout, and the C FFI boundary.
Status: current public API unless a rule is marked unsupported, unspecified, implementation-defined, or outside the language contract.
Memory And Concurrency Model
This chapter is the authoritative contract for memory safety, aliasing, lifetimes, raw pointers, unsafe code, data races, thread-send safety, and runtime-owned cleanup. If another document describes these topics, it must not contradict this chapter.
Behavior Classes
| Class | Meaning |
|---|---|
| Defined behavior | Part of the language contract; every conforming implementation must preserve it. |
| Statically rejected behavior | Invalid source, rejected at compile time with a diagnostic; it has no runtime semantics. |
| Runtime trap | Defined failure of a dynamic safety check. Aborts immediately per Exit And Cleanup Semantics; not an error value, does not unwind scopes. |
| Outside the language contract | Caused by violating an unsafe, raw-pointer, FFI, volatile, or concurrency precondition (invalid raw-pointer accesses, data races, false FFI caller contracts). The language does not define its behavior. |
| Unspecified behavior | Intentionally not promised. Source must not depend on the current implementation choice. |
| Implementation-defined behavior | Target/implementation behavior that may vary, but must be documented when exposed as part of a supported target, ABI, or SDK contract. |
Safe Zynx has no outside-the-language-contract memory behavior unless there is an implementation bug or the program crosses an unsafe, FFI, volatile, or documented runtime precondition boundary. unsafe grants permission to perform operations that cannot be checked for safety; it does not make them correct.
Owning Values And Moves
Ownership has two axes. Value flow — binding, assignment, and return — is move-by-default, so each value is either implicitly copied or move-only. Passing a value to a function is borrow-by-default: a bare parameter borrows its argument, the caller keeps ownership, and transferring ownership into a callee is written explicitly (see Passing Arguments).
Along the value-flow axis, numeric types, bool, enums, vectors, raw pointers, null, nullable values whose payload is implicitly copied, and structs marked Copy are duplicated on assignment and leave the source usable. Checked and nullable references, ^str, ^[T] dynamic slices, non-Copy fixed arrays, callables, error unions, Unique<T>, and unmarked structs are move-only: a move consumes the source, and reading the source afterward is statically rejected as use of a moved value. <-value is the explicit consume operator: it makes a move visible at a binding, assignment, or argument, and x <- y is a move-assignment that consumes y.
^str and ^[T] are owning, move-only three-word values. Their semantic shapes are respectively { ptr, size, capacity } and { ptr, length, capacity }. Bare str and [T] are two-word shared immutable views; &str and &[T] are two-word mutable views. The static role decides cleanup: views free nothing, and capacity is not an ownership discriminator. Cloning produces an independent owner. Only owner places ^str and ^[T] expose readonly logical capacity; borrowing either as a view projects only {ptr, size} for text or {ptr, length} for elements and cannot reallocate or replace the owner descriptor.
All built-in and base-standard-library owners use one compiler/runtime-private allocation domain per compatible runtime instance. Owners carry no allocator callback, provider identity, generic parameter, ambient selection, or public raw allocation handle. The complete public allocation error is std.mem.AllocError.OutOfMemory; std.mem exposes no Allocator and no raw alloc/realloc/free. An owner cannot cross a DSO, foreign runtime, or Wasm linear-memory boundary that would release it through another domain. Such a boundary copies the data or transfers a nominal foreign resource whose originating domain performs matching cleanup.
The size bytes of every str role are valid UTF-8 text and may contain U+0000. No role guarantees that ptr[size] exists or equals zero. Unsafe code and FFI adapters must pass text with an explicit size or construct a checked std.ffi.CStr; reading one byte beyond a text range to discover a terminator is outside its contract.
A hole-free string literal is always a static shared str. It cannot become ^str from expected type. Write try ^"text" for a statically nonempty owning literal. Any hole-free owning form whose decoded content is statically empty is allocation-free and infallible. Interpolation is accepted only after the same ^ prefix and always reports AllocError; no literal or interpolation obtains hidden storage from its destination context.
The invariant-preserving owner-growth methods consequently take ^self and return a successor owner. A named call is explicit, such as text = try (<-text).append(suffix) or text = try (<-text).reserve(total). clear(^self) retains the allocation and writes the new leading terminator. A consuming checked failure drops the taken owner and leaves its original binding moved; no hidden rollback or recovered owner is synthesized. Safe calls cannot append a view derived from the taken receiver because an outstanding loan blocks the take.
Owning-slice growth follows the same explicit consuming rule:
// RFC 0052 target; implementation pending.
values = try (<-values).reserve(total);
values = try (<-values).push(<-item);
values = try (<-values).insert(index, <-other);push and insert accept an owned ^T element. If their checked allocation fails, the taken slice and element are each dropped exactly once and their source bindings remain moved. reserve behaves the same for its taken slice. There is no transactional reinterpretation of <-values and no recovery wrapper.
clear(^self) drops elements in reverse order and retains pointer and capacity. pop(^self) -> ^(^[T], ^T?) and remove(^self, index) -> ^(^[T], ^T) return the successor owner directly in an owning tuple; there is no public or internal target Taken<T> container. Invalid insert and remove positions trap before pointer arithmetic or element movement. Zero-sized elements retain logical length, capacity, move, drop, and overflow behavior without requiring physical allocation.
import std.io;
import std.mem.{ AllocError };
fn main() throws(AllocError) {
let a: ^str = try ^"owned";
let b = a; // moves `a` into `b`; `a` is consumed
let xs: ^[i32] = try ^[1, 2, 3];
let ys = xs; // moves the owning slice into `ys`
io.println(try ^"{b} {ys[0]}");
}Reading a moved-from binding is rejected:
import std.io;
import std.mem.{ AllocError };
fn main() throws(AllocError) {
let a: ^str = try ^"owned";
let b = a;
io.println(try ^"{a}"); // rejected: use of moved value `a`
}A range-slice xs[lo..<hi] always creates a loan from its base. A loan from a named place — a local, field, or dereferenced owner such as items, b.items, or *p — is valid only while that base remains live. A loan from an owning temporary is valid only through the full expression, for example as a direct call argument; binding, storing, returning, or capturing it is rejected. Subscripting xs[i] reads or moves a single element rather than forming a view.
Copy, Unique<T>, Handle<T>, Capsule<T>, and the automatic-drop rules are specified in Ownership, Borrowing, And Drop. Automatic drop runs once at each owning value's lexical cleanup boundary, recurses into owned contents, and is disarmed on move so the destination drops the value and the moved-from source does not; partial moves are tracked at field and element granularity. defer bodies run before automatic drops at block exit, and std.drop(value) and @drop(value) request an explicit drop.
There is no generic ManuallyDrop<T>, forget, leak, or @forget escape from this graph. An original nominal ^self method may use the distinct discard self; statement to suppress only its own user destructor while the compiler immediately drops every still-live residual field. The statement leaves no readable wrapper or zombie value and does not weaken raw-pointer, initialization, or provenance obligations.
A program that would double-free, use a value after free, or leak an owning value is rejected at compile time. The rules:
- Using a value after it was moved out of is illegal.
- A value may not be accessed while an incompatible borrow of it is still live.
- A borrow may not be captured by something that outlives the borrowed value.
- An owned field or element may not be moved out of a bare shared borrow.
- An owning element may be moved out only by a constant-index move out of a direct owning slice; a runtime index, a view element, or a nested container is not allowed.
- A range-slice view of a named place may not be coerced into an owning
^[T]slot whose element owns a drop. - A partial range-slice of an owning temporary may not be returned as an owning
^[T].
The last three are the owning-slice hazards; see Ownership, Borrowing, And Drop for their full rationale. Each rejection has a stable diagnostic code; see the diagnostics reference.
Passing Arguments
Parameter passing is borrow-by-default, the second ownership axis. The parameter marker, not the argument expression, decides how the callee holds the value:
| Parameter | Mode | Caller after the call |
|---|---|---|
t: T (bare) | shared immutable borrow | keeps the value and drops it |
t: &T | exclusive mutable borrow | keeps the value; observes mutations |
t: ^T | ownership transfer (move-in) | the value is consumed |
A bare value parameter borrows: the same value may be passed to several bare parameters in turn, and the parameter cannot be reassigned in the body (copy it into a var local first). &T is an exclusive mutable borrow normally written &name at the call site over a stable mutable place rooted in named storage; assignment through it writes to that storage. A fresh inline struct literal is the sole direct-argument exception described under Checked Reference Lifetimes. ^T moves the argument into the callee, which then owns and drops it; the caller may spell the transfer with <-argument, and reading the argument after the call is rejected as use of a moved value. ^ on an implicitly copied type has no effect and warns. Bare types borrow shared, & borrows mutably, and <-/^ transfer ownership.
An ^T return hands ownership out. Bare -> T, -> str, and -> [T] are shared borrows; &T is mutable. Borrowed returns follow Checked Reference Lifetimes.
Checked References And Raw Pointers
Zynx separates checked references from raw pointers.
Bare T is a shared checked reference and &T is mutable. A place is a local, parameter, field, array element, slice element, dereferenced owner, or other addressable storage location.
Checked Reference Lifetimes
Shared references use the bare value expression. Mutable references are explicit with &value or &items[i]; nullable forms may additionally use null:
let r: T = value; // shared borrow
let m: &T = &value; // mutable borrow
return input.field; // shared projectionFunction and callable arguments follow the role: bare T accepts a shared borrow, while &T requires &value or a mutable-reference-valued expression. The borrow starts when the argument is evaluated and ends after the outermost call expression completes, unless the reference escapes through a returned value.
Address-of otherwise requires a stable place rooted in named storage, such as a local, parameter, field, or indexed element. It does not materialize backing storage for an arbitrary rvalue or extend a temporary's lifetime. The sole exception is a fresh inline struct literal used directly as an argument to an exclusive struct parameter:
struct Point { var x: i32, let y: i32 }
fn adjust(point: &Point) {
point.x = point.x + 1;
}
adjust(&{ x: 123, y: 123 });
adjust(&Point { x: 123, y: 123 });The inferred form is typed only after call resolution has independently selected the &Point parameter; the literal cannot select or complete an overload. A qualified literal already has its own type and participates in ordinary overload resolution. In either form, literal evaluation initializes one fresh anonymous temporary place. The callee may mutate it, and it is destroyed once at the end of the full expression.
The temporary's exclusive loan and every derived loan must end within that full expression. They may be consumed by another call in the same expression, but may not be bound, stored, returned, captured, or otherwise escape. Scalar, tuple, array, constructor-call, operator, and other computed rvalues remain invalid operands of & even as direct arguments. This exception therefore does not create a general address-of-rvalue or lifetime-extension rule.
Method receivers are the ergonomic exception: value.method() borrows value implicitly. Receivers and operator operands borrow implicitly because their syntax has no explicit argument list; everywhere else (ordinary calls, callable values, non-receiver method arguments) write the borrow explicitly.
| Receiver form | Borrow mode | Notes |
|---|---|---|
self | shared read-only | cannot assign through receiver; callable on a shared-borrowed value (e.g. a collection being iterated) |
&self | exclusive (mutable) | may assign through the receiver |
^self | owned (consuming) | the method owns and drops self; the caller cannot use the value afterwards |
self in L / &self in L | region-tied | region tie spelled after either form |
The receiver type is always inferred; the borrow mode is set by the marker, not a type. A bare self is the default immutable borrow, &self an exclusive mutable borrow, and ^self an owned receiver. Nested calls that would create an overlapping exclusive borrow are rejected. The receiver borrow follows the same lifetime and aliasing rules as an explicit argument borrow.
A method may be overloaded on receiver access alone: a &self and a bare self overload with the same name and arity can coexist because the call site makes the receiver access explicit. value.method() selects the shared self overload; (&value).method() selects the exclusive &self overload. Missing that exact overload is a hard error rather than a fallback to another receiver role.
Return type, expected type, later assignment, and later mutation never select a receiver overload. In particular, annotating a binding with an exclusive result type does not redirect value.method() to &self; write (&value).method(). This keeps accessor pairs under one operation name without adding a get_mut alias or hidden downstream-use inference:
let read: HashMapEntry<Key, Value>? in _ = map.get(key);
let edit: HashMapExclusiveEntry<Key, Value>? in _ = (&map).get(key);The explicit in _ is the local inferred region carried by each stored lookup view. While read is live, conflicting structural mutation is rejected. While edit is live, the receiver remains exclusively loaned. The expected binding types only check the already-selected overloads.
References are move-only borrow handles:
- Moving a reference transfers the borrow to the destination. The loan ends for a given owner when that reference value is consumed or moved away, but stays live under the new owner until the owning binding, field, element, temporary, or returned value leaves its source lifetime.
- A reference binding's lexical scope is the default upper bound for the borrow, even when an owned value with
dropcan be dropped earlier by liveness. - Assignment to a reference-typed expression writes through the reference; it does not rebind the handle. If
r: &i32,r = 2;assigns2to the referent andr's borrow stays active untilris moved or leaves scope. There is no reference-handle rebinding syntax.
&T is non-null; use &T? for a nullable reference. &T? means (&T)?, not &(T?); there is no reference-to-nullable payload type. null may initialize, assign, return, or pass to a &T?, but not to plain &T.
For reference parameters, bare T is shared and &T exclusive mutable. Assignment through bare T is rejected. Multiple shared borrows of a place may coexist; an exclusive borrow conflicts with any overlapping shared or exclusive borrow. While a place is borrowed, conflicting reads, writes, moves, and reassignments are rejected.
The borrow rules are flow-sensitive and conservative:
- At
if,match,catch, loop,break, andcontinuejoins, moved and active-borrow state merge across all reachable paths. If any surviving path may hold a borrow, conflicting use after the join is rejected. - Loop bodies are treated as if they may run again; a borrow or move that would invalidate the next iteration is rejected unless scoped inside the body and released before the back edge. For
for x in collection, the collection expression is shared-borrowed for the duration of each loop body when the loop creates an iterator from the collection. Mutating receiver calls such ascollection.remove(...)conflict with that loop borrow. Shared read-only receiver calls such ascollection.get(...)may run during iteration. - Field and array-element borrows are tracked as subplaces. Borrowing a whole place conflicts with borrowing any subplace and vice versa. Distinct struct fields are disjoint; constant array/tuple indices are disjoint when the literals differ. A dynamic index is conservative:
items[i]may overlap any element ofitems, conflicting withitems[0],items[j], etc. whose equality cannot be proven. - Moves track borrows at the same granularity: moving a borrowed place is rejected, borrowing a moved place is rejected, and moving a reference value transfers the borrow owner rather than the referent.
Borrowed references may not escape their source lifetime. Bare T/T? returns are shared; &T/&T? returns are mutable. They may project compatible inputs or module globals, and nullable forms may return null. Returning a borrow of a by-value parameter, local, temporary, literal, struct literal, tuple literal, or array literal as & is rejected; return an owned value or Unique<T> instead.
Functions that need to make a borrowed return contract explicit can declare region generics. Region arguments are inferred at call sites; callers do not write them in generic argument lists. Region annotations apply to checked references, str, and array or slice views, and do not change the runtime ABI:
fn pick<T, region L>(x: T in L, y: T in L) -> T in L {
return x;
}Inside a region-generic function, borrowed signature types and explicit local borrowed declarations must be annotated with in <Region>. Methods can write self in <Region> when a borrowed result is tied to the receiver. Raw pointers are excluded from regions because they are not lifetime-checked handles. Callable values can carry a region annotation to bound reference captures, as in () -> void in L; pass such callables as parameters and call them in place. The callable region is a compile-time noescape bound; callers do not write region arguments, and a borrowing closure can satisfy a differently named callable region when the value is consumed in place. Returning region-bounded callable values is not supported.
The current region model is a compile-time constraint system over borrowed signature types:
- A
region Lparameter names an abstract source lifetime. It is not a runtime value, cannot be written at call sites, and has no ABI representation. - A checked reference,
str, or array/slice view annotatedin Lmust be borrowed from a source whose lifetime is at leastL. - At a call site, each region argument is inferred from the concrete places passed to parameters annotated with that region. If one region appears on multiple input parameters, the inferred region is the common lifetime that is valid for all of those inputs.
- A return type annotated
in Lmay return an input reference annotatedin L, a field or element reachable through such an input reference, a call result whose borrowed-return contract is itself tied to an argument inL, a module global, ornullfor nullable reference/view returns. - A
catchexpression may return a borrowed valuein Lonly when every value-producing reachable arm returns a value allowed by the same region rule. Different input parameters are allowed at the join when they are annotated with the same return region. - A function whose borrowed return is not annotated is treated conservatively. Its return is still known to be a borrow, and when a single input parameter clearly supplies that borrow, callers may rely on that. If multiple input parameters could supply it, callers may use the value as a borrowed result but cannot rely on single-parameter alias information.
- Region annotations are invariant. There is no user-visible subtyping, lifetime widening, or lifetime shortening operator.
- Returning a borrow of a local, by-value parameter, temporary, literal, struct literal, tuple literal, or array literal is rejected even when the expression is annotated. Use an owned return type such as
Unique<T>when new storage must escape.
The lifetime model rejects stored reference-capturing futures, escaping reference-capturing closures, and returning region-bounded callable values without the required contract. Borrowed dynamic interface storage is not a lifetime exception: a stored I or &I declares an explicit named in L region, and a stored [^I] view declares its ordinary view region. Erasure, vtable dispatch, aggregate placement, and conversion never extend L or hide a checked reference. An owned ^I may erase a concrete payload only when every loan inside that payload remains represented by the fully instantiated type's ordinary named-region constraints; untracked lifetime erasure is rejected.
Regions are only lifetime constraints for checked references, str, array and slice views, region-typed aggregates, and region-bounded callables. They cannot name raw-pointer lifetimes, express ownership transfer, widen or shorten a lifetime, or make tuples and arrays carry borrowed values across a function boundary.
References Inside Aggregates
A struct may store checked references. A struct that stores a checked reference, str, or array/slice view must declare the region that reference is borrowed from as a region generic parameter, and annotate the field in <Region>:
struct Holder<region L> {
let value: i32 in L,
}Region parameters on a struct are compile-time only: like function region parameters they have no ABI, do not participate in monomorphization, and are not written as type-generic arguments. A use of the type carries its region with the existing in <Region> annotation, Holder in L, in the same positions a borrowed signature type may appear (parameters, return types, explicit local declarations). Holder<L> angle-bracket region arguments are not used.
- Construction. A region-generic struct literal infers each region from the borrowed place supplied to the corresponding
in <Region>field. The region argument is not written at the construction site. A field annotatedTcontributes a shared borrow of its source; a field annotated&Tcontributes an exclusive borrow. While the constructed value is live, moving or mutating a borrowed source in a way that conflicts with the field's borrow is rejected, exactly as for a directly bound reference. Several bare shared fields may share one source; an&field is exclusive. - Projection. Reading a reference field yields a checked reference bound to the aggregate value's region, and that reference obeys the ordinary lifetime, return, and join rules.
- Escape. A borrow may leave a function inside a region-typed struct only.
fn wrap<region L>(x: T in L) -> Holder in L { return Holder { value: x }; }is accepted: at each call site the result borrows every argument supplied to the region, so dropping or mutating any of those arguments while the result is live is rejected. A region-typed aggregate may borrow several parameters at once (Pair { a: p, b: q }), and each borrowed argument is held at the call site. Every reference field must be borrowed from a parameter in the return region; a field borrowed from a parameter in a different region is rejected. Returning an aggregate that borrows a parameter without a region contract — a non-region struct, or any tuple or array, since those cannot name a region — is rejected with a diagnostic suggestingstruct Name<region L>. Returning an aggregate that borrows a local, temporary, or by-value parameter is rejected as for any other borrowed return. - Drop order. Borrowed fields are non-owning: aggregate cleanup does not drop them. Owned fields drop in reverse declaration order after any
deferbodies, as for any aggregate.
Tuples and arrays may hold checked references for local use: each element borrow is tracked through the binding, so moving or mutating a source while the tuple or array is live is rejected just as for a struct field. They cannot carry a borrow across a function boundary because they cannot name a region, and an array of references additionally cannot escape by &. Reference fields in non-region structs, and references inside tuples and arrays, remain otherwise governed by the rules above.
Region Constraint Summary
- Equality and compatibility. Regions are compared by name within a single signature. A reference annotated
in Lis compatible only with a source whose lifetime is at leastL; there is no widening or shortening operator. - Inference. Region arguments are inferred at call sites and at region-struct construction sites from the concrete places passed to parameters or fields annotated with that region; callers and constructors never write them.
- Joins. A
catchthat yields a borrowed value or region-typed aggregatein Lis accepted only when every value-producing arm yields a value allowed by the same region rule. - Variance. Region annotations are invariant for references,
str, array and slice views, callable types, and region-typed aggregates alike. There is no user-visible subtyping among regions. - Diagnostics. Diagnostics name regions with the user-written region parameter name (for example
L) and the relevant parameter or field; region names are never synthetic.
An exclusive borrow owned by the current future frame may not already be live when that frame reaches await. Shared read-only borrows may remain live across await, but they still cannot be captured by a stored future, structured future, or other value that may outlive the source scope. Direct-await reference captures are the only future capture exception: await read_ref(value) and await mutate_ref(&value) are allowed because the future is consumed by the await expression and the caller cannot observe the borrowed source again until the await completes. Binding that future to a local, passing it to async.all, async.race, async.timeout, or adding it to a group is rejected when it captures references or raw pointers. A defer that would remain live across an await in an fn is rejected.
Closures capture by value: Copy captures are copied, move-only captures are moved. A closure that captures a checked reference is borrowing and may only be called in place or passed to a region-bounded callable; raw pointers cannot be captured. For capture rules and examples, see Closures And Callables.
*T is a read-only raw pointer and *mut T a writable raw pointer. Both are copyable, nullable machine addresses and are not borrow-checked. Raw pointers already include null, so nullable suffixes are rejected. Cast shared values to *T and mutable borrows to *mut T; checked references never decay implicitly.
Raw-pointer operations are explicit:
*ptrdereferences a raw pointer.ptr[i]is raw-pointer indexing.ptr.fieldaccesses a field through a raw pointer to a struct or union.ptr + n,ptr - n, and pointer subtraction are raw-pointer arithmetic.ptr as usizeandaddr as *Tcross the pointer/integer boundary.
Dereference, indexing, member access through a raw pointer, pointer arithmetic, pointer subtraction, and pointer/integer casts require an unsafe context. Address comparison and copying do not.
Assignment through a writable raw place is replacement, not an untyped store. For *p = rhs with p: *mut T (and likewise a field or index projected from that dereference), Zynx evaluates the pointer expression and stabilizes the destination place once, then evaluates rhs once into a fresh temporary. After successful evaluation it drops the old pointee, moves the temporary into the place, and disarms the temporary. If RHS evaluation returns a checked error, the old pointee remains initialized. For Copy types with no drop work, optimization may reduce this sequence to one store without changing evaluation order.
The unsafe caller must prove that the stabilized address is non-null, aligned, in bounds, writable, and live for a complete, fully initialized T; the destructive phase must have exclusive access, with no partial move or active destruction of the pointee. Uninitialized or partially initialized storage does not satisfy this contract. It must be initialized through an explicit trusted no-drop boundary. The accepted public boundary is one MaybeUninit<T> slot: unsafe initialize moves a value into caller-proven uninitialized storage, while unsafe take moves one complete valid value out and restores the slot's uninitialized state. Ordinary assignment never chooses either transition implicitly and does not return the old value; preserving the old value would require a separate explicit replace API.
MaybeUninit<T> has no runtime initialized tag and does not drop a possible payload when the wrapper is dropped. Double initialization can lose a cleanup obligation; taking before complete valid initialization can create an invalid T. Those are unsafe-contract violations rather than checked state errors. Its single role-directed .ptr property changes no state: slot.ptr under shared access yields *T, while explicit (&slot).ptr yields *mut T. There is no ptr_mut alias. The caller must prove an operation through that pointer established a complete valid T before take.
The boundary is intentionally singular. Ordinary aggregates and array or slice literals may contain explicitly constructed MaybeUninit<T> wrappers, with one independent state machine per element. What Zynx does not provide is a dedicated raw-range constructor, mem.view retyping of a fresh raw allocation, range-wide assume_init/publication, uninitialized-buffer API, ArrayBuilder, generic raw output pointer, or from_raw_parts adoption. Contiguous owner output uses OutputSpan<T>; sparse core containers use SDK-internal RawStorage<T>; a fully initialized borrowed range uses mem.view. Publishing a complete built-in allocation as ^[T] or ^str remains compiler/SDK-private. A foreign allocation remains owned by a nominal resource wrapper and may lend an anchored view rather than being adopted as a built-in owner. There is no general ManuallyDrop<T> or forget/leak boundary. The separate type-local discard self statement can skip only its defining nominal destructor and still performs ordinary cleanup of every residual field; it does not adopt raw storage or weaken this initialization protocol.
Creating a raw pointer from a reference is a safe expression only in a nonescaping context. The raw pointer itself does not keep the borrow alive, so a raw pointer value derived from a local place must not outlive that local place. This is a language rule, not a best-effort optimization: safe code may create, copy, compare, pass, and return raw pointers only when the value does not carry local lifetime provenance past the originating lifetime boundary.
Local-derived raw-pointer provenance follows the value through copies, casts, aggregate literals, field and tuple projections, enum payloads, ordinary local assignments, return expressions, closure captures, and future captures. Returning a raw pointer derived from a local, returning an aggregate containing such a pointer, capturing such a value in an escaping closure, storing it in module storage, or storing it in a future that can outlive the source is rejected even when the pointer is nested inside another value. Directly awaiting a Future-returning call that receives a local-derived raw pointer is the nonescaping exception; binding that future, passing it to structured future combinators, or returning it is an escape. Returning or copying a raw pointer derived from an input reference or another nonlocal source is allowed, but a safe function may not store a raw-pointer-bearing parameter, or a value derived from one, into module storage. Make that function unsafe fn when retaining or publishing the pointer is part of the caller-supplied contract.
The raw-pointer model is address based. A raw pointer value carries no language-visible borrow token and does not extend the lifetime of the place it came from. Local-derived raw-pointer provenance is a static safety property used only to reject escapes; it is not a runtime value and cannot be observed by programs. Pointer copies, casts, aggregate wrapping, field projection, and raw pointer arithmetic preserve this static provenance for escape checking. A pointer may be dereferenced only when it is non-null, correctly aligned for T, points at live initialized storage that is valid for a T access, and the access does not outlive or outsize the allocation or object. Pointer arithmetic is defined only within one allocated object or one-past its end; the one-past value may be compared or moved back into the object but may not be dereferenced. Casts from integers to pointers are unsafe and the resulting pointer has no validity guarantee unless the surrounding platform or FFI contract supplies one. Foreign and intrinsic calls are unsafe because they may read, write, retain, or publish raw pointers according to their external contract; callers must not pass a local-derived raw pointer to a callee that can store it beyond the call.
Violating one of these raw-pointer, provenance, aliasing, or other unsafe preconditions is outside the language contract. Native execution does not promise to detect the violation or fail in a particular way: SIGSEGV, a specific exit status, a diagnostic, and a backtrace are all unguaranteed. A debug checker, interpreter, or sanitizer may diagnose it, but that is a tooling contract, not a language safety trap. Automatic debug traces for language safety traps therefore do not create a trace guarantee for unsafe-precondition violations.
Raw pointers do not participate in borrow checking, but they also do not suspend the checked-reference rules. A program that uses a raw pointer to read or write storage after that storage has moved, dropped, gone out of scope, or while the access conflicts with an active checked borrow is outside the language contract. Checked references are guaranteed to obey the aliasing rules above. Conversely, raw pointer loads, stores, extern ABI calls, and intrinsic calls are treated as potentially aliasing any reachable mutable storage unless a more specific builtin or ABI rule says otherwise.
Owner-Anchored Checked Views
Unsafe std.mem.view is the sole public conversion from a raw pointer range to a checked slice view. Its final argument supplies the lifetime and access anchor that a raw (pointer, length) pair lacks:
unsafe fn view<T, A, region L>(
ptr: *T,
length: usize,
anchor: A in L,
) -> [T] in L;
unsafe fn view<T, A, region L>(
ptr: *mut T,
length: usize,
anchor: &A in L,
) -> &[T] in L;The ordinary-anchor overload creates shared access. Only the explicit exclusive anchor creates an exclusive view; *mut T alone does not upgrade access. The anchor is evaluated last, after ptr and length, so its loan activates only after those projections have been read. The returned view keeps that loan live. Moving, dropping, or reallocating the anchor therefore conflicts with either view, and any independent anchor access conflicts with the exclusive view.
The unsafe caller must establish non-nullness for a nonempty range, alignment, one-object/allocation bounds, provenance for the full range, and no overflow of length * sizeof(T). All length elements must be initialized valid T values and remain so for L. Shared construction forbids conflicting writes; exclusive construction requires exclusive access. A zero-length range may use null but still borrows the anchor. Nonempty zero-sized-element views preserve logical length and require a canonical aligned pointer without physical pointer arithmetic.
view does not create ^[T] ownership, infer a lifetime from use, or expose uninitialized storage. Unused owning-slice capacity becomes writable through the accepted OutputSpan<T> owner session, which establishes exactly one contiguous initialized suffix before commit. Foreign byte output uses only OutputSpan<u8>.spare_ptr() followed by unsafe advance(count); advance checks the session bound, while the caller proves those bytes were initialized. There is no generic raw output pointer from OutputSpan<T>. Hole-bearing storage for arenas or hash tables instead uses the accepted SDK-internal RawStorage<T> target. It is an affine owner of one built-in-domain allocation with an aligned MaybeUninit<T> slot prefix and an optional private byte tail, but it is deliberately liveness-blind. Arena occupancy or RawTable control bytes are the sole authoritative initialized-slot state; the raw owner has no bitmap, public slot tokens, growth operation, checked hole-bearing view, or public API.
MaybeUninit<T> does not weaken this rule. It stages exactly one slot. A normal aggregate or literal of already constructed wrappers is legal, but fresh raw allocation is not thereby an initialized range of wrapper values and cannot be retyped as one with mem.view. No operation publishes or converts the whole wrapper range at once.
The enclosing container must arm its authoritative state only after complete slot initialization, and must pair take or destruction with disarming that state before exposing an owner or invoking user cleanup. Such payload/metadata transitions contain no checked failure, callback, suspension, or other user code between their trusted steps. Slot loans are anchored in the whole nominal container rather than in its raw block, so active loans also prevent liveness changes and reallocation. Resizing allocates and validates a complete destination first, then moves only known-live slots; raw byte copying of the whole capacity and RawStorage.realloc are not valid substitutes. Zero-sized elements preserve logical slots and exact cleanup without payload allocation or element-pointer arithmetic. This target is accepted but not yet implemented.
The removed two-argument std.mem.slice/@slice_of(ptr, length) shape was invalid: it produced a false owner and had no source from which to derive a checked region. There is no view_mut, from_raw_parts, or unanchored alias.
Vector and Matrix projections are deliberately non-addressable. Safe code may copy v[i], m[r], or m[r][c], but cannot form &v[i], &m[r], or &m[r][c]; an immediate cast of such a reference to a raw pointer does not bypass this rule. Borrow the whole Vector or Matrix instead. Matrix element loads/stores use a checked flat row-major place internally, and a compound row or element update performs all trapping validation before its single final store.
Unsafe Code
Unsafe operations require an unsafe context. An unsafe context is created by an unsafe { ... } block, and the permission is lexical: it applies only inside that block and nested blocks. unsafe does not disable ordinary checks such as bounds checks, checked casts, borrow checking, or definite-initialization rules. A safe function may contain an internal unsafe block when it upholds the unsafe operation's preconditions.
These operations require an unsafe context:
- raw pointer dereference, including
*ptr,ptr[i],ptr.field, and assignment through any of those forms - raw pointer arithmetic and raw pointer subtraction
- pointer-to-integer and integer-to-pointer casts
- calls to extern ABI functions
asmstatements- exact volatile memory accesses through
std.mem.volatile.loadandstd.mem.volatile.store - checked device accesses through the methods of
std.mmio.Region - direct compiler/runtime intrinsics that bypass ordinary checks, including raw view constructors and known
@zynx.*runtime builtins MaybeUninit<T>.initializeandMaybeUninit<T>.take- calls to
@target_featurefunctions, unless the caller's own@target_featurelist covers the callee's (see below)
Pointer comparisons are ordinary expressions because they only compare address values. Comparing dangling raw pointers is still only address comparison and is allowed; dereferencing them is invalid. Dereferencing *void is rejected even in unsafe code; cast to a concrete pointer type first.
unsafe fn marks a function whose caller must uphold extra invariants. Calling an unsafe fn requires an unsafe context. The body of an unsafe fn is not implicitly unsafe; unsafe operations inside it still need unsafe { ... }.
@target_feature Calls
@target_feature(.X86Avx2, .X86Fma) compiles one function with all listed closed std.cpu.Feature values enabled. It takes one or more typed values and only enables capabilities; the former string, comma-list, alias, and +/- forms are rejected. The compiler normalizes prerequisite closure before checking calls. Duplicate, unknown-registry, and wrong-target values are hard diagnostics, with an @arch suggestion for a declaration intended for another architecture.
The compiler may then emit instructions the running CPU does not necessarily support, so calling such a function is an unsafe operation: executing it on a CPU without those features is outside the language contract.
- Calling a
@target_featurefunction requires an unsafe context. The caller asserts the features are supported, typically after checkingstd.cpu.hasat runtime. - A runtime
cpu.hasBoolean does not create a flow-sensitive static proof; a baseline caller still writes theunsafeblock explicitly. - Exception (superset rule): a caller whose own normalized feature set includes every normalized requirement of the callee may call it directly — the caller's own call was already gated.
- A
@target_featurefunction cannot be used as a callable value (assigned, passed, returned, or stored); it can only be called directly. This restriction remains until callable types preserve normalized feature requirements. @target_featureis not valid onmainor on extern functions.- For Wasm, a per-function feature is legal only when that feature already belongs to the artifact-wide baseline; otherwise compilation fails.
The ordinary safety checks that unsafe does not disable still trap on failure, and a trap aborts without running any drop, defer, or with cleanup. See Exit And Cleanup Semantics for the precise cleanup-skip and resource-safety rules. Violating an unsafe operation's preconditions remains outside the language contract and is not guaranteed to trap, signal, return a particular exit status, print a diagnostic, or produce a backtrace.
Volatile Operations
Volatile access preserves an exact compiler-visible scalar memory access when that access is externally observable. It is not an ordinary Zynx type qualifier and it is not a portable device or MMIO abstraction. Ordinary raw-pointer loads and stores are not volatile.
The spelling volatile<T> is reserved for C ABI qualifier metadata in extern library declarations generated from or written for C headers. It does not make ordinary Zynx loads or stores volatile. atomic<T>, the removed lowercase thread_local<T> pseudo-wrapper, and ordinary-source restrict<T> are rejected. The compiler-owned ThreadLocal<T> handle and std.atomic.Atomic<T> are separate Zynx types.
The public source API is the ordinary SDK namespace std.mem.volatile.load/store. Both names require an unsafe context:
import std.mem;
unsafe {
let status = mem.volatile.load(status_reg);
let done = mem.volatile.store(control_reg, status | 1);
assert done == void();
}Each name has exactly eight overloads, for i8, u8, i16, u16, i32, u32, i64, and u64:
unsafe fn load(source: *i8) -> i8;
unsafe fn load(source: *u8) -> u8;
unsafe fn load(source: *i16) -> i16;
unsafe fn load(source: *u16) -> u16;
unsafe fn load(source: *i32) -> i32;
unsafe fn load(source: *u32) -> u32;
unsafe fn load(source: *i64) -> i64;
unsafe fn load(source: *u64) -> u64;
unsafe fn store(destination: *mut i8, value: i8) -> void;
unsafe fn store(destination: *mut u8, value: u8) -> void;
unsafe fn store(destination: *mut i16, value: i16) -> void;
unsafe fn store(destination: *mut u16, value: u16) -> void;
unsafe fn store(destination: *mut i32, value: i32) -> void;
unsafe fn store(destination: *mut u32, value: u32) -> void;
unsafe fn store(destination: *mut i64, value: i64) -> void;
unsafe fn store(destination: *mut u64, value: u64) -> void;There is no generic load<T> or store<T>. bool, floating-point values, raw pointers, usize, isize, 128-bit integers, custom scalars, and aggregate or managed values are rejected. A signed payload preserves the raw same-width bit representation; the API performs no numeric or endian conversion. store returns the ordinary value void() and may be used wherever that value is accepted; it is not statement-only syntax. Volatile operations are not available during constant evaluation.
Every dynamically reached call produces exactly one target-certified native volatile memory access of the same width. The compiler may not eliminate, invent, duplicate, merge, split, or replace that access with a helper or library call, and it may not speculate it onto a path where the source call was not reached. Source evaluation order is preserved between volatile operations. A target that cannot certify one native same-width access rejects the program with a target-compilation diagnostic; it must not silently emulate the access. Every generic Wasm/WASI target rejects this API. RFC 0076's host-provided MMIO extension uses separate nominal Region methods; it does not enable these raw-pointer overloads.
The unsafe caller must prove the ordinary raw-pointer contract: the pointer is non-null, naturally aligned for the selected type, live for the complete access, and carries provenance and access authority for that storage. Converting an arbitrary integer to a pointer does not establish those facts. This API does not create an allocation, mapping, device capability, or MMIO authority. Access outside ordinary live storage needs a nominal mapping or a target-specific package whose contract establishes the required provenance and authority. RFC 0076 supplies that distinct mapping boundary through std.mmio.Region without enabling these raw-pointer overloads on otherwise rejected targets.
Volatile supplies no atomicity or tear-free guarantee, does not repair a data race, and creates no synchronization or happens-before edge. It orders only the compiler-visible sequence of volatile operations; it promises no ordering against ordinary memory or std.atomic. It also provides no fence, cache or DMA maintenance, interrupt ordering, device or bus protocol, posted-write completion, or endian conversion. A target-specific API must provide every such guarantee explicitly.
Direct ordinary-source @zynx.volatile.* calls are rejected. The SDK wrappers may lower through a compiler-private semantic primitive, but that primitive is not public language syntax or a compatibility alias.
Host MMIO Regions
RFC 0076 adds the nominal std.mmio.Region boundary for memory-mapped device registers. The executable composition root may request it only through the existing explicit host-loan form:
// RFC 0076 target; implementation pending.
import std.mmio;
@host let registers: mmio.Region in _;The host retains the mapping owner until entry quiescence and supplies one bounded external-mapping provenance through an explicit zynx-launch v5 grant. Region is non-Copy, non-Clone, neither Send nor Sync, and has no public constructor or raw pointer/address projection. User code cannot mint one from an integer, map a physical address, extend its bounds, duplicate it, close it, or unmap it.
The only accesses are sixteen unsafe byte-offset methods: load_i8/u8/i16/u16/i32/u32/i64/u64 and matching store_* methods. Permission, complete bounds, and natural alignment are checked before touching the device; failure reports mmio.AccessError.Permission, .OutOfRange, or .Misaligned and performs zero volatile accesses. A successful operation performs exactly one same-width private RFC 0075 volatile access. Its ordering and optimization guarantees are exactly those of the preceding section and no stronger.
Bounds and authority do not prove a device protocol, so every direct method is still unsafe. Region provides no atomicity, fence, ordinary-memory ordering, DMA/cache maintenance, posted-write completion, endian conversion, register schema, read-modify-write helper, typed register, subregion, async surface, or thread-transfer guarantee. A target-specific device package may wrap a region and expose safe semantic operations only where it can prove those additional rules. Every generic Wasm/WASI target rejects the type, host binding, and MMIO grant rather than mapping it into Wasm linear memory.
Atomics And Data Races
Atomic shared-memory operations are provided by std.atomic.Atomic<T> with explicit MemoryOrder. There is no atomic { ... } statement and no supported direct @zynx.atomic.* source intrinsic. The SDK type is the one public atomic boundary.
Safe Zynx does not provide unsynchronized shared mutable memory as a cross-thread communication mechanism. Mutable storage reachable from more than one thread or runtime must be coordinated through std/runtime synchronization primitives such as std.atomic, channels, and mutexes, or platform synchronization wrapped behind safe APIs.
A data race is outside the safe language model: two concurrent accesses to the same storage, at least one write, without synchronization. Raw pointers, FFI, globals, and unsafe do not by themselves create synchronization. std.mem.volatile also does not fix data races. Atomic ordering applies to ordinary shared memory and does not order volatile device-facing accesses.
Thread Send Safety
This section is the authoritative definition of thread-send safety. It is the rule for values that may cross OS-thread, worker-runtime, channel, or cross-runtime queue boundaries. Send and Sync are compiler-known structural capabilities usable in bounds and erased-callable qualifications. They have no runtime dictionary or witness object.
Values moved into channels or runtime worker queues must be thread-send-safe. The canonical async scheduler is same-runtime: direct await, async.all, async.race, async.timeout, and async.Group.add do not require thread-send safety. Stored and structured futures still obey the separate reference and raw-pointer capture restrictions. There is no public unstructured thread-spawn API.
| Thread-send-safe | Not thread-send-safe |
|---|---|
integer, float, bool, void, null | checked/nullable references, raw pointers, str, slices, fixed arrays |
| enums with send-safe payloads | Unique<T> and other ownership wrappers (cross-thread drop/aliasing unspecified) |
plain owned structs with send-safe fields and no user drop | user-defined structs with drop (no way to state the destructor is thread-safe) |
direct function/closure expressions with send-safe captures; erased (Args...) -> R where Send values | unqualified erased callable values (their structural proof was forgotten at erasure) |
language-recognized std/runtime handles (channel, socket); region-free Future<T> where Send | unqualified Future<T>, scoped Join<T>, and any Future whose checked region would escape |
Capsule<T> when T satisfies the capsule content rules |
Capsule<T> channel payload cleanup may drop the capsule on the receiving side or on a runtime owner side, which is valid because a capsule holds only language-recognized isolatable values and drop behavior.
A concrete closure derives Send and Sync from its explicit environment fields. Direct conversion to (Args...) -> R where Send, where Sync, or where Send + Sync checks those fields before hiding the environment. Named functions and noncapturing closures can prove both. Converting to an unqualified callable type forgets both proofs permanently; the erased value cannot later be inspected or cast back to a qualified type. Callable receiver access (self/&self/^self) is independent of this qualification.
Qualification does not turn a checked reference into transferable ownership. A closure capturing a loan keeps its named region and may cross a worker only through a structured scope whose capability, exclusivity, and mandatory join proof already permit that loan. No callable type creates an automatic cross-thread borrow.
Future uses a narrower capability surface than callable erasure. Plain Future<T> is neither Send nor Sync; only Future<T> where Send exists. Its producer proves every possible in-flight frame field portable, including suspend-live locals, cleanup, nested awaits, and runtime state. The qualifier does not imply T: Send, can only be forgotten, and has no runtime witness. Parallel Join transport checks success and error payloads separately. A region-bound qualified Future still follows the shared-Sync or exclusive-Send loan rules and cannot escape through a channel.
Drop Thread Affinity
Automatic drops run on the thread or runtime that owns the value at the point where it becomes unreachable. For ordinary local values, temporaries, and fields owned by the current source execution, that is the current execution thread or runtime tick.
Values captured into a future frame are owned by that frame. Captured values are dropped exactly once when the future is awaited to completion and consumed, explicitly std.dropped before it starts, cancelled before it starts, or when the runtime destroys the frame after cancellation or completion. Started future frames are cleaned up by the runtime that owns and drives that frame; explicit std.drop may enqueue that cleanup for a later runtime tick rather than running it synchronously at the source statement. Registered defer await bodies are part of that cleanup and may suspend; ordinary defer bodies remain synchronous. Both boundaries handle checked failures locally and return normally, so they cannot replace the primary outcome. Cancellation completes only after those defers and automatic drops finish; a runtime trap still aborts remaining cleanup.
Values sent through a channel become channel-owned until received. If a receiver gets Recv.Value(value), the receiver side owns value and drops it normally. If a buffered value is never received, channel cleanup destroys the buffered payload when the channel becomes unreachable. Because user-defined drop structs are not thread-send-safe, channel transfer cannot cause a user destructor to run on another worker thread. Language-recognized runtime handles whose drops may run on runtime threads must provide their own thread-safe cleanup as part of the std/runtime contract.
Runtime traps still abort immediately. They do not run automatic drops, defer bodies, with cleanup, channel payload cleanup for source values, or async frame cleanup.
Layout Attributes
The built-in void value type has fixed layout size = 0, alignment = 1. Bindings, fields, tuple positions, arrays, slices, and generic containers still retain logical initialization, arity, length, capacity, moves, and bounds even when no element bytes are emitted. A compiler may reuse or omit addresses for zero-sized values and elements; those addresses carry no value or position identity. Nominal empty structs remain distinct types even when their Zynx layout is also zero-sized.
Plain structs have language-managed layout and are not a C ABI contract. Do not depend on their field order, padding, alignment, or by-value calling convention outside Zynx. @strict and @packed are the current attributes for source-order layout.
@strict struct preserves the declared field order and uses the target ABI's ordinary field alignment and padding rules. The struct alignment is the maximum field alignment. Field references are allowed because fields remain naturally aligned according to the struct's layout.
@packed struct preserves the declared field order, removes inter-field and trailing padding, and gives the aggregate alignment 1. A packed field may be stored at an address that is not aligned for the field type, including through a nested packed parent. Reading or writing a packed field by value is a safe operation that uses an access sequence valid for the packed layout.
Creating a checked reference to a packed field is rejected because &T always promises alignment for T. This includes explicit &packed.field, method receiver borrows such as packed.field.method(), array/subscript borrows reached through a packed field, and nested forms such as outer.packed_inner.value.
When a program really needs the address of packed storage, it may create a raw pointer in an unsafe context:
unsafe {
let ptr = &packet.value as *u32;
}The resulting raw pointer is still subject to the raw-pointer validity rules: dereferencing it is valid only if the access is appropriate for the actual alignment and storage. Copying the field value into a local and borrowing that local is the safe pattern when a checked reference is needed.
Packed structs are FFI-safe by pointer only. This matches the extern ABI rule that rejects struct and union values by value. @packed does not make an extern-ABI-by-value signature legal.
FFI ABI
extern "C" fn and extern "C" let declare symbols owned outside Zynx that use the C ABI. extern library "name" { ... } groups C declarations under a logical extern library name:
extern "C" fn strlen(s: *u8) -> usize;
extern library "c" {
abi "C";
link "c";
@symbol("strlen")
fn zstrlen(s: *u8) -> usize;
fn malloc(size: usize) -> *mut void;
fn free(ptr: *mut void);
}Only ABI "C" is accepted. extern library is a static link-time declaration form; it is not dynamic loading and it does not bypass the locked package/import model.
Inside an extern library block, link "name"; declares a transitive linker library requirement. The name must be a bare library name such as z or ssl, not a path and not a linker flag such as -lz. On Unix-like targets it becomes -lname; on Windows it becomes /DEFAULTLIB:name.lib. Source imports and .zxm bundles carry these requirements to the final executable, so stdlib modules such as compression declare their own native library dependencies instead of relying on built-in special cases. Advanced platform-specific flags and explicit library paths still use driver link arguments.
extern fn and extern let are reserved for external Zynx-linkage declarations. They are not the C FFI spelling.
Extern ABI calls require an unsafe context, but unsafe is only permission to cross the boundary. The programmer must still uphold the C-side contract: symbol existence, ABI compatibility, pointer validity, ownership transfer, synchronization, and any C const or volatile requirements not represented by Zynx pointer roles or explicit volatile accesses.
Ownership and lifetimes do not cross the extern ABI boundary automatically. The boundary transfers raw bits only; it never transfers a Zynx drop obligation:
- A raw pointer passed to C grants no ownership unless the C contract says so. The caller must keep the pointee alive and unmoved for as long as the C side may access it, including after the call returns if C retains the pointer.
- A raw pointer returned from C is not owned by Zynx and is never dropped by Zynx. If the contract requires the caller to release it, do so through the matching C deallocator.
- Owned Zynx types such as
^str,Unique<T>, and any type with adropmethod are rejected in extern ABI signatures precisely because their drop obligation has no C representation. Pass*Tor*mut Tplus an explicit length and ownership convention instead, and adapt ownership inside a safe wrapper.
The extern ABI type surface is deliberately narrow. Extern ABI parameters, return values, and globals may use:
- standard integer scalar types except
bool; customiN/uNwidths are not C ABI-safe - C-native floating-point scalar types
f32andf64 - C
voidresults, written without a return arrow or as an explicit Zynx-> void; the semanticvoid()result emits no C result operand - raw pointers, including
*voidand*mut void; raw pointers are nullable and mapnullto a C null pointer - non-null C function pointers written
extern "C" fn(Args...) -> R, and explicit nullable forms through a named alias followed by? Vector<T, N>andMatrix<T, R, C>whenTis a C-native integer, pointer-sized integer,f32,f64, orboolmask lane
A raw pointer may target a scalar, void, another raw pointer, an FFI-safe Vector/Matrix, a C ABI struct/union declared in an extern library block, or a @strict/@packed struct. Use *void for an opaque handle whose pointee layout is not part of the contract. A pointer to a Zynx callable is never a C function pointer: *(Args...) -> R and *mut (Args...) -> R are rejected.
C function pointers and callbacks
The accepted C callback type is separate from both raw data pointers and Zynx callables:
type Mapper = (i32) -> i32;
type CMapper = extern "C" fn(i32) -> i32;
type MaybeCMapper = CMapper?;Mapper carries a hidden Zynx environment with its call and cleanup behavior. CMapper is an opaque non-null C code pointer. It is Copy + Send + Sync, but has no pointee mutation, dereference, arithmetic, ownership, drop, cast to *void, or promised one-word representation. Its arguments and result pass through the same recursive target C ABI classifier as a direct extern call. Every indirect invocation requires unsafe, even when the value originated from a function defined in Zynx.
extern "C" fn double_value(value: i32) -> i32 {
return value * 2;
}
fn invoke(callback: CMapper, value: i32) -> i32 {
unsafe {
return callback(value);
}
}Only an exact named extern "C" fn item, or a value received through the C ABI, forms a C function pointer. Ordinary fn items and closure literals do not convert, including noncapturing closures. The language inserts no thunk, environment boxing, executable trampoline, or callable erasure. A nullable C callback is a real CMapper?: null is not a no-op policy and the value must be narrowed before invocation.
A stateful C callback uses the exact context parameter specified by that C API:
type Visit = extern "C" fn(*mut void, i32) -> i32;
extern "C" fn visit_trampoline(context: *mut void, value: i32) -> i32 {
unsafe {
let state = context as *mut VisitContext;
state.sum += value;
}
return 0;
}
extern "C" fn c_visit(callback: Visit, context: *mut void) -> i32;The function pointer and context bits alone never transfer ownership or prove a lifetime. A synchronous safe wrapper may use stack context only when the API guarantees no retention and the loan covers the complete extern call. A retained safe wrapper must return an API-specific nominal registration owner; its drop first unregisters, then obtains quiescence for every active and future callback, and only then destroys the callback and context. If the API cannot prove quiescence, the boundary remains raw unsafe with application-managed storage. Foreign-thread callbacks additionally require exact Send + Sync context proofs and explicit synchronization; mutating or consuming callable receivers need stronger API-specific same-thread, no-reentry, or exactly-once guarantees.
C function-pointer signatures cannot carry checked errors, Future, Generator, Zynx references or regions, owners, callable environments, generics, or variadics. A trampoline translates failure to an explicit C status, sentinel, output field, or completion protocol. Trap aborts; Zynx errors, foreign exceptions, longjmp, and unwinding never cross the "C" boundary.
Inside extern library blocks, aggregate declarations are layout-compatibility ready for FFI:
struct,union, andenumdeclarations use C ABI layout.- C ABI structs imply strict source-order layout with target ABI alignment and padding. C ABI structs may contain bitfields through
@bits(width)on integer orboolfields; bitfields cannot have default values. Custom-width integer fields are rejected in C ABI structs and unions. uniondeclarations are only valid insideextern libraryblocks.- C ABI unions may contain only FFI-safe field types. They cannot be generic, implement interfaces, declare methods, declare
init, or declaredrop. - C ABI enum discriminants may be explicit integer literals. Tags must be unique, must fit the declared backing type, and must fit the compiler's signed 32-bit tag representation. Custom-width integer backing types are rejected for C ABI enums.
- C ABI enums cannot carry payloads and cannot currently be matched with
match. - Union field access requires an unsafe context, because reading the wrong active union field is a caller-side invariant.
- You can still use
@strict,@packed, or@c_nameto control their exact layout or C symbol mapping. volatile<T>andrestrict<T>are accepted only as C ABI pointer qualifier metadata, written on a raw pointer view such as*volatile<T>or*restrict<T>. They are transparent to Zynx runtime representation and do not provide volatile access, synchronization, or safe aliasing guarantees.
Handwritten extern ABI declarations are checked by the narrow surface above. They reject:
- by-value
voidparameters and fields, plus every empty Zynx aggregate in an ABI parameter, result, or C-layout declaration; only a function result maps to Cvoid str, slices, fixed-size arrays by value, tuples, nullable references, checked references, owned Zynx structs such asUnique<T>, closures, interfaces,Future,Task,Group, and error unions. Pass array storage as*Tplus an explicit count instead.- scalar
bool; only Vector/Matrix mask lanes have the specifieduint8_tC spelling - Zynx enums by value, including C ABI enums
- union values by value, including C ABI unions
- generic extern ABI functions, extern ABI methods, throwing extern ABI functions, and extern ABI variadics
- by-value or non-pointer qualifier wrappers such as
volatile<T>andrestrict<*T>, and all uses ofatomic<T>,thread_local<T>, andThreadLocal<T>
Text follows the same explicit boundary. A size-aware C signature receives str.ptr plus str.size; it must not read ptr[size]. When a C parameter requires terminated bytes, a safe wrapper accepts std.ffi.CStr and passes its read-only .ptr to the raw declaration. Every dynamic str constructs a checked owning ^CStr, normally through try ffi.CStr(Bytes(text)); interior NUL is rejected and no text role lends CStr without validation and copying. Neither str, ^str, nor the two-word CStr is magically passed by value through C ABI.
Raw foreign storage becomes a CStr only after mem.view establishes a real readable bound and owner/source anchor and bounded CStr.find finds the first NUL. There is no unbounded from_ptr/from_cstr, foreign-pointer adoption, or nullable sentinel CStr. Mutable foreign output remains an exclusive byte view or OutputSpan<u8> until the write ends and the initialized bytes are validated.
Passing structs by value
C-layout structs (a C ABI struct from an extern library block, or a @strict/@packed struct) may be passed to and returned from extern "C" function declarations by value; each shape is passed and returned according to the target's C ABI. There is no conservative fallback: a shape that is not modeled is a compile error, never a silent mis-lowering.
Vector and Matrix use the equivalent C declarations:
typedef struct { T lanes[N]; } Vector_T_N;
typedef struct { T elements[R][C]; } Matrix_T_R_C;Mask lanes use uint8_t; incoming zero is false, every nonzero byte is true, and every value written back to C is normalized to 0 or 1. usize maps to size_t and isize to intptr_t. C-layout structs may recursively contain these aggregate types when every field is FFI-safe.
These lane restrictions apply only at a C boundary. Internal raw pointers to Vector/Matrix values with other legal scalar lane types remain ordinary Zynx pointers and are not rejected by the FFI gate.
For the modeled ABIs — AAPCS64 (arm64), SysV x86-64 (Linux and Darwin), and Win64 — a struct follows that target's C rules: small structs travel in registers where the ABI allows (a homogeneous float aggregate in float registers), and larger structs are passed by a hidden pointer and returned through a hidden result pointer. A target ABI that is not modeled (for example riscv64) is a compile error.
Implementation note: the exact register, byval, and sret classification per ABI is chosen to match clang's lowering of the equivalent C and is verified at the IR level. AAPCS64 and SysV are additionally covered by execution tests against clang-built C fixtures; Win64 is IR-level only.
Explicitly refused by-value struct shapes (each is a compile error naming the reason): bitfield-containing structs, structs with union fields, bool fields, f16/f128 fields, 128-bit integer fields, nested non-C-layout struct fields, @packed layouts whose fields land on misaligned offsets, flexible or zero-length array fields, empty structs, and generic structs.
Both directions are covered. Calls into C go through extern declarations. extern "C" fn name(...) { body } defines a private addressable function with the C calling convention; adding export publishes its unmangled symbol. Thus ABI and linker visibility remain separate. Both private and exported definitions pass the same FFI gate and marshal parameters/results through the same target classifier. A semicolon instead of a body imports a foreign symbol, and declarations inside extern library blocks remain body-free.
Safe wrappers are the normal stdlib pattern: the wrapper validates arguments, performs any encoding or ownership adaptation, and contains the minimal unsafe { ... } extern ABI call internally. There is no universal std.ffi.Callback owner because unregister, quiescence, thread, reentrancy, and destruction obligations belong to the concrete foreign API.
Bindgen (zynx -o out.zx header.h) is a tool-generated C header modeling surface. A C function-pointer typedef becomes an alias of extern "C" fn(Args...) -> R, never an environment-carrying arrow callable. Because an unannotated C pointer type does not prove non-nullness, bindgen uses the nullable alias at parameters and fields unless a supported header annotation proves non-null. It may emit other declarations that are not accepted as handwritten FFI today, including C union and enum values by value, C variadics, and fixed-size array fields. (Generated struct-by-value signatures are accepted and callable — see "Passing structs by value" above.) Those generated forms preserve C header shape for parsing and tooling; they are not a promise that the same signature is accepted when written by hand as a supported extern ABI contract.
Bindgen skips declarations it cannot model. A skipped declaration is not emitted, and the skip is reported in the generated module as stable comments: one count line, followed by one line per skipped declaration in source declaration order, naming the declaration kind, the declaration name in backticks, and the reason:
// bindgen: skipped 2 unsupported declarations
// bindgen: skipped FunctionDecl `use_opaque`: unsupported return/parameter type
// bindgen: skipped EnumDecl `Mode`: incomplete/opaque enumThe kind, name, and reason strings are a stable, machine-readable surface and are covered by tests.
As described above, Zynx exposes atomic shared-memory operations through std.atomic but does not make raw pointers, FFI storage, or globals safe to share implicitly. Code that shares mutable storage between threads or runtimes through those boundaries must use synchronization provided by std/runtime APIs, safe platform wrappers, or the extern ABI code it calls. std.mem.volatile does not provide that synchronization or participate in atomic memory ordering.