Java libraries from jolt through OpenLDK on SBCL, no JVM (alpha)
Clojure
3
16 commits
updated Oct 14, 2026
[!WARNING] Alpha. This project is days old and still finding its shape. The API, the wire protocol between jolt and the bridge, the build layout and the supported platforms may all change without notice or a deprecation path. Don't build anything you can't easily rework on top of it yet.
Java libraries from jolt, with no JVM in the process.
OpenLDK is Anthony Green's Java runtime written in Common Lisp. It reads .class files, translates the bytecode into Lisp and lets SBCL compile that to machine code. This project loads SBCL as a shared library into a jolt process, starts it on a core with OpenLDK already warmed up, and gives Clojure a small API for calling into it.
(require '[net.b12n.jolt.openldk :as ldk])
(ldk/init! {:classpath ["classes" "lib/some.jar"]})
(ldk/call-static "java.lang.Math" "sqrt" "(D)D" 2.0) ;=> 1.4142135623730951
(ldk/with-ref [xs (ldk/new-object "java.util.ArrayList" "()V")]
(ldk/call xs "add" "(Ljava/lang/Object;)Z" "a")
(ldk/call xs "add" "(Ljava/lang/Object;)Z" 2)
(ldk/to-string xs)) ;=> "[a, 2]"
Verified on macOS arm64, Linux x86-64 and Linux arm64, with jolt 0.8.20, SBCL 2.6.9 and JDK 25, and checked on every push by CI. Platforms has the detail.
Probably not, if what you want is a Java library at full speed. A JVM over JNI is faster, far more complete, and what everyone else uses. OpenLDK says so about itself: it is for when you are in Lisp and need that one Java library, and it isn't trying to compete with HotSpot.
The reasons to want it anyway are narrower. Nothing here starts a JVM, so there is no second runtime with its own heap tuning and startup cost, and the whole thing lives in one process that you control. The catch is that it still reads the JDK's class library at run time, so a JDK 25 has to be installed even though it never runs.
Everything runs in one process. jolt calls down through a small C shim into SBCL, and Java calls back up through one jolt callback.
flowchart TB
subgraph jolt["jolt process"]
api["net.b12n.jolt.openldk<br/>init!, call-static, new-object, call, implement, release!"]
wire["net.b12n.jolt.openldk.wire<br/>request text out, reply text back"]
tramp["upcall trampoline<br/>one :collect-safe ffi/callback"]
api --> wire
end
subgraph shim["libjoltopenldk (bridge/ldk.c)"]
shimcall["ldk_call / ldk_free"]
shimup["ldk_upcall pointer<br/>set by ldk_set_upcall"]
end
subgraph sbcl["libsbcl + openldk.core"]
bridge["bridge/bridge.lisp<br/>reads requests, prints replies, makes proxies"]
ldk["OpenLDK<br/>JIT-translates each method on first call"]
end
jdk[("JDK 25 class library<br/>$JAVA_HOME/jmods or lib/modules")]
wire -- "jolt.ffi, by name" --> shimcall
shimcall -- "ldk_entry, filled in by SBCL at startup" --> bridge
bridge --> ldk
ldk -- reads --> jdk
bridge -- "Java calls a proxy" --> shimup
shimup --> tramp
tramp -- "your fn" --> api
One call into Java, and one callback out of it, look like this:
sequenceDiagram
participant C as Clojure
participant S as C shim
participant B as bridge.lisp
participant J as OpenLDK (Java)
C->>S: ldk_call with (:static java.util.Arrays sort ...)
S->>B: ldk_entry
B->>J: Arrays.sort(xs, comparator)
J->>B: comparator.compare(a, b)
B->>S: ldk_upcall with (:call 7 compare ...)
S->>C: trampoline runs your fn
C-->>B: reply (:ok (:i -1))
B-->>J: -1
J-->>B: sort returns
B-->>C: reply (:ok (:void)), malloc'd, freed by ldk_free
Three things make the middle layer work, and each one cost a failed run to find.
save-lisp-and-die :callable-exports '(ldk_entry) saves a core with no toplevel. When initialize_lisp starts it, SBCL writes the address of the ldk_entry callable into the C global of that name and returns to the caller rather than starting a REPL.dlsym. jolt.ffi/load-library loads RTLD_LOCAL, which hides it, and SBCL then dies at startup with UNDEFINED-ALIEN-VARIABLE-ERROR and takes jolt down with it. So ldk_init first re-opens itself and libsbcl with RTLD_NOLOAD | RTLD_GLOBAL.ldk_call is a named C function that calls the pointer.Every request is one line of text. Strings, numbers and booleans travel as tagged Lisp forms, (:static "java.lang.Math" "sqrt" "(D)D" ((:d 2.0))). The reply is printed so that clojure.edn reads it unchanged, (:ok (:d 1.4142135623730951)). The bridge reads requests with *read-eval* off and interns nothing outside KEYWORD. wire.clj has the full grammar in its docstring.
export JAVA_HOME=/path/to/jdk-25 # mise: ~/.local/share/mise/installs/java/25.0.2
bb build # runs bridge/build.sh
bridge/build.sh needs sbcl (any recent one, used only to compile SBCL itself), ocicl, git and cc on PATH. It writes nothing outside ~/.cache/jolt-openldk, or $JOLT_OPENLDK_HOME if that is set. It:
libsbcl. Homebrew's SBCL isn't built that way, so there is no shortcut.23da184e, installs its dependencies with ocicl, and dumps openldk.core with bridge/bridge.lisp loaded.manifest.edn naming what went in.From a clean cache it took about three and a half minutes here, two of them for SBCL, which is built once and reused after that. Set OPENLDK_SRC=/path/to/checkout to clone OpenLDK from a local copy instead of GitHub. The checkout itself is never modified.
Behind an HTTPS proxy that can't tunnel to ghcr.io, ocicl install fails with Unable to establish HTTPS tunnel through proxy. Run the build with the proxy variables unset.
The result in dist/ is a 146 MB core, a 412 KB libsbcl.dylib and a 34 KB shim.
init! starts SBCL and sets the classpath, which OpenLDK only accepts once per process. Calling it again with the same classpath does nothing. Calling it with a different one throws. Before starting anything it checks that JAVA_HOME is a JDK 25 with its class library and that every classpath entry exists, because OpenLDK reacts to the first by exiting the process and to the second by failing on the first class load, after the classpath can no longer change. If init! fails after SBCL was started, later calls say so and the process has to be restarted, since SBCL can't be started twice.
Methods are named the way JNI names them, a name plus a descriptor, because Java overloads. The descriptor's return type also tells the bridge how to hand back the result:
| Java | crosses as | comes back as |
|---|---|---|
int long short byte | Clojure integer, range-checked for the parameter | long |
double float | Clojure double; NaN and infinities included | double (a float is widened, so 1.0f/3 reads 0.3333333432674408) |
boolean | true / false | true / false |
char | Clojure char up to U+FFFF; past that it doesn't fit one Java char and is refused | char, except half a surrogate pair, which a jolt char can't hold: that comes back as its integer code |
String | Clojure string, any Unicode except NUL, which would end the C string the request travels in | string, NUL and control characters included; a lone surrogate, legal in Java, becomes U+FFFD |
Integer Long Double Boolean Character returned as objects | unboxed to the Clojure value | |
| a scalar or string where a reference type is expected | boxed as Java would: an integer as Long (or Integer for an Integer parameter), a double as Double, then Boolean, Character, String. Only where that box fits the parameter (Object, Number, Comparable, Serializable, CharSequence or the box itself); anything else is refused | |
an array ([I, [Ljava/lang/String;, [[I ...) | a Clojure vector, each element converted and checked as an argument of the component type, with the element's index in any error; or a JavaRef to an array | a JavaRef; read it with array->vec |
| anything else | a JavaRef you got back earlier | a JavaRef |
null | nil | nil |
A JavaRef keeps its object alive until release!. with-ref releases on the throwing path too. Every call that returns an object hands back a new handle, including one that returns the same object (StringBuilder.append), so release those as well.
Arrays go in as vectors and come back as handles, because an array is mutable and Java may keep it:
(ldk/call-static "java.util.Arrays" "toString" "([I)Ljava/lang/String;" [3 1 2]) ;=> "[3, 1, 2]"
(ldk/with-ref [xs (ldk/new-array "I" [3 1 2])] ; an int[] you keep
(ldk/call-static "java.util.Arrays" "sort" "([I)V" xs)
(ldk/array->vec xs)) ;=> [1 2 3]
array->vec converts by component type: int[] and long[] to integers, byte[] to signed integers as in Java, boolean[] to booleans, char[] to chars, String[] to strings. Object[] elements convert like returned values, so strings and boxed scalars become values and any other object, a nested array included, becomes a JavaRef to release. Only a vector is taken for an array, never a seq. A float[] reads back widened to double, so 0.1f arrives as 0.10000000149011612, the same as a returned float. A vector inside an Object[] is refused, since nothing says what array it should become: build it with new-array and put the handle in instead. Descriptors, for new-array and in method descriptors, are parsed strictly, so int, V or a dotted Ljava.lang.String; is an error rather than a strange array.
Java can call Clojure too. implement makes a Java object implementing one or more interfaces whose methods run Clojure fns:
(ldk/with-ref [desc (ldk/implement "java.util.Comparator" (fn [a b] (compare b a)))
xs (ldk/new-array "Ljava/lang/String;" ["b" "c" "a"])]
(ldk/call-static "java.util.Arrays" "sort" "([Ljava/lang/Object;Ljava/util/Comparator;)V" xs desc)
(ldk/array->vec xs)) ;=> ["c" "b" "a"]
(ldk/implement "java.util.Iterator" {"hasNext" (fn [] ...) "next" (fn [] ...)})
A fn handles every method, which suits a functional interface. A map picks a fn by method name, or by name plus descriptor for an overload. The object passes checkcast for its interfaces, and their default methods work, so Comparator.reversed() on it calls your compare. It's built the way OpenLDK builds classes for Java lambdas: a class made at run time whose superclasses are Object and the interfaces, with one method per abstract method.
RuntimeException in Java, which Java code can catch.RuntimeException with its message. If that exception comes back out through the call you made, you get your original throwable back.join, a latch or a monitor. In that case the fn must not call into Java.toString gives jolt-openldk proxy for java.util.Comparator #7, and hashCode and equals are Object's. None of the three reach Clojure. getClass() throws ClassNotFoundException, since no Java class stands behind the one made at run time, so Java code that reflects on the object will fail.release!, along with its fn. A proxy passed inline and never released stays for the life of the process.release! on the object drops its fn as well, so Java calling a stored copy later gets a RuntimeException rather than a call into a fn that's gone.A Java exception arrives as an ex-info:
(ex-data e)
;=> #:java{:class "java.lang.IllegalArgumentException",
; :message "negative: -1",
; :string "java.lang.IllegalArgumentException: negative: -1"}
Mistakes on the Clojure side, such as a missing class, a wrong argument count, or 99999999999 passed to an int, carry :openldk/error instead.
bb tour runs examples/tour, which works through Math, StringBuilder, TreeMap, java.time and a class of its own. Its date arithmetic and hex output were cross-checked against date and printf, since OpenLDK's correctness is the thing on trial.
On the machine above, 2026-10-09:
ldk_init) took 47 ms. The tour's whole init!, which adds OpenLDK's classpath setup, took 244 ms.Math.max calls in 175 ms). Nobody has profiled where that goes. The request and reply text is the obvious suspect, and a binary encoding would be the first thing to try if it ever matters.Comparator sort of 1,000 strings made 8,528 calls in 419 ms, sorting correctly).byte[] or an int[] took about 60 ms to build from a vector and 260 to 330 ms to read back across two runs, and came back equal. A million ints took 0.7 s in and 3.4 s out. Fine for configuration, slow for image data.implement takes interfaces only. Subclassing an abstract class from Clojure isn't supported.RuntimeException itself reaches your call. If Java wraps it in another exception first, you get the wrapper as an ex-info.System.exit exits the jolt process, since it is the only process there is (measured; Runtime.halt presumably does too, untested). So does SBCL on heap exhaustion or a corrupt core. ldk_init checks the core can be opened, and init! checks JAVA_HOME, before either reaches SBCL. Nothing else is covered. The heap is a fixed 8 GB reservation.docs/openldk-upstream-notes.md. One is worked around here. The other, ("é✓".toUpperCase() + "!") throwing a NullPointerException that escapes catch (Throwable), is not.System.out and jolt's *out* share file descriptor 1 but buffer separately. The bridge flushes after every call, but output written during a call can still land before output jolt had buffered before it.| Platform | Status |
|---|---|
| macOS arm64 | Verified. bb gates locally, and the macos-arm64 CI job. |
| Linux x86-64 | Verified. The linux-x86_64 CI job, on GitHub's Ubuntu runner: bridge/build.sh, every test with skipping disallowed, and the tour. |
| Linux arm64 | Verified. The linux-arm64 CI job, and bb linux on Apple silicon, both through linux/Dockerfile on Ubuntu 26.04. |
| macOS x86-64, Windows | Not attempted. |
Don't use bb linux with JOLT_OPENLDK_LINUX_PLATFORM=linux/amd64 on Apple silicon. Under Docker's x86-64 emulation, compiling SBCL 2.6.9 stops in src/code/irrat with the cross-compiler's Unimplemented., while the same build passes on real x86-64 hardware in CI. The emulator is at fault, not Linux.
CI (.github/workflows/ci.yml) runs lint and the three platform jobs on every push to main and every pull request. Each runs bridge/build.sh, the tests with JOLT_OPENLDK_REQUIRE=1 and the tour, with versions pinned and the SBCL build cached.
bb linux builds linux/Dockerfile for the machine's own architecture and runs linux/check.sh in it, with this checkout mounted read-only and the SBCL build cached in a Docker volume. jolt and ocicl publish no Linux arm64 binaries, so on arm64 the image builds both from their release tags. jolt brings its own pinned Chez Scheme, because Ubuntu's is too old (10.0.0 lacks vector-copy!). The first build takes about 8 minutes and later ones reuse it. JOLT_OPENLDK_LINUX_PLATFORM=linux/amd64 forces a platform.
On a network that inspects TLS, Zscaler for instance, ocicl can't fetch from ghcr.io inside the container, because the proxy re-signs that site with its own root. bb linux copies the host's CA bundle (JOLT_OPENLDK_CA_FILE, or else SSL_CERT_FILE) into linux/extra-ca/, which git ignores, and the image trusts it. With neither set, nothing is added.
bb test # wire tests always; the OpenLDK tests skip without a build
bb gates # lint, then all tests with JOLT_OPENLDK_REQUIRE=1 so a missing build fails, then the tour
bb linux # the same build, tests and tour on Linux in Docker; slow, so not part of gates
Last run: wire tests 11 tests and 65 assertions, OpenLDK tests 17 tests and 147 assertions, all passing.
Two licences, by file, with NOTICE saying which is which.
bridge/bridge.lisp is GPL-3.0-or-later with the Classpath exception (COPYING, COPYING.CLASSPATH-EXCEPTION), the same as OpenLDK. It has to be, since it's loaded into OpenLDK's image and calls its internals.LICENSE), like jolt, with GPL-2.0-or-later plus the Classpath exception named as a Secondary License.Every source file names its licence in an SPDX-License-Identifier line. bridge/build.sh downloads and builds SBCL and OpenLDK but copies neither into this repository, so its output in dist/ carries their licences as well. NOTICE has the details.
1,114 followers · starred Oct 2026
Java libraries from jolt through OpenLDK on SBCL, no JVM (alpha)
Clojure
3
16 commits
updated Oct 14, 2026
[!WARNING] Alpha. This project is days old and still finding its shape. The API, the wire protocol between jolt and the bridge, the build layout and the supported platforms may all change without notice or a deprecation path. Don't build anything you can't easily rework on top of it yet.
Java libraries from jolt, with no JVM in the process.
OpenLDK is Anthony Green's Java runtime written in Common Lisp. It reads .class files, translates the bytecode into Lisp and lets SBCL compile that to machine code. This project loads SBCL as a shared library into a jolt process, starts it on a core with OpenLDK already warmed up, and gives Clojure a small API for calling into it.
(require '[net.b12n.jolt.openldk :as ldk])
(ldk/init! {:classpath ["classes" "lib/some.jar"]})
(ldk/call-static "java.lang.Math" "sqrt" "(D)D" 2.0) ;=> 1.4142135623730951
(ldk/with-ref [xs (ldk/new-object "java.util.ArrayList" "()V")]
(ldk/call xs "add" "(Ljava/lang/Object;)Z" "a")
(ldk/call xs "add" "(Ljava/lang/Object;)Z" 2)
(ldk/to-string xs)) ;=> "[a, 2]"
Verified on macOS arm64, Linux x86-64 and Linux arm64, with jolt 0.8.20, SBCL 2.6.9 and JDK 25, and checked on every push by CI. Platforms has the detail.
Probably not, if what you want is a Java library at full speed. A JVM over JNI is faster, far more complete, and what everyone else uses. OpenLDK says so about itself: it is for when you are in Lisp and need that one Java library, and it isn't trying to compete with HotSpot.
The reasons to want it anyway are narrower. Nothing here starts a JVM, so there is no second runtime with its own heap tuning and startup cost, and the whole thing lives in one process that you control. The catch is that it still reads the JDK's class library at run time, so a JDK 25 has to be installed even though it never runs.
Everything runs in one process. jolt calls down through a small C shim into SBCL, and Java calls back up through one jolt callback.
flowchart TB
subgraph jolt["jolt process"]
api["net.b12n.jolt.openldk<br/>init!, call-static, new-object, call, implement, release!"]
wire["net.b12n.jolt.openldk.wire<br/>request text out, reply text back"]
tramp["upcall trampoline<br/>one :collect-safe ffi/callback"]
api --> wire
end
subgraph shim["libjoltopenldk (bridge/ldk.c)"]
shimcall["ldk_call / ldk_free"]
shimup["ldk_upcall pointer<br/>set by ldk_set_upcall"]
end
subgraph sbcl["libsbcl + openldk.core"]
bridge["bridge/bridge.lisp<br/>reads requests, prints replies, makes proxies"]
ldk["OpenLDK<br/>JIT-translates each method on first call"]
end
jdk[("JDK 25 class library<br/>$JAVA_HOME/jmods or lib/modules")]
wire -- "jolt.ffi, by name" --> shimcall
shimcall -- "ldk_entry, filled in by SBCL at startup" --> bridge
bridge --> ldk
ldk -- reads --> jdk
bridge -- "Java calls a proxy" --> shimup
shimup --> tramp
tramp -- "your fn" --> api
One call into Java, and one callback out of it, look like this:
sequenceDiagram
participant C as Clojure
participant S as C shim
participant B as bridge.lisp
participant J as OpenLDK (Java)
C->>S: ldk_call with (:static java.util.Arrays sort ...)
S->>B: ldk_entry
B->>J: Arrays.sort(xs, comparator)
J->>B: comparator.compare(a, b)
B->>S: ldk_upcall with (:call 7 compare ...)
S->>C: trampoline runs your fn
C-->>B: reply (:ok (:i -1))
B-->>J: -1
J-->>B: sort returns
B-->>C: reply (:ok (:void)), malloc'd, freed by ldk_free
Three things make the middle layer work, and each one cost a failed run to find.
save-lisp-and-die :callable-exports '(ldk_entry) saves a core with no toplevel. When initialize_lisp starts it, SBCL writes the address of the ldk_entry callable into the C global of that name and returns to the caller rather than starting a REPL.dlsym. jolt.ffi/load-library loads RTLD_LOCAL, which hides it, and SBCL then dies at startup with UNDEFINED-ALIEN-VARIABLE-ERROR and takes jolt down with it. So ldk_init first re-opens itself and libsbcl with RTLD_NOLOAD | RTLD_GLOBAL.ldk_call is a named C function that calls the pointer.Every request is one line of text. Strings, numbers and booleans travel as tagged Lisp forms, (:static "java.lang.Math" "sqrt" "(D)D" ((:d 2.0))). The reply is printed so that clojure.edn reads it unchanged, (:ok (:d 1.4142135623730951)). The bridge reads requests with *read-eval* off and interns nothing outside KEYWORD. wire.clj has the full grammar in its docstring.
export JAVA_HOME=/path/to/jdk-25 # mise: ~/.local/share/mise/installs/java/25.0.2
bb build # runs bridge/build.sh
bridge/build.sh needs sbcl (any recent one, used only to compile SBCL itself), ocicl, git and cc on PATH. It writes nothing outside ~/.cache/jolt-openldk, or $JOLT_OPENLDK_HOME if that is set. It:
libsbcl. Homebrew's SBCL isn't built that way, so there is no shortcut.23da184e, installs its dependencies with ocicl, and dumps openldk.core with bridge/bridge.lisp loaded.manifest.edn naming what went in.From a clean cache it took about three and a half minutes here, two of them for SBCL, which is built once and reused after that. Set OPENLDK_SRC=/path/to/checkout to clone OpenLDK from a local copy instead of GitHub. The checkout itself is never modified.
Behind an HTTPS proxy that can't tunnel to ghcr.io, ocicl install fails with Unable to establish HTTPS tunnel through proxy. Run the build with the proxy variables unset.
The result in dist/ is a 146 MB core, a 412 KB libsbcl.dylib and a 34 KB shim.
init! starts SBCL and sets the classpath, which OpenLDK only accepts once per process. Calling it again with the same classpath does nothing. Calling it with a different one throws. Before starting anything it checks that JAVA_HOME is a JDK 25 with its class library and that every classpath entry exists, because OpenLDK reacts to the first by exiting the process and to the second by failing on the first class load, after the classpath can no longer change. If init! fails after SBCL was started, later calls say so and the process has to be restarted, since SBCL can't be started twice.
Methods are named the way JNI names them, a name plus a descriptor, because Java overloads. The descriptor's return type also tells the bridge how to hand back the result:
| Java | crosses as | comes back as |
|---|---|---|
int long short byte | Clojure integer, range-checked for the parameter | long |
double float | Clojure double; NaN and infinities included | double (a float is widened, so 1.0f/3 reads 0.3333333432674408) |
boolean | true / false | true / false |
char | Clojure char up to U+FFFF; past that it doesn't fit one Java char and is refused | char, except half a surrogate pair, which a jolt char can't hold: that comes back as its integer code |
String | Clojure string, any Unicode except NUL, which would end the C string the request travels in | string, NUL and control characters included; a lone surrogate, legal in Java, becomes U+FFFD |
Integer Long Double Boolean Character returned as objects | unboxed to the Clojure value | |
| a scalar or string where a reference type is expected | boxed as Java would: an integer as Long (or Integer for an Integer parameter), a double as Double, then Boolean, Character, String. Only where that box fits the parameter (Object, Number, Comparable, Serializable, CharSequence or the box itself); anything else is refused | |
an array ([I, [Ljava/lang/String;, [[I ...) | a Clojure vector, each element converted and checked as an argument of the component type, with the element's index in any error; or a JavaRef to an array | a JavaRef; read it with array->vec |
| anything else | a JavaRef you got back earlier | a JavaRef |
null | nil | nil |
A JavaRef keeps its object alive until release!. with-ref releases on the throwing path too. Every call that returns an object hands back a new handle, including one that returns the same object (StringBuilder.append), so release those as well.
Arrays go in as vectors and come back as handles, because an array is mutable and Java may keep it:
(ldk/call-static "java.util.Arrays" "toString" "([I)Ljava/lang/String;" [3 1 2]) ;=> "[3, 1, 2]"
(ldk/with-ref [xs (ldk/new-array "I" [3 1 2])] ; an int[] you keep
(ldk/call-static "java.util.Arrays" "sort" "([I)V" xs)
(ldk/array->vec xs)) ;=> [1 2 3]
array->vec converts by component type: int[] and long[] to integers, byte[] to signed integers as in Java, boolean[] to booleans, char[] to chars, String[] to strings. Object[] elements convert like returned values, so strings and boxed scalars become values and any other object, a nested array included, becomes a JavaRef to release. Only a vector is taken for an array, never a seq. A float[] reads back widened to double, so 0.1f arrives as 0.10000000149011612, the same as a returned float. A vector inside an Object[] is refused, since nothing says what array it should become: build it with new-array and put the handle in instead. Descriptors, for new-array and in method descriptors, are parsed strictly, so int, V or a dotted Ljava.lang.String; is an error rather than a strange array.
Java can call Clojure too. implement makes a Java object implementing one or more interfaces whose methods run Clojure fns:
(ldk/with-ref [desc (ldk/implement "java.util.Comparator" (fn [a b] (compare b a)))
xs (ldk/new-array "Ljava/lang/String;" ["b" "c" "a"])]
(ldk/call-static "java.util.Arrays" "sort" "([Ljava/lang/Object;Ljava/util/Comparator;)V" xs desc)
(ldk/array->vec xs)) ;=> ["c" "b" "a"]
(ldk/implement "java.util.Iterator" {"hasNext" (fn [] ...) "next" (fn [] ...)})
A fn handles every method, which suits a functional interface. A map picks a fn by method name, or by name plus descriptor for an overload. The object passes checkcast for its interfaces, and their default methods work, so Comparator.reversed() on it calls your compare. It's built the way OpenLDK builds classes for Java lambdas: a class made at run time whose superclasses are Object and the interfaces, with one method per abstract method.
RuntimeException in Java, which Java code can catch.RuntimeException with its message. If that exception comes back out through the call you made, you get your original throwable back.join, a latch or a monitor. In that case the fn must not call into Java.toString gives jolt-openldk proxy for java.util.Comparator #7, and hashCode and equals are Object's. None of the three reach Clojure. getClass() throws ClassNotFoundException, since no Java class stands behind the one made at run time, so Java code that reflects on the object will fail.release!, along with its fn. A proxy passed inline and never released stays for the life of the process.release! on the object drops its fn as well, so Java calling a stored copy later gets a RuntimeException rather than a call into a fn that's gone.A Java exception arrives as an ex-info:
(ex-data e)
;=> #:java{:class "java.lang.IllegalArgumentException",
; :message "negative: -1",
; :string "java.lang.IllegalArgumentException: negative: -1"}
Mistakes on the Clojure side, such as a missing class, a wrong argument count, or 99999999999 passed to an int, carry :openldk/error instead.
bb tour runs examples/tour, which works through Math, StringBuilder, TreeMap, java.time and a class of its own. Its date arithmetic and hex output were cross-checked against date and printf, since OpenLDK's correctness is the thing on trial.
On the machine above, 2026-10-09:
ldk_init) took 47 ms. The tour's whole init!, which adds OpenLDK's classpath setup, took 244 ms.Math.max calls in 175 ms). Nobody has profiled where that goes. The request and reply text is the obvious suspect, and a binary encoding would be the first thing to try if it ever matters.Comparator sort of 1,000 strings made 8,528 calls in 419 ms, sorting correctly).byte[] or an int[] took about 60 ms to build from a vector and 260 to 330 ms to read back across two runs, and came back equal. A million ints took 0.7 s in and 3.4 s out. Fine for configuration, slow for image data.implement takes interfaces only. Subclassing an abstract class from Clojure isn't supported.RuntimeException itself reaches your call. If Java wraps it in another exception first, you get the wrapper as an ex-info.System.exit exits the jolt process, since it is the only process there is (measured; Runtime.halt presumably does too, untested). So does SBCL on heap exhaustion or a corrupt core. ldk_init checks the core can be opened, and init! checks JAVA_HOME, before either reaches SBCL. Nothing else is covered. The heap is a fixed 8 GB reservation.docs/openldk-upstream-notes.md. One is worked around here. The other, ("é✓".toUpperCase() + "!") throwing a NullPointerException that escapes catch (Throwable), is not.System.out and jolt's *out* share file descriptor 1 but buffer separately. The bridge flushes after every call, but output written during a call can still land before output jolt had buffered before it.| Platform | Status |
|---|---|
| macOS arm64 | Verified. bb gates locally, and the macos-arm64 CI job. |
| Linux x86-64 | Verified. The linux-x86_64 CI job, on GitHub's Ubuntu runner: bridge/build.sh, every test with skipping disallowed, and the tour. |
| Linux arm64 | Verified. The linux-arm64 CI job, and bb linux on Apple silicon, both through linux/Dockerfile on Ubuntu 26.04. |
| macOS x86-64, Windows | Not attempted. |
Don't use bb linux with JOLT_OPENLDK_LINUX_PLATFORM=linux/amd64 on Apple silicon. Under Docker's x86-64 emulation, compiling SBCL 2.6.9 stops in src/code/irrat with the cross-compiler's Unimplemented., while the same build passes on real x86-64 hardware in CI. The emulator is at fault, not Linux.
CI (.github/workflows/ci.yml) runs lint and the three platform jobs on every push to main and every pull request. Each runs bridge/build.sh, the tests with JOLT_OPENLDK_REQUIRE=1 and the tour, with versions pinned and the SBCL build cached.
bb linux builds linux/Dockerfile for the machine's own architecture and runs linux/check.sh in it, with this checkout mounted read-only and the SBCL build cached in a Docker volume. jolt and ocicl publish no Linux arm64 binaries, so on arm64 the image builds both from their release tags. jolt brings its own pinned Chez Scheme, because Ubuntu's is too old (10.0.0 lacks vector-copy!). The first build takes about 8 minutes and later ones reuse it. JOLT_OPENLDK_LINUX_PLATFORM=linux/amd64 forces a platform.
On a network that inspects TLS, Zscaler for instance, ocicl can't fetch from ghcr.io inside the container, because the proxy re-signs that site with its own root. bb linux copies the host's CA bundle (JOLT_OPENLDK_CA_FILE, or else SSL_CERT_FILE) into linux/extra-ca/, which git ignores, and the image trusts it. With neither set, nothing is added.
bb test # wire tests always; the OpenLDK tests skip without a build
bb gates # lint, then all tests with JOLT_OPENLDK_REQUIRE=1 so a missing build fails, then the tour
bb linux # the same build, tests and tour on Linux in Docker; slow, so not part of gates
Last run: wire tests 11 tests and 65 assertions, OpenLDK tests 17 tests and 147 assertions, all passing.
Two licences, by file, with NOTICE saying which is which.
bridge/bridge.lisp is GPL-3.0-or-later with the Classpath exception (COPYING, COPYING.CLASSPATH-EXCEPTION), the same as OpenLDK. It has to be, since it's loaded into OpenLDK's image and calls its internals.LICENSE), like jolt, with GPL-2.0-or-later plus the Classpath exception named as a Secondary License.Every source file names its licence in an SPDX-License-Identifier line. bridge/build.sh downloads and builds SBCL and OpenLDK but copies neither into this repository, so its output in dist/ carries their licences as well. NOTICE has the details.
1,114 followers · starred Oct 2026