This is the normative contract between a rulisp glue cdylib (Rust side) and
the rulisp loader (Common Lisp side). The #[rulisp::export] /
#[rulisp::handle] / rulisp::module! macros generate exactly this;
tests/m1-handwritten/ is the permanent hand-written reference
implementation ("ABI oracle") and tests/golden/ pins the manifest bytes.
Breaking anything in this document is an ABI break: bump abi_version()
(exact-match required by the loader). Adding manifest keys is NOT a break —
unknown keys are ignored.
- Every export is prefixed
<crate>_rulisp_(crate name with-→_). - Each cdylib statically links its own copy of
rulisp-runtime: last-error TLS and allocator pairing are per library. The loader therefore resolves every symbol against the specificdlopenhandle, never the global namespace — after a reload, two generations export identical names. - Library-level entry points:
uint32_t <p>abi_version(void)— checked first;1.const uint8_t *<p>manifest(uintptr_t *len)— static UTF-8 s-expression manifest; permanent borrow, never freed by the caller.void <p>last_error(const uint8_t **type_ptr, uintptr_t *type_len, const uint8_t **msg_ptr, uintptr_t *msg_len)— see §3.void <p>dealloc(uint8_t *ptr, uintptr_t size, uintptr_t align)— see §4.
Every function shim returns int32_t; out-params are written only on OK.
| code | name | meaning | CL reaction |
|---|---|---|---|
| 0 | OK | success, out-params valid | convert and return |
| 1 | ERR | Rust returned Err |
signal typed condition (⊂ rulisp:rust-error) |
| 2 | PANIC | panic caught in the shim | signal rulisp:rust-panic |
| 3 | INVALID | boundary rejected an argument (e.g. bad UTF-8) | signal rulisp:invalid-argument |
| 4 | CB_ERR | a Lisp callback signaled; condition stashed CL-side | re-signal the original condition |
Exception: *_free shims return void; a panic inside free is caught,
logged to stderr and swallowed (finalizer context has no error channel).
- Thread-local, per library. Valid (borrowed) until the next call into the same library on the same thread; the CL wrapper copies both strings immediately after reading a non-zero status.
typeis the Rust error type name (e.g."ParseError","Error","panic","InvalidUtf8");msgis theDisplayoutput (for panics: the payload if it is&str/String, else"panic (non-string payload)").
- Wire format both directions:
(const uint8_t *ptr, uintptr_t len), UTF-8, no NUL terminator, interior NULs legal. - Lisp→Rust: borrowed for the duration of the call. Rust validates UTF-8
(failure → status 3). Rust must not retain the pointer. How the loader
lends the buffer is per host and must never stop the world: on SBCL the
Lisp vector is pinned and Rust borrows it in place (zero-copy); on ECL
the pin aliases the storage without inhibiting GC; on CCL a pin is
without-gcing, so the loader pins only long enough to memcpy into a heap buffer and lends the copy (v05.pin-does-not-stop-the-worldmeasures collections completing on another thread during a 600 ms borrow on every host). Retention is a compile error, enforced twice (issue #1 showed one layer is not enough: an explicit&'static strin an export signature used to defeat it from safe Rust): the macro rejects any explicit lifetime in an export signature, and every borrowing helper takes a per-callShimFramewhose borrow pins the returned lifetime to the shim invocation — so even code that bypasses the macro check cannot name a longer lifetime and have it unify. The same two layers cover byte buffers, scalar slices, handle references and borrowed callbacks. - Rust→Lisp: out-params
(uint8_t **ptr, uintptr_t *len). Ownership transfers to Lisp, which copies then releases exactly once via the owning library'sdealloc(ptr, len, 1)(strings have align 1;len == capacityguaranteed by construction). - Empty transfer:
len == 0carries no allocation — the pointer is dangling, must not be dereferenced, anddeallocis skipped (a size-0 dealloc is a no-op). - Never
libc free(); never another library's dealloc. Buffers returned by a generation-N call are released through generation N's dealloc (the CL wrapper's immutable generation context guarantees this even across concurrent reloads).
- A handle is
void*=Box::into_raw(Box<T>); constructors return it viavoid **out. Method shims reborrowconst void*→&T. There is no&mutacross the boundary and no by-value consumption. T: Send + Sync + 'staticis enforced at compile time (supertrait bounds ofHandleType): finalizers may drop on any thread (Send) and concurrent&selfcalls on one handle are allowed (Sync).- Free:
void <p>_<type>_free(void*), NULL is a no-op. A non-NULL double free is undefined behavior at the C level; exactly-once is guaranteed by the CL handle cell state machine (in-flight counting + deferred free: a free racing an in-flight call is accepted, deferred, and executed by the last call to finish). - Generation discipline: a handle is stamped with the generation of the wrapper that created it and is only accepted by wrappers of that same birth generation; it is always freed by its birth generation's free shim.
Wire: one function-pointer parameter (per-signature typedef, first param
uint64_t userdata, then the encoded arguments) plus a reserved
uint64_t userdata (0 in v1). The trampoline returns 0 (OK) or 1
(a condition was signaled and stashed CL-side).
Contract:
- A callback may be invoked only on the calling thread, before the export
returns. Enforced in Rust:
Callback<'a, A, ()>is!Send/!Syncand lifetime-pinned to the shim frame — storing or moving it is a compile error. - A Lisp condition never unwinds Lisp-style through Rust frames: the
trampoline catches
serious-condition, stashes it, returns 1. Rust seesErr(CallbackError)and unwinds normally (destructors run). Propagated with?, the shim reports status 4 and the CL wrapper re-signals the original condition object. - If Rust swallows the
CallbackErrorand returnsOk, the stash is discarded (its dynamic binding scope ends). If Rust swallows it and then fails with its own error while a callback had signaled during the same shim call, the call is conservatively classified as status 4 and the stashed condition is re-signaled. - Documented UB: a non-local Lisp exit (
throw,return-from, a restart transfer) out of a callback unwinds through live Rust frames, skipping destructors. This cannot be prevented from Lisp; the trampoline logs a best-effort warning. Signal conditions instead — they are caught and tunneled correctly.
Stored callbacks (0.2, StoredCallback<A>): same wire, different
trampoline — the leading uint64 carries a registry id minted by
rulisp:callback, whose CALLBACK-TOKEN keeps the closure registered.
Differences from the borrowed form:
- Storable,
Clone/Copy,Send + Sync: Rust may keep it and invoke from any thread (the Lisp adopts foreign threads on entry; verified on SBCL and CCL). - Lifetime is fail-safe, not compile-enforced: once the token is unregistered (explicitly or by GC), invocation returns an error status after a Lisp-side warning — a dead id can never dangle.
- Error protocol: there may be no rulisp call frame on the invoking
thread, so a signaled condition is WARNED and reported as
Err(CallbackError)(status 1; a dead id is status 2). No stash, no re-signal, no status 4. - The non-local-exit clause above applies identically.
- Any Lisp thread may call any export; execution is on the calling thread. No global lock, no runtime.
- Concurrent method calls on one handle are allowed (
T: Synccarries the load). Free-vs-in-flight races are resolved by deferral (§5). - Rust code may spawn internal threads but cannot call Lisp callbacks from
them (
!Send, §6.1). - Blocking exports must cap their wait in Rust, and the loop belongs in
Lisp. A Lisp thread inside a foreign call cannot be interrupted — SBCL
cannot deliver
sb-thread:interrupt-threadthere — so an export that waits without a bound makes the image unkillable. Take await_msparameter, cap it (100 ms is a good default), return a "not ready yet" answer, and let the caller loop in Lisp where interrupts and restarts work. - A blocking export must refuse re-entry from its own runtime's worker
thread (e.g.
Handle::try_current().is_some()) and report it as an error, rather than letting the async runtime panic insidecatch_unwind. - ECL specifics: callback trampolines are natively compiled at load time
(a C toolchain must be present — bytecodes defcallbacks are unsafe on
ECL, see docs/upstream/ecl-dynamic-callback-gc.md), and foreign-thread
invocation of stored callbacks is UNSUPPORTED there: ECL cannot adopt
threads it didn't create (
ecl_import_current_threadwould have to run on the foreign thread itself). Invoke stored callbacks from Lisp-visible threads only, or use the queue pattern. - Signals: a plain Rust cdylib (tokio included) installs no signal
handlers. Audit glue-crate dependencies for
sigaction(JIT, wasmtime, crash-handler crates are the usual offenders) — SBCL's GC is signal-driven and foreign handlers are a stability hazard. The Lisp side owns SIGINT/SIGTERM; expose an explicit Rust shutdown function instead of trapping signals.
- Every shim body runs under
catch_unwind; a panic maps to status 2. panic = "abort"builds are rejected at compile time (compile_error!in rulisp-runtime): with abort, the first panic would kill the host image.- Shims are
extern "C"(not"C-unwind"): an escaped panic — which the wrapper makes impossible — would abort rather than corrupt the caller. - Poisoned mutexes after a caught panic are the glue crate's own concern
(subsequent
.lock().unwrap()panics are themselves caught and reported as status 2).
- The loader never calls
dlclose. Old generations stay mapped forever (one leaked mapping per reload; zero in production). This is what makes TLS destructors,std::thread, and stale-generation frees safe. - Every load dlopens a unique copy of the artifact, defeating dlopen path caching (macOS dyld would otherwise silently return the old mapping). Older cache copies are unlinked (mappings keep the inodes alive).
- On restore, the loader first bumps a global session counter — every pre-dump handle cell and captured wrapper closure is instantly invalid (signaling, never dereferencing) — then reloads each crate from its recorded artifact path and regenerates all bindings.
- Freeing a dead-session handle performs no foreign call (the pointer belonged to a previous process image).
- There is no guardrail against dumping with live foreign threads.
save-lisp-and-dierefuses to run with multiple Lisp threads, but it does not see threads a glue crate spawned — the dump succeeds and those threads simply do not exist in the restored image, while any state they owned is gone. A crate that owns threads (an async runtime, a watcher) must export an explicit shutdown entry point. - Declared dump hooks (
:on-dump, wire-additive since 0.4): a crate may name ONE of its own exports in the manifest key(:on-dump "symbol"). The named function must be declared in:functionswith zero parameters and:unitresult (:errormay name a type); the loader refuses the manifest otherwise. The loader registers a single image-dump hook that, immediately before a dump, calls every loaded crate's declared hook in load order. A non-OK status (error or panic) is reported as a warning and the dump proceeds — a dump must never be wedged by its own cleanup. Hook bodies are subject to §7's capped-wait rule as a normative requirement: an unbounded wait here makes the image undumpable. The hook is dump-only: reload does not call it (stale-generation state is already recoverable through §9), and the restored image never re-runs it. Crates without the key keep the manual pattern: export a shutdown function and have the application register the hook itself.
-
UTF-8 s-expression, grammar: keywords, strings, integers, lists,
nil. Read hardened:*read-eval*nil, scratch package, closed grammar. -
:schema(form version, accept<=supported),:abi(must equalabi_version()exactly),:crate,:crate-version,:target(checked against the host, unknown tokens pass),:prefix,:errors(Rust error type names that become condition classes),:handles,:functions. -
Unknown keys are ignored everywhere: additive evolution is free.
-
Type vocabulary (closed):
:unit :bool :i8 :i16 :i32 :i64 :u8 :u16 :u32 :u64 :f32 :f64 :string :bytes (:option T) (:vec S) (:handle "Name") (:callback :params (...) :result :unit) (:stored-callback :params (...) :result :unit).(:option T), T ∈ scalars/:string/:bytes(0.2): a leadinguint8present flag; the value's usual wire representation follows and is meaningful only when present. Lisp NIL ↔ None.:boolinner is rejected (nil ambiguity).(:vec S), S a scalar (0.2):(const S*, uintptr_t len)counted in ELEMENTS; owned returns are freed viadealloc(ptr, len * sizeof(S), alignof(S)).:bytes(added in 0.2, wire-additive on ABI 1) uses the exact:stringconvention — borrowed(ptr,len)in, owned + dealloc'd out, empty transfer at len 0 — minus UTF-8 validation; Rust sees&[u8]in and returnsVec<u8>.
-
Key classes (0.5). A new manifest key is an enhancement — an older loader that ignores it still runs the crate correctly — or load-bearing: a crate that declares it is not correct without it. A load-bearing key raises
:schemain the release that introduces it, so an older loader refuses the crate instead of mis-running it; an enhancement key rides the ignore-unknown-keys rule.:on-dump(0.4) is load-bearing and predates this rule; it stays at:schema1 because a retroactive bump would refuse every newer crate on the 0.4.0 loaders that support it fully — the 0.3-loader exposure (a hook silently never runs) is documented in CHANGELOG 0.5 rather than re-fixed. -
:rulisp-version(0.5, enhancement): the rulisp release the crate was built with, e.g.(:rulisp-version "0.5.0"). A loader whose own major.minor is older signalsrulisp-version-skew, astyle-warning, and loads the crate anyway.
Issue #1 was a documented claim that turned out to be enforced by nothing. This table is the standing answer to "which of these sentences are backed by what": every normative claim in §1–§11, classified. Rebuild it whenever a section changes. (v0.4 plan item 1; produced by an exhaustive sweep with per-citation verification.)
Legend: compile-error = macro check or type-system mechanism · runtime-check = code on the load/call path · test = a suite/trybuild/shell gate that fails when the claim breaks · UB-by-design = documented, deliberately unenforced · GAP = documented, enforced by nothing found.
| Claim | Enforcement | Where |
|---|---|---|
| §1 preamble — ABI discipline | ||
Breaking the contract requires bumping abi_version(); loader requires exact match |
runtime-check | lisp/src/crate.lisp:147-149 (abi-mismatch-error unless = +abi-version+) |
| Adding manifest keys is NOT a break — unknown keys are ignored | test | fx.unknown-keys-ignored (tests/suite/m2.lisp:57-61) |
tests/golden/ pins the manifest bytes (macro output byte-identical) |
test | fx.golden-manifest (tests/suite/m2.lisp:125-138) |
| §1 — Symbols and per-library resolution | ||
Every export is prefixed <crate>_rulisp_ (crate - → _) |
runtime-check | lisp/src/crate.lisp:180-185 (manifest-error if prefixed symbol absent); prefix minted crates/rulisp-macros/src/lib.rs:22-28 |
| Loader resolves every symbol against the specific dlopen handle, never the global namespace | runtime-check | lisp/src/ffi.lisp:77-80 (dlsym-ptr); %find-symbol-in ffi.lisp:43-44 |
| After a reload two generations export identical names yet stay distinct per wrapper/handle | test | m6.reload + m6.captured-wrapper-gate (tests/suite/m1.lisp:234,248) |
| Each cdylib statically links its own rulisp-runtime copy: last-error TLS and allocator pairing per library | test | v04.last-error-is-per-library (tests/suite/v04.lisp): concurrent failures in wordbag and rx never cross |
abi_version() is checked first, before the manifest is read; must equal 1 |
runtime-check | lisp/src/crate.lisp:140-149 (%open-and-verify) |
manifest() is a static permanent borrow ('static str behind OnceLock/static) |
compile-error | crates/rulisp-macros/src/lib.rs:1184-1208; oracle tests/m1-handwritten/src/lib.rs:17,37-42 |
| Caller copies the manifest and never frees it | runtime-check | lisp/src/crate.lisp:163-172 (%read-library-manifest: copies, no dealloc) |
last_error and dealloc entry points exist per library (resolved at load) |
runtime-check | lisp/src/crate.lisp:186-187 (resolve signals manifest-error if missing) |
| §2 — Status codes | ||
Every function shim returns int32_t |
compile-error | crates/rulisp-macros/src/lib.rs:719-729 (generated shim signature -> i32) |
| Out-params are written only on OK | test | v04.out-params-untouched-on-err (tests/suite/v04.lisp:117-147) |
| OK (0): out-params valid; CL converts and returns | test | m3.strings (tests/suite/m1.lisp:102); dispatch lisp/src/codegen.lisp:448-451 |
ERR (1): Rust Err → typed condition ⊂ rulisp:rust-error |
test | m3.typed-conditions (tests/suite/m3.lisp:11); m2.fallible (tests/suite/m1.lisp:87) |
PANIC (2): panic caught in shim → rulisp:rust-panic, image survives |
test | m1.panic (tests/suite/m1.lisp:57-63) |
| INVALID (3): boundary rejects a bad argument (e.g. bad UTF-8) as status 3 | runtime-check | crates/rulisp-runtime/src/lib.rs:148-162 (str_arg); no e2e test drives Rust-side status 3 |
Status 3 → CL signals rulisp:invalid-argument |
runtime-check | lisp/src/codegen.lisp:492-496 |
| CB_ERR (4): CL re-signals the original stashed condition object | test | m5.callback-condition-identity (tests/suite/m1.lisp:191-204) |
Exception: *_free returns void; a panic inside free is caught, logged to stderr, swallowed |
test | v04.free-shim-swallows-drop-panic (tests/suite/v04.lisp:53-67); shim crates/rulisp-macros/src/lib.rs:864-872 |
| §3 — last_error | ||
last_error is thread-local |
test | v04.last-error-is-thread-local (tests/suite/v04.lisp:96-110) |
last_error is per library (each cdylib its own TLS slot; errors never cross libraries) |
test | v04.last-error-is-per-library (tests/suite/v04.lisp) |
CL reads last_error through the owning generation's resolved pointer |
runtime-check | lisp/src/codegen.lisp:479-496 (gen-ctx-last-error-ptr); resolved lisp/src/crate.lisp:186 |
| Borrowed until next call into same library on same thread; CL copies both strings immediately | runtime-check | lisp/src/ffi.lisp:221-232 (read-crate-last-error copies; called from codegen.lisp:479-494) |
type is the Rust error type name; msg is the Display output |
test | m2.fallible (tests/suite/m1.lisp:94-96: "ParseError" + exact Display text) |
Panics: type "panic", msg is the payload when &str/String |
test | m1.panic (tests/suite/m1.lisp:57-62); crates/rulisp-runtime/src/lib.rs:105-120 |
Non-string panic payload → msg "panic (non-string payload)" |
runtime-check | crates/rulisp-runtime/src/lib.rs:109-116 (branch untested) |
UTF-8 rejection records type "InvalidUtf8" |
runtime-check | crates/rulisp-runtime/src/lib.rs:159 |
| §4 — Strings and buffers | ||
Wire format both directions: (ptr,len) UTF-8 |
test | m3.strings (tests/suite/m1.lisp:102-108) |
| No NUL terminator; interior NULs legal | runtime-check | lisp/src/ffi.lisp:178-181 (length-delimited encode); crates/rulisp-runtime/src/lib.rs:148-162; no interior-NUL test |
| Lisp→Rust: borrowed for the duration of the call (dynamic-extent pin/copy) | runtime-check | lisp/src/ffi.lisp:152-181 (call-with-bytes-arg / call-with-utf8-arg) |
Lending a buffer never stops the world (CCL's pin is without-gcing, so it copies) |
test | v05.pin-does-not-stop-the-world (tests/suite/v05.lisp) on SBCL, CCL and ECL; %call-with-copied-buffer (lisp/src/ffi.lisp, #+ccl) |
| Rust validates UTF-8; failure → status 3 | runtime-check | crates/rulisp-runtime/src/lib.rs:148-162 (str_arg) |
| Retention layer 1: macro rejects any explicit lifetime in an export signature | compile-error | crates/rulisp-macros/src/lib.rs:97-138,909-917; pinned by crates/rulisp/tests/ui/static_lifetime.rs (trybuild) |
| Retention layer 2: borrowing helpers pin the returned lifetime to a per-call ShimFrame | compile-error | crates/rulisp-runtime/src/lib.rs:130-162 (ShimFrame + str_arg<'a>(&'a ShimFrame)) |
| Both layers cover byte buffers, scalar slices, handle references and borrowed callbacks | compile-error | crates/rulisp-runtime/src/lib.rs:202-255; crates/rulisp/src/lib.rs:167-177; ui/static_lifetime.rs:22-56 |
Rust→Lisp: Lisp copies then releases exactly once via the owning library's dealloc(ptr,len,1) |
test | m3.string-allocations (tests/suite/m1.lisp:110-117); v02.bytes-alloc-pairing (tests/suite/v02.lisp:33-39) |
Strings have align 1; len == capacity guaranteed by construction |
runtime-check | crates/rulisp-runtime/src/lib.rs:168-181 (into_boxed_slice); align-1 dealloc lisp/src/ffi.lisp:243-245 |
| Empty transfer: len 0 carries no allocation and dealloc is skipped | test | v02.bytes-roundtrip + v02.bytes-alloc-pairing (tests/suite/v02.lisp:20,33); m3.strings ECHO "" |
| len-0 dangling pointer never dereferenced; size-0 dealloc is a no-op | runtime-check | lisp/src/ffi.lisp:134-150,234-241; crates/rulisp-runtime/src/lib.rs:231-234 |
| Never libc free/another library's dealloc; gen-N buffers freed via gen N's dealloc across reloads | test | m4h.reload-under-load (tests/suite/m4.lisp): threads round-trip strings across three live reloads and every generation's allocation counter returns to zero; mechanism lisp/src/codegen.lisp (immutable gen-ctx captures dealloc-ptr) |
| §5 — Handles | ||
Handle is void* = Box::into_raw(Box<T>); constructors return it via void **out |
runtime-check | crates/rulisp-runtime/src/lib.rs:243-245 (handle_new); crates/rulisp-macros/src/lib.rs:623-626 (out: *mut *mut c_void) |
Method shims reborrow const void* → &T |
runtime-check | crates/rulisp-runtime/src/lib.rs:253-255 (handle_ref); crates/rulisp-macros/src/lib.rs:535-544 |
No &mut across the boundary and no by-value consumption |
compile-error | crates/rulisp-macros/src/lib.rs:144-150 (&mut param), 919-937 (&mut/by-value self); tests/ui/mut_self.rs |
T: Send + Sync + 'static enforced at compile time (HandleType supertrait bounds) |
compile-error | crates/rulisp/src/lib.rs:58; impl emitted at crates/rulisp-macros/src/lib.rs:846-852; tests/ui/non_send_handle.rs |
| Free shim: NULL is a no-op | runtime-check | crates/rulisp-runtime/src/lib.rs:261-265 (handle_free null check) |
| A non-NULL double free is undefined behavior at the C level | UB-by-design | BOUNDARY.md:86-88; safety contract crates/rulisp-runtime/src/lib.rs:257-260 |
| Exactly-once free via CL cell state machine; free racing in-flight call accepted, deferred, run by last call | runtime-check | lisp/src/handle.lisp:54-97 (cell-end-call, %cell-free); tests m4.handle-lifecycle, m4.free-vs-in-flight (tests/suite/m1.lisp:124-163) |
| Handle stamped with birth generation; accepted only by wrappers of that same birth generation | runtime-check | lisp/src/handle.lisp:30-52 (cell-begin-call gate); lisp/src/codegen.lisp:400-406,316-326; test m6.captured-wrapper-gate |
| Handle always freed by its birth generation's free shim | runtime-check | lisp/src/codegen.lisp:316-326 (gen-ctx free-table); lisp/src/handle.lisp:65,91 (cell-free-fn); test m6.reload (tests/suite/m1.lisp:243) |
| §6 — Callbacks (wire + borrowed) | ||
| Fn-ptr param + reserved uint64 userdata (0 in v1); trampoline returns 0 OK / 1 condition-stashed | runtime-check | lisp/src/codegen.lisp:111-139 (returns), :420 (:uint64 0); crates/rulisp-macros/src/lib.rs:546-559 |
§6.1 Callback invoked only on calling thread before export returns; !Send/!Sync + lifetime-pinned; storing = compile error |
compile-error | crates/rulisp/src/lib.rs:115-119 (PhantomData *mut ()), 162-177 (ShimFrame pin); tests/ui/stored_callback.rs, tests/ui/static_lifetime.rs |
| §6.2 Lisp condition never unwinds through Rust: trampoline catches serious-condition, stashes it, returns 1 | runtime-check | lisp/src/codegen.lisp:119-133; test m5.callback-condition-identity |
§6.2 Rust sees Err(CallbackError) and unwinds normally: destructors run |
test | m5.callback-condition-identity (tests/suite/m1.lisp:191-204, guard-drops assertion :203); Err mapping crates/rulisp/src/lib.rs:182-190 |
§6.2 Propagated with ?: shim reports status 4; CL wrapper re-signals the original condition object |
runtime-check | crates/rulisp-macros/src/lib.rs:683-716 (CB tunnel); lisp/src/codegen.lisp:497-501; identity tested by m5.callback-condition-identity, m4h.nested-callbacks |
| §6.3 Rust swallows CallbackError and returns Ok: stash discarded (dynamic binding scope ends) | test | v04.swallowed-callback-error-is-discarded (tests/suite/v04.lisp:14-26); binding lisp/src/codegen.lisp:414-417 |
| §6.3 Swallow-then-fail with a stashed condition is conservatively status 4; original condition re-signaled | runtime-check | crates/rulisp-macros/src/lib.rs:704-716 (tunnel_check on Err branch); test v04.swallow-then-fail-resignals-the-original (tests/suite/v04.lisp:35-46) |
| §6.4 Non-local Lisp exit unwinds through Rust frames skipping destructors; trampoline logs best-effort warning | UB-by-design | BOUNDARY.md:117-121; warning at lisp/src/codegen.lisp:134-139 |
| §6 — Stored callbacks | ||
Same wire, different trampoline; leading uint64 carries registry id minted by rulisp:callback; token keeps closure registered |
runtime-check | lisp/src/stored-callback.lisp:37-53; lisp/src/codegen.lisp:421-435 (token-id in :uint64 slot) |
| Storable, Clone/Copy, Send+Sync; Rust may invoke from any thread (Lisp adopts foreign threads; SBCL/CCL) | test | v02.stored-callback-cross-thread (tests/suite/v02.lisp:118-137); derives crates/rulisp/src/lib.rs:132-137 |
| Unregistered/GC'd token: invocation returns error status after Lisp-side warning; dead id never dangles | runtime-check | lisp/src/codegen.lisp:179-185 (lookup nil → warn, status 2); test v02.stored-callback-dead-id (tests/suite/v02.lisp:139-152) |
Signaled condition is warned, reported Err(CallbackError) status 1 (dead id 2); no stash, no re-signal, no status 4 |
runtime-check | lisp/src/codegen.lisp:194-197; no tunnel in crates/rulisp/src/lib.rs:151-159; test v02.stored-callback-condition (v02.lisp:154-170) |
| Non-local-exit UB clause applies identically to stored callbacks | UB-by-design | BOUNDARY.md:138; warning at lisp/src/codegen.lisp:198-200 |
| §7 — Threads and signals | ||
| Any Lisp thread may call any export; execution on the calling thread; no global lock, no runtime | test | m4h.thread-race (tests/suite/m4.lisp:18-53); also fetch.concurrent-lisp-threads (tests/suite/fetch.lisp; CI: SBCL/Linux and CCL/Linux required jobs) |
Concurrent method calls on one handle allowed (T: Sync carries the load) |
compile-error | crates/rulisp/src/lib.rs:58 (Sync supertrait); tests/ui/non_send_handle.rs; exercised by m4h.thread-race |
| Free-vs-in-flight races resolved by deferral (restates §5) | test | m4.free-vs-in-flight (tests/suite/m1.lisp:151-163); mechanism lisp/src/handle.lisp:54-97 |
Rust internal threads cannot call borrowed Lisp callbacks (!Send) |
compile-error | crates/rulisp/src/lib.rs:115-118 (PhantomData *mut ()); tests/ui/stored_callback.rs |
| Blocking exports must cap their wait in Rust; the loop belongs in Lisp (glue-author norm) | test | fetch.waits-are-capped (tests/suite/fetch.lisp; CI: SBCL/Linux and CCL/Linux required jobs); cap examples/fetch/src/lib.rs:42-47 — reference crate only, unenforceable for third-party glue |
| Blocking export must refuse re-entry from its own runtime's worker thread, reported as an error | runtime-check | examples/fetch/src/lib.rs:52-59 (refuse_reentry; call sites 322,338,603,641) — reference crate only; no test can trigger it from Lisp |
ECL: callback trampolines natively compiled at load time; missing C toolchain fails via named manifest-error |
runtime-check | lisp/src/codegen.lisp:87-103 (#+ecl compile-file branch); test v04.ecl-toolchain-failure-is-named (tests/suite/v04.lisp:156-166, non-vacuous on ECL only) |
| ECL: foreign-thread invocation of stored callbacks UNSUPPORTED (cannot adopt foreign threads) | UB-by-design | BOUNDARY.md:158-164; docs/upstream/ecl-dynamic-callback-gc.md; #+ecl skip tests/suite/v02.lisp:118-124 |
| Plain Rust cdylib installs no signal handlers; audit glue-crate deps for sigaction | test · GAP on Windows | tools/rulisp-audit.sh (signal-symbol sweep on the artifact, tokio signal/process features, block_on), run over all examples by make audit in CI; self-tested against tools/audit-fixture. The sweep is implemented for ELF and Mach-O only: on Windows the script prints SKIP and exits 0, so the four .dll assets a release attaches are not swept (dumpbin /imports is the manual equivalent) |
| Lisp side owns SIGINT/SIGTERM; a glue crate's artifact imports no signal-disposition symbol | test | tools/rulisp-audit.sh over every example (make audit, CI), with tools/rulisp-audit-selftest.sh proving the gate rejects a signal-importing fixture |
| §8 — Panics and non-local exits | ||
Every shim body runs under catch_unwind; a panic maps to status 2 |
runtime-check | crates/rulisp-runtime/src/lib.rs:105-120 (rt::shim); every generated shim wrapped crates/rulisp-macros/src/lib.rs:719-729; test m1.panic |
panic = "abort" builds are rejected at compile time (compile_error! in rulisp-runtime) |
compile-error | crates/rulisp-runtime/src/lib.rs:11-17; proven non-vacuous by m1.panic-abort-guard (tests/suite/m1.lisp:65-81) |
Shims are extern "C" not "C-unwind": an escaped panic aborts rather than corrupting the caller |
runtime-check | crates/rulisp-macros/src/lib.rs:721,865,1198-1221 (all shims/entry points extern "C"; abort-on-escape is rustc's extern-C backstop) |
| Poisoned mutexes after a caught panic are the glue crate's own concern (rulisp adds no mitigation) | UB-by-design | BOUNDARY.md:180-182 |
Subsequent .lock().unwrap() on a poisoned mutex panics, is caught, reported as status 2 |
test | v04.poisoned-mutex-reports-panic (tests/suite/v04.lisp:74-89; oracle word_bag_poison tests/m1-handwritten/src/lib.rs:391-398) |
| §9 — Reload and unloading | ||
| The loader never calls dlclose; old generations stay mapped forever | test | m6.captured-wrapper-gate (tests/suite/m1.lisp:248-268): old-gen wrapper+handle callable post-reload (:262); no dlclose/FreeLibrary anywhere in lisp/src |
| One leaked mapping per reload (zero in production) — a deliberate, accepted leak | UB-by-design | BOUNDARY.md:185-188; lisp/src/crate.lisp:3-6 |
Persisting mappings make TLS destructors, std::thread, and stale-generation frees safe |
test | m6.reload (tests/suite/m1.lisp:243: stale handle freed via birth-gen shim); TLS-destructor half rides on no-dlclose, untested directly |
| Every load dlopens a unique copy of the artifact, defeating dlopen path caching | runtime-check | lisp/src/crate.lisp:110-121 (counter+timestamp copy name, uiop:copy-file), :140-141 (dlopen the copy) |
| Older cache copies are unlinked; live mappings keep the inodes alive | runtime-check | lisp/src/crate.lisp:282-292 (%sweep-crate-cache), invoked at :209 — no test exercises the sweep |
| §10 — Image dump / restore | ||
| On restore the loader bumps the global session counter FIRST, before any reload | runtime-check | lisp/src/crate.lisp:306-311 (incf *session* precedes the reload loop; hook registered :321) |
Every pre-dump handle cell is invalid: signals stale-handle-error, never dereferences |
runtime-check | lisp/src/handle.lisp:37-52 (session gate in cell-begin-call); pinned by m7.dump-restore (tests/suite/m1.lisp:312-316) |
Every captured wrapper closure of a dead session refuses (crate-not-loaded-error) |
runtime-check | lisp/src/codegen.lisp:469-474; pinned by m7.dump-restore (tests/suite/m1.lisp:319-323) |
| Then each crate reloads from its recorded artifact path and all bindings regenerate | test | m7.dump-restore (tests/suite/m1.lisp:310-311: post-restore GREET works); mechanism lisp/src/crate.lisp:311-319 |
| Freeing a dead-session handle performs no foreign call | runtime-check | lisp/src/handle.lisp:69-75 (%maybe-foreign-free session gate); m7 exercises explicit free + GC-finalizer path (m1.lisp:317,324-329) |
| No guardrail against dumping with live foreign threads; they vanish on restore, their state gone | UB-by-design | BOUNDARY.md:202-209 |
Thread-owning crates quiesce before a dump via the declared :on-dump hook (or the manual pattern) |
runtime-check | %run-crate-dump-hooks + %validate-on-dump; tests v04.on-dump-* (every suite host), fetch.dump-hook-quiesces, fetch.dump-restore-refuses (CI: SBCL/Linux and CCL/Linux required jobs, make test-fetch) |
| §11 — Manifest | ||
Manifest read hardened: *read-eval* nil, symbols land in a scratch package |
runtime-check | lisp/src/manifest.lisp:8-10,24-33; test fx.read-eval-blocked (tests/suite/m2.lisp:26-29) |
| Grammar closed: keywords, strings, integers, lists, nil only — anything else rejected | runtime-check | lisp/src/manifest.lisp:35-49 (%sanitize); test fx.unknown-symbols-rejected (tests/suite/m2.lisp:31-33) |
:schema accepted only when <= supported form version (1) |
runtime-check | lisp/src/manifest.lisp:125-128; test fx.newer-schema-refused (tests/suite/m2.lisp:35-37) |
:abi must equal abi_version() exactly (loader exact-match + manifest/entry-point agreement) |
runtime-check | lisp/src/crate.lisp:147-153 — no suite test simulates a mismatch |
:target checked against the host; unknown tokens pass |
runtime-check | lisp/src/manifest.lisp:103-118 + lisp/src/crate.lisp:154-160; test fx.target-check (tests/suite/m2.lisp:71-78) |
Required keys and well-formed specs; any corruption signals a named manifest-error |
runtime-check | lisp/src/manifest.lisp:51-61,120-145; tests fx.missing-required-keys, fx.not-a-manifest, fx.bad-param-form (tests/suite/m2.lisp) |
:errors names become condition classes, subclasses of rulisp:rust-error |
test | m3.typed-conditions (tests/suite/m3.lisp:11-28); mechanism lisp/src/crate.lisp:191-193,267-269 |
Load-bearing keys raise :schema; enhancement keys ride the ignore rule; a newer crate warns rulisp-version-skew on an older loader |
test | v05.newer-rulisp-warns (tests/suite/v05.lisp); %check-rulisp-version (lisp/src/manifest.lisp); rule text §11 |
| Unknown keys are ignored everywhere; additive evolution is free | test | fx.unknown-keys-ignored (tests/suite/m2.lisp:57-61); getf-based parsing ignores by construction |
Type vocabulary is closed: tokens outside it signal manifest-error at binding generation |
runtime-check | lisp/src/codegen.lisp:40-42,63-69,141-149; test fx.partial-generation-ban (m2.lisp:96-118); macro side: trybuild ui/bad_type.rs |
(:option T): leading uint8 present flag; value meaningful only when present; Lisp NIL ↔ None |
test | v02.option-result + v02.option-param (tests/suite/v02.lisp:52-62); mechanism codegen.lisp:248-269,360-379, ffi.lisp:183-193 |
(:option T) with :bool inner is rejected (nil ambiguity) |
compile-error | crates/rulisp-macros/src/lib.rs:200-204 (param), 333-337 (result) |
(:option :bool) rejected by the loader too (hand-written manifests) |
runtime-check | option-inner (lisp/src/codegen.lisp), both param and result branches; test v04.option-bool-rejected-by-the-loader |
(:vec S): S scalar only; len counts ELEMENTS; freed via dealloc(ptr, len*sizeof(S), alignof(S)) |
runtime-check | lisp/src/codegen.lisp:63-69,300-314; crates/rulisp-runtime/src/lib.rs:186-194; test v02.vec-roundtrip (v02.lisp:69-81) |
:bytes = exact :string convention minus UTF-8 validation; empty transfer at len 0 |
test | v02.bytes-in/bytes-roundtrip/bytes-alloc-pairing (tests/suite/v02.lisp:10-45); no-validation path crates/rulisp-runtime/src/lib.rs:210-222 |
- Every
file:lineabove was read and confirmed against the working tree. Two citations in the input were corrected: the oracle manifest static (tests/m1-handwritten/src/lib.rs:17,37-42, was 18,40-43) and the stored-callback dead-id branch (codegen.lisp:179-185, was 179-186). - All five GAP rows were re-hunted: no enforcement found for any (grep for
on_dump, cross-library tests, general signal audit, loader-side option-bool validation all came up empty). - Test spot-checks (fail-if-broken confirmed):
fx.unknown-keys-ignored,m5.callback-condition-identity(eq-identity assertion),m4.free-vs-in-flight(live-count timing assertions),v02.bytes-alloc-pairing(LIVE_ALLOCATIONS drift),m7.dump-restore(subprocess exit-code + RESTORE-OK), plus all seven new v0.4 tests.