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

159. A native image carries its own library

Date: 2026-08-20

Status

Accepted. Finishes what ADR-0156 started, and narrows ADR-0019’s “the library is a file the application chooses” for the image case only.

Context

The first working image shipped as two files: the binary, and lib/libgoldberry.so beside it, with a launcher script setting -Dgoldberry.native.library to point one at the other. Run the binary on its own and it failed — which is what a person does, because a native image is supposed to be a program.

So: can the native libraries go inside it?

libgoldberry is already one shared object with Blend2D, AsmJit, Yoga, HarfBuzz and SDL3 linked in statically (BUILD_SHARED_LIBS OFF), exporting exactly the 185 symbols exports/goldberry.symbols lists. There is no “several libraries” problem — only the question of whether that one is linked into the image or loaded beside it.

Decision

The image embeds the classifier jar’s library resource and unpacks it on first use.

:natives already packages libgoldberry at exactly the path NativeLibrary.resourcePath() looks for — that is the released-application path, and an image is an application. So nativeImage puts that jar on the image’s class path (not its module path: nothing requires it, so module resolution would drop it), registers the resource, and NativeLibrary takes the branch it already had.

One file. No launcher script, no lib/ directory, no property to set.

Alternatives considered

Statically link the archives into the image. The obvious answer, tried first, and it does not work — the evidence is worth recording because the failure is not where one would guess.

Linking is fine: -H:NativeLinkerOption=<archive> plus -Wl,-u,<symbol> pulls the code in, verified by the binary growing 2.7 MB for libblend2d.a alone. It also needs -lstdc++ and -lm, which native-image does not pass and Blend2D being C++ requires.

What fails is symbol visibility. Goldberry resolves every native function by name at runtime through SymbolLookup, so the symbols have to be in the executable’s dynamic symbol table for dlsym to find them. They are not: native-image links with its own --version-script, which makes every symbol it does not list local. Three ways round it were tried and all three failed:

  • -Wl,--export-dynamic-symbol=<name> — the version script still wins;
  • a second --version-script, anonymous tag — “anonymous version tag cannot be combined with other version tags”;
  • the same with a named tag — same error, because SVM’s own tag is the anonymous one.

There is no -H: option to add to the exported set. So on Linux with GNU ld, a statically linked archive’s symbols cannot be reached by name from Java in a native image. Revisit if native-image grows a way to extend its export list, or if the bindings ever stop resolving by name.

Ship the library beside the binary, as before. Works, and is what the jlink image does (ADR-0048) — but a jlink image is a directory by nature and a native image is a file. Two files with a launcher between them gives up most of what the format is for.

Consequences

A 9 MB temporary file per run, and a writable temp directory becomes a requirement. The library is unpacked because a shared object must be a real file to be dlopened — it cannot be mapped out of the binary. Measured at ~5 ms on this machine, once, at start-up. A deployment with a read-only or noexec /tmp cannot run the image, and -Dgoldberry.native.library remains the way out.

The image is 41 MB where it was 32. The library is carried rather than compressed away.

Two bugs surfaced that were not about images at all.

A named module cannot see a class-path resource. NativeLibrary lives in io.github.digitalsmile.goldberry.natives, and Class.getResourceAsStream on a class in a named module searches that module and never the class path. So the classifier jar — the mechanism a released application is supposed to use — could never have worked for a module-path deployment. It went unnoticed because every module-path run in this repository points at a locally built library with -Dgoldberry.native.library, which takes the other branch. There is now a ClassLoader.getSystemResourceAsStream fallback.

deleteOnExit runs in reverse. The unpacked library registered itself for deletion before its parent directory, and the queue is drained in reverse registration order — so the directory was attempted first, failed because it was not empty, and every run left one behind, on the JVM as much as in an image. The two registrations are now the other way round.

Neither has a test, and that is the honest position. Both bugs live where this repository does not look: the first only manifests on the module path, and tests run on the class path — which is precisely why it survived. The second needs a process to exit to observe. What caught them was building the artifact and running it with nothing beside it, and the lasting lesson is that the artifact is the test.

:example:nativeImage now depends on :natives:stageHostArtifact. An image built from a source tree needs the local library staged into the artifacts directory before the classifier jar can package it. The ordering is declared from :example rather than by making nativeJar depend on staging, because that dependency is wrong in CI: the Linux legs build inside the manylinux container and download the result, and a cmakeBuild on the runner would link against a different glibc (ADR-0012).