Skip to content

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

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

codeconsole wants to merge 4 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.

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.

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 does not pass today:

./gradlew :native-i18n:check -PnativeTests

Limitations

  • The native half cannot pass on 8.0.x, for reasons upstream of Grails. Dynamic Groovy does not run in a native image on any released Groovy, whichever way it was compiled: without invokedynamic an image refuses the call-site classes Groovy defines as it runs (UnsupportedFeatureError: Tried to define class), and with invokedynamic the bootstrap method runs before IndyInterface.<clinit> and NPEs at makeBootHandle. What closes it is GROOVY-12234, an AOT link mode on the Groovy 6 line — 5.1.2, which the BOM pins, carries neither aotDispatch nor the ensureInitialized guard. Building the image also needs GraalVM 25 or later; on the 21 line nativeCompile fails during "Initializing" because GraalVM's bundled Groovy substitution targets a method Groovy 4+ removed (oracle/graal#10200). The module has been run successfully exactly once, against an unreleased build of GROOVY-12234 forced onto runtimeClasspath. The README says all of this; the wiring is here so that it starts passing the day those land rather than having to be written then.
  • Grails 8 publishes without indy on purpose. CompilePlugin defaults grailsIndy to false because Groovy 5's indy default is a large runtime regression for dynamic Groovy (Groovy - invoke dynamic performance problems #15293); its own comment records Grails 9 / Groovy 6 as where that can flip. So a released Grails 8 could not produce an image even if Groovy were fixed.
  • 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 — it keeps the bundles and the assertions from drifting apart — and, as it turned out, at catching lifecycle bugs: it is what found BUGFIX: message bundles silently failing to resolve in development #16179.
  • No native job in CI. A full nativeCompile is 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 60.2669%. Comparing base (ac1bd29) to head (767c950).

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                9.0.x     #16180        +/-   ##
==================================================
- Coverage     60.2724%   60.2669%   -0.0055%     
+ Complexity      25692      25691         -1     
==================================================
  Files            2208       2208                
  Lines          109030     109030                
  Branches        19755      19755                
==================================================
- Hits            65715      65709         -6     
- Misses          34561      34565         +4     
- Partials         8754       8756         +2     

see 3 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
@testlens-app

testlens-app Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

🚨 All tests passed but jobs failed 🚨

Failed Jobs without Test Failures

❌ Build Health / Core Projects

🏷️ Commit: 767c950
▶️ Tests: 96120 executed
⚪️ Checks: 91/91 completed

Rerun Controls

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