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

453. A face that moved module takes its declaration with it

Date: 2026-09-21

Status

Accepted.

Context

The Linux showcase’s native image started, opened its window, painted, and died on the first paragraph with an emoji in it:

java.io.IOException: /io/github/digitalsmile/goldberry/emoji/fonts/OpenMoji-color.ttf
is missing from goldberry-emoji, which means this jar was assembled without the asset step
  at ...emoji.OpenMojiFont.read(OpenMojiFont.java:62)
  at ...text.font.Fonts.emojiAt(Fonts.java:230)
  at ...widget.WidgetRenderer$1.paragraph(WidgetRenderer.java:141)

The message is OpenMojiFont’s own, and in this case it is wrong. The jar was assembled with the asset step; the font is in it. What was missing is the declaration that puts a resource inside a native image, so getResourceAsStream answered null and the only diagnostic the class has for null is the one about the asset step.

ADR-0160 settled how resources reach an image: declared by glob in a META-INF/native-image/**/reachability-metadata.json that travels in the jar, not traced from a run. :core, :widgets and :example each ship one. :emoji shipped none.

The best evidence that this was foreseen is in :core’s own metadata, in the comment explaining why globs beat traces:

The first image built here recorded nord-dark.css and not nord-light.css, and Inter but neither JetBrains Mono nor OpenMoji — because the run never switched theme and never drew mono text or an emoji. Each of those is a control the user can reach and an image that dies when they do.

At the time that was true and OpenMoji was covered, because it lived in :core under io/github/digitalsmile/goldberry/assets/fonts/ and the glob assets/fonts/*.ttf over module io.…goldberry.core caught it. ADR-0384 then moved the face into :emoji — an artifact an application opts into, because CC BY-SA wants credit where the work is seen — and ADR-0387 moved the resource under this module’s own package, because a resource directory is a package and the same one in two modules stops the application starting. Both moves were right. Neither took the declaration along, and there was nothing to notice: the glob still matched a directory, just not one that exists any more, over a module that no longer holds the font.

It then hid for the same reason the other two failures of this batch hid. The showcase’s image ran three frames, which is the first screen; nothing drew an emoji. The 300-frame walk of ADR-0342 reaches a screen that does.

Decision

:emoji ships its own reachability-metadata.json, declaring the one resource it has, over its own module, under the path the asset step actually writes to:

{ "module": "io.github.digitalsmile.goldberry.emoji",
  "glob": "io/github/digitalsmile/goldberry/emoji/fonts/*.ttf" }

A glob rather than the one file name, to match what :core and :widgets already spell and because the asset step’s output is the authority on what it wrote.

Three files have to agree, so a test says so

The path appears in three places that nothing connected: the --root the asset step is given in emoji/build.gradle, the glob in the metadata, and the RESOURCE constant OpenMojiFont reads. Any two can be changed without a build failing, and the symptom is an image that works until something draws an emoji — which, as this record shows, can be a long time.

DeclaredFontResourceTest holds all three together, and its last assertion is the one a text comparison cannot make: the font is really on the classpath under the name that was declared, so a declaration naming a file the asset step does not produce fails too.

Consequences

An emoji can be drawn in a native image. Not only the showcase’s: the metadata travels in the goldberry-emoji jar, so any application that takes the dependency and builds an image gets it without knowing it needed it, which is ADR-0160’s whole point.

OpenMojiFont’s diagnostic is still misleading in this case and is left alone. It names the likeliest cause of a null stream for a developer running on a JVM, which is the common case; teaching it to distinguish “not in the jar” from “not in the image” would mean asking whether it is in an image, and the answer would be wrong on the day the check is needed. The test above is the better guard: it makes the case impossible rather than better-reported.

The other modules were checked, and :html had the same hole. It ships markdown/view/markdown.css and html/view/html.css, reads both by name, and declared neither. It works today for a reason worth writing down: the showcase traces a run, that run opens the Markdown and HTML screens, and the traced metadata :example ships lists both files literally. So :html’s stylesheets reach the showcase’s image by luck and would not reach the image of anyone else who took the module and traced a run that did not open a document. That is exactly the failure ADR-0160 was written about, surviving inside the mechanism meant to prevent it. :html now declares its own.

:core, :widgets and :example were already right. :natives ships a metadata file for foreign calls (ADR-0339, ADR-0451) and no resource glob; its library is linked into the image rather than read out of a jar, so there is nothing there to declare.

A repository-wide guard, because per-module was what got forgotten twice. DeclaredResourcesTest in build-logic holds every module’s shipped resources to that module’s own globs. The “own” is the whole point and was learned the hard way: the first version of the test pooled every module’s declarations, saw :example’s traced list covering :html, and passed. A declaration that does not travel with the module it describes is not a declaration.