Repository navigation
Add an end-to-end module verifying message bundles resolve - #16180
codeconsole wants to merge 48 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ 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 🚀 New features to boost your workflow:
|
Review FindingsHead CI on this head is green, including "End to End Tests (end-to-end build only)", which runs 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 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. FindingsHigh: the native half cannot pass on 8.0.x as pinned, and the README recipe does not produce a working imageWhat happens when the README is followed:
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 Groovy knows about it.
The same hello-world against 6.0.0-RC-1 fails at that guard with Grails 8.0.x pins Consequences for this PR:
What would make the native half useful sooner: the snapshot canary workflow already rebuilds Grails against a Groovy branch by rewriting Medium: the "bundle never registered" diagnostic can never fire under this configurationReferences:
With Suggested fix: keep the property (the unknown-code assertion needs it), but make Low: leftover debugging in the build scriptReference: // 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 Low:
|
|
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 The "Native image verification" section is rewritten. It now leads with the fact that Medium — the dead Low — commented-out AOT probe. Removed. Low — Low — the JDK justification. Fixed. It now says Low — GraalVM plugin version. 1.1.7 → 1.1.12, with a comment on why it is pinned in Nits. All taken: explicit exit code on failure via a single 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 |
… 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.
|
@matrei I am just moving this to 9.0.x so it can be feature complete |
655879b to
0760a7c
Compare
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 detected 3 failed tests 🚨Test SummaryTip Inspect all details with the TestLens CLI: CI / Build Grails-Core (Windows JDK 25 shard 2) > :grails-core-cli-legacy:test
CI / Build Grails-Core (Windows JDK 25 shard 2) > :grails-core:testCli
CI / Build Grails-Core (Windows JDK 25 shard 2) > :grails-shell-cli:test
🏷️ Commit: f2a1277 Test FailuresFileSystemInteractionImplSpec > 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))
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))
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))
Rerun ControlsNote 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 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. |
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
messagesnative-messagesmessages*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.config.i18n.customgrails-app/i18n, dotted, so it proves the base name is converted toconfig/i18n/custombefore 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 proveMessageSourcePropertieswas bound — the whole object binds at once or not at all. The application also reads a value from the plugin'splugin.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:
Applicationclass's static initializer records the main class with a directSystem.setPropertycall.SettingsandMultiTenancySettingsare compiled statically. The default stack trace filterer is created directly instead of reflectively.META-INF/grails.factories; and the beans plugins declare through aBeanRegistraror the bean DSL, which are created and wired reflectively at startup.plugin.ymlis 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, andPluginUtils.readPluginConfigurationprefers that copy.GrailsClasseach handler creates, read through the newArtefactHandlerAdapter.getGrailsClassImpl(), and every artefact, including the default URL mappings and the ones plugins provide.setPropertylookup on every Groovy class.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@Autowiredmethods 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
checkand needs nothing special:cd end-to-end ./gradlew :native-i18n:checkThe native half is opt-in and needs GraalVM 25 or later in
GRAALVM_HOMEandJAVA_HOME(on the 21 linenativeCompilefails on oracle/graal#10200):Limitations
nativeCompiletakes 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.