Date: 2026-06-16
The boundary contract names [:owned T] and [:borrowed T] for
returns and says returned native memory must be explicitly owned,
copied, or wrapped, and that the form must define who frees the memory
and whether Clojure copies or wraps it. A returned Zig slice is a
pointer and a length with no stable C-ABI return shape, and its memory
outlives the call, so the boundary needs a concrete protocol for getting
the bytes to Clojure and settling who frees them.
[:owned T] and [:borrowed T] are supported in return position only;
an ownership wrapper in argument position is rejected when the spec is
built, with :clj-zig/unsupported-ownership. For now T must be a slice
of a carrier scalar, [:slice S] or [:slice :const S]; any other
wrapped type is rejected with :clj-zig/unsupported-ownership.
Both forms copy the slice into an immutable Clojure vector of the element values at the boundary, never a native-backed wrapper. They differ only in who frees the native memory.
For :owned, the body allocates the slice with std.heap.c_allocator,
and the generator provides const std = @import("std"); and a free shim
<symbol>__free. The export wrapper carries two trailing out-parameters,
a *usize for the pointer and a *usize for the length, writes
@intFromPtr(result.ptr) and result.len, and returns void. clj-zig
reads the pointer and length, copies the elements into the vector, then
calls the free shim.
For :borrowed, the body returns a view it still owns, such as a
sub-slice of an argument. The wrapper uses the same two out-parameters,
clj-zig copies the elements, and nothing is freed. The view is read before
the call's confined arena closes, so its lifetime stays within the call.
The lifetime rules stay conservative: Clojure receives an immutable copy, no native pointer escapes the call, and owned memory is freed the moment the copy is taken.
A returned slice reads as ordinary Clojure data with no lifetime burden
on the caller, and :owned and :borrowed differ by exactly one freed
pointer. The costs are real. Every returned slice is copied, so a large
result pays a full copy rather than a borrow. :owned standardizes on
std.heap.c_allocator, so a body that allocates with a different
allocator leaks or corrupts, and a custom allocator is out of reach until
a later record threads one through. The wrapper carries two synthetic
out-parameters the boundary contract does not name, and an :owned
function compiles a second exported symbol for its free shim.
Wrapping the native memory in a zero-copy Clojure sequence backed by an
FFM segment was rejected for now: tying native lifetime to JVM garbage
collection through a Cleaner or a shared Arena is intricate and easy
to get wrong, while a copy is sound and matches the conservative lifetime
rules. Handing the caller a free function to call by hand was rejected
because it pushes lifetime onto the caller and invites a leak or a double
free; copy-then-free keeps it safe and invisible. Returning the slice as
an extern struct { ptr, len } was rejected in favor of out-parameters,
which reuse the protocol the error-union and struct returns already use
and avoid the ABI fragility of returning an aggregate by value. Asking
the caller to pass a pre-sized buffer, as struct returns do, was rejected
because a slice length is not known before the call.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |