Skip to content

Add an end-to-end module verifying message bundles resolve - #16180

Open
codeconsole wants to merge 48 commits into
apache:9.0.xfrom
codeconsole:feature/native-i18n-verification-8.0.x
Open

codeconsole wants to merge 48 commits into
apache:9.0.xfrom
codeconsole:feature/native-i18n-verification-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

per request of @borinquenkid

Adds the end-to-end module #16176 asks for: an application that resolves messages from each way a bundle reaches the message source. It also makes the framework changes that let that application run as a GraalVM native image with no recorded metadata.

What it checks

Bundle Base name Why
the application's own messages The case Spring Boot's own hints already cover, as a control.
the plugin's native-messages Namespaced, so Boot's two hardcoded messages* patterns never reach it. The plugin name is multi-word, so it also covers the descriptor's hyphenated spelling being matched against the plugin's camel-case one.
one the application configured config.i18n.custom Outside grails-app/i18n, dotted, so it proves the base name is converted to config/i18n/custom before use.

Each is resolved in English and in a locale variant. An unknown code is resolved too, under use-code-as-default-message, because base names alone would not prove MessageSourceProperties was bound — the whole object binds at once or not at all. The application also reads a value from the plugin's plugin.yml, which an image could not find before this change.

Native image without recorded metadata

Now that #16165 has merged, 9.0.x compiles the framework and its plugins with invokedynamic on Groovy 6.0.0, so every remaining gap was in the framework itself. The image is built only from what the framework registers while the code is generated, plus the hints derived from the configured base names. No agent-traced metadata is checked in or generated, so a native failure points at the framework, not at this fixture.

The framework changes, each in its own commit with tests:

  • No dynamic dispatch before anything can register for it. The Application class's static initializer records the main class with a direct System.setProperty call. Settings and MultiTenancySettings are compiled statically. The default stack trace filterer is created directly instead of reflectively.
  • Plugins. Registered: the plugin descriptors, the plugin classes they name, and those classes' class files; the classes named in META-INF/grails.factories; and the beans plugins declare through a BeanRegistrar or the bean DSL, which are created and wired reflectively at startup.
  • Plugin configuration. A plugin's plugin.yml is found relative to its class root, and an image has none, so every plugin looked for the same /plugin.yml. Each plugin's configuration is now copied under its plugin class's name while the code is generated, and PluginUtils.readPluginConfiguration prefers that copy.
  • Artefacts. Registered: the GrailsClass each handler creates, read through the new ArtefactHandlerAdapter.getGrailsClassImpl(), and every artefact, including the default URL mappings and the ones plugins provide.
  • What compiled Groovy does reflectively, read from the bytecode of the framework's packages and of the application's: Groovy enum constructors, closures coerced to interfaces (JDK proxies), trait helpers, members Spring injects through annotations, and the setProperty lookup on every Groovy class.
  • Dynamically compiled descriptors and the bean DSL. Registered: the framework's constants and static helpers they read, plus the JDK collection, string and number types they call through the metaclass.
  • Odds and ends. Registered: the property editors, the Caffeine cache classes GraalVM's own metadata misses, META-INF/grails.build.info (without it an image starts in development), the default validation messages bundle, the URL mappings builder, the servlet context holder, and the promise factories the event bus reads its executor from.

One bug fixed along the way also affects the JVM: when running on generated artifacts (-Dspring.aot.enabled=true), Grails registers the annotation processors a second time. They then re-injected beans the generated code had already injected, so side-effecting @Autowired methods ran twice and every executor's metrics were registered twice. Those processors now leave a bean alone only when the generated code already injected it, meaning the bean is exactly the type its generated definition declares. A bean declared as an interface, like the link generator, is still injected by them.

The deployment guide's AOT section has a new "What the framework registers for you" list.

Running it

The JVM half runs in check and needs nothing special:

cd end-to-end
./gradlew :native-i18n:check

The native half is opt-in and needs GraalVM 25 or later in GRAALVM_HOME and JAVA_HOME (on the 21 line nativeCompile fails on oracle/graal#10200):

./gradlew :native-i18n:check -PnativeTests

Limitations

  • An application's own dynamically compiled code still needs its reflection recorded. A call made without static compilation is resolved against whatever it is made on, so only a run shows what it reaches. The guide says to compile such code statically or record it with the tracing agent.
  • The JVM half cannot falsify a resource hint. On a JVM every bundle on the classpath is readable whether it was registered or not. Its value is as a fixture guard that keeps the bundles and the assertions from drifting apart, and it has caught lifecycle bugs: it is what found BUGFIX: message bundles silently failing to resolve in development #16179.
  • No native job in CI. A full nativeCompile takes minutes of CPU and needs a toolchain no other job uses.

Thanks to @matrei for running the native half and working out both upstream blockers.

@codecov

codecov Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 63.7646%. Comparing base (1d3bf77) to head (b82eb7f).
⚠️ Report is 73 commits behind head on 9.0.x.

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                9.0.x     #16180        +/-   ##
==================================================
+ Coverage     61.4500%   63.7646%   +2.3146%     
- Complexity      27010      28265      +1255     
==================================================
  Files            2260       2260                
  Lines          112192     112197         +5     
  Branches        20351      20360         +9     
==================================================
+ Hits            68942      71542      +2600     
+ Misses          34190      31509      -2681     
- Partials         9060       9146        +86     

see 71 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codeconsole
codeconsole marked this pull request as ready for review September 2, 2026 03:19
@matrei

matrei commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Review Findings

Head 2fb4179e56 on 8.0.x, merge base 43510784cf. #16179 has merged, so the diff is now only the end-to-end/ module (14 files, 477 additions). git merge-tree is clean.

CI on this head is green, including "End to End Tests (end-to-end build only)", which runs ./gradlew check in end-to-end and therefore the new i18nCheckOnJvm task.

I also ran the native half, which the PR says nobody has. Setup followed the README exactly: Oracle GraalVM for JDK 21 (21.0.12) as JAVA_HOME and GRAALVM_HOME, grails-gradle and the framework republished to build/local-maven with -PgrailsIndy=true (verified: 0 of 300 sampled grails-core classes carry a CallSiteArray, 168 contain invokedynamic), then ./gradlew :native-i18n:check -PnativeTests. The JVM half passed. The native half fails, twice over, for reasons that are upstream of this PR but that the PR's README presents as solved. Details in the first finding.

The PR is no longer marked draft, but the description still says "the reason this is a draft" and "the native half has not been executed yet". The second statement is accurate and should stay; the draft flag should come back until the first finding is resolved.

Findings

High: the native half cannot pass on 8.0.x as pinned, and the README recipe does not produce a working image

What happens when the README is followed:

Step GraalVM for JDK 21 (21.0.12) Oracle GraalVM 25.3.4 (native-image 25.0.4.1)
nativeCompile fails in 4s at "Initializing": Could not find target method: ... Target_org_codehaus_groovy_vmplugin_v7_IndyInterface_invalidateSwitchPoints.invalidateSwitchPoints() succeeds, 5m 34s, 157 MiB binary
i18nCheckOnNative not reached binary dies before Spring starts: BootstrapMethodError: NullPointerException at IndyInterface.makeBootHandle(IndyInterface.java:247) from Application.<clinit>

The first failure is GraalVM's bundled Groovy substitution targeting a method Groovy 4+ no longer has (oracle/graal #10200, #13096; fixed on master in April 2026 and present in the 25 line, not in 21). So the README's "a GraalVM JDK" has to say "GraalVM for JDK 25 or later", and the JDK-matching advice in the same section needs rewording, since the framework is then published on 21 and the image built on 25.

The second failure is the real blocker. Line 247 passes FROM_CACHE_HANDLE_METHOD, a static final handle that IndyInterface.<clinit> assigns, and it is null: the bootstrap method ran before its declaring class was initialised. This is not Grails-specific and not a metadata gap. I reproduced the identical exception with a ten-line Groovy hello-world, no Spring, no Grails, metadata from the tracing agent, against Groovy 5.1.2 and against Groovy 4.0.27. Registering every IndyInterface method for reflection changes nothing; --exact-reachability-metadata raises no missing-registration error; --initialize-at-build-time for the class fails the build on image-heap objects, as Groovy's own tracker says it will. The class-initialisation report confirms IndyInterface is RUN_TIME.

Groovy knows about it. IndyInterface.java in 6.0.0-RC-1 carries this comment above a new ensureInitialized() guard:

Guards against a GraalVM native-image gap: the runtime invokedynamic linkage invokes a bootstrap method without running its declaring class's <clinit> first (observed on GraalVM CE 25.2.4; on HotSpot the bootstrap's DirectMethodHandle carries a class-initialization barrier).

The same hello-world against 6.0.0-RC-1 fails at that guard with BUG! unreachable: LOOKUP read before initialization, so even the Groovy 6 workaround does not hold on Oracle GraalVM 25.3.4. The wider work is all on the Groovy 6 line: GROOVY-12234 "Indy: AOT link mode so dynamic Groovy dispatch works in GraalVM native images" (6.0.0-beta-2), GROOVY-12227, and in the unreleased 6.0.0-RC-2 GROOVY-12364, GROOVY-12365 (ship reachability metadata in the groovy jar) and GROOVY-12366 (ship native-image.properties). GROOVY-12234's description states the status plainly: before it, dynamic Groovy cannot run in an image at all, indy or not, and the only workaround was compiling everything non-indy with groovy-callsite.

Grails 8.0.x pins groovy.version 5.1.2 in dependencies.gradle. None of the above is in any 5.1.x release.

Consequences for this PR:

  • The README section "Native image verification" describes a recipe (GraalVM JDK, indy on both sides) as if following it yields an image that runs the check. It does not, on any released Groovy the BOM can use. The section should say what the actual precondition is (a Groovy 6 with the AOT link mode, and a GraalVM on which the class-init gap is closed) and that neither exists in a release today, so -PnativeTests is expected to fail until then. Otherwise the next person loses a day the way I nearly did.
  • The two build comments in native-i18n/build.gradle about 16 threads and six minutes, and the README's UnsupportedFeatureError: Tried to define class paragraph, are observations from some earlier build. Whichever toolchain and Groovy they came from should be named, because they are not reproducible with what 8.0.x pins.
  • "Released Grails artifacts are built without indy, so no released Grails can produce a working image" (README.md:103) is true but misleading in isolation: with Groovy 5 no build of Grails can, indy or not. The sentence should say so.
  • The claim in the description that this module lets a missing hint "actually be falsified" is not yet true. The JVM half is a fixture guard; the native half needs the Groovy 6 line before it can assert anything. That is fine for a first landing as long as the module and README say it, and #16176 is updated with the two upstream blockers.

What would make the native half useful sooner: the snapshot canary workflow already rebuilds Grails against a Groovy branch by rewriting groovy.version. Running this module in that job, on GraalVM 25, once GROOVY-12366 ships in RC-2, is the cheapest way to learn when the upstream gap closes. I did not attempt a Groovy 6 rebuild of the framework here; it is a different review.

Medium: the "bundle never registered" diagnostic can never fire under this configuration

References:

  • end-to-end/native-i18n/grails-app/conf/application.yml:27 sets use-code-as-default-message: true
  • end-to-end/native-i18n/grails-app/init/nativei18n/Application.groovy:96-99 catches an exception from getMessage and reports "its bundle was never registered"
  • end-to-end/README.md:60 and the class Javadoc say a missing hint shows up "as a NoSuchMessageException"

With useCodeAsDefaultMessage on, AbstractMessageSource.getMessage(code, args, locale) returns the code itself instead of throwing, so a bundle that is missing from the image produces actual == code, and the failure surfaces through the other branch as 'native.plugin.greeting' for fr resolved to 'native.plugin.greeting', expected '...'. The check still fails, so this is not a false pass, but the catch block is dead code, its message is the one a reader would want, and the README and Javadoc describe an exception that this application is configured never to see.

Suggested fix: keep the property (the unknown-code assertion needs it), but make check treat actual == code as "bundle not registered" and say so, and drop or reword the NoSuchMessageException sentence in the README and the Javadoc.

Low: leftover debugging in the build script

Reference: end-to-end/native-i18n/build.gradle:28-31

// Applied the legacy way: the Spring Boot plugin is on this build's classpath through the
// Grails plugin, so the id resolves, but it carries no version for the plugins block.
// TEMP probe: disabled
//apply plugin: 'org.springframework.boot.aot'

Should go. It is also unnecessary: Spring Boot's Gradle plugin applies org.springframework.boot.aot itself when it sees org.graalvm.buildtools.native, so the native path already runs processAot and the JVM path deliberately does not. If the point was to run AOT processing on the JVM too, that is a separate decision worth a sentence, not a commented-out line.

Low: grails { indy = true } duplicates a convention the Grails Gradle plugin already sets

References:

  • end-to-end/native-i18n/build.gradle:36-41
  • grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:1066-1071 (configureNativeImage sets indy.convention(true) when the GraalVM plugin is applied)

Not wrong, and setting it unconditionally means the JVM run is compiled the same way as the image, which is a reasonable choice. Note that with Groovy 5 it also makes no difference to the outcome, see the first finding. But the comment presents it as something the application has to do, when the plugin does it for any application that builds an image. Either drop the setting and let the convention prove itself (arguably more end-to-end), or keep it and say that it is there so the JVM half matches the native half rather than because an image needs it.

Low: the README's justification for the JDK requirement does not match the build

Reference: end-to-end/README.md:87-90: "grails-gradle pins no release target, so it stamps whatever JDK ran it onto its class files".

grails-gradle includes build-logic, and its four modules apply org.apache.grails.buildsrc.compile, whose configureJavaVersion sets options.release on every JavaCompile. What is true is that GroovyCompile has no release option, so the Groovy half of those modules targets the bytecode level of the JDK running the compiler. The advice (export the GraalVM JDK before publishing) is right; the reason given is not. And as the first finding shows, the GraalVM has to be the 25 line, so the README's implied "one JDK for everything" does not survive contact either: publish on 21, build the image with GRAALVM_HOME on 25, which the GraalVM plugin honours with toolchainDetection = false. Say "the Groovy sources in grails-gradle are compiled for the running JDK" or similar.

Low: the GraalVM plugin version is pinned in a new place, one step behind

Reference: end-to-end/gradle.properties:27, graalvmBuildtoolsVersion=1.1.7

Nothing else in the repository pins org.graalvm.buildtools (checked dependencies.gradle and grails-gradle), so a property is a fair place for it. Maven Central has 1.1.12 as the latest. Not blocking, but since this is the first pin it may as well start current, and a one-line comment saying why it lives in end-to-end/gradle.properties rather than dependencies.gradle would save the next person the search.

Nits

  • end-to-end/native-i18n/grails-app/init/nativei18n/Application.groovy:88: System.exit(0) is only on the success path. On failure the exception leaves main and the JVM exits non-zero only once every non-daemon thread has stopped. The context is closed in finally, so this works today, but a catch that prints and calls System.exit(1) would make the exit code independent of thread cleanup, which matters more for the native binary than the jar.
  • end-to-end/native-i18n/build.gradle:91: commandLine resolves the binary path eagerly with .get() at configuration time. Wiring it from nativeCompile's output (tasks.named('nativeCompile').flatMap { it.outputFile } or the binary's outputDirectory) keeps the path and the dependency in one place.
  • end-to-end/native-i18n/build.gradle:84: the comment on toolchainDetection = false explains why a failed image is a failure, not what the setting does (use GRAALVM_HOME/JAVA_HOME instead of a Gradle toolchain). Worth one clause.
  • end-to-end/native-i18n/grails-app/i18n/messages_fr.properties:19: "de l application" reads as a typo. Spring's ResourceBundleMessageSource does not pass a message with no arguments through MessageFormat, so l'application renders as written; if the apostrophe was dropped to be safe, l''application is the conventional spelling.
  • Neither new module applies org.apache.grails.buildsrc.vulnerability-scan, which legacy-commands, legacy-commands-plugin and spring-dependency-management all do. Probably deliberate for a fixture with two dependencies, but consistency is cheap.
  • README table rows are inserted between spring-dependency-management and taglib-index-incremental; the table was alphabetical.

Verified as correct

  • The plugin fixture does exercise the multi-word matching it claims: NativeMessagesGrailsPlugin yields native-messages via GrailsNameUtils.getPluginName in the descriptor, the runtime reports nativeMessages, and EffectiveI18nDescriptors normalises both through PluginUtils.normalizePluginName. The base name native-messages also passes GenerateI18nDescriptorTask.validatePluginNamespace.
  • The dotted base name is covered by I18nRuntimeHintsProcessor.toResourcePath, which is exactly the conversion the third case tests, and I18nEnvironmentPostProcessor.compose keeps an application-declared base name ahead of the discovered ones, so config.i18n.custom survives the merge.
  • fallback-to-system-locale: false is what makes the Locale.ENGLISH assertions land on the base bundle regardless of the machine's default locale. Good.
  • Apache license headers are present on every new file, including the bundles.

Beyond this PR

  • Native image support in Grails 8 depends on Groovy 6. Everything above reduces to this. #16176 should record it, and whoever owns the AOT work (Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) #16094) should decide whether native image is an 8.0 claim or an 8.x-with-Groovy-6 claim, because the upgrade guide and the Gradle plugin's configureNativeImage convention currently imply the former.
  • Indy default for releases. README.md:103 is right that CompilePlugin defaults grailsIndy to false, so even once Groovy is fixed a released Grails cannot be imaged. That needs an issue or a decision before 8.0.0.

@codeconsole

Copy link
Copy Markdown
Contributor Author

Thank you for actually building the image — that is the half nobody had run, and it turned a "not executed yet" into a diagnosis. Everything below is fixed or accepted.

High — the native half cannot pass, and the README recipe does not produce a working image. Correct, and the README was worse than incomplete: it was missing the precondition that made it work the one time it did. That run used an unreleased build of GROOVY-12234 (apache/groovy#2766) forced onto runtimeClasspath; nothing about it was in the README, so following the README could only ever reproduce your result. Confirmed your diagnosis against the jars: groovy-5.1.2 has makeBootHandle but neither aotDispatch nor the ensureInitialized guard, while the GROOVY-12234 build has AotDispatch, aotDispatch and ensureInitialized.

The "Native image verification" section is rewritten. It now leads with the fact that -PnativeTests is expected to fail, gives both failure modes in a table (non-indy defines call-site classes, indy NPEs at makeBootHandle), names GROOVY-12234 and oracle/graal#10200 as the two preconditions, says the module has been run successfully exactly once and against what, and states that indy is necessary but not sufficient. Your point that "no released Grails can produce a working image" was true for the wrong reason in isolation is called out explicitly. The UnsupportedFeatureError and parallelism observations are now attributed to the toolchain they came from.

Medium — the dead catch. Right, and it is the more useful message of the two. check now treats actual == code as "its bundle never reached the message source"; the NoSuchMessageException wording is gone from the README and the Javadoc, which instead say that a missing bundle surfaces as a code resolving to itself because use-code-as-default-message is on.

Low — commented-out AOT probe. Removed.

Low — grails { indy = true } duplicates the convention. Kept, comment rewritten to say why: so the JVM half is compiled the same way as the image rather than being a second dialect of the same fixture, and noting it changes nothing about the outcome on the pinned Groovy.

Low — the JDK justification. Fixed. It now says GroovyCompile has no release option so the Groovy sources in grails-gradle target the running JDK, and that publish-JDK and image-JDK do not have to agree because toolchainDetection = false makes the plugin honour GRAALVM_HOME.

Low — GraalVM plugin version. 1.1.7 → 1.1.12, with a comment on why it is pinned in end-to-end/gradle.properties rather than dependencies.gradle.

Nits. All taken: explicit exit code on failure via a single close() in finally; i18nCheckOnNative takes its path from nativeCompile's outputFile provider; the toolchainDetection comment now says what the setting does; l'application; vulnerability-scan applied to both new modules; README table back in alphabetical order.

On the draft flag — the description claimed this "lets a missing hint actually be falsified", which is not true yet, and still referred to being a draft. Rewritten to say what the module does and does not currently prove.

On where this belongs. Leaving it on 8.0.x rather than moving it to 9.0.x. The JVM half stands on its own here — it guards the descriptor → base name → hint pipeline, the multi-word plugin normalisation and the dotted base name, and it is what caught #16179 — and the native half is inert behind -PnativeTests with no CI job, so it costs 8.0.x nothing while it cannot pass. Written this way it starts passing when the preconditions land instead of having to be written then, and it reaches 9.0.x by forward-merge anyway. What should be decided separately, as you say, is whether native image is an 8.0 claim at all, given configureNativeImage and the upgrade guide currently imply it is.

… native image

AOT processing on a JVM cannot falsify a resource hint: every bundle on a JVM
classpath is readable whether it was registered or not, so a missing hint shows
up only in a compiled image. The application resolves one code from each way a
bundle reaches the message source - the application's own bundle, a namespaced
plugin bundle, and a dotted base name the application configured - in English
and in a locale variant, plus an unknown code to prove MessageSourceProperties
bound as a whole.

This belongs on 9.0.x rather than 8.0.x. Dynamic Groovy in an image needs both
GROOVY-12234's AOT link mode, which ships in the 6.0.0-beta-2 this branch pins
and in no 5.x release, and invokedynamic, which this branch compiles with by
default and which 8.0.x deliberately forces off for published artifacts. Either
one missing is fatal to the image, so native image is not a Grails 8 capability.

The JVM half runs in check and needs nothing special. The native half is opt-in
behind -PnativeTests because it needs a GraalVM toolchain and minutes of CPU.
@codeconsole
codeconsole changed the base branch from 8.0.x to 9.0.x September 11, 2026 18:06
@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei I am just moving this to 9.0.x so it can be feature complete

@codeconsole
codeconsole force-pushed the feature/native-i18n-verification-8.0.x branch from 655879b to 0760a7c Compare September 11, 2026 18:40
Applying GraalVM's plugin already makes the Grails Gradle plugin default
invokedynamic on, so the native half compiles with it either way. The
JVM half resolves the same bundles whichever way it is compiled.
Declaring it in native-i18n alone gave that project a plugin classloader
of its own, so its vulnerabilityScan task could not be created: the
shared OsvVulnerabilityService had already been registered from another
project's classloader. Declared once at the root with apply false, every
project shares the same classes and native-i18n still applies the plugin
only under -PnativeTests.
9.0.x is on Groovy 6.0.0, which carries the AOT link mode and ships its
own native-image configuration, not 6.0.0-beta-2. Invokedynamic is on
for the library modules only: the plugin modules, including grails-gsp,
grails-sitemesh3 and grails-cache from the web starter, still follow the
Grails Gradle plugin's default until apache#16165 changes it.
apache#16165 made invokedynamic the default for the framework's plugin modules
and for applications, so grails-gsp, grails-sitemesh3 and grails-cache are
no longer published with classic call sites.
A generated application's Application class is @CompileStatic. This one
was dynamic, so in an image it failed in its own static initializer: the
System.setProperty(BuildSettings.MAIN_CLASS_NAME, ...) statement Grails
injects into every Application class was dispatched through a metaclass
with no reflection metadata for BuildSettings. Compiled statically, the
check depends on resource hints for what it checks and not on reflection
metadata for how it checks it.
Every Application class gets System.setProperty(BuildSettings.MAIN_CLASS_NAME, ...)
in its static initializer. The key was read as a dynamic property, so an
Application class that is not statically compiled failed in a native image
before anything could register reflection metadata for BuildSettings. The key
is now a constant and the call has its target, so it compiles to a direct
invokestatic however the class is compiled.
Its one computed constant, DEFAULT_ENCODING, called System.getProperty through
a metaclass as the interface was initialized, which a native image cannot do
for a JDK method nothing registered.
Each multi tenancy mode was built with a map created by a dynamic new, so
every mapping context failed to initialize the enum in a native image.
Creating it reflectively needed its constructor registered in a native image,
where every application logged that it fell back to the very class it asked
for. A configured subclass is still created as configured.
META-INF/grails.build.info says which environment the application was packaged
for. Without it an image started in the development environment.
ConstrainedProperty loads them with ResourceBundle.getBundle as it is
initialized. Without the bundle the URL mappings failed to initialize in a
native image, so the web server never started.
Plugins are found by reading META-INF/grails-plugin.xml, creating each class it
names and reading its getters, and are located by their class files. A native
image carried none of that, so no plugin was found.
The artefact handlers and every other factory are created from their names in
META-INF/grails.factories, through their constructors.
GraalVM's metadata for Caffeine registers the entry class of a size-bounded
cache only once a cache recording statistics is reached, and the framework's
caches record none, so the URL mappings could not be created in a native image.
Some of what a Groovy class does is answered by reflection however it was
compiled: an enum chooses its constructor through Groovy, a closure passed for
an interface becomes a JDK proxy, a class implementing a trait initializes it
through a dynamic call to the trait's helpers, Spring injects annotated members
reflectively, and a Groovy object given a property is first asked whether it
overrides setProperty. A native image answers none of it unless asked, and
grails.util.Environment, the first thing an application touches, is a Groovy
enum.

The hints are read from the bytecode while they are written: the framework's
packages by a registrar, the application's packages -- the ones its
application class names -- by a bean factory processor.
Every artefact is represented by a GrailsClass its handler creates through a
constructor taking the artefact's class, and each kind of artefact is read
reflectively. The build registers the application's own classes, but not the
artefacts that come from the framework, like the default URL mappings, nor the
ones plugins provide. They are registered from the assembled application,
using the new ArtefactHandlerAdapter.getGrailsClassImpl().
Spring generates code only for the definitions it is shown. A BeanRegistrar's
beans are left out of generation, and the bean DSL's are declared again at run
time, so both are created the ordinary, reflective way: through a constructor,
their setters, and their annotated members. Every bean definition with a class
is covered, however a plugin declared it.
An application running on generated artifacts has the annotation processors
registered again, for beans built by a supplier and declared as an interface.
Those processors also injected every bean the generated code had already
injected, so a side-effecting @Autowired method ran twice: every executor's
metrics were registered a second time on a JVM running with
-Dspring.aot.enabled=true.

The definitions the generated code contributed are now recorded as the
context is initialized, before any plugin declares a bean, and the processors
registered again leave them alone. They are also declared with suppliers, so
creating them needs no reflection.
A plugin descriptor written without static compilation reads constants such
as Settings.MIME_TYPES, asks Environment.current, creates its @slf4j logger
and calls into the bean DSL reflectively. The first descriptor declaring a
logger stopped the application before any plugin had been registered.
Spring creates a fresh instance of each editor through its constructor for
every bean it sets properties on, so the first bean it configured failed with
"No default constructor found".
Calls on a map, a string or a number in a descriptor's closures, the bean DSL
and the framework's older Groovy are resolved through the metaclass and made
reflectively, as are constructors and constants such as File.separator.
Groovy's own metadata covers Groovy's types, not the JDK's.
Every mapping in a URL mappings closure is a dynamic call on the builder it is
delegated to, which reads the builder's fields as well as its methods.
Holders cannot depend on the servlet API, so it creates the holder from its
name. Without the constructor the dispatcher servlet failed to start with a
NullPointerException.
The event bus reads the executor as the promise factory's bean property, so
that events need not depend on the async module. Without the getter the event
bus, and with it the application context, could not be created.
Lists what an application gets without recording any metadata: the plugins,
their beans and artefacts, the reflection compiled Groovy implies, and the
resources the framework reads.
The image is built from what the framework registers and the hints derived
from the configured base names, so a failure is a gap in the framework.
A plugin's plugin.yml is read relative to the root its class was loaded
from, its JAR or its build directory. A native image has no such roots:
every class in it is at the same root, so every plugin looked for the same
/plugin.yml, which the image did not carry, and every plugin's
configuration was lost.

While the code is generated, and the roots still exist, each loaded
plugin's configuration is now copied to
META-INF/grails/plugin-configuration/<plugin class>/, keeping its file name,
and registered as a resource. PluginUtils.readPluginConfiguration prefers
that copy to the class-relative lookup.
The plugin now ships a plugin.yml, and the application fails unless a value
from it reached the environment. Nothing else checks that a plugin's
configuration reaches an image, and this image is built anyway.
The application that consumes it applies the vulnerability scan, and so does
legacy-commands-plugin, the other plugin in this build.
The generated code injects a bean only through the annotated members of
the type its definition declares. A factory method declaring an interface,
as the link generator's does, leaves every annotated member of the class it
returns to the annotation processors Grails registers again. Skipping every
generated bean left the link generator without its URL mappings, so the AOT
example application failed to render a link.

The processors now leave a generated bean alone only when it is of exactly
the type its definition declares, or the subclass Spring generates for a
configuration class. A side-effecting @Autowired method on such a bean
still runs once.
@testlens-app

testlens-app Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

🚨 TestLens detected 3 failed tests 🚨

Test Summary

Tip

Inspect all details with the TestLens CLI: testlens pr 16180

CI / Build Grails-Core (Windows JDK 25 shard 2) > :grails-core-cli-legacy:test

Test Runs Flakiness
FileSystemInteractionImplSpec > files that do not exist yet are resolved like existing ones ❌ 17% 🔴

CI / Build Grails-Core (Windows JDK 25 shard 2) > :grails-core:testCli

Test Runs Flakiness
FileSystemInteractionImplSpec > files that do not exist yet are resolved like existing ones ❌ 17% 🔴

CI / Build Grails-Core (Windows JDK 25 shard 2) > :grails-shell-cli:test

Test Runs Flakiness
FileSystemInteractionImplSpec > files that do not exist yet are resolved like existing ones ❌ 8% 🟠

🏷️ Commit: f2a1277
▶️ Tests: 111453 executed
🟡 Checks: 86/87 completed

Test Failures

FileSystemInteractionImplSpec > files that do not exist yet are resolved like existing ones (:grails-core-cli-legacy:test in CI / Build Grails-Core (Windows JDK 25 shard 2))
Condition not satisfied:

interaction.projectPath(missing) == 'nested/missing.txt'
|           |           |        |
|           |           |        false
|           |           |        1 difference (94% similarity)
|           |           |        nested(\\)missing.txt
|           |           |        nested(/~)missing.txt
|           |           C:\Users\RUNNER~1\AppData\Local\Temp\spock_files_that_do_not_e_0_directory17966906177577152\nested\missing.txt
|           nested\missing.txt
<grails.dev.commands.io.FileSystemInteractionImpl@f57ced1 baseDir=C:\Users\RUNNER~1\AppData\Local\Temp\spock_files_that_do_not_e_0_directory17966906177577152 resourceLoader=org.grails.io.support.DefaultResourceLoader@1d9c18b9 resourcePatternResolver=org.grails.io.support.PathMatchingResourcePatternResolver@1243ad9a resourceLocator=org.grails.io.support.ResourceLocator@2dfdea3a>

	at grails.dev.commands.io.FileSystemInteractionImplSpec.files that do not exist yet are resolved like existing ones(FileSystemInteractionImplSpec.groovy:69)
expected actual
nested/missing.txt nested\missing.txt
FileSystemInteractionImplSpec > files that do not exist yet are resolved like existing ones (:grails-core:testCli in CI / Build Grails-Core (Windows JDK 25 shard 2))
Condition not satisfied:

interaction.projectPath(missing) == 'nested/missing.txt'
|           |           |        |
|           |           |        false
|           |           |        1 difference (94% similarity)
|           |           |        nested(\\)missing.txt
|           |           |        nested(/~)missing.txt
|           |           C:\Users\RUNNER~1\AppData\Local\Temp\spock_files_that_do_not_e_0_directory11686126905717594762\nested\missing.txt
|           nested\missing.txt
<org.apache.grails.core.cli.io.FileSystemInteractionImpl@52421f92 baseDir=C:\Users\RUNNER~1\AppData\Local\Temp\spock_files_that_do_not_e_0_directory11686126905717594762 resourceLoader=org.grails.io.support.DefaultResourceLoader@1916d0eb resourcePatternResolver=org.grails.io.support.PathMatchingResourcePatternResolver@4f7bc46e resourceLocator=org.grails.io.support.ResourceLocator@29e94753>

	at org.apache.grails.core.cli.io.FileSystemInteractionImplSpec.files that do not exist yet are resolved like existing ones(FileSystemInteractionImplSpec.groovy:69)
expected actual
nested/missing.txt nested\missing.txt
FileSystemInteractionImplSpec > files that do not exist yet are resolved like existing ones (:grails-shell-cli:test in CI / Build Grails-Core (Windows JDK 25 shard 2))
Condition not satisfied:

interaction.projectPath(missing) == 'nested/missing.txt'
|           |           |        |
|           |           |        false
|           |           |        1 difference (94% similarity)
|           |           |        nested(\\)missing.txt
|           |           |        nested(/~)missing.txt
|           |           C:\Users\RUNNER~1\AppData\Local\Temp\spock_files_that_do_not_e_0_directory15482343063996403058\nested\missing.txt
|           nested\missing.txt
<org.grails.cli.profile.commands.io.FileSystemInteractionImpl@16ab7faa executionContext=Mock for type 'ExecutionContext' named 'context' resourceLoader=org.grails.io.support.DefaultResourceLoader@76968f03 resourcePatternResolver=org.grails.io.support.PathMatchingResourcePatternResolver@44173c2b resourceLocator=org.grails.io.support.ResourceLocator@21ec5672>

	at org.grails.cli.profile.commands.io.FileSystemInteractionImplSpec.files that do not exist yet are resolved like existing ones(FileSystemInteractionImplSpec.groovy:83)
expected actual
nested/missing.txt nested\missing.txt

Rerun Controls

Note

Checks are currently running using the configuration below.

Select tests to mute in this pull request:

🔲 FileSystemInteractionImplSpec > files that do not exist yet are resolved like existing ones
🔲 FileSystemInteractionImplSpec > files that do not exist yet are resolved like existing ones
🔲 FileSystemInteractionImplSpec > files that do not exist yet are resolved like existing ones

Reuse successful test results:

🔲 ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

🔲 Rerun jobs


Learn more about TestLens at testlens.app/docs.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants