Cream

July 26, 2026 · View on GitHub

Implementation details and known issues. See the README for usage.

Custom Clojure fork

Branch crema of github.com/borkdude/clojure (1.13.0-cream-SNAPSHOT).

git clone -b crema --depth 1 https://github.com/borkdude/clojure.git /tmp/clojure-fork
cd /tmp/clojure-fork && mvn install -Dmaven.test.skip=true

Fork changes

RT.java:

  • Skip loading clojure/core at native-image runtime (already loaded at build time) using org.graalvm.nativeimage.imagecode property check
  • Skip doInit() at native-image runtime (user ns, refer, server were set up at build time)
  • Wrap clojure.core.server loading in !nativeImageRuntime guard

Var.java:

  • During build-time class init (imagecode=buildtime), set! falls back to bindRoot() instead of throwing. Fixes "Can't change/establish root binding of: warn-on-reflection with set".

Architecture

GraalVM substitutions (src-java/Target_jdk_internal_misc_VM.java)

  • jdk.internal.misc.VM.initialize() — no-op
  • jdk.internal.jrtfs.SystemImage.findHome() — returns System.getProperty("java.home") to work around getProtectionDomain().getCodeSource() issue for boot classes
  • VM.getRuntimeArguments() substitution removed — 25e1 EA provides it internally (duplicate causes "conflicts with previously registered" error)

Class initialization

--initialize-at-build-time=clojure AOT-compiles Clojure core. The Var.set() fork fix handles *warn-on-reflection*. jdk.internal.jrtfs.SystemImage must be --initialize-at-run-time or the analysis phase deadlocks.

Deterministic class initialization (ClojureFeature)

--initialize-at-build-time=clojure eagerly initializes all clojure.* classes in parallel. This causes circular deadlocks: compiled Clojure classes reference RT in <clinit>, while RT.<clinit> loads core which needs those classes.

ClojureFeature (a GraalVM Feature) forces RT.<clinit> to complete in beforeAnalysis() on a single thread before parallel analysis starts. All core namespaces, fn classes, and deftype classes get initialized sequentially. When analysis later discovers them, they're already done — no deadlocks.

Earlier approaches modified PersistentTreeMap, MultiFn, Compiler, and __init class generation to break circular deps. All reverted — the Feature makes them unnecessary since Java's reentrant class init allows same-thread access to partially-initialized classes.

Preserve packages

Preserved via -H:Preserve= in build_native.clj. A package missing here shows up as Fatal error: Unable to call AOT method naming the method and the package to add.

  • clojure.langcreator static field (functional interface support)
  • java.lang, java.lang.invoke, java.lang.reflectjava.lang covers the StringConcat classes that invokedynamic string concatenation generates
  • java.ioFilterInputStream, reading a subprocess's streams
  • java.net, javax.net, javax.net.ssl — http-kit, including HTTPS
  • java.nio, java.nio.channels, java.nio.channels.spi, java.nio.charset, java.nio.file
  • java.util, java.util.concurrent, java.util.concurrent.atomic, java.util.concurrent.locks, java.util.function, java.util.jar, java.util.regex, java.util.stream, java.util.zipjava.util.stream for the Closure compiler's Collector.of
  • java.security, java.text, java.time
  • module java.logging, module java.sqljava.sql for cljs.closure. --add-modules=java.sql does nothing on its own, Preserve=module is what includes the classes

The java.util.* entries beyond java.util itself, plus java.security, java.text and java.time, were added while chasing tools.deps in-process. That is blocked by oracle/graal#14075, so they are not carrying their weight yet. bb/test_preserve.clj removes each entry in turn to find which are load-bearing.

URL protocols

--enable-url-protocols=http,https,jar,unixjar: is needed for JarClassLoader.getResource() to construct jar:file:...!/... URLs.

JarClassLoader

Custom classloader extending DynamicClassLoader for native images. URLClassLoader.findResource() doesn't work in Crema, so this reads JARs via java.util.jar.JarFile directly.

  • Indexes all JAR entries at construction for O(1) lookup
  • Supports both JAR files and directories on classpath
  • Falls back to parent classloader

Build-time namespace loading

Libraries that transitively depend on standard library namespaces (e.g. data.jsonpprintclojure.walk) would fail because core fns like use aren't reachable by native-image analysis.

All standard namespaces are required at build time in src/cream/main.clj, so runtime require calls for them are no-ops.

clojure.reflect.java__init ordering

clojure.reflect.clj loads reflect/java via (load "reflect/java") from source, so clojure.reflect.java__init is never class-initialized during normal loading. When native-image discovers it later, it fails because the TypeReference protocol isn't visible yet.

ClojureFeature.beforeAnalysis() forces the right order: RTcream.main__init (loads all standard namespaces including clojure.reflect) → clojure.reflect.java__init.

Reflection config

Generated by bb bb/gen_reflect_config.clj — ~470 classes based on babashka's impl/classes.clj. All entries have allPublicMethods, allPublicConstructors, allPublicFields enabled.

Known issues

  1. Crema method handle bug (seen with stock Clojure 1.12.3): ClassCastException: Integer cannot be cast to Boolean in MethodHandleUtils.intUnbox when Reflector.canAccess() calls Method.canAccess(Object) through a method handle.

  2. getRawAnnotations not implemented for runtime-loaded classes, which threw UnsupportedOperationException and broke Clojure 1.13's @FunctionalInterface detection. Fixed upstream. The fork's guard was removed on ea.04.

  3. Enum support broken (oracle/graal#13034): enum.values() and EnumMap crash with NPE in InterpreterResolvedObjectType.getDeclaredMethodsList(). Blocks http-kit, cheshire, clj-yaml.

  4. Class.forName not dispatchable (oracle/graal#13031): fixed upstream by oracle/graal#13187. See below.

  5. SwitchBootstraps.typeSwitch not dispatchable — same pattern as Class.forName: the bootstrap method for Java 21+ pattern matching switch expressions is substituted/inlined and not compiled as a standalone entry point. Affects Java libraries using switch with pattern matching.

  6. Verifier NPE on a class/interface merge (oracle/graal#14075): findLeastCommonAncestor walks both hierarchies in lockstep, so an interface operand, which has no superclass, becomes null and is dereferenced on the next iteration. Only class files without a StackMapTable (major < 50) reach it, since newer ones carry frames the verifier reads instead of inferring. commons-logging 1.2 (major 46) hits it in LogFactory.getFactory, which merges SecurityException with Enumeration, and that is on the Maven resolver's classpath, so -X:deps and -T:build crash. Repro at repro/verifier-npe/.

  7. JAVA_HOME is needed for classes Crema loads at runtime from the JDK's lib/modules. Clojure code and the tested libraries do not hit this. Such classes fail with ClassNotFoundException when no jimage is reachable.

    BootClassRegistry warns on stdout the first time it consults the boot class loader without a jimage, through LogUtils.warning, which uses System.out. Class loading is parent-first, so any runtime-generated class triggers it and the warning lands in the middle of program output. -main forces that lookup at startup with stdout swallowed, which leaves the ClassNotFoundException as the only diagnostic.

Class.forName and GraalVM substitutions

Class.forName is internally substituted by GraalVM native-image. The substitution is inlined at each call site — the original method is never compiled as a standalone entry point. When Crema encounters invokestatic java.lang.Class.forName(String) in runtime bytecode, there's no compiled code to dispatch to.

What doesn't help:

  • Adding Class.forName calls in application code (inlined away)
  • Adding it to reflect-config.json (reflection != method compilation)
  • Custom @Substitute (conflicts with GraalVM's internal one)

What works: RT.classForName(String) — a non-substituted method that internally calls the 3-arg Class.forName (inlined at compile time).

The Clojure fork redirected (Class/forName ...) interop to emit invokestatic RT.classForName. That workaround was removed on ea.04, where Class.forName dispatches from runtime-loaded bytecode.