Safety, FFI, and Layout

References, raw pointers, unsafe contexts, volatile access, send safety, layout attributes, and the current C ABI.

Zynx separates checked references from raw pointers. Checked references are tracked by the analyzer; raw pointers are nullable machine addresses used for interop and manual memory.

Checked References

&T is a checked reference to a live place. const &T is a shared read-only borrow, while &T is an exclusive mutable borrow.

Long-lived references are explicit:

let r: &T = &value;
return &input.field;

Function and callable arguments follow the same explicit rule. If a parameter expects &T or const &T, pass &value or an expression that is already reference-valued. Method receivers keep receiver auto-borrowing for self: &T and self: const &T, and operator overload operands keep the same receiver-style borrowing because operator syntax has no explicit argument list.

References are move-only borrow handles. Moving a reference transfers ownership of the loan to the destination reference value. Assignment to a reference-typed expression writes through the reference; it does not rebind the reference.

&T is non-null. Use &T? for a nullable reference.

Borrow Scope

Multiple shared borrows may coexist. An exclusive borrow conflicts with any overlapping shared or exclusive borrow.

Borrow checking is flow-sensitive and conservative at joins such as if, match, catch, ternary branches, loops, break, and continue. If any reachable path may still hold a borrow, conflicting use after the join is rejected.

Field and array-element borrows are tracked as subplaces. Distinct struct fields are disjoint. Constant array or tuple indices are disjoint when the literal indices differ. Dynamic array and slice indices are conservative and may overlap any element.

Returning a borrow of an input reference, a field reachable through an input reference, a module global, or null for &T? is allowed. Returning a borrow of a by-value parameter, local, temporary, literal, struct literal, tuple literal, or array literal is rejected.

Async Borrowing

An exclusive borrow owned by the current async frame may not be live when that frame reaches await or select. Shared read-only borrows may remain live across await, but they still cannot be captured by stored futures or other values that may outlive the source scope.

Direct-await reference captures are the current exception:

await mutate_ref(&value);

Binding that future to a local, passing it to future.all, future.race, future.timeout, or adding it to a group is rejected when it captures references or raw pointers.

Closures capture by value in current source. Checked references and raw pointers cannot be captured, so returning or storing a closure cannot hide a borrowed local behind the closure object.

Raw Pointers

*T is a raw pointer. Raw pointers are copyable, nullable addresses and are not borrow-checked. *T? is invalid because *T already includes null.

Raw pointers can be created from null, with an explicit reference-to-pointer cast such as &value as *T, through pointer casts, and through raw-memory APIs such as std.mem.address_of.

Raw pointers do not extend lifetimes. A raw pointer may be dereferenced only when it is non-null, correctly aligned for T, points at live initialized storage valid for a T access, and the access stays within the allocation. Pointer arithmetic is defined only within one allocated object or one-past its end.

Unsafe Contexts

Unsafe operations require an unsafe context:

unsafe {
    *ptr = 42;
}

Unsafe context is lexical and does not disable ordinary checks such as bounds checks, checked casts, borrow checking, or definite-initialization rules.

The current unsafe operations are:

  • raw pointer dereference, including *ptr, ptr[i], and assignment through either form
  • raw pointer arithmetic and raw pointer subtraction
  • pointer-to-integer and integer-to-pointer casts
  • calls to foreign functions
  • asm statements
  • volatile memory access
  • direct compiler/runtime intrinsics that bypass ordinary checks

Pointer comparisons are ordinary expressions because they only compare address values. Dereferencing *void is rejected even in unsafe code; cast to a concrete pointer type first.

unsafe fn means callers must use unsafe context. The body of an unsafe fn is not implicitly unsafe; unsafe operations inside it still need unsafe {}.

Volatile and Atomics

Volatile access is the current API for externally observable memory such as memory-mapped device registers. It is not a type qualifier.

unsafe {
    let status = @zynx.volatile.read<u32>(status_reg);
    @zynx.volatile.write<u32>(control_reg, status | 1);
}

@zynx.volatile.read<T>(ptr: *T) -> T performs one volatile load. @zynx.volatile.write<T>(ptr: *T, value: T) performs one volatile store and is valid only as a statement. Volatile is not atomic and is not synchronization.

The current volatile payload type may be an integer scalar except bool, a floating-point scalar, or a raw pointer value. Aggregates, managed values, references, vectors, futures, groups, closures, and interfaces are rejected.

Atomics are not part of 0.0.0-dev. There is no public atomic {} block and no public @zynx.atomic.* intrinsic.

Thread Send Safety

Thread-send safety controls values that may cross OS-thread, worker-runtime, channel, or cross-runtime queue boundaries. The compiler uses an internal send-safety check; there is no public Send interface in 0.0.0-dev.

Thread-send-safe values include:

  • integer, float, bool, void, and null values
  • enums whose payloads are thread-send-safe
  • plain owned structs whose fields are thread-send-safe and that do not declare user-defined drop
  • direct function values and direct closure expressions whose captures are thread-send-safe
  • compiler-recognized runtime handles such as channel and socket handles

Not thread-send-safe in 0.0.0-dev:

  • checked references, nullable references, raw pointers, str, slices, and fixed arrays
  • Unique<T> and other ownership wrappers without a cross-thread drop contract
  • user-defined structs with drop
  • erased callable values
  • Future<T> and scoped Task<T> values across thread/channel boundaries

Direct await, future.all, future.race, future.timeout, and future.Group.add are same-runtime APIs and do not require thread-send-safe futures.

Drop Affinity

Automatic drops run on the thread or runtime that owns the value at the point it becomes unreachable. Ordinary local values drop on the current execution thread or runtime tick.

Values captured into a future frame are owned by that frame. Captured values drop exactly once when the future is awaited and consumed, explicitly discarded with std.drop before it starts, cancelled before it starts, or destroyed by the runtime after completion or cancellation.

Values sent through a channel become channel-owned until received. Because user-defined drop structs are not thread-send-safe in 0.0.0-dev, channel transfer cannot cause a user destructor to run on another worker thread.

Runtime traps still abort immediately and do not run drops, defer, with cleanup, channel payload cleanup for source values, or async frame cleanup.

Layout Attributes

Plain structs have language-managed layout and are not a C ABI contract. @strict and @packed are the current source-order layout attributes.

@strict struct preserves declared field order and uses the target ABI's ordinary field alignment and padding rules.

@packed struct preserves declared field order, removes inter-field and trailing padding, and gives the aggregate alignment 1. Reading or writing a packed field by value is safe, but creating a checked reference to a packed field is rejected because &T always promises alignment for T.

Packed structs are FFI-safe by pointer only in current source. @packed does not make a foreign-by-value signature legal.

C ABI

foreign "C" fn and foreign "C" let declare symbols owned outside Zynx that use the C ABI. Foreign calls require unsafe context.

foreign "C" fn strlen(s: const *u8) -> usize;

foreign library "c" {
    abi "C";

    @symbol("abs")
    fn c_abs(value: i32) -> i32;
}

Only ABI "C" is accepted in 0.0.0-dev. foreign library is a static link-time declaration form; it is not dynamic loading and does not bypass locked imports or packages.

extern fn and extern let are reserved for external Zynx-linkage declarations. They are not the C FFI spelling.

Allowed foreign parameter, return, and global types are:

  • integer scalar types except bool
  • floating-point scalar types
  • void return
  • raw pointers, including *void

Raw pointer targets may be scalars, void, other raw pointers, or @native, @strict, or @packed structs/unions.

Rejected foreign surface includes str, slices, fixed arrays by value, vectors, tuples, nullable references, checked references, owned Zynx structs such as String, Unique<T>, closures, interfaces, Future, Task, Group, error unions, enums by value, structs/unions by value, generics, methods, async functions, throwing functions, and variadics.

Safe wrappers should validate arguments, adapt ownership and encoding, and keep the unsafe foreign call as small as possible.