Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Native image

A Goldberry application can be built as a GraalVM native image: no class loading, no reflection on the binding path, and a start-up measured against the process rather than against a JVM. That is what ADR-0127 designed the binding schema for.

Built and run in CI on linux-x64, macOS and Windows (ADR-0337) — wired, not yet run there. One 41 MiB file with nothing beside it, starting in well under a second and painting at about 1 ms a frame headless — faster than the JVM build over a short run, because there is nothing to warm up (ADR-0161). The FFM downcalls, the six upcalls, the fonts, the icons, the stylesheets, the KDL, the WidgetCatalog service and libgoldberry itself all travel inside it.

It logs, too, which took one hand-written metadata entry — see below.

Before anything else: the C toolchain

native-image links with the system gcc, so it needs a C toolchain and the development package for zlib — not merely the runtime one, which is what a desktop already has:

sudo apt install build-essential zlib1g-dev     # Debian / Ubuntu
sudo dnf install gcc glibc-devel zlib-devel libstdc++-static

Without it the build runs to completion, spends a minute on analysis, and fails at the last step with cannot find -lz. zlib1g alone is not enough: the linker resolves -lz through the libz.so symlink that zlib1g-dev installs.

The two commands

./gradlew :example:nativeImageMetadata -Pgraalvm.home=/path/to/graalvm
./gradlew :example:nativeImage         -Pgraalvm.home=/path/to/graalvm

GRAALVM_HOME works instead of the property. Either way it must be a GraalVM and not a stock JDK — native-image and the tracing agent ship only with the former, and the task says so if you point it at the wrong thing.

The result is one file: example/build/native/goldberry-showcase-<target>. No launcher, no lib/ directory, nothing to set. libgoldberry is carried inside the binary as the same classifier-jar resource a released application would use, and unpacked to a temporary file on first use — a shared object has to be a real file to be dlopened, so it cannot be mapped straight out of the image (ADR-0159).

That makes a writable temp directory a requirement, and -Dgoldberry.native.library is still the way out of one that is read-only or noexec.

Why there are two commands

A closed world has to know every foreign function the program will call before it runs, and Goldberry’s are not knowable from the source: a binding class takes a SymbolLookup obtained at run time and builds its handles from it, which is what lets an application choose which libgoldberry it loads. libgoldberry exports 246 symbols — SDL3 75, Yoga 69, Blend2D 56, HarfBuzz 25, libwebp 11, and 10 of the shim’s own, md4c reaching Java through those rather than through exports of its own — plus six upcalls, from five owners: SdlEventWatch, SdlFileDialogs, SdlTray, Yoga’s MeasureCallback and SdlClipboard. ExportListTest holds the list to the bindings in both directions — an exported symbol nothing binds is dead weight in every artifact, and a bound symbol nothing exports is a link error at the first call — and ForeignSurfaceTest.upcalls holds the six.

So the first command runs the showcase under GraalVM’s tracing agent and records what it saw — the foreign descriptors, the resources, the reflection Logback does — into

example/src/main/resources/META-INF/native-image/io.github.digitalsmile/goldberry-example/

That path is under src, not build: the metadata is source. It is reviewed in a diff, it changes when the application does, and being inside the jar is what lets a downstream image build find it without being told (ADR-0156).

The trace is only as good as the run, and that is why resources are not traced. :core, :widgets and :example each ship a META-INF/native-image/…/reachability-metadata.json declaring their own files by glob, because that set is finite and a directory listing cannot be one screen short. The first image built here proved the point by omitting nord-light.css, density-compact.css, JetBrainsMono.ttf and OpenMoji-black.ttf — every one the far side of a toggle the run never flipped (ADR-0160).

Because those declarations travel in the jars, an application building its own image gets the toolkit’s resources without knowing it needs them.

What is still traced — the reflection, the services, the upcall stubs — really does depend on what the code did, and the warning applies to it unchanged: a screen the run never reaches contributes nothing. Re-run the metadata task after adding one, and read the diff.

The FFM descriptors are no longer traced at all (ADR-0339). This page used to say they were never run-dependent, because a holder links its handle in its class initializer; it forgot that a …Calls record binds when its wrapper is first used, so a run that opened no Markdown initialised no MarkdownCalls and the agent recorded none of its five functions — which is how the Windows image died on the Markdown screen with MissingForeignRegistrationError. Now :natives:foreignMetadata initialises every holder and every upcall owner and writes the foreign section itself, into the goldberry-natives jar under META-INF/native-image/, where native-image reads it for any application.

The image is woven, the jar is not

nativeImage depends on weaveModels, and the build orders it before jar. That is not a detail: an image built from unwoven classes would bind its models by reflection, which is the one thing an image must not do (ADR-0155). Everything on the weaving page about -Pgoldberry.nativeImage=true applies to building the modules by hand; nativeImage arranges it for you.

What the flags are for

FlagWhy
--module-path / --moduleThe showcase runs modular, as it does everywhere else (ADR-0007)
--enable-native-access=…nativesJEP 472, naming the one module that touches native code
--no-fallbackRemoved. A fallback image was a JVM in a trench coat; GraalVM 25.3 no longer builds them, and the flag only warned that it had no effect (ADR-0337)
-H:+ReportExceptionStackTracesNames the class that could not be reached, rather than a stack in the builder

Nothing about class initialization is passed here. :natives ships its own META-INF/native-image/io.github.digitalsmile/goldberry-natives/native-image.properties naming the two classes that have an opinion, and they are opposite opinions:

ClassWhenWhy
NativeLibraryrun timeIt dlopens in its initializer, which must not happen in the builder
Downcallsbuild timeIt holds the shared Linker, and every holder’s initializer calls Downcalls.link
the …calls packagesbuild timeA downcall handle is only a call if it is a compile-time constant, and only a build-time initializer makes it one (ADR-0161)

They are packages and they have to be. A holder is a nested class, and naming its enclosing class does not reach it — measured at 4538 ns/call against 8, and silently, because the image builds and runs. Naming a hundred and thirty-four nested classes in a flag is not a list anyone can maintain, and naming the binding packages instead would build-time initialize Sdl and Blend2D, whose holder idiom dlopens the library in the builder. So the holders live in packages that contain nothing else (ADR-0173).

All of them travel in the jar, so an application building its own image gets them without knowing they exist — the same argument ADR-0160 makes for resources.

The one flag the frame rate depends on

GraalVM’s FFM downcalls are not optimized — oracle/graal#8113 lists it as open work, and it costs a factor of 450 on the call itself. Goldberry takes the workaround: a holder’s FD_<symbol> handle is unbound (it takes the address to call as an argument), so it can be linked while the image is being built, which is what turns it into a constant the compiler can lower into a direct call.

Sixty frames of the showcase, headless, on this machine (--resize=WxH walks the window a pixel a frame while it runs, and --late-budget=N fails the run past N missed refreshes — ADR-0342):

./example/build/native/goldberry-showcase-linux-x64     -Dgoldberry.backend.videoDriver=dummy --frames=60
60 framesper frame
without the --initialize-at-build-time lines2.55 s42.5 ms
with it0.061 s1.0 ms

The same rule applies one level down, and it is the trap to know before editing a binding: a downcall handle has to be read by the method that calls it. Passing one into a helper as an argument costs 810 ns a call in an image against 8.9 ns when the helper names the constant itself — the JVM inlines and folds it, and native-image does not. That is why a holder’s call names its own FD_<symbol> field rather than taking a handle, and why the handle is static final on the holder rather than a component of it: an instance field is a value read from an object, not a constant read from a class, and measures 4540 ns/call (ADR-0173).

And the list of packages is its own hazard. native-image.properties is the one file in :natives that nothing compiles, runs or reads — it is a hand-typed package list consumed by a tool that is not part of this build — so a package added to the module and not to the file is invisible in every way but the measurement above. Three of the nine were missing when the 2026-09-18 review counted them. NativeImagePropertiesTest reads the shipped resource against ForeignSurface.holderClassNames() and requires the two to agree, naming the one deliberate exception: desktop.calls stays out, because PortalSettings binds a dozen libdbus functions in a static initialiser and build-time initialising it either fails the image with a MemorySegment in the image heap or bakes the build machine’s D-Bus into it.

Nothing fails when it is missing. The image builds, runs, paints correctly and is forty times slower, which is why the number is written down here. (It is silent only because the generated metadata registers the descriptors anyway; a descriptor registered nowhere raises MissingForeignRegistrationError and names itself.) To check it, move the properties file aside and rebuild passing the run-time half by hand:

./gradlew :example:nativeImage -Pgraalvm.home=… \
    -Pgraalvm.args="--initialize-at-run-time=io.github.digitalsmile.goldberry.natives.NativeLibrary"

Two metadata directories: traced, and written

The agent’s output goes to META-INF/native-image/io.github.digitalsmile/goldberry-example, and nothing hand-written goes in there — the next trace overwrites it. Anything a human has to add lives in the sibling …/goldberry-example-manual. native-image reads every META-INF/native-image/** it finds, so the two are merged for the tool and kept apart for the diff.

There is exactly one entry in it so far, and it is instructive:

{ "module": "io.github.digitalsmile.goldberry.example", "glob": "logback.xml" }

Logback asks a ClassLoader for logback.xml, so the agent records it as a classpath resource. The image runs on the module path, where that file is at the root of a named module and has to be registered against that module or it is not there at all. The symptom is the worst kind: with no configuration found, logback ends with no appenders and prints nothing — not even its own status — so an image that is working perfectly looks like an image that is doing nothing.

The general shape of that trap is worth remembering: the agent records how a lookup was made, not where the file will be. A resource fetched through a ClassLoader by a library that knows nothing of modules is recorded without one.

Why the library is carried rather than linked

Statically linking the archives into the image is the obvious answer and it does not work. The linking part is fine — -Wl,-u,<symbol> pulls the code in, given -lstdc++ and -lm which native-image does not pass. What fails is that Goldberry resolves every native function by name at run time, so the symbols have to be in the executable’s dynamic symbol table, and native-image links with its own --version-script that makes everything it does not list local. --export-dynamic-symbol does not beat it, and a second version script is refused outright — “anonymous version tag cannot be combined with other version tags”.

ADR-0159 records the experiments. Revisit if native-image grows a way to extend its export list.

What is not built

No CI job. Built (ADR-0337): every showcase.yml leg installs GraalVM Community, builds the image, runs it for three frames and uploads it; on a v* tag the three go on the tag’s GitHub Release (ADR-0340), and the workflow runs only on a tag or by hand. Linux builds from the checked-in trace; macOS and Windows trace first, and those traces are not reviewed. Neither task is wired into build, still, because a local build has no GraalVM to count on.

No image of the toolkit on its own. :core and :widgets are libraries; an image is a property of an application, and :example is the application here.