Read this before touching the code. The user guide is doc/guide.md, the API listing is API.md, and the decisions are in doc/ai/adr/.
Babashka consumes this repository as a git submodule. In this checkout .git is a file, and worktrees live under babashka's .git/modules/ffi.
cfn picks the path once, when the binding is made. The binding's metadata names it as :babashka.ffi/backend.
| Signature | JVM | Native image |
|---|---|---|
| fixed, scalars, up to 20 args | binding.clj generated class with the FFM handle as a constant, 3 to 6 ns, about 70 us to create | compiled trampoline when the shape is in the set, about 30 ns, else libffi, about 1 us |
| struct by value | generated class, a segment per struct and an allocator slot for a struct return, about 70 ns | libffi |
| variadic, tail inferred per call | cached binding per tail shape, about 55 ns more than a declared tail | libffi |
| variadic, tail declared | generated class with firstVariadicArg | libffi |
| more than 20 args, fixed or variadic | FFM handle with invokeWithArguments | libffi |
The FFM struct path and the FFM variadic paths are JVM only, and ffi.clj wraps them in (when-not native-image? ...). An image initializes the namespace when it is built, skips those forms and does not carry the code, 33 KB in babashka, measured. Code that only the JVM calls goes inside such a form, and its call site asks native-image? first.
Every type keyword has a carrier: :long, :double, :float or :void. The A trampoline takes every argument as a long, which is not the width C gives a narrow integer. That shows only once an argument reaches the stack, and only where the ABI packs a stack slot to the width of the argument, which macOS on AArch64 does. The shape set is the same everywhere, so the generated sources are too and can be committed; trampoline-id declines a shape with a narrow type past the eighth argument at run time instead, and the call goes to libffi. apple-aarch64? and narrow-on-stack? hold that rule. It reads os.arch when the image is built, so it does not survive a cross build.
trampoline set and the generated class bytes are keyed on carriers, not types. The generated class passes every argument and result as a long, doubles and floats as raw bits, and resolves the symbol on the first call through a MutableCallSite.
Callbacks use FFM upcall stubs on both hosts and keep the limits listed in doc/guide.md under Callbacks.
Adding or changing a type keyword touches each of these. Keep them in sync.
jvm-return-conversion-test checks narrow-ret and bits-ret-fn against each other through a callback that returns each type.
signature-layout is what a descriptor names a type by, at the width C gives it. :bool is one byte, as in FFM and node:ffi, and every conversion of a :bool masks to that byte: narrow-ret, bits-ret-fn and the callback argument. The rest of the register is not part of a C bool, a callee that ends in sete %al leaves it as it was, and a trampoline returns the whole register. A carrier is what the call path moves it in. The two differ for every integer narrower than 64 bits, so a handle built from a descriptor is cast between them: carrier-handle for the generic invoker, struct-handle in binding.clj for the generated class, and explicitCastArguments onto signature-method-type for an upcall stub. Naming a narrow integer by its carrier reads the wrong bytes once arguments spill to the stack, which stack-arguments-test covers.
A callback in a native image is the exception: it keeps the carrier shape, through carrier-descriptor, and narrows each value on arrival instead, in the in-c table in callback. babashka registers the upcall shapes an image can make when it builds it, from script/gen_ffi_metadata.clj, and one shape per width per position is not a set anything can register. The narrowing is what makes that sound, not the six-argument cap: a C caller writes the low half of the register and leaves the upper half zero, so a narrow integer read at its carrier width arrives without its sign, in a register as much as on the stack. narrow-int? lists the types this applies to, and narrow-ret does the conversion.
JVM, needs JDK 25 or newer, the alias enables native access:
clojure -M:test
Babashka, through its built-in copy of this namespace:
bb test:bb
A native image, needs GRAALVM_HOME and a C compiler. This is the only run
that exercises the branch's code on the trampolines and the upcall shapes an
image registers. Running the suite against a released babashka does not:
that binary carries the babashka.ffi it was built with, so bb test:bb
reports on babashka's code, not on the tree.
bb test:native
BABASHKA_LIBFFI=system bb test:native
The first image links no libffi and checks that a struct call and a variadic signature are refused. The second links the libffi of the system with -lffi and checks that they are called. BABASHKA_LIBFFI can also be the path of an archive. CI runs both on Linux, macOS and Windows.
Node.js, needs Node.js 26.1 or newer on PATH. The three commands run test-node under nbb, ClojureScript and shadow-cljs. The last two need JDK 25 or newer:
bb test:node
bb test:cljs
bb test:shadow
In ffi.cljs, hint a Pointer, an Arena and a node:ffi object at every field or method access. shadow-cljs warns where it cannot infer the type, and an advanced build renames what has no hint.
The babashka run only sees the code that its binary was built with. A test for new code fails there until babashka updates the submodule. Guard such a test with:
(when-not (System/getProperty "babashka.version") ...)
The guard is on the host, so the test stays off in babashka after the submodule update too. Once babashka carries the code, drop the guard, and keep the test inside the native image limits in doc/guide.md under Performance and limits, such as at most 6 callback arguments. A test of behavior that already shipped needs no guard and runs on both hosts.
CI runs bb on Linux, macOS and Windows, and the JVM suite on JDK 25 on the same three.
Test conventions:
clj-kondo --lint src test test-jvm test-node
One pre-existing info about a redundant long coercion in ffi.clj is known.
There is no bench directory. Measure with criterium from a script:
clojure -Sdeps '{:deps {criterium/criterium {:mvn/version "0.4.6"}}}' \
-J--enable-native-access=ALL-UNNAMED -M bench.clj
(require '[babashka.ffi :as ffi] '[criterium.core :as c])
(def abs-i (ffi/cfn "abs" [:int] :int))
(c/quick-bench (abs-i -5))
Compare a branch against main in the same session. A scalar call on the JVM is 3 to 6 ns. Anything above 10 ns for a scalar call is a regression.
For the babashka side, build babashka with the submodule pointed at the branch and time a loop. Numbers for the trampoline path are in doc/guide.md under Performance and limits.
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 |