Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
12 KiB
zset-ffi: the reasoning
crates/zset-ffi is a C ABI over zset for CFFI callers (the first is Common Lisp). It is a
separate crate so zset stays std-only, serde-free and wasm32-clean (ADR 009 clause 1). It is
native only: it is not in nix/ci.nix's wasmCrates, and the workspace-wide native
build (--workspace) already compiles it, so nothing in the nix files changes. The contract
is crates/zset-ffi/include/zset_ffi.h; this file records the decisions behind it. The
evidence is in context/test-evidence/zset-ffi.md.
Build
cargo build -p zset-ffi --release writes target/release/libzset_ffi.{so,dylib} (the
cdylib), libzset_ffi.a (the staticlib) and the rlib. The rlib is what the Rust tests
link, so they call the extern "C" functions as plain functions.
Decisions
-
Monomorphic. Keys are byte strings
(ptr, len), weights areint64_t. The genericZSet<K>is instantiated once, atVec<u8>. A caller encodes whatever it keys by. -
Opaque handles, explicit create and free.
ZsZSet,ZsCircuitandZsRebuildareBox::into_rawpointers;zs_X_new(&out)makes one andzs_X_freereleases it. Freeing null is a no-op. A double free or a use after free is undefined, as in C. Circuit streams, inputs and sinks areuint32_tids local to their circuit, not handles: they own nothing. -
Error codes, never unwinding. Every function but the frees and two accessors returns
int32_t: 0, or aZS_ERR_*. Every entry point body runs incatch_unwind(guard). Azsetweight-overflow panic is recognised by its message prefix and becomesZS_ERR_OVERFLOW; any other panic isZS_ERR_PANIC. Results come back through out-pointers written only on success.zs_last_error()is a thread-local, NUL-terminated message, never null, valid until the next failure on that thread. The Rust panic hook still prints the overflow message to stderr before the code returns, because replacing a process-wide hook from a library is not this library's call. A host that wants silence installs its own hook, or avoids the overflow. -
Failure leaves the Z-set unchanged.
add,sub,neg,scaleanddecode_addcompute a new value and then assign it, so an overflow half way through cannot leave a half-applied update (zs_zset_insertis one addition, so it has no half). -
A failed step poisons the circuit, and a rollback revives it. A panic in
stepleaves operator state half updated. So the first failure in a step, or in a rebuild step, marks the circuit poisoned, and every later call that uses it returnsZS_ERR_POISONED. The one way out iszs_circuit_rollbackof a transaction that the failed step ran in (decision 13); a poison from outside a transaction is permanent, and the caller drops the circuit and builds another. Ids that do not exist, and malformed arguments, are refused before any state changes and do not poison. -
Buffers. The caller owns every buffer it passes and may reuse it once the call returns; the library copies. The library hands out no memory except handles. Output of variable size uses a two-call protocol:
zs_zset_encode(z, buf, cap, &needed)always setsneeded, and writes only ifcap >= needed(elseZS_ERR_BUFFER_TOO_SMALL). -
Wire shape. A Z-set encodes as entries ascending by key, each
[u32 LE key length][key] [i64 LE weight]. A keyed element ispair(k, v) = [u32 LE length of k][k][v]. These are this crate's own, not clause 9's wire form (that lives inimprovise-protocol). -
What the circuit offers. Input, output sinks,
delay,integrate,differentiate,distinct,plus,filterandflat_map(C callbacks),index_by(a callback emits the one group key),joinover two pair streams, andaggregatewith count, sum, min and max overint64values.mapisflat_mapthat emits once.aggregate's fold is fixed to those four because a C callback cannot return anOptionof a group result without a second encoding; richer folds belong to a caller that wants one. TheTraceinterface and the_inconstructors are Rust-side seams and are not exposed. -
Callbacks are
extern "C"function pointers with avoid *ctx; the library never dereferencesctx, and keeps both for the circuit's life. A callback reports failure by its return value (flat_map/index_bynon-zero,keepandexpirednegative), and the step then fails withZS_ERR_CALLBACK. A callback must return normally. A Rust panic acrossextern "C"aborts, and a Lisphandler-caseoutsidezs_circuit_stepthat unwinds through the Rust frames skips destructors and leaves the circuit half way through a step. The ABI cannot stop the unwinding, so it detects it:ZsCircuit::in_stepis set for the duration ofstepandrebuild_step, and cleared only when they return. Every entry point first reads it, so a circuit whose frames were skipped answersZS_ERR_POISONEDforever, and a callback that re-enters its own circuit getsZS_ERR_POISONEDwithout the library touching the engine.zs_rebuild_stepreadscircuit(c)before it borrows the job, so re-entry from anexpiredcallback is refused first.zs_circuit_freeon a mid-step circuit andzs_rebuild_freeon a job being stepped do nothing: the object is leaked, not freed under the frames that are running. The first fault recorded in a step is the one reported (note), so a failingindex_bycallback isZS_ERR_CALLBACK, not the consequentBAD_VALUE. The CL rule is to puthandler-caseinside the callback and return the failure value (tests/smoke.lispdoes).index_byneeds exactly one emitted key and a keyed stream valid pairs, elseZS_ERR_BAD_VALUE; these faults are recorded in a shared cell rather than panicking. -
Threads (
zset.md, "Threads"). AZsCircuitmay be moved between threads and is used by one at a time; it isSendand notSync, with no lock. Callbacks run on whichever thread callssteporrebuild_step, with that thread's thread-local state (Lisp dynamic bindings,errno,zs_last_error), so the caller'sctxmust be usable from every thread that may step (the wrapperCtxisunsafe impl Sendon that contract). The first-fault cell is the one lock, and it is the FFI's own. AZsRebuildno longer shares a reference count with its circuit (the handles are ids), so it is a separate object, and azs_rebuild_stepcall uses both. The caller still keeps each to one thread at a time. Z-sets are separate handles. The error message is per thread (tests/threads_abi.rs: callbacks ran on the thread that stepped). -
The header is hand-written (cbindgen is not a dependency of this repo).
tests/header.rskeeps it honest. It maps each Rust parameter and return type of every exported function (including theunary_op!expansions) to its C spelling, through a table that panics on an unlisted type, and compares them with the header's declarations. EveryCodeis declared by thecodes!macro, which generates the enum,Code::ALLand the header name, so a new code cannot exist without a#defineline and a matching value. -
Versioning.
zs_abi_version()andZS_ABI_VERSIONare 1. A change that breaks a signature or a code bumps it. -
Transactions (improvise-bw4p.5;
zset.md, "Transactions").zs_circuit_begin,zs_circuit_commitandzs_circuit_rollbackwrapCircuit::begin/commit/rollback; a handle-returningsnapshotwas not chosen, because the circuit logs what changes and there is no snapshot object to free. The new code isZS_ERR_TRANSACTION(10) for abeginon an open transaction, acommitorrollbackwith none open, and azs_circuit_takewhile one is open (take after commit: a rollback subtracts the step's output from the sink).beginon a poisoned circuit isZS_ERR_POISONED, and so iscommitafter a failed step: the host rolls back.rollbackis the only call that clears the poison, and only a poison that came from a step inside the transaction. This is additive, soZS_ABI_VERSIONstays 1. What a rollback cannot undo is a callback's own side effects and aZsRebuildstepped in the transaction, finished or not (it answersZS_ERR_INTERRUPTEDafter, and the host starts another job;rollback_abi.rs). A circuit left mid-step by a non-local exit answersZS_ERR_POISONEDto a rollback like to everything else: the frames are skipped and the engine is not touched.
A rebuild is one logical tick however many zs_rebuild_step calls it takes (zset.md,
decision 8): a delay fed by it shows the previous tick once, and done is 1 as soon as the
last chunk ran, whatever the deadline callback says.
Ids, handles and unsafety
All exported functions are pub unsafe extern "C" fn: the rlib is public API, and a safe
Rust caller could otherwise pass any pointer. Output pointers are checked before any work
(with_out): a refused call changes nothing and leaks nothing. Stream ids and sink ids are
different spaces (sink ids carry the top bit). A ZsRebuild records its circuit's id (a process-wide counter, not an address, which can be
reused) and is refused on another. A Circuit::step between two zs_rebuild_step calls is
ZS_ERR_INTERRUPTED, without poisoning: zset's Rebuild::interrupted, checked first. An id from another circuit is not detected. zs_zset_add(a, a) is
defined: the sum is computed through shared references and then stored. Keys of 4 GiB or
longer are ZS_ERR_BAD_VALUE, not truncated. A non-empty aggregate group always has a row,
even when its count or sum is zero.
Miri
Miri checks the aliasing, leak and null rules the C compiler cannot. It runs tests/abi.rs
on a nightly built through the flake's rust-overlay input. The recipe below is not
pinned: builtins.getFlake "github:oxalica/rust-overlay" and <nixpkgs> (hence --impure)
resolve to whatever is current, and only the nightly date is fixed. It passed at the date of
this commit (about 12s):
nix shell --impure --expr 'let p = import <nixpkgs> { overlays = [ (builtins.getFlake "github:oxalica/rust-overlay").overlays.default ]; }; in p.rust-bin.nightly."2026-09-24".default.override { extensions = [ "miri" "rust-src" ]; }' --command cargo miri test -p zset-ffi --lib --test abi --test rollback_abi
It is not a CI job. A proposed one: a miri job in .github/workflows/ci.yml that takes the
nightly from the flake's own pinned rust-overlay input in nix develop .#ci.
Calling it from Common Lisp
crates/zset-ffi/tests/smoke.lisp is a runnable CFFI program (defcfun for each call, a
foreign string for keys, with-foreign-object for out-parameters, the overflow code checked
and the message read back with zs-last-error). It loads the built library and passes. It is
not a CI gate: sbcl and cffi come from the user's profile and Quicklisp here, and are
not in the nix dev shell. The command to run it is in the file's header. Sketch of the
definitions it uses:
(defcfun "zs_zset_new" :int32 (out :pointer))
(defcfun "zs_zset_insert" :int32 (z :pointer) (key :pointer) (len :size) (weight :int64))
(defcfun "zs_zset_weight" :int32 (z :pointer) (key :pointer) (len :size) (out :pointer))
(defcfun "zs_last_error" :string)
(defcfun "zs_zset_free" :void (z :pointer))
A callback (filter, flat_map) is a defcallback passed with (callback name), with a
handler-case inside it that returns the failure value; its emit argument is a foreign
function pointer to call with foreign-funcall-pointer.