Date: 2026-07-01
A :slice, :array, :ptr, or :manyptr had to hold a scalar
element. A parser could not return []Token, a renderer []Vertex, a
simulator []Entity: the natural return of almost every native API was
out of reach, forced instead into a single owned []u8 the caller
unpacked by hand. The boundary already returned one struct by value and
nested structs inside one (ADR 43); the bulk case was the remaining
gap.
A :slice or :array may hold a named struct element whose layout is
scalar-only (the same gate ADR 43 uses for a nested field, exposed as
layout/scalar-only-layout?). The element crosses by value in bulk:
the slice's wire form is still {ptr, len}, and the marshaller walks
the slice at the element's stride, reading or writing one struct per
element.
[:slice :const Point] accepts a Clojure collection of
maps; the marshaller allocates n*stride bytes and writes each map
via the recursive struct writer. There is no copy-back: the caller
supplied immutable maps, so a mutable struct slice does not propagate
in-place edits back to Clojure. The const slice is the natural shape.[:array N Point] argument is the fixed-length form, length-
checked against the declared N.[:owned [:slice Point]] return is copied out as a vector of maps
and the one slab allocation is freed through the existing owned-slice
shim; [:borrowed [:slice Point]] copies without freeing.A pointer (:ptr, :manyptr) must still hold a scalar element. A slice
or array of a buffer-carrying struct in argument position is rejected
with :clj-zig/unsupported-element: the argument marshaller writes
extern slots at known offsets and cannot lay out a nice struct's slice
fields there. An [:owned [:slice Buf]] return of one is accepted (see
the amendment below); a [:borrowed [:slice Buf]] return is rejected
with :clj-zig/unsupported-borrowed-buffer-slice. A slice of an enum is
not added here either.
A developer returns []Vertex, []Token, []Entity directly as
vectors of maps, and accepts const slices of them as arguments. The
scalar-only gate keeps the bulk path single-allocator: the slab is one
c_allocator allocation the free shim releases, with no per-element
buffers to walk. Nested scalar structs compose: a slice of a Rect
whose fields are Points round-trips through the same element reader.
The cost is that a struct-element slice must be declared :const. A
scalar slice propagates the body's in-place edits back to the caller's
mutable primitive array; a struct slice cannot, because the caller
supplies immutable maps. Rather than silently drop the edits, the spec
rejects a non-const struct-element slice with
:clj-zig/mutable-struct-slice. (An :array of structs is unaffected:
arrays never copy back, for scalar elements either, so the behavior is
uniform.) A slice element may not carry a buffer field. Both are
deliberate scope limits; the motivating cases (vertices, tokens,
entities, particles) fit the scalar interior.
Pack a collection of structs into one owned []u8 and unpack on the
Clojure side. Rejected: it duplicates the byte-framing tax ADR 17 and
ADR 21 were written to remove, on both sides of the boundary, and the
two sides can silently disagree on the layout.
Lift the scalar-only gate to allow per-element buffer fields now. Implemented as the owned-return amendment below: the free shim walks every element and frees every buffer before freeing the slab, a distinct ownership protocol with its own nice-to-wire transform. The scalar- interior case shipped first; the buffer-carrying case follows.
Copy back mutations into the caller's maps. Rejected: maps are immutable, so a copy-back would rebuild the whole collection, and the common case is a read-only const slice. A caller that needs the body's edits returns an owned slice and reads the result.
Date: 2026-07-02
An [:owned [:slice Buf]] return whose element carries buffer fields
(strings, byte slices, or scalar slices) is now accepted. The body
builds nice records (a regular Zig struct with real slice fields the FFM
reader cannot read at known offsets), so the wrapper transforms the
body's []Buf into a wire (extern) slab:
__impl fn returns []Buf (nice records, c_allocator).[]Buf__wire slab, iterates the nice slice,
and copies each field (scalars and enums direct, each buffer field
decomposed to its pointer and length).__free shim iterates the wire slab, frees each element's
buffer fields (reinterpreting each usize pointer back to a slice of
its element type), then frees the slab itself.The FFM reader reads the wire slab at the C-ABI offsets the layout descriptor computes, the same path the owned-record return (ADR 21) uses for a single record. The cost is a transient double allocation (the nice slab plus the wire slab) during the call; the nice slab is freed before the call returns, so only the wire slab and its element buffers live until the free shim runs.
Argument slices and arrays of buffer-carrying structs are now supported
(amendment, 2026-07-02): a [:slice :const Buf] or [:array N Buf]
argument where Buf carries buffer fields crosses as a slab of wire
(extern) structs. The FFM marshaller copies each caller value's buffer
fields into the call arena and writes the {ptr, len} pair into the
extern slot. The wrapper allocates a nice-record slab with
c_allocator, converts each wire element (scalars direct, each
{ptr, len} pair reinterpreted as a real slice), runs the body, and
frees the nice slab in a defer. The buffer contents live in the FFM
call arena; the nice slab's slice fields point at them for the call's
duration. A non-const buffer-carrying struct slice is still rejected
(:mutable-struct-slice): the caller's immutable maps have no mutable
container for copy-back. A borrowed slice of a buffer-carrying struct
remains rejected for returns (the wrapper-allocated wire slab would
leak with no free shim).
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 |