docs(zset-ffi): the ABI's reasoning, a CFFI smoke test and test evidence (improvise-bw4p.1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (dbsp-circuit)
2026-10-10 11:56:17 -07:00
committed by Edward Langley
parent 0755222562
commit 8a76469bcb
6 changed files with 242 additions and 11 deletions
+90
View File
@@ -0,0 +1,90 @@
# `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
1. **Monomorphic.** Keys are byte strings `(ptr, len)`, weights are `int64_t`. The generic
`ZSet<K>` is instantiated once, at `Vec<u8>`. A caller encodes whatever it keys by.
2. **Opaque handles, explicit create and free.** `ZsZSet`, `ZsCircuit` and `ZsRebuild` are
`Box::into_raw` pointers; `zs_X_new(&out)` makes one and `zs_X_free` releases 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 are `uint32_t` ids local to their circuit, not handles: they own nothing.
3. **Error codes, never unwinding.** Every function but the frees and two accessors returns
`int32_t`: 0, or a `ZS_ERR_*`. Every entry point body runs in `catch_unwind` (`guard`).
A `zset` weight-overflow panic is recognised by its message prefix and becomes
`ZS_ERR_OVERFLOW`; any other panic is `ZS_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.
4. **Failure leaves the Z-set unchanged.** `add`, `sub`, `neg`, `scale` and `decode_add`
compute a new value and then assign it, so an overflow half way through cannot leave a
half-applied update (`zs_zset_insert` is one addition, so it has no half).
5. **A failed step poisons the circuit.** A panic in `step` can leave operator state half
updated, and the circuit has no rollback (ADR 009 clause 11 is the host's). So the first
failure in a step, or in a rebuild step, marks the circuit poisoned, and every later call
that uses it returns `ZS_ERR_POISONED`. A caller drops it and builds another. Ids that do
not exist, and malformed arguments, are refused before any state changes and do not poison.
6. **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 sets
`needed`, and writes only if `cap >= needed` (else `ZS_ERR_BUFFER_TOO_SMALL`).
7. **Wire shape.** A Z-set encodes as entries ascending by key, each `[u32 LE key length][key]
[i64 LE weight]`. A keyed element is `pair(k, v) = [u32 LE length of k][k][v]`. These are
this crate's own, not clause 9's wire form (that lives in `improvise-protocol`).
8. **What the circuit offers.** Input, output sinks, `delay`, `integrate`, `differentiate`,
`distinct`, `plus`, `filter` and `flat_map` (C callbacks), `index_by` (a callback emits the
one group key), `join` over two pair streams, and `aggregate` with count, sum, min and max
over `int64` values. `map` is `flat_map` that emits once. `aggregate`'s fold is fixed to
those four because a C callback cannot return an `Option` of a group result without a
second encoding; richer folds belong to a caller that wants one. The `Trace` interface and
the `_in` constructors are Rust-side seams and are not exposed.
9. **Callbacks** are `extern "C"` function pointers with a `void *ctx`; the library never
dereferences `ctx`. A callback emits images by calling the `emit` it is given, which copies
the bytes during the call. A callback must not call back into the library and must not
unwind (a Rust panic across an `extern "C"` boundary aborts; a CL callback should not
non-locally exit through the library). `index_by`'s callback must emit exactly one key and a
keyed stream must carry valid pairs; otherwise the step fails with `ZS_ERR_BAD_VALUE`
(poisoning). The engine records that fault rather than panicking.
10. **Not thread-safe.** A handle is used from one thread at a time (the circuit is `Rc`
inside); different handles are independent. The error message is per thread.
11. **The header is hand-written** (cbindgen is not a dependency of this repo). `tests/header.rs`
keeps it honest: the set of function names and each function's parameter count must match
the exported `extern "C"` functions in `src/lib.rs` (including the `unary_op!` expansions),
and every `#define` code must equal the Rust enum. Parameter *types* are checked by review
and by the ABI tests, which call each function with the header's types, not by the scanner.
12. **Versioning.** `zs_abi_version()` and `ZS_ABI_VERSION` are 1. A change that breaks a
signature or a code bumps it.
## 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:
```lisp
(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)`; its
`emit` argument is a foreign function pointer to call with `foreign-funcall-pointer`.
+60
View File
@@ -0,0 +1,60 @@
# Test evidence: `zset-ffi`
What each test guards and where a rival failed. Keyed by test name; the reasoning is in
`context/code/zset-ffi.md`. The tests are `crates/zset-ffi/tests/{abi,header,dylib}.rs`.
**Injected in the source**, one at a time, each reverted and the suite rerun (a build that
printed `Running` was confirmed each time). The failing tests, by name:
- `guard` without `catch_unwind`: the overflow tests abort the test binary (`SIGABRT`, a panic
across `extern "C"`), which is the failure this crate exists to prevent;
- `zs_zset_add` that adds in place: `overflow_is_an_error_code_and_leaves_the_set_unchanged`;
- every panic classed `ZS_ERR_PANIC`: `overflow_is_an_error_code_and_leaves_the_set_unchanged`,
`a_weight_overflow_in_a_step_is_a_code_and_poisons_the_circuit`,
`a_loaded_library_adds_queries_frees_and_reports_overflow`;
- a failed step that does not poison: `a_weight_overflow_in_a_step_is_a_code_and_poisons_the_circuit`;
- a recorded fault that does not poison: `a_malformed_pair_and_an_unknown_id_are_codes`;
- `rebuild_step` that ignores the deadline: `a_rebuild_resumes_across_calls_and_matches_one_step`;
- `Min` that reads non-positive weights: `min_and_max_read_only_values_of_positive_weight`
(the first version of the suite did not catch this; the test was added for it);
- a header declaration with a parameter dropped:
`the_header_declares_exactly_the_exported_functions_with_their_arity`;
- a header code renumbered: `the_header_constants_are_the_rust_constants`;
- `zs_zset_encode` that reports the wrong size: eight ABI tests, among them
`a_small_buffer_reports_the_size_it_needs`.
### overflow_is_an_error_code_and_leaves_the_set_unchanged
`a` holds `k = MAX` and `j = 1`; adding a set that overflows `k` must return the overflow code
and leave **both** keys as they were. The key `j` sorts before `k`, so a partial in-place add
changes `j` first; a fixture where the overflowing key came first would pass that rival.
### a_weight_overflow_in_a_step_is_a_code_and_poisons_the_circuit
A weight reaches `MAX` in an `integrate`, then one more overflows inside `step`. The step
returns the overflow code, the next step and a new operator return the poisoned code.
### a_rebuild_resumes_across_calls_and_matches_one_step
Nine keys, chunks of two, a deadline callback that is always expired: the job needs five
calls' worth of chunks (the callback is counted: five, one per chunk), and the sum of what
the sink yields equals one uninterrupted step on a second circuit. The loop is bounded at 20.
### min_and_max_read_only_values_of_positive_weight
Weights of mixed sign in one group, where the smallest value has a negative weight, so
`> 0` and `!= 0` give different minima.
### a_loaded_library_adds_queries_frees_and_reports_overflow
The built `libzset_ffi.{so,dylib}` is opened with `libloading` and driven through symbol
lookups: the contract a CFFI caller meets, including the exported names, not the rlib.
### the_header_declares_exactly_the_exported_functions_with_their_arity
Parses `src/lib.rs` and the header (comments stripped) and compares names and parameter
counts; asserts the scanner saw more than 30 functions, so an empty parse cannot pass.
### the_header_constants_are_the_rust_constants
Every `ZS_ERR_*`, `ZS_ABI_VERSION`, `ZS_OK` and the four `ZS_AGG_*` equal the Rust values; the
last code's number equals the number of codes, so a new code without a header line fails.
+2 -3
View File
@@ -1,6 +1,5 @@
//! The byte-keyed circuit behind the C ABI, in plain Rust: every fallible operation
//! returns a `Result`, and a panic from `zset` is caught and classified here.
//! The reasoning is in `context/code/zset-ffi.md`.
//! The byte-keyed circuit behind the C ABI, as plain `Result`-returning Rust; panics from
//! `zset` are caught and classified here. See `context/code/zset-ffi.md`.
use std::any::Any;
use std::cell::Cell;
+10 -7
View File
@@ -1,6 +1,5 @@
//! The C ABI over `zset`: byte-string keys, `i64` weights, opaque handles, an `i32` code
//! from every function. Ownership, lifetimes and the error model: `context/code/zset-ffi.md`
//! and `include/zset_ffi.h`.
//! The C ABI over `zset`: byte keys, `i64` weights, opaque handles, `i32` codes.
//! Ownership and errors: `context/code/zset-ffi.md` and `include/zset_ffi.h`.
pub mod engine;
@@ -64,6 +63,13 @@ fn bytes<'a>(p: *const u8, n: usize) -> Result<&'a [u8], Fail> {
}
}
fn write_out(buf: *mut u8, data: &[u8]) {
if !data.is_empty() {
// SAFETY: the ABI contract says `buf` has room for `data`; the caller checked `cap`.
unsafe { std::ptr::copy_nonoverlapping(data.as_ptr(), buf, data.len()) };
}
}
fn put<T>(out: *mut T, v: T) -> Result<(), Fail> {
*as_mut(out)? = v;
Ok(())
@@ -193,10 +199,7 @@ pub extern "C" fn zs_zset_encode(
"the buffer is smaller than *needed",
));
}
if !enc.is_empty() {
// SAFETY: the contract says `buf` has `cap >= enc.len()` writable bytes.
unsafe { std::ptr::copy_nonoverlapping(enc.as_ptr(), buf, enc.len()) };
}
write_out(buf, &enc);
Ok(())
})
}
+38 -1
View File
@@ -262,7 +262,7 @@ extern "C" fn upper(_: *mut c_void, k: *const u8, n: usize, emit: engine::Emit,
}
extern "C" fn even_len(_: *mut c_void, _: *const u8, n: usize) -> i32 {
i32::from(n % 2 == 0)
i32::from(n.is_multiple_of(2))
}
extern "C" fn first_byte(
@@ -460,3 +460,40 @@ fn a_rebuild_without_a_deadline_runs_to_completion() {
zs_rebuild_free(job);
zs_zset_free(snapshot);
}
#[test]
fn min_and_max_read_only_values_of_positive_weight() {
let c = C::new();
let i = c.input();
let (mut lo, mut hi) = (0, 0);
assert_eq!(zs_circuit_aggregate(c.0, i, 2, &mut lo), OK);
assert_eq!(zs_circuit_aggregate(c.0, i, 3, &mut hi), OK);
let (slo, shi) = (c.sink(lo), c.sink(hi));
c.push(
i,
&[
(&pair(b"g", &le(5)), 1),
(&pair(b"g", &le(1)), -1),
(&pair(b"g", &le(9)), -1),
],
);
assert_eq!(zs_circuit_step(c.0), OK);
assert_eq!(c.take(slo), one(&[(&pair(b"g", &le(5)), 1)]));
assert_eq!(c.take(shi), one(&[(&pair(b"g", &le(5)), 1)]));
}
#[test]
fn an_index_by_callback_that_emits_nothing_is_a_bad_value() {
extern "C" fn nothing(_: *mut c_void, _: *const u8, _: usize, _: engine::Emit, _: *mut c_void) {
}
let c = C::new();
let i = c.input();
let mut o = 0;
assert_eq!(
zs_circuit_index_by(c.0, i, Some(nothing), null_mut(), &mut o),
OK
);
c.push(i, &[(b"x", 1)]);
assert_eq!(zs_circuit_step(c.0), Code::BadValue as i32);
assert!(last_error().contains("exactly one key"), "{}", last_error());
}
+42
View File
@@ -0,0 +1,42 @@
;;; A CFFI smoke test: load libzset_ffi, add weights, query, hit the overflow code, free.
;;; Run: sbcl --non-interactive --load ~/quicklisp/setup.lisp \
;;; --eval '(defvar cl-user::*zset-lib* "target/release/libzset_ffi.dylib")' \
;;; --load crates/zset-ffi/tests/smoke.lisp
;;; Not a CI gate: sbcl and cffi are not in the dev shell. See context/code/zset-ffi.md.
(ql:quickload :cffi :silent t)
(defpackage :zset-smoke (:use :cl :cffi))
(in-package :zset-smoke)
(load-foreign-library cl-user::*zset-lib*)
(defcfun "zs_abi_version" :uint32)
(defcfun "zs_last_error" :string)
(defcfun "zs_zset_new" :int32 (out :pointer))
(defcfun "zs_zset_free" :void (z :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_zset_len" :int32 (z :pointer) (out :pointer))
(defconstant +overflow+ 2)
(defun check (what got want)
(unless (equal got want) (error "~a: got ~s, wanted ~s" what got want))
(format t "ok ~a~%" what))
(defun key-call (fn z key &rest more)
"Calls FN with Z, a foreign copy of the string KEY's UTF-8 bytes, its length, MORE."
(with-foreign-string ((ptr len) key :null-terminated-p nil)
(apply fn z ptr len more)))
(with-foreign-object (out :pointer)
(check "new" (zs-zset-new out) 0)
(let ((z (mem-ref out :pointer)))
(check "insert" (key-call #'zs-zset-insert z "k" 40) 0)
(check "insert again" (key-call #'zs-zset-insert z "k" 2) 0)
(with-foreign-object (w :int64)
(check "weight" (progn (key-call #'zs-zset-weight z "k" w) (mem-ref w :int64)) 42)
(check "overflow code" (key-call #'zs-zset-insert z "k" (1- (expt 2 63))) +overflow+)
(check "error text" (and (search "overflow" (zs-last-error)) t) t)
(check "unchanged" (progn (key-call #'zs-zset-weight z "k" w) (mem-ref w :int64)) 42))
(zs-zset-free z)))
(format t "zset smoke: all ok~%")