You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@grails.apache.org by "codeconsole (via GitHub)" <gi...@apache.org> on 2026/08/04 23:45:49 UTC

[PR] Make Grails applications processable by Spring AOT [grails-core]

codeconsole opened a new pull request, #16094:
URL: https://github.com/apache/grails-core/pull/16094

   Makes a Grails application processable by Spring AOT. A stock web application now completes `processAot` and starts with `spring.aot.enabled=true`:
   
   ```
   $ ./gradlew bootJar
   $ java -Dspring.aot.enabled=true -jar build/libs/app.jar
   Starting AOT-processed Application v0.1 using Java 21.0.9
   Started Application in 1.475 seconds
   ```
   
   Apply the Spring Boot AOT plugin to opt in:
   
   ```groovy
   apply plugin: 'org.springframework.boot.aot'
   ```
   
   AOT is also the gate on GraalVM native images: Boot refuses to start a native image without a build-time generated initializer, so nothing native was reachable before this.
   
   ## What was blocking it
   
   Four independent mechanisms, each only visible once the previous was cleared.
   
   **Live object instances in bean definitions.** `GroovyPagesGrailsPlugin` passed the live `GrailsApplication` as a constructor argument and a `new GroovyPagesServlet()` instance. A bean definition is a recipe; `ValueCodeGenerator` cannot emit source that reconstructs an arbitrary object:
   
   ```
   UnsupportedTypeValueCodeGenerationException:
       Code generation does not support grails.core.DefaultGrailsApplication
   ```
   
   Both now use forms the generator understands — a reference by bean name and an inner bean definition. This was the first `processAot` failure on 7.2.1, 8.0.0-M4 and 8.0.0-SNAPSHOT alike.
   
   **A second `ConfigurationClassPostProcessor`.** `CoreGrailsPlugin` registers one so that `@Configuration` beans contributed by plugins through `doWithSpring` — which arrive after Spring's own processor has finished — still get parsed. An AOT context has none of its own by design, and adding one back parses the configuration classes a second time, colliding with what AOT already emitted:
   
   ```
   BeanDefinitionStoreException: Invalid bean definition with name
   'propertySourcesPlaceholderConfigurer' defined in org.grails.plugins.CoreAutoConfiguration:
   Bean name derived from @Bean method 'propertySourcesPlaceholderConfigurer' clashes with
   bean name for containing configuration class
   ```
   
   For a static `@Bean` method AOT emits the definition with the configuration class as its bean class, so the re-parse rediscovers `CoreAutoConfiguration` under the bean's own name and collides with itself.
   
   **Abstract bean definitions.** `BeanDefinitionPropertiesCodeGenerator` emits `lazyInit`, `primary`, `scope`, `role` and `synthetic` but not the abstract flag, and nothing filters abstract definitions out beforehand — a template is silently regenerated as a concrete `Object` bean still carrying the template's properties. `CoreGrailsPlugin` contributes exactly such a template (`abstractGrailsResourceLocator`), and it is public surface: third-party plugins inherit from it with `bean.parent`, asset-pipeline's `assetResourceLocator` among them. A `BeanRegistrationExcludeFilter` keeps them out of generation; children are unaffected because AOT generates them from their merged definition.
   
   **A servlet context wired as a bean reference.** SiteMesh's bean-definition wrap referenced the `servletContext` bean, which the container registers only once the web server starts and which has no bean definition at all.
   
   ## Requires a SiteMesh release
   
   The last item is fixed upstream in `spring-webmvc-sitemesh` — `SiteMeshViewResolver` now implements `ServletContextAware` instead of taking the context as a constructor argument. `dependencies.gradle` therefore moves to `3.3.0-SNAPSHOT`, and **must be pinned to a released version before this merges**.
   
   ## Limitations
   
   - `processAot` must run in production mode. In development `reloadEnabled` is true and the URL mappings holder becomes a `ProxyFactoryBean` whose lazy object type cannot be predicted without instantiating it. Set it on the task:
     ```groovy
     tasks.named('processAot') { systemProperty 'grails.env', 'production' }
     ```
   - This makes an application AOT-processable, not native-image ready. Native images additionally need reachability metadata for plugin descriptor scanning and Groovy class loading.
   - 13 `beanRegistrar()`-contributed beans are still excluded from generation by Spring's own `aotProcessingIgnoreRegistration` flag and re-registered at runtime when `GrailsApplicationPostProcessor` re-runs. Correct, but it repeats the plugin scan on every boot — the work AOT exists to precompute.
   - `GroovyPagesServlet` is now created by the container rather than with `new`, so it passes through bean post-processing. Its `pluginManager` is unaffected (`initFrameworkServlet` already autowires by type), but a post-processor whose pointcut matched it would hand `ServletRegistrationBean` a proxy. No pointcut in the framework does.
   
   ## Verification
   
   An AOT-processed application's bean definitions differ from a normal boot only by the annotation-processing infrastructure AOT replaces — 410 beans against 405, the difference being the configuration, autowired and common-annotation processors, Boot's shared metadata reader factory, and `grailsConfigurationClassPostProcessor`. No application bean is missing and none is added.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3776026528


##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -1115,6 +1268,150 @@ ${importStatements}
         }
     }
 
+    /**
+     * Wires up the cache the JDK can write for an application, so the next start reads what a
+     * training run worked out rather than working it out again.
+     *
+     * <p>Three steps, because the cache is only usable against the layout it was trained on: the
+     * archive is extracted, the extracted application is run and asked for its pages, and what the
+     * run recorded is left beside it. An application asks for this with
+     * {@code grails.aotCache.enabled}, and says which of its pages matter.</p>
+     */
+    protected void configureAotCache(Project project) {
+        AotCacheExtension extension = ((ExtensionAware) project.extensions.getByName('grails'))
+                .extensions.create('aotCache', AotCacheExtension)
+        extension.enabled.convention(false)
+        extension.paths.convention([])
+        extension.jvmArguments.convention(['-Dspring.aot.enabled=true', '-Dgrails.env=production'])
+        extension.port.convention(TRAINING_PORT)
+        extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS)
+
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            TaskProvider<?> bootJar = project.tasks.named('bootJar')
+            Provider<Directory> application = project.layout.buildDirectory.dir('aot-cache/application')
+            Provider<JavaLauncher> launcher = trainingLauncher(project)
+
+            TaskProvider<Exec> extract = project.tasks.register('extractAotCacheApplication', Exec) { Exec task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Extracts the application, which is the form the cache is read against'
+                task.onlyIf { extension.enabled.get() }
+                task.dependsOn(bootJar)
+                // Named so the extraction is skipped when the archive it came from has not moved,
+                // rather than repeated on every run because nothing said what it produced.
+                task.inputs.file(project.provider { archiveOf(bootJar) })
+                task.outputs.dir(application)
+                task.doFirst {
+                    File destination = application.get().asFile
+                    project.delete(destination)

Review Comment:
   `project.delete(...)` inside `doFirst` is `Project` access at execution time, which is a hard error under the Gradle configuration cache (Gradle 9). The surrounding `task.commandLine(...)` also captures `bootJar`/`project` state at execution time.
   
   Use `@Inject FileSystemOperations`/`ExecOperations` (or make this a proper task type like `TrainAotCacheTask` already is) and resolve the archive through a `Provider<RegularFile>` input rather than `archiveOf(bootJar)`, which calls `TaskProvider.get()` eagerly.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "codeconsole (via GitHub)" <gi...@apache.org>.
codeconsole merged PR #16094:
URL: https://github.com/apache/grails-core/pull/16094


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3775962488


##########
grails-common/src/main/groovy/org/grails/aot/RegistrableTypes.java:
##########
@@ -0,0 +1,251 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.aot;

Review Comment:
   We should use `org.apache.grails.common.aot` so we are JPMS compatible on newly added code. 



##########
.sdkmanrc:
##########
@@ -5,7 +5,7 @@
 # $JAVA_VERSION_MICRONAUT in release.yml; for local verification, install that JDK 25
 # alongside this one (sdk install java <version>-librca) and follow the dual-JDK
 # instructions in RELEASE.md "Manual Verification: Reproducible Jar Files".
-java=21.0.7-librca
+java=21.0.12-librca

Review Comment:
   The JDK bump (here, `release.yml`, `release-publish-docs.yml`, `etc/bin/Dockerfile`) has nothing to do with AOT and changes the JDK the reproducible-build guarantee is pinned to. Please pull it into its own PR so it can be verified against `verify-reproducible.sh` independently — a release-toolchain change should not ride along in a feature branch.



##########
grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy:
##########
@@ -189,7 +237,12 @@ class CoreGrailsPlugin extends Plugin {
                 }
             }
 
-            registry.registerBean('proxyHandler', DefaultProxyHandler)
+            // The GORM implementations register a proxy handler that knows how to unwrap their own
+            // proxies; this is the one for an application that has none. Registering it over theirs
+            // left a Hibernate application unwrapping Hibernate proxies with the general case.
+            if (!hasBeanDefinition(application, 'proxyHandler')) {

Review Comment:
   This is a real bug fix (GORM's `proxyHandler` was being clobbered), but it is unrelated to AOT and it changes runtime behaviour for every Hibernate/Mongo/Neo4j application, not just AOT ones. It deserves its own PR and its own regression test asserting that a Hibernate context ends up with the Hibernate proxy handler.
   
   Two further concerns with the mechanism:
   
   - `hasBeanDefinition` consults `application.mainContext`, not the `BeanRegistry` being written to. The javadoc on `hasConfigurationClassPostProcessor` even describes the case where those differ ("a test slice registering this plugin's beans on a bare registry") — in that case the check answers about the wrong object. If `BeanRegistry` genuinely can't be queried, that limitation should be stated explicitly here rather than in the sibling method.
   - If `mainContext` is null or not a `ConfigurableApplicationContext` the method returns `false` and registers anyway, so the fix silently degrades to the old behaviour. Is `mainContext` guaranteed to be set by the time registrars run?



##########
grails-core/src/main/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilter.java:
##########
@@ -0,0 +1,48 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.spring.beans.aot;

Review Comment:
   Same point as my note on `RegistrableTypes`, and it applies to every new package in this PR (`org.grails.spring.beans.aot`, `org.grails.aot`, `org.grails.plugins.web.taglib.aot`, `org.grails.web.mapping.aot`, `org.grails.datastore.gorm.aot`, ...): new code should go under `org.apache.grails.*` so we stay JPMS-compatible. Worth fixing across the PR in one pass since these are all brand-new classes with no compatibility constraint.



##########
grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy:
##########
@@ -155,6 +156,25 @@ class UrlMappingsGrailsPlugin extends Plugin {
         }
     }
 
+    /**
+     * Whether the mappings are to be reloadable, which decides how the holder is defined.
+     *
+     * <p>Not while the code is being generated, whatever the machine generating it looks like.
+     * Reloading swaps the mappings behind a proxy, and the proxy produces its {@code UrlMappings}
+     * through a target source rather than declaring the type -- so Spring can only learn what it
+     * produces by building it, which is exactly what reading a generated definition avoids.
+     * Generated that way nothing could be autowired by that type, and the application did not
+     * start. An image cannot reload anything in any case.</p>
+     */
+    protected boolean isReloadEnabled() {

Review Comment:
   This makes `isReloadEnabled()` answer `false` whenever `AOT_PROCESSING` is set, which is exactly the condition the PR description says users must work around by setting `grails.env=production` on `processAot` — and `GrailsGradlePlugin.configureAheadOfTimeProcessing` now sets that automatically as well.
   
   So there are three overlapping mechanisms for the same problem. Which one is actually load-bearing? If this flag check is sufficient, the "Limitations" note in the description and the manual `systemProperty 'grails.env', 'production'` in `grails-test-examples/aot/build.gradle` are stale and should go. If it isn't sufficient, please document why.



##########
grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy:
##########
@@ -363,10 +666,13 @@ class GrailsBanner implements Banner {
 
     /**
      * Enumeration of optional version options.
+     *
+     * <p>The container being run is shown by default under {@code container}, which is the one an
+     * application is on. These name a particular container instead, for an application that wants
+     * to be told about one whether or not it is the one serving.</p>
      */
     @CompileStatic
     enum OptionalVersionOption {

Review Comment:
   `SPRING_SECURITY` is removed from `OptionalVersionOption` and moved into `DefaultVersionOption`. `OptionalVersionOption` is a public enum on a public class — removing a constant is a source- and binary-breaking change for anything referencing it, and it isn't listed in `upgrading80x.adoc`.
   
   More generally: the whole banner rework in this file (ANSI colouring, the NATIVE/AOT CACHE mark, `CONTAINER`, the default-set changes) is a user-visible feature that is independent of making an application AOT-processable. Only `resolveMark()` needs the AOT work at all. Please split the banner into its own PR — it's the part most likely to attract bikeshedding, and holding the AOT fix behind it helps nobody.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -1115,6 +1268,150 @@ ${importStatements}
         }
     }
 
+    /**
+     * Wires up the cache the JDK can write for an application, so the next start reads what a
+     * training run worked out rather than working it out again.
+     *
+     * <p>Three steps, because the cache is only usable against the layout it was trained on: the
+     * archive is extracted, the extracted application is run and asked for its pages, and what the
+     * run recorded is left beside it. An application asks for this with
+     * {@code grails.aotCache.enabled}, and says which of its pages matter.</p>
+     */
+    protected void configureAotCache(Project project) {
+        AotCacheExtension extension = ((ExtensionAware) project.extensions.getByName('grails'))
+                .extensions.create('aotCache', AotCacheExtension)
+        extension.enabled.convention(false)
+        extension.paths.convention([])
+        extension.jvmArguments.convention(['-Dspring.aot.enabled=true', '-Dgrails.env=production'])
+        extension.port.convention(TRAINING_PORT)
+        extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS)
+
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            TaskProvider<?> bootJar = project.tasks.named('bootJar')
+            Provider<Directory> application = project.layout.buildDirectory.dir('aot-cache/application')
+            Provider<JavaLauncher> launcher = trainingLauncher(project)
+
+            TaskProvider<Exec> extract = project.tasks.register('extractAotCacheApplication', Exec) { Exec task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Extracts the application, which is the form the cache is read against'
+                task.onlyIf { extension.enabled.get() }
+                task.dependsOn(bootJar)
+                // Named so the extraction is skipped when the archive it came from has not moved,
+                // rather than repeated on every run because nothing said what it produced.
+                task.inputs.file(project.provider { archiveOf(bootJar) })
+                task.outputs.dir(application)
+                task.doFirst {
+                    File destination = application.get().asFile
+                    project.delete(destination)

Review Comment:
   `project.delete(...)` inside `doFirst` is `Project` access at execution time, which is a hard error under the Gradle configuration cache (Gradle 9). The surrounding `task.commandLine(...)` also captures `bootJar`/`project` state at execution time.
   
   Use `@Inject FileSystemOperations`/`ExecOperations` (or make this a proper task type like `TrainAotCacheTask` already is) and resolve the archive through a `Provider<RegularFile>` input rather than `archiveOf(bootJar)`, which calls `TaskProvider.get()` eagerly.
   
   Has this been run with `--configuration-cache`? A `Exec` task that can't be config-cached will fail builds that have it enabled, and the new test projects under `src/test/resources/test-projects` don't appear to exercise it.



##########
dependencies.gradle:
##########
@@ -126,8 +126,8 @@ ext {
             'sitemesh.version'              : '2.6.0',
             'scribejava.version'            : '8.3.3',
             'spock.version'                 : '2.4-groovy-5.0',
-            'starter-sitemesh.version'      : '3.3.0-M3',
-            'spring-webmvc-sitemesh.version': '3.3.0-M3',
+            'starter-sitemesh.version'      : '3.3.0-SNAPSHOT',

Review Comment:
   Blocker for merge, as you note in the description. Beyond the pin itself, two things need resolving before this can land:
   
   1. An ASF release cannot depend on a SNAPSHOT. This has to be a released `spring-webmvc-sitemesh`/`starter-sitemesh` before merge, not after.
   2. `3.3.0-SNAPSHOT` is not resolvable from the repositories this build declares, so CI on this branch is not actually building what the PR claims. Anything green here should be treated as untested until the pin lands.
   
   Given the size of the rest of the change, I'd rather see the SiteMesh-dependent part (`GrailsSiteMeshViewResolver` + this bump) split out and merged after the upstream release, so the AOT work isn't held hostage to it.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -836,6 +869,39 @@ ${importStatements}
                 it.destinationDirectory = project.layout.buildDirectory.dir('assetCompile/assets')
             }
         }
+        configureAssetsOnTheClasspath(project)
+    }
+
+    /**
+     * Packages the compiled assets where an executable jar can read them.
+     *
+     * <p>The asset pipeline plugin puts them at the root of whatever archive is built, which is
+     * where a war serves its web content from and is therefore right for a war. An executable jar
+     * has no web content: it serves assets by reading them off the classpath, and its classpath is
+     * {@code BOOT-INF/classes} -- so the same assets, at the same place, in a jar rather than a war,
+     * are packaged but unreachable, and every asset a page asks for is a 404 while the page itself
+     * renders. Adding them under the classpath directory is what makes them found.</p>
+     *
+     * <p>Only for {@code bootJar}. A war already serves them from the root, and putting them on its
+     * classpath as well would ship the same bytes twice.</p>
+     */
+    private void configureAssetsOnTheClasspath(Project project) {
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            // Read after the build script has run, and by the task the pipeline registers rather
+            // than by the plugin that registers it: the asset pipeline's plugin id has changed
+            // once already, and the task name has not.
+            project.afterEvaluate {

Review Comment:
   `afterEvaluate` + `findByName` is the pattern this build has been trying to move away from. `project.tasks.withType(...)`/`named(...).configure { }` on the `assetCompile` task name, guarded by `pluginManager.withPlugin`, gets the same laziness without an ordering-sensitive callback.
   
   Also: this asset-packaging fix (`BOOT-INF/classes/assets`) is a genuine bug fix but is independent of AOT — please split it out. It has its own spec (`AssetClasspathPackagingSpec`) already, so it should be easy to land separately and quickly.



##########
grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy:
##########
@@ -166,27 +187,22 @@ class GroovyPagesGrailsPlugin extends Plugin {
                 }
             }
 
-            def deployed = !Metadata.getCurrent().isDevelopmentEnvironmentAvailable()
             groovyPageLocator(CachingGrailsConventionGroovyPageLocator) { bean ->
                 bean.lazyInit = true
                 if (customResourceLoader) {
                     resourceLoader = groovyPageResourceLoader
                 }
-                if (deployed) {
-                    Resource defaultViews = applicationContext?.getResource('gsp/views.properties')
-
-                    if (defaultViews != null) {
-                        if (!defaultViews.exists()) {
-                            defaultViews = applicationContext?.getResource('classpath:gsp/views.properties')
-                        }
-                    }
-
-                    if (defaultViews?.exists()) {
-                        precompiledGspMap = { PropertiesFactoryBean pfb ->
-                            ignoreResourceNotFound = true
-                            locations = [defaultViews] as Resource[]
-                        }
-                    }
+                // Where the pages compiled at build time are listed. Attached whatever the
+                // surroundings, because whether to read from it is decided where a page is looked
+                // up, at run time, and only there is the answer knowable: deciding it here settles
+                // it while the definition is being generated, in the directory the application was
+                // built in, where a development environment is available -- so an image would be
+                // built believing it has to compile its pages, which is the one thing it cannot do.
+                // Named rather than resolved, so that what is written down is a location to look in
+                // and not a path on the machine that did the building.
+                precompiledGspMap = { PropertiesFactoryBean pfb ->

Review Comment:
   This drops the `deployed` gate: `precompiledGspMap` is now always configured, in every environment. `DefaultGroovyPageLocator.isPrecompiledAvailable()` still guards on `!isDevelopmentMode()`, so the intended behaviour is preserved — but only as long as that guard holds.
   
   The practical risk is a stale `gsp/views.properties` on the classpath (left by a previous `compileGroovyPages`, or shipped inside a plugin jar) now being loaded in dev where it previously wasn't even resolved. Since the whole point of `AbstractAotProcessor.AOT_PROCESSING` in `isDevelopmentMode()` below is to answer this question at generation time, could the location list stay gated on `!isDevelopmentMode()` and get the same AOT override, rather than being made unconditional?
   
   At minimum this needs a spec asserting dev-mode page reloading still works when `gsp/views.properties` is present on the classpath — I don't see one in this PR.



##########
grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java:
##########
@@ -92,6 +101,43 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp
         }
     }
 
+    /**
+     * Colours the output of an image running at a terminal, which it otherwise cannot tell it has.
+     *
+     * <p>Spring Boot decides by asking for the console, and an image answers that it has none even
+     * when it is being watched at a terminal. So the same application whose start-up is coloured
+     * under {@code bootRun} arrives plain once it is built, for a reason that has nothing to do with
+     * the terminal it is running at.</p>
+     *
+     * <p>What the environment names as the terminal is read instead, which an image does carry.
+     * That does not distinguish output being watched from output being redirected -- nothing in an
+     * image does, which is the whole difficulty -- so a shell that redirects to a file still gets
+     * the escapes. It does distinguish a shell from the places that name no terminal at all: a
+     * build, a container, a service manager, where the output is only ever read later and stays
+     * plain. An application that has said either way is left alone.</p>
+     */
+    private void colourTheOutputOfAnImageThatHasATerminal(ConfigurableEnvironment environment) {

Review Comment:
   This turns ANSI escapes on by default in a native image whenever `TERM` is set, and the javadoc acknowledges it will write escapes into redirected output. That's a regression for the common `./app > app.log` and `./app | grep` cases, and it silently overrides a Boot default on the user's behalf.
   
   Defaulting to *not* colouring is the safe direction; someone who wants colour in an image can already set `spring.output.ansi.enabled=always`. If this stays, it should be opt-in (e.g. keyed off an explicit Grails property), not inferred from `TERM`.
   
   Separately: this is banner/console cosmetics in the middle of an AOT bean-definition PR — same split request as `GrailsBanner`.
   
   Also, `isImage()` and `terminal()` are `protected` purely so a Spock spec can override them. Per the repo guidance, tests should exercise the public surface; a package-private seam or an injected `Supplier` would be preferable to widening the API of a public `EnvironmentPostProcessor`.



##########
grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy:
##########
@@ -88,6 +96,29 @@ class GrailsAutoConfiguration implements GrailsApplicationClass, ApplicationCont
         return classes
     }
 
+    /**
+     * The artefacts written down while the application's code was generated, or {@code null} where
+     * nothing was written down and they are to be found the usual ways.
+     *
+     * <p>Both usual ways need something an image does not have: one walks the classpath, the other
+     * reads a list the compile-time transform builds as it goes, which is empty in anything the
+     * transform did not itself compile. So an image found no artefacts at all, and an application
+     * could only start by naming its own -- a list to keep in step with itself forever after.</p>
+     *
+     * <p>They were found while the code was generated, on an ordinary JVM where both ways work, and
+     * left here.</p>
+     */
+    protected Collection<Class> artefactsWrittenDownAheadOfTime() {

Review Comment:
   `classes()` is called by `GrailsApplicationPostProcessor` while it builds the `DefaultGrailsApplication`. Is `applicationContext` guaranteed to be injected on the `GrailsAutoConfiguration` instance by then? If it is null the method returns `null`, `classes()` falls back to classpath scanning, and in an image that yields *nothing* — i.e. the exact failure this is meant to fix, but silently and only in the packaged artifact.
   
   If that ordering is guaranteed, please say so in the javadoc and cover it with a spec that goes through the post-processor rather than calling `artefactsWrittenDownAheadOfTime()` directly. If it isn't, the singleton needs to be reachable without the context.



##########
grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy:
##########
@@ -152,52 +368,136 @@ class GrailsBanner implements Banner {
                 case VersionOption.SPRING_SECURITY:
                     ['Spring Security': findVersion('org.springframework.security.core.SpringSecurityCoreVersion')]
                     break
+                case VersionOption.CONTAINER:
+                    findContainerVersion()
+                    break
                 case VersionOption.TOMCAT:
-                    ['Tomcat': findVersion('org.apache.catalina.util.ServerInfo')]
+                    ['Tomcat': findTomcatVersion()]
                     break
                 case VersionOption.JETTY:
-                    ['Jetty': findVersion('org.eclipse.jetty.util.Jetty')]
+                    ['Jetty': findJettyVersion()]
                     break
                 case VersionOption.UNDERTOW:
-                    ['Undertow': findVersion('io.undertow.Undertow')]
+                    ['Undertow': findUndertowVersion()]
                     break
                 default:
                     null
             }
         } as Map<String, String>
+        versions.findAll { String label, String version -> version != null }
+    }
+
+    /**
+     * The servlet container the application is running on, and the version it records.
+     *
+     * <p>An application runs on one container: choosing another is done by excluding the starter
+     * for this one, so two are not on the classpath together. They are therefore tried in the order
+     * they are commonly used and the first one found is the answer -- an application on Tomcat
+     * never goes looking for Jetty.</p>
+     *
+     * <p>On a container that records no version, or on none of these, this is empty and the banner
+     * leaves the line out rather than saying it does not know.</p>
+     */
+    protected Map<String, String> findContainerVersion() {
+        String tomcat = findTomcatVersion()
+        if (tomcat != null) {
+            return ['Tomcat': tomcat]
+        }
+        String jetty = findJettyVersion()
+        if (jetty != null) {
+            return ['Jetty': jetty]
+        }
+        String undertow = findUndertowVersion()
+        if (undertow != null) {
+            return ['Undertow': undertow]
+        }
+        return [:]
     }
 
     /**
-     * Finds the implementation version of the specified class.
+     * Tomcat's version, read from the resource it ships rather than only from its manifest.
      *
-     * @param className the fully qualified class name
-     * @return the implementation version, or 'unknown' if not found
+     * <p>A resource survives being repackaged into an executable jar or built into an image, where
+     * the manifest's attributes are no longer attached to the package -- which is why the manifest
+     * route reads as nothing in exactly the two places a version is most worth having.</p>
      */
-    private static String findVersion(String className) {
+    protected String findTomcatVersion() {
+        findVersionInResource('org/apache/catalina/util/ServerInfo.properties', 'server.number')
+                ?: findVersion('org.apache.catalina.util.ServerInfo')
+    }
+
+    protected String findJettyVersion() {
+        findVersion('org.eclipse.jetty.util.Jetty')
+    }
+
+    protected String findUndertowVersion() {
+        findVersion('io.undertow.Undertow')
+    }
+
+    /**
+     * A version a library records in a resource it ships, read without loading any of its classes.
+     *
+     * <p>The manifest route only works while a jar is a plain entry on the classpath. Repackaged
+     * into an executable jar its attributes are no longer attached to the package, and an image has
+     * no jars at all -- which is why a container version read that way reads as nothing in exactly
+     * the two places it is most worth having. A resource is still a resource in both.</p>
+     *
+     * @param resource the classpath location of the resource to read
+     * @param key the property within it that carries the version
+     * @return the version, or {@code null} where the resource or the property is absent
+     */
+    protected static String findVersionInResource(String resource, String key) {
+        InputStream stream = GrailsBanner.classLoader.getResourceAsStream(resource)
+        if (stream == null) {
+            return null
+        }
         try {
-            def pkg = Class.forName(className).package
-            return pkg?.implementationVersion ?: 'unknown'
+            Properties properties = new Properties()
+            stream.withCloseable { properties.load(it) }
+            return properties.getProperty(key)
+        }
+        catch (IOException ignored) {
+            return null
+        }
+    }
+
+    /**
+     * The version a library records in the manifest of the jar it ships in.
+     *
+     * <p>Loaded without being initialised. A version is read <em>about</em> a library rather than
+     * <em>from</em> it, and running a static initialiser to find one lets the library do whatever it
+     * does on the way -- Spring Security logs a line of its own from there, which arrived in the
+     * middle of the banner, between the mark and the very versions it was being read for. The
+     * manifest is attached to the package when the class is loaded, and loading is all this
+     * needs.</p>
+     *
+     * @param className the fully qualified name of a class the library ships
+     * @return the version, or {@code null} where the class is absent or records none
+     */
+    protected static String findVersion(String className) {

Review Comment:
   Behaviour change that isn't called out: `findVersion` previously returned `'unknown'` and the line was printed; now it returns `null` and `createBannerVersions` filters the entry out entirely. An application that explicitly asked for `tomcat` in `grails.banner.versions.include` and got `Tomcat: unknown` now gets silence, which reads as "the option was ignored" — the very failure mode `warnAboutUnrecognisedVersions` was added to fix.
   
   Suggest keeping `unknown` for versions the application explicitly *asked* for, and omitting only the defaults.



##########
grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java:
##########
@@ -61,10 +63,25 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t
         }
         GenericBeanDefinition definition = new GenericBeanDefinition();
         definition.setAbstract(true);
-        definition.getPropertyValues().add("searchLocations", this.searchLocations);
+        definition.getPropertyValues().add("searchLocations", searchLocationsToInherit());
         registry.registerBeanDefinition(BEAN_NAME, definition);
     }
 
+    /**
+     * The locations to be inherited, which while code is being generated are none.
+     *
+     * <p>These are directories on the machine this runs on, and a child definition merges them in.
+     * Generating code for that child writes them into it, so an application would carry the
+     * directory it was built in and look for its resources there -- a path that says where it was
+     * built and, wherever it then runs, is not where its resources are. A generated application
+     * reads them from its own contents instead, which is what is left when there is nowhere named
+     * to look.</p>
+     */
+    private List<String> searchLocationsToInherit() {

Review Comment:
   `SpringProperties.getFlag(AbstractAotProcessor.AOT_PROCESSING)` now appears here, in `GroovyPagesGrailsPlugin.isDevelopmentMode()`, and in `UrlMappingsGrailsPlugin.isReloadEnabled()`, each with its own long-form javadoc explaining the same thing. Please extract a single supported helper (e.g. alongside the new `org.apache.grails...aot` code) so there is one place to change if Spring renames the flag, and one place documenting the invariant.
   
   Substantively: returning `List.of()` during generation means an AOT-generated `grailsResourceLocator` (and every third-party child, including asset-pipeline's `assetResourceLocator`) ships with *no* search locations. Is that verified to still resolve `classpath*:` resources at runtime, or does it depend on each child declaring its own defaults? A spec asserting an AOT-generated resource locator can still find a packaged resource would make this much less scary.



##########
grails-core/src/main/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessor.java:
##########
@@ -0,0 +1,180 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.spring.beans.aot;
+
+import java.lang.reflect.Array;
+import java.lang.reflect.Executable;
+import java.util.Collection;
+import java.util.List;
+import java.util.function.Predicate;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.generate.GenerationContext;
+import org.springframework.beans.factory.aot.BeanRegistrationAotContribution;
+import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor;
+import org.springframework.beans.factory.aot.BeanRegistrationCode;
+import org.springframework.beans.factory.aot.BeanRegistrationCodeFragments;
+import org.springframework.beans.factory.aot.BeanRegistrationCodeFragmentsDecorator;
+import org.springframework.beans.factory.config.ConstructorArgumentValues;
+import org.springframework.beans.factory.config.ConstructorArgumentValues.ValueHolder;
+import org.springframework.beans.factory.support.RegisteredBean;
+import org.springframework.beans.factory.support.RootBeanDefinition;
+import org.springframework.javapoet.CodeBlock;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Gathers a variable-argument constructor argument into the array it feeds, ahead of time.
+ *
+ * <p>A bean declared through the plugin DSL passes its arguments positionally, and a constructor
+ * that ends in a variable-argument parameter is called the way the language allows: one value where
+ * the parameter is an array, or a collection where it is an array of that element type. Building the
+ * bean, Spring adapts the argument to the parameter. Reading the definition to generate code for it,
+ * Spring does not: it looks the argument up by the parameter's type, and a lone {@code String} does
+ * not answer to {@code String[]}.</p>
+ *
+ * <p>The argument is then missed and resolved as a dependency instead, and an array of a type nobody
+ * publishes as a bean resolves to an empty array rather than failing. So the bean is built, and
+ * built wrong: a datastore that maps no classes, or a servlet registration with no URL mapping,
+ * which then falls back to mapping everything. Nothing is logged, and the bean that goes wrong is
+ * rarely the one that reports it -- the first symptom is a page that 404s or a domain class that
+ * says it is not one.</p>
+ *
+ * <p>Gathering the argument into an array here means the generator writes out {@code new String[]
+ * {"*.gsp"}}, which the lookup does find. Only an argument that is already usable as the array is
+ * left alone, and an argument that would need its elements converted is left to the resolution that
+ * exists today rather than guessed at here.</p>
+ *
+ * @since 8.0
+ */
+public class VarargsBeanRegistrationAotProcessor implements BeanRegistrationAotProcessor {
+
+    @Override
+    @Nullable
+    public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) {
+        Executable executable = resolveExecutable(registeredBean);
+        if (executable == null || !executable.isVarArgs()) {
+            return null;
+        }
+        Class<?>[] parameterTypes = executable.getParameterTypes();
+        RootBeanDefinition beanDefinition = registeredBean.getMergedBeanDefinition();
+        Object gathered = gatherTrailingArgument(beanDefinition.getConstructorArgumentValues(), parameterTypes);
+        if (gathered == null) {
+            return null;
+        }
+        return BeanRegistrationAotContribution.withCustomCodeFragments(
+                codeFragments -> new VarargsCodeFragments(codeFragments, gathered));
+    }
+
+    /**
+     * The constructor or factory method the generator will write the call to.
+     *
+     * <p>Resolution reads the bean class and its members, so a bean whose class cannot be resolved
+     * fails here rather than at the point of use. It is not this processor's place to report that:
+     * generation carries on and fails where it means something.</p>
+     */
+    @Nullable
+    private Executable resolveExecutable(RegisteredBean registeredBean) {
+        try {
+            return registeredBean.resolveConstructorOrFactoryMethod();
+        }
+        catch (Throwable ignored) {

Review Comment:
   `catch (Throwable ignored)` swallows `Error`s — `OutOfMemoryError`, `StackOverflowError`, `NoClassDefFoundError` from an unrelated cause — and turns them into "this bean has no varargs constructor". The stated intent is to tolerate a bean class that can't be resolved; catch that (`BeanCreationException` / `IllegalStateException`, or at most `Exception`) rather than `Throwable`, and log at debug so a genuinely broken bean leaves a trace.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3776026408


##########
grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy:
##########
@@ -189,7 +237,12 @@ class CoreGrailsPlugin extends Plugin {
                 }
             }
 
-            registry.registerBean('proxyHandler', DefaultProxyHandler)
+            // The GORM implementations register a proxy handler that knows how to unwrap their own
+            // proxies; this is the one for an application that has none. Registering it over theirs
+            // left a Hibernate application unwrapping Hibernate proxies with the general case.
+            if (!hasBeanDefinition(application, 'proxyHandler')) {

Review Comment:
   This is a real bug fix (GORM's `proxyHandler` was being clobbered), but it is unrelated to AOT and it changes runtime behaviour for every Hibernate/Mongo/Neo4j application, not just AOT ones. It deserves its own PR and its own regression test asserting that a Hibernate context ends up with the Hibernate proxy handler.
   
   Two further concerns with the mechanism:
   
   - `hasBeanDefinition` consults `application.mainContext`, not the `BeanRegistry` being written to. The javadoc on `hasConfigurationClassPostProcessor` even describes the case where those differ ("a test slice registering this plugin's beans on a bare registry") — in that case the check answers about the wrong object. If `BeanRegistry` genuinely can't be queried, that limitation should be stated explicitly here rather than in the sibling method.
   - If `mainContext` is null or not a `ConfigurableApplicationContext` the method returns `false` and registers anyway, so the fix silently degrades to the old behaviour. Is `mainContext` guaranteed to be set by the time registrars run?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5263641725

   ## ✅ All tests passed ✅
   
   🏷️ Commit: 6fa81e7777969c97fdeea95ba9bb18092f9f2d1d
   ▶️ Tests:  69426 executed
   ⚪️ Checks: 78/78 completed
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5291614052

   ## ✅ All tests passed ✅
   
   🏷️ Commit: b22be605696ec37a422bd0a962e70bd4c8657a23
   ▶️ Tests:  65782 executed
   ⚪️ Checks: 78/78 completed
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "codeconsole (via GitHub)" <gi...@apache.org>.
codeconsole commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3781983182


##########
grails-common/src/main/groovy/org/grails/aot/RegistrableTypes.java:
##########
@@ -0,0 +1,251 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.aot;

Review Comment:
   Moved in 79bd5b9: `org.grails.aot` -> `org.apache.grails.common.aot`.



##########
grails-core/src/main/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilter.java:
##########
@@ -0,0 +1,48 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.spring.beans.aot;

Review Comment:
   Done across the PR in 79bd5b9:
   
   ```
   org.grails.aot                          -> org.apache.grails.common.aot
   org.grails.spring.beans.aot             -> org.apache.grails.core.aot
   org.grails.plugins.web.controllers.aot  -> org.apache.grails.controllers.aot
   org.grails.gsp.aot                      -> org.apache.grails.gsp.aot
   org.grails.plugins.web.taglib.aot       -> org.apache.grails.gsp.taglib.aot
   org.grails.web.mime.aot                 -> org.apache.grails.mimetypes.aot
   grails.plugin.scaffolding.aot           -> org.apache.grails.scaffolding.aot
   org.grails.web.databinding.aot          -> org.apache.grails.web.databinding.aot
   org.grails.web.mapping.aot              -> org.apache.grails.web.mapping.aot
   org.grails.datastore.gorm.aot           -> org.apache.grails.datamapping.aot
   grails.gorm.validation.aot              -> org.apache.grails.datamapping.validation.aot
   ```
   
   Each module contributes its own package. The Gradle plugin classes stay in `org.grails.gradle.plugin.aot` beside the rest of that plugin, since that jar is a build plugin rather than something an application puts on its module path. Say if you want those moved as well.



##########
grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy:
##########
@@ -152,52 +368,136 @@ class GrailsBanner implements Banner {
                 case VersionOption.SPRING_SECURITY:
                     ['Spring Security': findVersion('org.springframework.security.core.SpringSecurityCoreVersion')]
                     break
+                case VersionOption.CONTAINER:
+                    findContainerVersion()
+                    break
                 case VersionOption.TOMCAT:
-                    ['Tomcat': findVersion('org.apache.catalina.util.ServerInfo')]
+                    ['Tomcat': findTomcatVersion()]
                     break
                 case VersionOption.JETTY:
-                    ['Jetty': findVersion('org.eclipse.jetty.util.Jetty')]
+                    ['Jetty': findJettyVersion()]
                     break
                 case VersionOption.UNDERTOW:
-                    ['Undertow': findVersion('io.undertow.Undertow')]
+                    ['Undertow': findUndertowVersion()]
                     break
                 default:
                     null
             }
         } as Map<String, String>
+        versions.findAll { String label, String version -> version != null }
+    }
+
+    /**
+     * The servlet container the application is running on, and the version it records.
+     *
+     * <p>An application runs on one container: choosing another is done by excluding the starter
+     * for this one, so two are not on the classpath together. They are therefore tried in the order
+     * they are commonly used and the first one found is the answer -- an application on Tomcat
+     * never goes looking for Jetty.</p>
+     *
+     * <p>On a container that records no version, or on none of these, this is empty and the banner
+     * leaves the line out rather than saying it does not know.</p>
+     */
+    protected Map<String, String> findContainerVersion() {
+        String tomcat = findTomcatVersion()
+        if (tomcat != null) {
+            return ['Tomcat': tomcat]
+        }
+        String jetty = findJettyVersion()
+        if (jetty != null) {
+            return ['Jetty': jetty]
+        }
+        String undertow = findUndertowVersion()
+        if (undertow != null) {
+            return ['Undertow': undertow]
+        }
+        return [:]
     }
 
     /**
-     * Finds the implementation version of the specified class.
+     * Tomcat's version, read from the resource it ships rather than only from its manifest.
      *
-     * @param className the fully qualified class name
-     * @return the implementation version, or 'unknown' if not found
+     * <p>A resource survives being repackaged into an executable jar or built into an image, where
+     * the manifest's attributes are no longer attached to the package -- which is why the manifest
+     * route reads as nothing in exactly the two places a version is most worth having.</p>
      */
-    private static String findVersion(String className) {
+    protected String findTomcatVersion() {
+        findVersionInResource('org/apache/catalina/util/ServerInfo.properties', 'server.number')
+                ?: findVersion('org.apache.catalina.util.ServerInfo')
+    }
+
+    protected String findJettyVersion() {
+        findVersion('org.eclipse.jetty.util.Jetty')
+    }
+
+    protected String findUndertowVersion() {
+        findVersion('io.undertow.Undertow')
+    }
+
+    /**
+     * A version a library records in a resource it ships, read without loading any of its classes.
+     *
+     * <p>The manifest route only works while a jar is a plain entry on the classpath. Repackaged
+     * into an executable jar its attributes are no longer attached to the package, and an image has
+     * no jars at all -- which is why a container version read that way reads as nothing in exactly
+     * the two places it is most worth having. A resource is still a resource in both.</p>
+     *
+     * @param resource the classpath location of the resource to read
+     * @param key the property within it that carries the version
+     * @return the version, or {@code null} where the resource or the property is absent
+     */
+    protected static String findVersionInResource(String resource, String key) {
+        InputStream stream = GrailsBanner.classLoader.getResourceAsStream(resource)
+        if (stream == null) {
+            return null
+        }
         try {
-            def pkg = Class.forName(className).package
-            return pkg?.implementationVersion ?: 'unknown'
+            Properties properties = new Properties()
+            stream.withCloseable { properties.load(it) }
+            return properties.getProperty(key)
+        }
+        catch (IOException ignored) {
+            return null
+        }
+    }
+
+    /**
+     * The version a library records in the manifest of the jar it ships in.
+     *
+     * <p>Loaded without being initialised. A version is read <em>about</em> a library rather than
+     * <em>from</em> it, and running a static initialiser to find one lets the library do whatever it
+     * does on the way -- Spring Security logs a line of its own from there, which arrived in the
+     * middle of the banner, between the mark and the very versions it was being read for. The
+     * manifest is attached to the package when the class is loaded, and loading is all this
+     * needs.</p>
+     *
+     * @param className the fully qualified name of a class the library ships
+     * @return the version, or {@code null} where the class is absent or records none
+     */
+    protected static String findVersion(String className) {

Review Comment:
   Changed in 437e627. A version asked for by name reads `unknown`; only the defaults are left out.
   
   The lookups moved out of `createBannerVersions` into `versionsFor(key, env)`, because deciding this per option needs the option that produced a label. `GrailsBannerNativeMarkSpec` covers both directions. Upgrade guide and what's-new updated to match.



##########
grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java:
##########
@@ -92,6 +101,43 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp
         }
     }
 
+    /**
+     * Colours the output of an image running at a terminal, which it otherwise cannot tell it has.
+     *
+     * <p>Spring Boot decides by asking for the console, and an image answers that it has none even
+     * when it is being watched at a terminal. So the same application whose start-up is coloured
+     * under {@code bootRun} arrives plain once it is built, for a reason that has nothing to do with
+     * the terminal it is running at.</p>
+     *
+     * <p>What the environment names as the terminal is read instead, which an image does carry.
+     * That does not distinguish output being watched from output being redirected -- nothing in an
+     * image does, which is the whole difficulty -- so a shell that redirects to a file still gets
+     * the escapes. It does distinguish a shell from the places that name no terminal at all: a
+     * build, a container, a service manager, where the output is only ever read later and stays
+     * plain. An application that has said either way is left alone.</p>
+     */
+    private void colourTheOutputOfAnImageThatHasATerminal(ConfigurableEnvironment environment) {

Review Comment:
   Removed in 437e627. `GrailsEnvironmentPostProcessor` is byte-identical to the merge base again and the spec is gone, which takes `isImage()` and `terminal()` with it.
   
   An image that should be coloured can be told so with `spring.output.ansi.enabled`, which needs nothing here.



##########
grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy:
##########
@@ -166,27 +187,22 @@ class GroovyPagesGrailsPlugin extends Plugin {
                 }
             }
 
-            def deployed = !Metadata.getCurrent().isDevelopmentEnvironmentAvailable()
             groovyPageLocator(CachingGrailsConventionGroovyPageLocator) { bean ->
                 bean.lazyInit = true
                 if (customResourceLoader) {
                     resourceLoader = groovyPageResourceLoader
                 }
-                if (deployed) {
-                    Resource defaultViews = applicationContext?.getResource('gsp/views.properties')
-
-                    if (defaultViews != null) {
-                        if (!defaultViews.exists()) {
-                            defaultViews = applicationContext?.getResource('classpath:gsp/views.properties')
-                        }
-                    }
-
-                    if (defaultViews?.exists()) {
-                        precompiledGspMap = { PropertiesFactoryBean pfb ->
-                            ignoreResourceNotFound = true
-                            locations = [defaultViews] as Resource[]
-                        }
-                    }
+                // Where the pages compiled at build time are listed. Attached whatever the
+                // surroundings, because whether to read from it is decided where a page is looked
+                // up, at run time, and only there is the answer knowable: deciding it here settles
+                // it while the definition is being generated, in the directory the application was
+                // built in, where a development environment is available -- so an image would be
+                // built believing it has to compile its pages, which is the one thing it cannot do.
+                // Named rather than resolved, so that what is written down is a location to look in
+                // and not a path on the machine that did the building.
+                precompiledGspMap = { PropertiesFactoryBean pfb ->

Review Comment:
   Gated again in 67abf890, on `!developmentMode` — which already carries the AOT override, so the manifest is attached for a deployed application and for one whose code is being written out, and not for a project on disk. The locations stay named rather than resolved; that was the part that had to change.
   
   `GroovyPagesGrailsPluginPrecompiledSpec` asserts both directions: the property is absent from the `groovyPageLocator` definition in development mode, present when deployed. So a stale `gsp/views.properties`, or one shipped inside a plugin jar, no longer reaches the locator in dev.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -1115,6 +1268,150 @@ ${importStatements}
         }
     }
 
+    /**
+     * Wires up the cache the JDK can write for an application, so the next start reads what a
+     * training run worked out rather than working it out again.
+     *
+     * <p>Three steps, because the cache is only usable against the layout it was trained on: the
+     * archive is extracted, the extracted application is run and asked for its pages, and what the
+     * run recorded is left beside it. An application asks for this with
+     * {@code grails.aotCache.enabled}, and says which of its pages matter.</p>
+     */
+    protected void configureAotCache(Project project) {
+        AotCacheExtension extension = ((ExtensionAware) project.extensions.getByName('grails'))
+                .extensions.create('aotCache', AotCacheExtension)
+        extension.enabled.convention(false)
+        extension.paths.convention([])
+        extension.jvmArguments.convention(['-Dspring.aot.enabled=true', '-Dgrails.env=production'])
+        extension.port.convention(TRAINING_PORT)
+        extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS)
+
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            TaskProvider<?> bootJar = project.tasks.named('bootJar')
+            Provider<Directory> application = project.layout.buildDirectory.dir('aot-cache/application')
+            Provider<JavaLauncher> launcher = trainingLauncher(project)
+
+            TaskProvider<Exec> extract = project.tasks.register('extractAotCacheApplication', Exec) { Exec task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Extracts the application, which is the form the cache is read against'
+                task.onlyIf { extension.enabled.get() }
+                task.dependsOn(bootJar)
+                // Named so the extraction is skipped when the archive it came from has not moved,
+                // rather than repeated on every run because nothing said what it produced.
+                task.inputs.file(project.provider { archiveOf(bootJar) })
+                task.outputs.dir(application)
+                task.doFirst {
+                    File destination = application.get().asFile
+                    project.delete(destination)

Review Comment:
   Fixed in 67abf890. `extractAotCacheApplication` is now an `ExtractApplicationTask`: `@InputFile` archive, `@OutputDirectory` destination, `@Input` java executable, with injected `FileSystemOperations` and `ExecOperations`. Nothing reaches `Project` at execution time.
   
   `archiveOf` is now `archiveFileOf` returning `Provider<RegularFile>`, which the trace and training tasks take as well, so nothing calls `TaskProvider.get()` while the build is configured.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -836,6 +869,39 @@ ${importStatements}
                 it.destinationDirectory = project.layout.buildDirectory.dir('assetCompile/assets')
             }
         }
+        configureAssetsOnTheClasspath(project)
+    }
+
+    /**
+     * Packages the compiled assets where an executable jar can read them.
+     *
+     * <p>The asset pipeline plugin puts them at the root of whatever archive is built, which is
+     * where a war serves its web content from and is therefore right for a war. An executable jar
+     * has no web content: it serves assets by reading them off the classpath, and its classpath is
+     * {@code BOOT-INF/classes} -- so the same assets, at the same place, in a jar rather than a war,
+     * are packaged but unreachable, and every asset a page asks for is a 404 while the page itself
+     * renders. Adding them under the classpath directory is what makes them found.</p>
+     *
+     * <p>Only for {@code bootJar}. A war already serves them from the root, and putting them on its
+     * classpath as well would ship the same bytes twice.</p>
+     */
+    private void configureAssetsOnTheClasspath(Project project) {
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            // Read after the build script has run, and by the task the pipeline registers rather
+            // than by the plugin that registers it: the asset pipeline's plugin id has changed
+            // once already, and the task name has not.
+            project.afterEvaluate {

Review Comment:
   Replaced in 67abf890 with a `FileCollection` over `tasks.matching { it.name == 'assetCompile' }`, still guarded by `pluginManager.withPlugin`.
   
   Worth noting `matching {}.configureEach {}` on its own is not equivalent: it fires when the task is realized, which can be after `bootJar` has already run, and the assets end up missing from the jar. `AssetClasspathPackagingSpec` catches that.



##########
grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java:
##########
@@ -61,10 +63,25 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t
         }
         GenericBeanDefinition definition = new GenericBeanDefinition();
         definition.setAbstract(true);
-        definition.getPropertyValues().add("searchLocations", this.searchLocations);
+        definition.getPropertyValues().add("searchLocations", searchLocationsToInherit());
         registry.registerBeanDefinition(BEAN_NAME, definition);
     }
 
+    /**
+     * The locations to be inherited, which while code is being generated are none.
+     *
+     * <p>These are directories on the machine this runs on, and a child definition merges them in.
+     * Generating code for that child writes them into it, so an application would carry the
+     * directory it was built in and look for its resources there -- a path that says where it was
+     * built and, wherever it then runs, is not where its resources are. A generated application
+     * reads them from its own contents instead, which is what is left when there is nowhere named
+     * to look.</p>
+     */
+    private List<String> searchLocationsToInherit() {

Review Comment:
   Extracted in d81902f: `org.apache.grails.common.aot.AheadOfTimeProcessing.isGeneratingCode()`, beside the other shared ahead-of-time code as you suggested. It went from four sites to one — `AbstractResourceLocatorPostProcessor`, `GroovyPagesGrailsPlugin`, `AbstractDatastoreInitializer`, and `UrlMappingsGrailsPlugin`, the last of which no longer needs it at all (see the thread on that file).
   
   GORM reaches it through `grails-datastore-core`'s existing `api` dependency on `grails-common`, so no new module dependency.
   
   On the search locations: resolution falls through to the locator's resource loader, which is the application context, so a child with none still finds a packaged resource. Spec added in b22be60 asserting exactly that.



##########
grails-core/src/main/groovy/org/grails/spring/beans/aot/VarargsBeanRegistrationAotProcessor.java:
##########
@@ -0,0 +1,180 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.spring.beans.aot;
+
+import java.lang.reflect.Array;
+import java.lang.reflect.Executable;
+import java.util.Collection;
+import java.util.List;
+import java.util.function.Predicate;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.aot.generate.GenerationContext;
+import org.springframework.beans.factory.aot.BeanRegistrationAotContribution;
+import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor;
+import org.springframework.beans.factory.aot.BeanRegistrationCode;
+import org.springframework.beans.factory.aot.BeanRegistrationCodeFragments;
+import org.springframework.beans.factory.aot.BeanRegistrationCodeFragmentsDecorator;
+import org.springframework.beans.factory.config.ConstructorArgumentValues;
+import org.springframework.beans.factory.config.ConstructorArgumentValues.ValueHolder;
+import org.springframework.beans.factory.support.RegisteredBean;
+import org.springframework.beans.factory.support.RootBeanDefinition;
+import org.springframework.javapoet.CodeBlock;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Gathers a variable-argument constructor argument into the array it feeds, ahead of time.
+ *
+ * <p>A bean declared through the plugin DSL passes its arguments positionally, and a constructor
+ * that ends in a variable-argument parameter is called the way the language allows: one value where
+ * the parameter is an array, or a collection where it is an array of that element type. Building the
+ * bean, Spring adapts the argument to the parameter. Reading the definition to generate code for it,
+ * Spring does not: it looks the argument up by the parameter's type, and a lone {@code String} does
+ * not answer to {@code String[]}.</p>
+ *
+ * <p>The argument is then missed and resolved as a dependency instead, and an array of a type nobody
+ * publishes as a bean resolves to an empty array rather than failing. So the bean is built, and
+ * built wrong: a datastore that maps no classes, or a servlet registration with no URL mapping,
+ * which then falls back to mapping everything. Nothing is logged, and the bean that goes wrong is
+ * rarely the one that reports it -- the first symptom is a page that 404s or a domain class that
+ * says it is not one.</p>
+ *
+ * <p>Gathering the argument into an array here means the generator writes out {@code new String[]
+ * {"*.gsp"}}, which the lookup does find. Only an argument that is already usable as the array is
+ * left alone, and an argument that would need its elements converted is left to the resolution that
+ * exists today rather than guessed at here.</p>
+ *
+ * @since 8.0
+ */
+public class VarargsBeanRegistrationAotProcessor implements BeanRegistrationAotProcessor {
+
+    @Override
+    @Nullable
+    public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) {
+        Executable executable = resolveExecutable(registeredBean);
+        if (executable == null || !executable.isVarArgs()) {
+            return null;
+        }
+        Class<?>[] parameterTypes = executable.getParameterTypes();
+        RootBeanDefinition beanDefinition = registeredBean.getMergedBeanDefinition();
+        Object gathered = gatherTrailingArgument(beanDefinition.getConstructorArgumentValues(), parameterTypes);
+        if (gathered == null) {
+            return null;
+        }
+        return BeanRegistrationAotContribution.withCustomCodeFragments(
+                codeFragments -> new VarargsCodeFragments(codeFragments, gathered));
+    }
+
+    /**
+     * The constructor or factory method the generator will write the call to.
+     *
+     * <p>Resolution reads the bean class and its members, so a bean whose class cannot be resolved
+     * fails here rather than at the point of use. It is not this processor's place to report that:
+     * generation carries on and fails where it means something.</p>
+     */
+    @Nullable
+    private Executable resolveExecutable(RegisteredBean registeredBean) {
+        try {
+            return registeredBean.resolveConstructorOrFactoryMethod();
+        }
+        catch (Throwable ignored) {

Review Comment:
   Narrowed in 437e627 to `catch (Exception)`, with a debug line naming the bean. An `Error` is a JVM out of memory or a class that will not link, and neither is a bean without a variable-argument constructor.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT [grails-core]

Posted by "codecov[bot] (via GitHub)" <gi...@apache.org>.
codecov[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5200815573

   ## [Codecov](https://app.codecov.io/gh/apache/grails-core/pull/16094?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) Report
   :x: Patch coverage is `90.47619%` with `2 lines` in your changes missing coverage. Please review.
   :white_check_mark: Project coverage is 52.3803%. Comparing base ([`4bc5bd8`](https://app.codecov.io/gh/apache/grails-core/commit/4bc5bd82334650c24dee42ce326f1414876f2fb3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)) to head ([`075c648`](https://app.codecov.io/gh/apache/grails-core/commit/075c6488722296d8923759a0bfeb41737a37c2a2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)).
   :warning: Report is 11 commits behind head on 8.0.x.
   
   | [Files with missing lines](https://app.codecov.io/gh/apache/grails-core/pull/16094?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Patch % | Lines |
   |---|---|---|
   | [...bootstrap/MongoDbDataStoreSpringInitializer.groovy](https://app.codecov.io/gh/apache/grails-core/pull/16094?src=pr&el=tree&filepath=grails-data-mongodb%2Fcore%2Fsrc%2Fmain%2Fgroovy%2Fgrails%2Fmongodb%2Fbootstrap%2FMongoDbDataStoreSpringInitializer.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWRhdGEtbW9uZ29kYi9jb3JlL3NyYy9tYWluL2dyb292eS9ncmFpbHMvbW9uZ29kYi9ib290c3RyYXAvTW9uZ29EYkRhdGFTdG9yZVNwcmluZ0luaXRpYWxpemVyLmdyb292eQ==) | 66.6667% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/apache/grails-core/pull/16094?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
   
   <details><summary>Additional details and impacted files</summary>
   
   
   
   [![Impacted file tree graph](https://app.codecov.io/gh/apache/grails-core/pull/16094/graphs/tree.svg?width=650&height=150&src=pr&token=qJrryhpk7u&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)](https://app.codecov.io/gh/apache/grails-core/pull/16094?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)
   
   ```diff
   @@                Coverage Diff                 @@
   ##                8.0.x     #16094        +/-   ##
   ==================================================
   + Coverage     52.3426%   52.3803%   +0.0377%     
   - Complexity      18293      18312        +19     
   ==================================================
     Files            2036       2037         +1     
     Lines           96346      96355         +9     
     Branches        16831      16830         -1     
   ==================================================
   + Hits            50430      50471        +41     
   + Misses          38492      38462        -30     
   + Partials         7424       7422         -2     
   ```
   
   | [Files with missing lines](https://app.codecov.io/gh/apache/grails-core/pull/16094?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Coverage Δ | |
   |---|---|---|
   | [.../groovy/org/grails/plugins/CoreGrailsPlugin.groovy](https://app.codecov.io/gh/apache/grails-core/pull/16094?src=pr&el=tree&filepath=grails-core%2Fsrc%2Fmain%2Fgroovy%2Forg%2Fgrails%2Fplugins%2FCoreGrailsPlugin.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWNvcmUvc3JjL21haW4vZ3Jvb3Z5L29yZy9ncmFpbHMvcGx1Z2lucy9Db3JlR3JhaWxzUGx1Z2luLmdyb292eQ==) | `69.3333% <100.0000%> (+0.4144%)` | :arrow_up: |
   | [...beans/aot/AbstractBeanDefinitionExcludeFilter.java](https://app.codecov.io/gh/apache/grails-core/pull/16094?src=pr&el=tree&filepath=grails-core%2Fsrc%2Fmain%2Fgroovy%2Forg%2Fgrails%2Fspring%2Fbeans%2Faot%2FAbstractBeanDefinitionExcludeFilter.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWNvcmUvc3JjL21haW4vZ3Jvb3Z5L29yZy9ncmFpbHMvc3ByaW5nL2JlYW5zL2FvdC9BYnN0cmFjdEJlYW5EZWZpbml0aW9uRXhjbHVkZUZpbHRlci5qYXZh) | `100.0000% <100.0000%> (ø)` | |
   | [...onfigurableApplicationContextEventPublisher.groovy](https://app.codecov.io/gh/apache/grails-core/pull/16094?src=pr&el=tree&filepath=grails-datamapping-core%2Fsrc%2Fmain%2Fgroovy%2Forg%2Fgrails%2Fdatastore%2Fgorm%2Fevents%2FConfigurableApplicationContextEventPublisher.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWRhdGFtYXBwaW5nLWNvcmUvc3JjL21haW4vZ3Jvb3Z5L29yZy9ncmFpbHMvZGF0YXN0b3JlL2dvcm0vZXZlbnRzL0NvbmZpZ3VyYWJsZUFwcGxpY2F0aW9uQ29udGV4dEV2ZW50UHVibGlzaGVyLmdyb292eQ==) | `84.6154% <100.0000%> (+6.8376%)` | :arrow_up: |
   | [.../plugins/sitemesh3/GrailsSiteMeshViewResolver.java](https://app.codecov.io/gh/apache/grails-core/pull/16094?src=pr&el=tree&filepath=grails-gsp%2Fgrails-sitemesh3%2Fsrc%2Fmain%2Fjava%2Forg%2Fgrails%2Fplugins%2Fsitemesh3%2FGrailsSiteMeshViewResolver.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWdzcC9ncmFpbHMtc2l0ZW1lc2gzL3NyYy9tYWluL2phdmEvb3JnL2dyYWlscy9wbHVnaW5zL3NpdGVtZXNoMy9HcmFpbHNTaXRlTWVzaFZpZXdSZXNvbHZlci5qYXZh) | `100.0000% <100.0000%> (ø)` | |
   | [.../grails/plugins/web/GroovyPagesGrailsPlugin.groovy](https://app.codecov.io/gh/apache/grails-core/pull/16094?src=pr&el=tree&filepath=grails-gsp%2Fplugin%2Fsrc%2Fmain%2Fgroovy%2Forg%2Fgrails%2Fplugins%2Fweb%2FGroovyPagesGrailsPlugin.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWdzcC9wbHVnaW4vc3JjL21haW4vZ3Jvb3Z5L29yZy9ncmFpbHMvcGx1Z2lucy93ZWIvR3Jvb3Z5UGFnZXNHcmFpbHNQbHVnaW4uZ3Jvb3Z5) | `59.6774% <100.0000%> (ø)` | |
   | [...bootstrap/MongoDbDataStoreSpringInitializer.groovy](https://app.codecov.io/gh/apache/grails-core/pull/16094?src=pr&el=tree&filepath=grails-data-mongodb%2Fcore%2Fsrc%2Fmain%2Fgroovy%2Fgrails%2Fmongodb%2Fbootstrap%2FMongoDbDataStoreSpringInitializer.groovy&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-Z3JhaWxzLWRhdGEtbW9uZ29kYi9jb3JlL3NyYy9tYWluL2dyb292eS9ncmFpbHMvbW9uZ29kYi9ib290c3RyYXAvTW9uZ29EYkRhdGFTdG9yZVNwcmluZ0luaXRpYWxpemVyLmdyb292eQ==) | `62.9630% <66.6667%> (+11.1773%)` | :arrow_up: |
   
   ... and [12 files with indirect coverage changes](https://app.codecov.io/gh/apache/grails-core/pull/16094/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)
   </details>
   <details><summary> :rocket: New features to boost your workflow: </summary>
   
   - :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
   - :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
   </details>


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3776025542


##########
dependencies.gradle:
##########
@@ -126,8 +126,8 @@ ext {
             'sitemesh.version'              : '2.6.0',
             'scribejava.version'            : '8.3.3',
             'spock.version'                 : '2.4-groovy-5.0',
-            'starter-sitemesh.version'      : '3.3.0-M3',
-            'spring-webmvc-sitemesh.version': '3.3.0-M3',
+            'starter-sitemesh.version'      : '3.3.0-SNAPSHOT',

Review Comment:
   Blocker for merge, as you note in the description. Beyond the pin itself, two things need resolving before this can land:
   
   1. An ASF release cannot depend on a SNAPSHOT. This has to be a released `spring-webmvc-sitemesh`/`starter-sitemesh` before merge, not after.
   2. `3.3.0-SNAPSHOT` is not resolvable from the repositories this build declares, so CI on this branch is not actually building what the PR claims. Anything green here should be treated as untested until the pin lands.
   
   Given the size of the rest of the change, I'd rather see the SiteMesh-dependent part (`GrailsSiteMeshViewResolver` + this bump) split out and merged after the upstream release, so the AOT work isn't held hostage to it.



##########
.sdkmanrc:
##########
@@ -5,7 +5,7 @@
 # $JAVA_VERSION_MICRONAUT in release.yml; for local verification, install that JDK 25
 # alongside this one (sdk install java <version>-librca) and follow the dual-JDK
 # instructions in RELEASE.md "Manual Verification: Reproducible Jar Files".
-java=21.0.7-librca
+java=21.0.12-librca

Review Comment:
   The JDK bump (here, `release.yml`, `release-publish-docs.yml`, `etc/bin/Dockerfile`) has nothing to do with AOT and changes the JDK the reproducible-build guarantee is pinned to. Please pull it into its own PR so it can be verified against `verify-reproducible.sh` independently — a release-toolchain change should not ride along in a feature branch.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3776026654


##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -836,6 +869,39 @@ ${importStatements}
                 it.destinationDirectory = project.layout.buildDirectory.dir('assetCompile/assets')
             }
         }
+        configureAssetsOnTheClasspath(project)
+    }
+
+    /**
+     * Packages the compiled assets where an executable jar can read them.
+     *
+     * <p>The asset pipeline plugin puts them at the root of whatever archive is built, which is
+     * where a war serves its web content from and is therefore right for a war. An executable jar
+     * has no web content: it serves assets by reading them off the classpath, and its classpath is
+     * {@code BOOT-INF/classes} -- so the same assets, at the same place, in a jar rather than a war,
+     * are packaged but unreachable, and every asset a page asks for is a 404 while the page itself
+     * renders. Adding them under the classpath directory is what makes them found.</p>
+     *
+     * <p>Only for {@code bootJar}. A war already serves them from the root, and putting them on its
+     * classpath as well would ship the same bytes twice.</p>
+     */
+    private void configureAssetsOnTheClasspath(Project project) {
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            // Read after the build script has run, and by the task the pipeline registers rather
+            // than by the plugin that registers it: the asset pipeline's plugin id has changed
+            // once already, and the task name has not.
+            project.afterEvaluate {

Review Comment:
   `afterEvaluate` + `findByName` is the pattern this build has been trying to move away from. `project.tasks.withType(...)`/`named(...).configure { }` on the `assetCompile` task name, guarded by `pluginManager.withPlugin`, gets the same laziness without an ordering-sensitive callback.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5281574267

   I assume the failing tests are due to the snapshot resolution issue.  


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5308238062

   On your open question from the package-move thread: leave the Gradle plugin classes in `org.grails.gradle.plugin.aot`..  Let's go ahead and fix the package to be unique now so we don't have to revisit later.  We need to be gradually switching to org.apache.grails and this is a good opportunity for any newly added code. 


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5308230952

   FYI @codeconsole  5.2.0-M3 is released on asset pipeline 


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5201572573

   ## ✅ All tests passed ✅
   
   🏷️ Commit: 075c6488722296d8923759a0bfeb41737a37c2a2
   ▶️ Tests:  67814 executed
   ⚪️ Checks: 62/62 completed
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5186853923

   ## ✅ All tests passed ✅
   
   🏷️ Commit: bc537807e332ba021f6a332a51bc63f7418c724a
   ▶️ Tests:  67749 executed
   ⚪️ Checks: 62/62 completed
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5348089294

   ## 🚨 TestLens detected 6 failed tests 🚨
   
   Here is what you can do:
   
   1) Inspect the test failures carefully.
   2) If you are convinced that some of the tests are flaky, you can mute them below.
   3) Finally, trigger a rerun by checking the rerun checkbox.
   
   ### Test Summary
   
   #### [CI - Groovy Joint Validation Build / Build Grails with Groovy snapshot \(shard 1\)](https://github.com/apache/grails-core/actions/runs/32300611266/job/96223059193?pr=16094) > :grails-data-mongodb-core:test
   
   | Test | Runs | Flakiness |
   |---|---|--:|
   | MongoTransactionSpec > test a REQUIRES\_NEW inner transaction commits independently of a rolled back outer transaction | ❌ | 0%&nbsp;🟢 |
   | MongoTransactionSpec > test a committed transaction persists all writes atomically | ❌ | 0%&nbsp;🟢 |
   | MongoTransactionSpec > test a findOneAndDelete via the MongoEntity API participates in the transaction | ❌ | 0%&nbsp;🟢 |
   | MongoTransactionSpec > test a rolled back transaction discards all writes on the server | ❌ | 0%&nbsp;🟢 |
   | MongoTransactionSpec > test read-your-writes within an active transaction | ❌ | 0%&nbsp;🟢 |
   | MongoTransactionSpec > test writes across multiple collections roll back together | ❌ | 0%&nbsp;🟢 |
   
   🏷️ Commit: 8444cc5466db5f2d4155c492191f2fd4efee2408
   ▶️ Tests:  37602 executed
   🟡 Checks: 31/75 completed
   
   ### Test Failures
   
   <details>
   
   <summary><strong>MongoTransactionSpec > test a REQUIRES_NEW inner transaction commits independently of a rolled back outer transaction</strong> (:grails-data-mongodb-core:test in <a href="https://github.com/apache/grails-core/actions/runs/32300611266/job/96223059193?pr=16094">CI - Groovy Joint Validation Build / Build Grails with Groovy snapshot (shard 1)</a>)</summary>
   
   ```
   com.mongodb.MongoTimeoutException: Timed out while waiting for a server that matches WritableServerSelector. Client view of cluster state is {type=UNKNOWN, servers=[{address=localhost:32844, type=UNKNOWN, state=CONNECTING, exception={com.mongodb.MongoSocketOpenException: Exception opening socket}, caused by {java.net.ConnectException: Connection refused}}]
   	at com.mongodb.internal.connection.BaseCluster.logAndThrowTimeoutException(BaseCluster.java:431)
   	at com.mongodb.internal.connection.BaseCluster.lambda$selectServer$0(BaseCluster.java:166)
   	at com.mongodb.internal.time.Timeout.lambda$onExistsAndExpired$16(Timeout.java:238)
   	at com.mongodb.internal.time.Timeout.lambda$run$10(Timeout.java:203)
   	at com.mongodb.internal.time.TimePoint.checkedCall(TimePoint.java:100)
   	at com.mongodb.internal.time.Timeout.call(Timeout.java:176)
   	at com.mongodb.internal.time.Timeout.run(Timeout.java:196)
   	at com.mongodb.internal.time.Timeout.onExistsAndExpired(Timeout.java:235)
   	at com.mongodb.internal.time.Timeout.onExpired(Timeout.java:228)
   	at com.mongodb.internal.connection.BaseCluster.selectServer(BaseCluster.java:165)
   	at com.mongodb.internal.connection.SingleServerCluster.selectServer(SingleServerCluster.java:47)
   	at com.mongodb.internal.binding.ClusterBinding.getWriteConnectionSource(ClusterBinding.java:91)
   	at com.mongodb.client.internal.ClientSessionBinding.getConnectionSource(ClientSessionBinding.java:109)
   	at com.mongodb.client.internal.ClientSessionBinding.getWriteConnectionSource(ClientSessionBinding.java:102)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSuppliedResource(SyncOperationHelper.java:169)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSourceAndConnection(SyncOperationHelper.java:138)
   	at com.mongodb.internal.operation.SyncOperationHelper.withConnection(SyncOperationHelper.java:117)
   	at com.mongodb.internal.operation.DropDatabaseOperation.execute(DropDatabaseOperation.java:73)
   	at com.mongodb.internal.operation.DropDatabaseOperation.execute(DropDatabaseOperation.java:48)
   	at com.mongodb.client.internal.MongoClusterImpl$OperationExecutorImpl.execute(MongoClusterImpl.java:472)
   	at com.mongodb.client.internal.MongoDatabaseImpl.executeDrop(MongoDatabaseImpl.java:230)
   	at com.mongodb.client.internal.MongoDatabaseImpl.drop(MongoDatabaseImpl.java:220)
   	at org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec.setup_closure1(MongoTransactionSpec.groovy:58)
   	at org.grails.datastore.gorm.GormStaticApi.withNewSession_closure23(GormStaticApi.groovy:767)
   	at org.grails.datastore.mapping.core.DatastoreUtils.executeWithNewSession(DatastoreUtils.java:371)
   	at org.grails.datastore.gorm.GormStaticApi.withNewSession(GormStaticApi.groovy:766)
   	at org.grails.datastore.gorm.GormEntity$Trait$Helper.withNewSession(GormEntity.groovy:1157)
   	at org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec.setup(MongoTransactionSpec.groovy:57)
   ```
   
   </details>
   <details>
   
   <summary><strong>MongoTransactionSpec > test a committed transaction persists all writes atomically</strong> (:grails-data-mongodb-core:test in <a href="https://github.com/apache/grails-core/actions/runs/32300611266/job/96223059193?pr=16094">CI - Groovy Joint Validation Build / Build Grails with Groovy snapshot (shard 1)</a>)</summary>
   
   ```
   com.mongodb.MongoSocketReadException: Prematurely reached end of stream
   	at com.mongodb.internal.connection.SocketStream.read(SocketStream.java:184)
   	at com.mongodb.internal.connection.InternalStreamConnection.receiveResponseBuffers(InternalStreamConnection.java:942)
   	at com.mongodb.internal.connection.InternalStreamConnection.receiveCommandMessageResponse(InternalStreamConnection.java:567)
   	at com.mongodb.internal.connection.InternalStreamConnection.sendAndReceiveInternal(InternalStreamConnection.java:494)
   	at com.mongodb.internal.connection.InternalStreamConnection.lambda$sendAndReceive$1(InternalStreamConnection.java:383)
   	at com.mongodb.internal.connection.InternalStreamConnection.sendAndReceive(InternalStreamConnection.java:386)
   	at com.mongodb.internal.connection.UsageTrackingInternalConnection.sendAndReceive(UsageTrackingInternalConnection.java:111)
   	at com.mongodb.internal.connection.DefaultConnectionPool$PooledConnection.sendAndReceive(DefaultConnectionPool.java:757)
   	at com.mongodb.internal.connection.CommandProtocolImpl.execute(CommandProtocolImpl.java:60)
   	at com.mongodb.internal.connection.DefaultServer$DefaultServerProtocolExecutor.execute(DefaultServer.java:207)
   	at com.mongodb.internal.connection.DefaultServerConnection.executeProtocol(DefaultServerConnection.java:112)
   	at com.mongodb.internal.connection.DefaultServerConnection.command(DefaultServerConnection.java:82)
   	at com.mongodb.internal.connection.DefaultServerConnection.command(DefaultServerConnection.java:74)
   	at com.mongodb.internal.connection.DefaultServer$OperationCountTrackingConnection.command(DefaultServer.java:297)
   	at com.mongodb.internal.operation.SyncOperationHelper.lambda$executeRetryableWrite$11(SyncOperationHelper.java:285)
   	at com.mongodb.internal.operation.SyncOperationHelper.lambda$withSourceAndConnection$1(SyncOperationHelper.java:146)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSuppliedResource(SyncOperationHelper.java:177)
   	at com.mongodb.internal.operation.SyncOperationHelper.lambda$withSourceAndConnection$2(SyncOperationHelper.java:142)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSuppliedResource(SyncOperationHelper.java:177)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSourceAndConnection(SyncOperationHelper.java:138)
   	at com.mongodb.internal.operation.SyncOperationHelper.lambda$executeRetryableWrite$12(SyncOperationHelper.java:270)
   	at com.mongodb.internal.operation.SyncOperationHelper.lambda$decorateWriteWithRetries$13(SyncOperationHelper.java:329)
   	at com.mongodb.internal.async.function.RetryingSyncSupplier.get(RetryingSyncSupplier.java:67)
   	at com.mongodb.internal.operation.SyncOperationHelper.executeRetryableWrite(SyncOperationHelper.java:297)
   	at com.mongodb.internal.operation.BaseFindAndModifyOperation.execute(BaseFindAndModifyOperation.java:81)
   	at com.mongodb.client.internal.MongoClusterImpl$OperationExecutorImpl.execute(MongoClusterImpl.java:472)
   	at com.mongodb.client.internal.MongoCollectionImpl.executeFindOneAndUpdate(MongoCollectionImpl.java:787)
   	at com.mongodb.client.internal.MongoCollectionImpl.findOneAndUpdate(MongoCollectionImpl.java:766)
   	at org.grails.datastore.mapping.mongo.engine.MongoCodecEntityPersister.generateIdentifier(MongoCodecEntityPersister.groovy:443)
   	at org.grails.datastore.mapping.mongo.engine.MongoCodecEntityPersister.persistEntity(MongoCodecEntityPersister.groovy:226)
   	at org.grails.datastore.mapping.mongo.engine.MongoCodecEntityPersister.persistEntity(MongoCodecEntityPersister.groovy:567)
   	at org.grails.datastore.mapping.engine.EntityPersister.persist(EntityPersister.java:188)
   	at org.grails.datastore.mapping.core.AbstractSession.persist(AbstractSession.java:609)
   	at org.grails.datastore.gorm.GormInstanceApi.save_closure5(GormInstanceApi.groovy:213)
   	at org.grails.datastore.mapping.core.DatastoreUtils.execute(DatastoreUtils.java:334)
   	at org.grails.datastore.gorm.AbstractGormApi.execute(AbstractGormApi.groovy:164)
   	at org.grails.datastore.gorm.GormInstanceApi.save(GormInstanceApi.groovy:206)
   	at org.grails.datastore.gorm.GormInstanceApi.save(GormInstanceApi.groovy:173)
   	at org.grails.datastore.gorm.GormEntity$Trait$Helper.save(GormEntity.groovy:110)
   	at org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec.test a committed transaction persists all writes atomically_closure2(MongoTransactionSpec.groovy:73)
   	at grails.gorm.transactions.GrailsTransactionTemplate$2.doInTransaction(GrailsTransactionTemplate.groovy:103)
   	at org.springframework.transaction.support.TransactionTemplate.execute(TransactionTemplate.java:137)
   	at grails.gorm.transactions.GrailsTransactionTemplate.execute(GrailsTransactionTemplate.groovy:100)
   	at org.grails.datastore.gorm.GormStaticApi.withTransaction(GormStaticApi.groovy:703)
   	at org.grails.datastore.gorm.GormEntity$Trait$Helper.withTransaction(GormEntity.groovy:1066)
   	at org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec.test a committed transaction persists all writes atomically(MongoTransactionSpec.groovy:71)
   ```
   
   </details>
   <details>
   
   <summary><strong>MongoTransactionSpec > test a findOneAndDelete via the MongoEntity API participates in the transaction</strong> (:grails-data-mongodb-core:test in <a href="https://github.com/apache/grails-core/actions/runs/32300611266/job/96223059193?pr=16094">CI - Groovy Joint Validation Build / Build Grails with Groovy snapshot (shard 1)</a>)</summary>
   
   ```
   com.mongodb.MongoTimeoutException: Timed out while waiting for a server that matches WritableServerSelector. Client view of cluster state is {type=UNKNOWN, servers=[{address=localhost:32844, type=UNKNOWN, state=CONNECTING, exception={com.mongodb.MongoSocketOpenException: Exception opening socket}, caused by {java.net.ConnectException: Connection refused}}]
   	at com.mongodb.internal.connection.BaseCluster.logAndThrowTimeoutException(BaseCluster.java:431)
   	at com.mongodb.internal.connection.BaseCluster.lambda$selectServer$0(BaseCluster.java:166)
   	at com.mongodb.internal.time.Timeout.lambda$onExistsAndExpired$16(Timeout.java:238)
   	at com.mongodb.internal.time.Timeout.lambda$run$10(Timeout.java:203)
   	at com.mongodb.internal.time.TimePoint.checkedCall(TimePoint.java:100)
   	at com.mongodb.internal.time.Timeout.call(Timeout.java:176)
   	at com.mongodb.internal.time.Timeout.run(Timeout.java:196)
   	at com.mongodb.internal.time.Timeout.onExistsAndExpired(Timeout.java:235)
   	at com.mongodb.internal.time.Timeout.onExpired(Timeout.java:228)
   	at com.mongodb.internal.connection.BaseCluster.selectServer(BaseCluster.java:165)
   	at com.mongodb.internal.connection.SingleServerCluster.selectServer(SingleServerCluster.java:47)
   	at com.mongodb.internal.binding.ClusterBinding.getWriteConnectionSource(ClusterBinding.java:91)
   	at com.mongodb.client.internal.ClientSessionBinding.getConnectionSource(ClientSessionBinding.java:109)
   	at com.mongodb.client.internal.ClientSessionBinding.getWriteConnectionSource(ClientSessionBinding.java:102)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSuppliedResource(SyncOperationHelper.java:169)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSourceAndConnection(SyncOperationHelper.java:138)
   	at com.mongodb.internal.operation.SyncOperationHelper.withConnection(SyncOperationHelper.java:117)
   	at com.mongodb.internal.operation.DropDatabaseOperation.execute(DropDatabaseOperation.java:73)
   	at com.mongodb.internal.operation.DropDatabaseOperation.execute(DropDatabaseOperation.java:48)
   	at com.mongodb.client.internal.MongoClusterImpl$OperationExecutorImpl.execute(MongoClusterImpl.java:472)
   	at com.mongodb.client.internal.MongoDatabaseImpl.executeDrop(MongoDatabaseImpl.java:230)
   	at com.mongodb.client.internal.MongoDatabaseImpl.drop(MongoDatabaseImpl.java:220)
   	at org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec.setup_closure1(MongoTransactionSpec.groovy:58)
   	at org.grails.datastore.gorm.GormStaticApi.withNewSession_closure23(GormStaticApi.groovy:767)
   	at org.grails.datastore.mapping.core.DatastoreUtils.executeWithNewSession(DatastoreUtils.java:371)
   	at org.grails.datastore.gorm.GormStaticApi.withNewSession(GormStaticApi.groovy:766)
   	at org.grails.datastore.gorm.GormEntity$Trait$Helper.withNewSession(GormEntity.groovy:1157)
   	at org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec.setup(MongoTransactionSpec.groovy:57)
   ```
   
   </details>
   <details>
   
   <summary><strong>MongoTransactionSpec > test a rolled back transaction discards all writes on the server</strong> (:grails-data-mongodb-core:test in <a href="https://github.com/apache/grails-core/actions/runs/32300611266/job/96223059193?pr=16094">CI - Groovy Joint Validation Build / Build Grails with Groovy snapshot (shard 1)</a>)</summary>
   
   ```
   com.mongodb.MongoTimeoutException: Timed out while waiting for a server that matches WritableServerSelector. Client view of cluster state is {type=UNKNOWN, servers=[{address=localhost:32844, type=UNKNOWN, state=CONNECTING, exception={com.mongodb.MongoSocketOpenException: Exception opening socket}, caused by {java.net.ConnectException: Connection refused}}]
   	at com.mongodb.internal.connection.BaseCluster.logAndThrowTimeoutException(BaseCluster.java:431)
   	at com.mongodb.internal.connection.BaseCluster.lambda$selectServer$0(BaseCluster.java:166)
   	at com.mongodb.internal.time.Timeout.lambda$onExistsAndExpired$16(Timeout.java:238)
   	at com.mongodb.internal.time.Timeout.lambda$run$10(Timeout.java:203)
   	at com.mongodb.internal.time.TimePoint.checkedCall(TimePoint.java:100)
   	at com.mongodb.internal.time.Timeout.call(Timeout.java:176)
   	at com.mongodb.internal.time.Timeout.run(Timeout.java:196)
   	at com.mongodb.internal.time.Timeout.onExistsAndExpired(Timeout.java:235)
   	at com.mongodb.internal.time.Timeout.onExpired(Timeout.java:228)
   	at com.mongodb.internal.connection.BaseCluster.selectServer(BaseCluster.java:165)
   	at com.mongodb.internal.connection.SingleServerCluster.selectServer(SingleServerCluster.java:47)
   	at com.mongodb.internal.binding.ClusterBinding.getWriteConnectionSource(ClusterBinding.java:91)
   	at com.mongodb.client.internal.ClientSessionBinding.getConnectionSource(ClientSessionBinding.java:109)
   	at com.mongodb.client.internal.ClientSessionBinding.getWriteConnectionSource(ClientSessionBinding.java:102)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSuppliedResource(SyncOperationHelper.java:169)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSourceAndConnection(SyncOperationHelper.java:138)
   	at com.mongodb.internal.operation.SyncOperationHelper.withConnection(SyncOperationHelper.java:117)
   	at com.mongodb.internal.operation.DropDatabaseOperation.execute(DropDatabaseOperation.java:73)
   	at com.mongodb.internal.operation.DropDatabaseOperation.execute(DropDatabaseOperation.java:48)
   	at com.mongodb.client.internal.MongoClusterImpl$OperationExecutorImpl.execute(MongoClusterImpl.java:472)
   	at com.mongodb.client.internal.MongoDatabaseImpl.executeDrop(MongoDatabaseImpl.java:230)
   	at com.mongodb.client.internal.MongoDatabaseImpl.drop(MongoDatabaseImpl.java:220)
   	at org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec.setup_closure1(MongoTransactionSpec.groovy:58)
   	at org.grails.datastore.gorm.GormStaticApi.withNewSession_closure23(GormStaticApi.groovy:767)
   	at org.grails.datastore.mapping.core.DatastoreUtils.executeWithNewSession(DatastoreUtils.java:371)
   	at org.grails.datastore.gorm.GormStaticApi.withNewSession(GormStaticApi.groovy:766)
   	at org.grails.datastore.gorm.GormEntity$Trait$Helper.withNewSession(GormEntity.groovy:1157)
   	at org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec.setup(MongoTransactionSpec.groovy:57)
   ```
   
   </details>
   <details>
   
   <summary><strong>MongoTransactionSpec > test read-your-writes within an active transaction</strong> (:grails-data-mongodb-core:test in <a href="https://github.com/apache/grails-core/actions/runs/32300611266/job/96223059193?pr=16094">CI - Groovy Joint Validation Build / Build Grails with Groovy snapshot (shard 1)</a>)</summary>
   
   ```
   com.mongodb.MongoTimeoutException: Timed out while waiting for a server that matches WritableServerSelector. Client view of cluster state is {type=UNKNOWN, servers=[{address=localhost:32844, type=UNKNOWN, state=CONNECTING, exception={com.mongodb.MongoSocketOpenException: Exception opening socket}, caused by {java.net.ConnectException: Connection refused}}]
   	at com.mongodb.internal.connection.BaseCluster.logAndThrowTimeoutException(BaseCluster.java:431)
   	at com.mongodb.internal.connection.BaseCluster.lambda$selectServer$0(BaseCluster.java:166)
   	at com.mongodb.internal.time.Timeout.lambda$onExistsAndExpired$16(Timeout.java:238)
   	at com.mongodb.internal.time.Timeout.lambda$run$10(Timeout.java:203)
   	at com.mongodb.internal.time.TimePoint.checkedCall(TimePoint.java:100)
   	at com.mongodb.internal.time.Timeout.call(Timeout.java:176)
   	at com.mongodb.internal.time.Timeout.run(Timeout.java:196)
   	at com.mongodb.internal.time.Timeout.onExistsAndExpired(Timeout.java:235)
   	at com.mongodb.internal.time.Timeout.onExpired(Timeout.java:228)
   	at com.mongodb.internal.connection.BaseCluster.selectServer(BaseCluster.java:165)
   	at com.mongodb.internal.connection.SingleServerCluster.selectServer(SingleServerCluster.java:47)
   	at com.mongodb.internal.binding.ClusterBinding.getWriteConnectionSource(ClusterBinding.java:91)
   	at com.mongodb.client.internal.ClientSessionBinding.getConnectionSource(ClientSessionBinding.java:109)
   	at com.mongodb.client.internal.ClientSessionBinding.getWriteConnectionSource(ClientSessionBinding.java:102)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSuppliedResource(SyncOperationHelper.java:169)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSourceAndConnection(SyncOperationHelper.java:138)
   	at com.mongodb.internal.operation.SyncOperationHelper.withConnection(SyncOperationHelper.java:117)
   	at com.mongodb.internal.operation.DropDatabaseOperation.execute(DropDatabaseOperation.java:73)
   	at com.mongodb.internal.operation.DropDatabaseOperation.execute(DropDatabaseOperation.java:48)
   	at com.mongodb.client.internal.MongoClusterImpl$OperationExecutorImpl.execute(MongoClusterImpl.java:472)
   	at com.mongodb.client.internal.MongoDatabaseImpl.executeDrop(MongoDatabaseImpl.java:230)
   	at com.mongodb.client.internal.MongoDatabaseImpl.drop(MongoDatabaseImpl.java:220)
   	at org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec.setup_closure1(MongoTransactionSpec.groovy:58)
   	at org.grails.datastore.gorm.GormStaticApi.withNewSession_closure23(GormStaticApi.groovy:767)
   	at org.grails.datastore.mapping.core.DatastoreUtils.executeWithNewSession(DatastoreUtils.java:371)
   	at org.grails.datastore.gorm.GormStaticApi.withNewSession(GormStaticApi.groovy:766)
   	at org.grails.datastore.gorm.GormEntity$Trait$Helper.withNewSession(GormEntity.groovy:1157)
   	at org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec.setup(MongoTransactionSpec.groovy:57)
   ```
   
   </details>
   <details>
   
   <summary><strong>MongoTransactionSpec > test writes across multiple collections roll back together</strong> (:grails-data-mongodb-core:test in <a href="https://github.com/apache/grails-core/actions/runs/32300611266/job/96223059193?pr=16094">CI - Groovy Joint Validation Build / Build Grails with Groovy snapshot (shard 1)</a>)</summary>
   
   ```
   com.mongodb.MongoTimeoutException: Timed out while waiting for a server that matches WritableServerSelector. Client view of cluster state is {type=UNKNOWN, servers=[{address=localhost:32844, type=UNKNOWN, state=CONNECTING, exception={com.mongodb.MongoSocketOpenException: Exception opening socket}, caused by {java.net.ConnectException: Connection refused}}]
   	at com.mongodb.internal.connection.BaseCluster.logAndThrowTimeoutException(BaseCluster.java:431)
   	at com.mongodb.internal.connection.BaseCluster.lambda$selectServer$0(BaseCluster.java:166)
   	at com.mongodb.internal.time.Timeout.lambda$onExistsAndExpired$16(Timeout.java:238)
   	at com.mongodb.internal.time.Timeout.lambda$run$10(Timeout.java:203)
   	at com.mongodb.internal.time.TimePoint.checkedCall(TimePoint.java:100)
   	at com.mongodb.internal.time.Timeout.call(Timeout.java:176)
   	at com.mongodb.internal.time.Timeout.run(Timeout.java:196)
   	at com.mongodb.internal.time.Timeout.onExistsAndExpired(Timeout.java:235)
   	at com.mongodb.internal.time.Timeout.onExpired(Timeout.java:228)
   	at com.mongodb.internal.connection.BaseCluster.selectServer(BaseCluster.java:165)
   	at com.mongodb.internal.connection.SingleServerCluster.selectServer(SingleServerCluster.java:47)
   	at com.mongodb.internal.binding.ClusterBinding.getWriteConnectionSource(ClusterBinding.java:91)
   	at com.mongodb.client.internal.ClientSessionBinding.getConnectionSource(ClientSessionBinding.java:109)
   	at com.mongodb.client.internal.ClientSessionBinding.getWriteConnectionSource(ClientSessionBinding.java:102)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSuppliedResource(SyncOperationHelper.java:169)
   	at com.mongodb.internal.operation.SyncOperationHelper.withSourceAndConnection(SyncOperationHelper.java:138)
   	at com.mongodb.internal.operation.SyncOperationHelper.withConnection(SyncOperationHelper.java:117)
   	at com.mongodb.internal.operation.DropDatabaseOperation.execute(DropDatabaseOperation.java:73)
   	at com.mongodb.internal.operation.DropDatabaseOperation.execute(DropDatabaseOperation.java:48)
   	at com.mongodb.client.internal.MongoClusterImpl$OperationExecutorImpl.execute(MongoClusterImpl.java:472)
   	at com.mongodb.client.internal.MongoDatabaseImpl.executeDrop(MongoDatabaseImpl.java:230)
   	at com.mongodb.client.internal.MongoDatabaseImpl.drop(MongoDatabaseImpl.java:220)
   	at org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec.setup_closure1(MongoTransactionSpec.groovy:58)
   	at org.grails.datastore.gorm.GormStaticApi.withNewSession_closure23(GormStaticApi.groovy:767)
   	at org.grails.datastore.mapping.core.DatastoreUtils.executeWithNewSession(DatastoreUtils.java:371)
   	at org.grails.datastore.gorm.GormStaticApi.withNewSession(GormStaticApi.groovy:766)
   	at org.grails.datastore.gorm.GormEntity$Trait$Helper.withNewSession(GormEntity.groovy:1157)
   	at org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec.setup(MongoTransactionSpec.groovy:57)
   ```
   
   </details>
   
   ### Rerun Controls
   > [!NOTE]
   > Checks are currently running using the configuration below.
   
   Select tests to mute in this pull request:
   
   🔲 MongoTransactionSpec > test a REQUIRES\_NEW inner transaction commits independently of a rolled back outer transaction <!-- uniqueId=[engine:spock]/[spec:org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec]/[feature:$spock_feature_1_6] -->
   🔲 MongoTransactionSpec > test a committed transaction persists all writes atomically <!-- uniqueId=[engine:spock]/[spec:org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec]/[feature:$spock_feature_1_1] -->
   🔲 MongoTransactionSpec > test a findOneAndDelete via the MongoEntity API participates in the transaction <!-- uniqueId=[engine:spock]/[spec:org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec]/[feature:$spock_feature_1_5] -->
   🔲 MongoTransactionSpec > test a rolled back transaction discards all writes on the server <!-- uniqueId=[engine:spock]/[spec:org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec]/[feature:$spock_feature_1_2] -->
   🔲 MongoTransactionSpec > test read-your-writes within an active transaction <!-- uniqueId=[engine:spock]/[spec:org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec]/[feature:$spock_feature_1_4] -->
   🔲 MongoTransactionSpec > test writes across multiple collections roll back together <!-- uniqueId=[engine:spock]/[spec:org.grails.datastore.gorm.mongo.transactions.MongoTransactionSpec]/[feature:$spock_feature_1_3] -->
   
   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](https://testlens.app/docs/features/pr-comment/)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5270721755

   ## 🚨 TestLens detected 1 failed test 🚨
   
   Here is what you can do:
   
   1) Inspect the test failures carefully.
   2) If you are convinced that some of the tests are flaky, you can mute them below.
   3) Finally, trigger a rerun by checking the rerun checkbox.
   
   ### Test Summary
   
   #### [CI / Build Grails-Core \(Windows JDK 25 shard 1\)](https://github.com/apache/grails-core/actions/runs/31625201224/job/94209886938?pr=16094) > :grails-async-rxjava2:test
   
   | Test | Runs | Flakiness |
   |---|---|--:|
   | RxPromiseSpec > Test promise timeout handling | ❌ | [1%&nbsp;🟡](https://github.com/apache/grails-core/issues/16030) |
   
   🏷️ Commit: fb6518ead975a30780358db028c42263d5076416
   ▶️ Tests:  3010 executed
   🟡 Checks: 19/70 completed
   
   ### Test Failures
   
   <details>
   
   <summary><strong>RxPromiseSpec > Test promise timeout handling</strong> (:grails-async-rxjava2:test in <a href="https://github.com/apache/grails-core/actions/runs/31625201224/job/94209886938?pr=16094">CI / Build Grails-Core (Windows JDK 25 shard 1)</a>)</summary>
   
   ```
   Expected exception of type 'java.util.concurrent.TimeoutException', but no exception was thrown
   	at org.grails.async.factory.rxjava2.RxPromiseSpec.Test promise timeout handling(RxPromiseSpec.groovy:61)
   ```
   
   </details>
   
   ### Muted Tests
   > [!NOTE]
   > Checks are currently running using the configuration below.
   
   Select tests to mute in this pull request:
   
   🔲 RxPromiseSpec > Test promise timeout handling <!-- uniqueId=[engine:spock]/[spec:org.grails.async.factory.rxjava2.RxPromiseSpec]/[feature:$spock_feature_0_2] -->
   
   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](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5248213935

   ## ✅ All tests passed ✅
   
   🏷️ Commit: 33af958b12e5e97badd87e1a642145a5905e2d75
   ▶️ Tests:  69174 executed
   ⚪️ Checks: 78/78 completed
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3776025825


##########
grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy:
##########
@@ -363,10 +666,13 @@ class GrailsBanner implements Banner {
 
     /**
      * Enumeration of optional version options.
+     *
+     * <p>The container being run is shown by default under {@code container}, which is the one an
+     * application is on. These name a particular container instead, for an application that wants
+     * to be told about one whether or not it is the one serving.</p>
      */
     @CompileStatic
     enum OptionalVersionOption {

Review Comment:
   `SPRING_SECURITY` is removed from `OptionalVersionOption` and moved into `DefaultVersionOption`. `OptionalVersionOption` is a public enum on a public class — removing a constant is a source- and binary-breaking change for anything referencing it, and it isn't listed in `upgrading80x.adoc`.
   
   More generally: the whole banner rework in this file (ANSI colouring, the NATIVE/AOT CACHE mark, `CONTAINER`, the default-set changes) is a user-visible feature that is independent of making an application AOT-processable. Only `resolveMark()` needs the AOT work at all. Please split the banner into its own PR — it's the part most likely to attract bikeshedding, and holding the AOT fix behind it helps nobody.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "codeconsole (via GitHub)" <gi...@apache.org>.
codeconsole commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3792613465


##########
grails-core/src/main/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilter.java:
##########
@@ -0,0 +1,48 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.spring.beans.aot;

Review Comment:
   Moved. `org.grails.gradle.plugin.aot` -> `org.apache.grails.gradle.plugin.aot` in `e28672d89e`, and `org.grails.plugins.web.TagLibBeanDefinitionsPostProcessor` -> `org.apache.grails.gsp.taglib` in `63a462a776` (not `.aot`: `GroovyPagesGrailsPlugin` registers it unconditionally, so it runs in every environment).
   
   Every class this PR adds under `src/main` is now in `org.apache.grails.*`. What remains under `org.grails.*` is test specs sitting in the package of the pre-existing class they test.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5308961388

   ## 🚨 TestLens detected 1 failed test 🚨
   
   Here is what you can do:
   
   1) Inspect the test failures carefully.
   2) If you are convinced that some of the tests are flaky, you can mute them below.
   3) Finally, trigger a rerun by checking the rerun checkbox.
   
   ### Test Summary
   
   #### [CI / Functional Tests \(Java 21, indy\=true, shard 1\)](https://github.com/apache/grails-core/actions/runs/31963210367/job/95204246015?pr=16094) > :grails-test-examples-app1:integrationTest
   
   | Test | Runs | Flakiness |
   |---|---|--:|
   | AsyncPromiseSpec > async service processes string input | ❌ | 0%&nbsp;🟢 |
   
   🏷️ Commit: db49c9488f61b269822d4afb03e5cf156d07d939
   ▶️ Tests:  21040 executed
   🟡 Checks: 24/74 completed
   
   ### Test Failures
   
   <details>
   
   <summary><strong>AsyncPromiseSpec > async service processes string input</strong> (:grails-test-examples-app1:integrationTest in <a href="https://github.com/apache/grails-core/actions/runs/31963210367/job/95204246015?pr=16094">CI / Functional Tests (Java 21, indy=true, shard 1)</a>)</summary>
   
   ```
   java.net.http.HttpTimeoutException: request timed out
   	at java.net.http/jdk.internal.net.http.HttpClientImpl.send(HttpClientImpl.java:953)
   	at java.net.http/jdk.internal.net.http.HttpClientFacade.send(HttpClientFacade.java:133)
   	at org.apache.grails.testing.http.client.HttpClientSupport$Trait$Helper.send(HttpClientSupport.groovy:1133)
   	at org.apache.grails.testing.http.client.HttpClientSupport$Trait$Helper.send(HttpClientSupport.groovy:1122)
   	at org.apache.grails.testing.http.client.HttpClientSupport$Trait$Helper.http(HttpClientSupport.groovy:203)
   	at org.apache.grails.testing.http.client.HttpClientSupport$Trait$Helper.http(HttpClientSupport.groovy:169)
   	at functionaltests.async.AsyncPromiseSpec.async service processes string input(AsyncPromiseSpec.groovy:139)
   ```
   
   </details>
   
   ### Muted Tests
   > [!NOTE]
   > Checks are currently running using the configuration below.
   
   Select tests to mute in this pull request:
   
   🔲 AsyncPromiseSpec > async service processes string input <!-- uniqueId=[engine:spock]/[spec:functionaltests.async.AsyncPromiseSpec]/[feature:$spock_feature_0_6] -->
   
   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](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "codeconsole (via GitHub)" <gi...@apache.org>.
codeconsole commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5346120713

   @borinquenkid
   
   #2 `5c836c436f` — class literals for the six declared by `grails-controllers` or the JDK. `RestResponder` stays a string: it's in `grails-rest-transforms`, not a dependency.
   
   #3 and #4 already exist in `deploymentAotCache.adoc`, lines 105 and 85 — present in `6d468648a8`, the reviewed commit.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5347564827

   ## ✅ All tests passed ✅
   
   🏷️ Commit: 49e51adbdc66321a1c536b4aed979e40fe8ce671
   ▶️ Tests:  70319 executed
   ⚪️ Checks: 81/81 completed
   
   ---
   _Learn more about TestLens at [testlens.app/docs](https://testlens.app/docs/features/pr-comment/)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "codeconsole (via GitHub)" <gi...@apache.org>.
codeconsole commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3781984527


##########
grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy:
##########
@@ -88,6 +96,29 @@ class GrailsAutoConfiguration implements GrailsApplicationClass, ApplicationCont
         return classes
     }
 
+    /**
+     * The artefacts written down while the application's code was generated, or {@code null} where
+     * nothing was written down and they are to be found the usual ways.
+     *
+     * <p>Both usual ways need something an image does not have: one walks the classpath, the other
+     * reads a list the compile-time transform builds as it goes, which is empty in anything the
+     * transform did not itself compile. So an image found no artefacts at all, and an application
+     * could only start by naming its own -- a list to keep in step with itself forever after.</p>
+     *
+     * <p>They were found while the code was generated, on an ordinary JVM where both ways work, and
+     * left here.</p>
+     */
+    protected Collection<Class> artefactsWrittenDownAheadOfTime() {

Review Comment:
   It is guaranteed on both paths, and now said so in the javadoc (b22be60):
   
   - `GrailsEarlyPluginRegistrationPostProcessor.scanApplicationSource` calls `setApplicationContext` on the instance it creates, before `classes()`.
   - The `@Bean` method is invoked on this configuration class after Spring has applied `ApplicationContextAware`.
   
   It already had to hold before this PR: the scan branch of `classes()` resolves through `new PathMatchingResourcePatternResolver(applicationContext)`, so a null context would have failed there.
   
   Also changed to read `getBeanFactory()` rather than `getAutowireCapableBeanFactory()`, which refuses a context that has not been refreshed — this is asked while the definitions are still being contributed, which is why the classes are left as a singleton at all. `GrailsAutoConfigurationArtefactsSpec` covers `classes()` rather than the method behind it.



##########
grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy:
##########
@@ -155,6 +156,25 @@ class UrlMappingsGrailsPlugin extends Plugin {
         }
     }
 
+    /**
+     * Whether the mappings are to be reloadable, which decides how the holder is defined.
+     *
+     * <p>Not while the code is being generated, whatever the machine generating it looks like.
+     * Reloading swaps the mappings behind a proxy, and the proxy produces its {@code UrlMappings}
+     * through a target source rather than declaring the type -- so Spring can only learn what it
+     * produces by building it, which is exactly what reading a generated definition avoids.
+     * Generated that way nothing could be autowired by that type, and the application did not
+     * start. An image cannot reload anything in any case.</p>
+     */
+    protected boolean isReloadEnabled() {

Review Comment:
   `grails.env=production`, set on the task by the Gradle plugin, is the load-bearing one. The other two are gone in 1714f2b:
   
   - The `AOT_PROCESSING` check here is redundant: with the environment already not a reloading one it changes nothing. That check, the two seams it was reached through and their tests are removed, and `UrlMappingsGrailsPlugin` is byte-identical to the merge base again.
   - The manual `systemProperty 'grails.env', 'production'` in `grails-test-examples/aot` repeated what the plugin does; removed. The guide no longer asks for it either, and the Limitations note in the description is stale.
   
   Verified by removing each in turn and running `:grails-test-examples-aot:check`.
   
   The equivalent check in `GroovyPagesGrailsPlugin` is *not* redundant and stays. `isDevelopmentEnvironmentAvailable()` asks whether `grails-app` is on disk, which the environment does not change. Removing it does not fail the build — it writes
   
   ```java
   addPropertyValue("baseResource", "file:/home/build/app/")
   ```
   
   into the generated definitions, which starts cleanly on the machine that generated it and looks for its pages somewhere that exists nowhere else. There is now a check on the example application's generated sources that fails the build if anything writes a build path into them.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5276517649

   ## 🚨 TestLens detected 2 failed tests 🚨
   
   Here is what you can do:
   
   1) Inspect the test failures carefully.
   2) If you are convinced that some of the tests are flaky, you can mute them below.
   3) Finally, trigger a rerun by checking the rerun checkbox.
   
   ### Test Summary
   
   #### [CI / Functional Tests \(Java 25, indy\=false, shard 1\)](https://github.com/apache/grails-core/actions/runs/31670867336/job/94355018020?pr=16094) > :grails-test-examples-app1:integrationTest
   
   | Test | Runs | Flakiness |
   |---|---|--:|
   | BarFunctionalSpec > Test that a scaffolded view is resolved correctly | ❌ | 0%&nbsp;🟢 |
   | FooFunctionalSpec > Test that a scaffolded view is resolved correctly | ❌ | 0%&nbsp;🟢 |
   
   🏷️ Commit: abd0453525d877b414ae54eb6d38e67b727182eb
   ▶️ Tests:  18638 executed
   🟡 Checks: 22/72 completed
   
   ### Test Failures
   
   <details>
   
   <summary><strong>BarFunctionalSpec > Test that a scaffolded view is resolved correctly</strong> (:grails-test-examples-app1:integrationTest in <a href="https://github.com/apache/grails-core/actions/runs/31670867336/job/94355018020?pr=16094">CI / Functional Tests (Java 25, indy=false, shard 1)</a>)</summary>
   
   ```
   Condition failed with Exception:
   
   to(BarListPage)
   |  |
   |  class functionaltests.pages.BarListPage
   geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
   	at geb.waiting.Wait.waitFor(Wait.groovy:128)
   	at geb.waiting.PotentiallyWaitingExecutor.execute(PotentiallyWaitingExecutor.groovy:31)
   	at geb.Page.verifyThisPageAtOnly(Page.groovy:424)
   	at geb.Page.getAtVerificationResult(Page.groovy:217)
   	at geb.Page.verifyAt(Page.groovy:188)
   	at geb.Browser.doAt(Browser.groovy:1208)
   	at geb.Browser.at(Browser.groovy:410)
   	at geb.Browser.to(Browser.groovy:566)
   	at geb.Browser.to(Browser.groovy:543)
   	at geb.Browser.to(Browser.groovy:532)
   	at grails.plugin.geb.support.delegate.BrowserDelegate$Trait$Helper.to(BrowserDelegate.groovy:160)
   	at grails.plugin.geb.support.delegate.BrowserDelegate$Trait$Helper.to(BrowserDelegate.groovy:159)
   	at functionaltests.scaffolding.BarFunctionalSpec.Test that a scaffolded view is resolved correctly(BarFunctionalSpec.groovy:30)
   Caused by: Assertion failed: 
    
   title == pageTitle
   |     |  |
   |     |  'Bar List'
   |     false
   'Error'
    
   	at functionaltests.pages.BarListPage._clinit__closure1(BarListPage.groovy:28)
   	at geb.waiting.Wait.waitFor(Wait.groovy:117)
   	... 12 more
   
   	at functionaltests.scaffolding.BarFunctionalSpec.Test that a scaffolded view is resolved correctly(BarFunctionalSpec.groovy:30)
   Caused by: geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
   	at geb.waiting.Wait.waitFor(Wait.groovy:128)
   	at geb.waiting.PotentiallyWaitingExecutor.execute(PotentiallyWaitingExecutor.groovy:31)
   	at geb.Page.verifyThisPageAtOnly(Page.groovy:424)
   	at geb.Page.getAtVerificationResult(Page.groovy:217)
   	at geb.Page.verifyAt(Page.groovy:188)
   	at geb.Browser.doAt(Browser.groovy:1208)
   	at geb.Browser.at(Browser.groovy:410)
   	at geb.Browser.to(Browser.groovy:566)
   	at geb.Browser.to(Browser.groovy:543)
   	at geb.Browser.to(Browser.groovy:532)
   	at grails.plugin.geb.support.delegate.BrowserDelegate$Trait$Helper.to(BrowserDelegate.groovy:160)
   	at grails.plugin.geb.support.delegate.BrowserDelegate$Trait$Helper.to(BrowserDelegate.groovy:159)
   	... 1 more
   Caused by: Assertion failed: 
   
   title == pageTitle
   |     |  |
   |     |  'Bar List'
   |     false
   'Error'
   
   	at functionaltests.pages.BarListPage._clinit__closure1(BarListPage.groovy:28)
   	at geb.waiting.Wait.waitFor(Wait.groovy:117)
   	... 12 more
   ```
   
   </details>
   <details>
   
   <summary><strong>FooFunctionalSpec > Test that a scaffolded view is resolved correctly</strong> (:grails-test-examples-app1:integrationTest in <a href="https://github.com/apache/grails-core/actions/runs/31670867336/job/94355018020?pr=16094">CI / Functional Tests (Java 25, indy=false, shard 1)</a>)</summary>
   
   ```
   Condition failed with Exception:
   
   to(FooListPage)
   |  |
   |  class functionaltests.pages.FooListPage
   geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
   	at geb.waiting.Wait.waitFor(Wait.groovy:128)
   	at geb.waiting.PotentiallyWaitingExecutor.execute(PotentiallyWaitingExecutor.groovy:31)
   	at geb.Page.verifyThisPageAtOnly(Page.groovy:424)
   	at geb.Page.getAtVerificationResult(Page.groovy:217)
   	at geb.Page.verifyAt(Page.groovy:188)
   	at geb.Browser.doAt(Browser.groovy:1208)
   	at geb.Browser.at(Browser.groovy:410)
   	at geb.Browser.to(Browser.groovy:566)
   	at geb.Browser.to(Browser.groovy:543)
   	at geb.Browser.to(Browser.groovy:532)
   	at grails.plugin.geb.support.delegate.BrowserDelegate$Trait$Helper.to(BrowserDelegate.groovy:160)
   	at grails.plugin.geb.support.delegate.BrowserDelegate$Trait$Helper.to(BrowserDelegate.groovy:159)
   	at functionaltests.scaffolding.FooFunctionalSpec.Test that a scaffolded view is resolved correctly(FooFunctionalSpec.groovy:30)
   Caused by: Assertion failed: 
    
   title == pageTitle
   |     |  |
   |     |  'Foo List'
   |     false
   'Error'
    
   	at functionaltests.pages.FooListPage._clinit__closure1(FooListPage.groovy:28)
   	at geb.waiting.Wait.waitFor(Wait.groovy:117)
   	... 12 more
   
   	at functionaltests.scaffolding.FooFunctionalSpec.Test that a scaffolded view is resolved correctly(FooFunctionalSpec.groovy:30)
   Caused by: geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
   	at geb.waiting.Wait.waitFor(Wait.groovy:128)
   	at geb.waiting.PotentiallyWaitingExecutor.execute(PotentiallyWaitingExecutor.groovy:31)
   	at geb.Page.verifyThisPageAtOnly(Page.groovy:424)
   	at geb.Page.getAtVerificationResult(Page.groovy:217)
   	at geb.Page.verifyAt(Page.groovy:188)
   	at geb.Browser.doAt(Browser.groovy:1208)
   	at geb.Browser.at(Browser.groovy:410)
   	at geb.Browser.to(Browser.groovy:566)
   	at geb.Browser.to(Browser.groovy:543)
   	at geb.Browser.to(Browser.groovy:532)
   	at grails.plugin.geb.support.delegate.BrowserDelegate$Trait$Helper.to(BrowserDelegate.groovy:160)
   	at grails.plugin.geb.support.delegate.BrowserDelegate$Trait$Helper.to(BrowserDelegate.groovy:159)
   	... 1 more
   Caused by: Assertion failed: 
   
   title == pageTitle
   |     |  |
   |     |  'Foo List'
   |     false
   'Error'
   
   	at functionaltests.pages.FooListPage._clinit__closure1(FooListPage.groovy:28)
   	at geb.waiting.Wait.waitFor(Wait.groovy:117)
   	... 12 more
   ```
   
   </details>
   
   ### Muted Tests
   > [!NOTE]
   > Checks are currently running using the configuration below.
   
   Select tests to mute in this pull request:
   
   🔲 BarFunctionalSpec > Test that a scaffolded view is resolved correctly <!-- uniqueId=[engine:spock]/[spec:functionaltests.scaffolding.BarFunctionalSpec]/[feature:$spock_feature_1_0] -->
   🔲 FooFunctionalSpec > Test that a scaffolded view is resolved correctly <!-- uniqueId=[engine:spock]/[spec:functionaltests.scaffolding.FooFunctionalSpec]/[feature:$spock_feature_1_0] -->
   
   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](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "codeconsole (via GitHub)" <gi...@apache.org>.
codeconsole commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3792529535


##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisher.groovy:
##########
@@ -32,14 +34,26 @@ import org.springframework.context.ConfigurableApplicationContext
  * @since 6.0
  */
 @CompileStatic
-class ConfigurableApplicationContextEventPublisher implements ConfigurableApplicationEventPublisher {
+class ConfigurableApplicationContextEventPublisher implements ConfigurableApplicationEventPublisher, ApplicationContextAware {
 
-    final ConfigurableApplicationContext applicationContext
+    ConfigurableApplicationContext applicationContext
+
+    /**
+     * Takes the context from the container. A bean definition built this way holds no
+     * already-constructed object, which is what allows it to be processed ahead of time.
+     */
+    ConfigurableApplicationContextEventPublisher() {
+    }
 
     ConfigurableApplicationContextEventPublisher(ConfigurableApplicationContext applicationContext) {
         this.applicationContext = applicationContext
     }
 
+    @Override
+    void setApplicationContext(ApplicationContext applicationContext) {

Review Comment:
   `416096af49`.
   
   - `setApplicationContext` narrows with `instanceof` and leaves the field unset otherwise.
   - `addApplicationListener` and both `publishEvent` overloads go through `context()`, which throws `IllegalStateException` naming the plain-bean-factory case instead of an NPE from inside GORM.
   - The field is `volatile`.
   



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/ExtractApplicationTask.groovy:
##########
@@ -0,0 +1,88 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.aot
+
+import javax.inject.Inject
+
+import groovy.transform.CompileStatic
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.FileSystemOperations
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.process.ExecOperations
+
+/**
+ * Unpacks an executable jar into the form an AOT cache is read against.
+ *
+ * <p>An executable jar loads its dependencies through a nested-jar class loader; the extracted form
+ * loads them as ordinary jars on the classpath. A cache records the class path it saw, so only the
+ * second is a layout a later run can reuse one against.</p>
+ *
+ * <p>The work is done through injected services rather than through the project, so that nothing is
+ * reached at execution time that the configuration cache forbids.</p>
+ *
+ * @since 8.0
+ */
+@CacheableTask
+@CompileStatic
+abstract class ExtractApplicationTask extends DefaultTask {
+
+    /** The archive to unpack, which has to be the one the cache will be trained against. */
+    @InputFile
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract RegularFileProperty getArchiveFile()
+
+    /**
+     * The java to unpack with. Its {@code jarmode} does the extracting, so it is the JDK the
+     * application was built for rather than whichever one is running Gradle.
+     */
+    @Input
+    abstract Property<String> getJavaExecutable()

Review Comment:
   `a3d5a48b39`. `@Input Property<String> javaExecutable` → `@Nested Property<JavaLauncher> javaLauncher`, as in `GroovyPageForkCompileTask`. The plugin sets it with the launcher provider, so reading the project still provisions no JDK. Added `ExtractApplicationTaskSpec`.
   
   `TrainAotCacheTask` keeps its executable path: it is `@DisableCachingByDefault`, and its spec substitutes a shell script for the JVM, which a `JavaLauncher` cannot express.
   



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/TrainAotCacheTask.groovy:
##########
@@ -0,0 +1,376 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.aot
+
+import java.security.MessageDigest
+import java.time.Duration
+
+import groovy.transform.CompileStatic
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputDirectory
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.work.DisableCachingByDefault
+
+/**
+ * Runs the application once so the JDK can write down what starting it needs.
+ *
+ * <p>The run is the point. A cache records the classes loaded and linked, and the profiles of the
+ * methods that ran, so the next start reads them rather than working them out again -- which means
+ * what the next start is <em>fast at</em> is whatever this run did. A run that only refreshes the
+ * context leaves every request path to be worked out on the day.</p>
+ *
+ * <p>So the application is started, asked for the pages an application is asked for, and then asked
+ * to stop. It has to stop of its own accord: the cache is written as the JVM exits, and a run that is
+ * killed writes nothing.</p>
+ *
+ * @since 8.0
+ */
+@CompileStatic
+@DisableCachingByDefault(because = 'Runs the application and records what it did, which is not reproducible')
+abstract class TrainAotCacheTask extends DefaultTask {
+
+    /**
+     * The extracted application: the cache is only usable against the layout it was trained on.
+     *
+     * <p>Compared by what is in it and where each file sits within it, not by where the directory
+     * itself is. Without saying so, Gradle takes the absolute path to be part of the input and
+     * refuses to validate the task at all -- and a build that did run would key its result to a
+     * path, so the same application checked out somewhere else, or built on CI, would agree about
+     * everything and still share nothing.</p>
+     */
+    @InputDirectory
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract DirectoryProperty getApplicationDirectory()
+
+    @Input
+    abstract Property<String> getArchiveFileName()
+
+    @OutputFile
+    abstract RegularFileProperty getCacheFile()
+
+    @Input
+    abstract Property<String> getJavaExecutable()
+
+    /** The version of the JDK above, which is the JDK the cache will only ever be readable by. */
+    @Input
+    abstract Property<String> getJavaVersion()
+
+    @Input
+    abstract Property<String> getJavaVendor()
+
+    /** Given to the training run, and to be given to every run that reads the cache. */
+    @Input
+    abstract ListProperty<String> getJvmArguments()
+
+    /** The paths to ask for, so their methods are profiled rather than met for the first time later. */
+    @Input
+    abstract ListProperty<String> getPaths()
+
+    @Input
+    abstract Property<Integer> getPort()
+
+    @Input
+    abstract Property<Integer> getStartTimeoutSeconds()
+
+    /** Written beside the cache, so what the cache was made from can be checked before it is used. */
+    @OutputFile
+    abstract RegularFileProperty getMetadataFile()
+
+    /** The first JDK that can write one. Before it, {@code -XX:AOTCacheOutput} is not an option. */
+    private static final int MINIMUM_JAVA_VERSION = 25
+
+    @TaskAction
+    void train() {
+        refuseWhereTheRunCannotBeAskedToStop()
+        refuseWhereTheJdkCannotWriteACache()
+        File directory = applicationDirectory.get().asFile
+        File cache = cacheFile.get().asFile
+        cache.delete()
+
+        List<String> command = []
+        command << javaExecutable.get()
+        command << "-XX:AOTCacheOutput=${cache.absolutePath}".toString()
+        command.addAll(jvmArguments.get())
+        command << '-jar' << archiveFileName.get()
+        command << "--server.port=${port.get()}".toString()
+
+        File output = new File(temporaryDir, 'training.log')
+        ProcessBuilder builder = new ProcessBuilder(command)
+                .directory(directory)
+                .redirectErrorStream(true)
+                .redirectOutput(output)
+        withoutEmptyVariables(builder.environment())
+        Process process = builder.start()
+        try {
+            awaitStarted(process, output)
+            exercise()
+        }
+        finally {
+            stop(process)
+        }
+        if (!cache.isFile()) {
+            throw new GradleException("The training run wrote no cache. What it printed is in ${output}")
+        }
+        describe(cache, new File(directory, archiveFileName.get()))
+        logger.lifecycle('Trained {} ({} MB) over {} paths',
+                cache.name, (cache.length() / (1024 * 1024)) as long, paths.get().size())
+    }
+
+    /**
+     * Drops the variables that are present but empty from what the run will inherit.
+     *
+     * <p>The run inherits the daemon's environment, and the daemon's is not the one the build was
+     * started from. A daemon that once ran a build with a variable set keeps the name afterwards
+     * and empties the value, so a later build started from a shell that never mentioned it hands
+     * the run {@code SOME_VARIABLE=""} all the same.</p>
+     *
+     * <p>Spring Boot binds an environment variable over the application's own configuration, and
+     * relaxed binding means {@code GRAILS_MONGODB_URL} is {@code grails.mongodb.url}. So an empty
+     * leftover replaced a configured value with nothing, and the training run failed on a property
+     * the application had set correctly -- reporting it against a name nobody had typed, in a
+     * build that had run cleanly minutes earlier from a different shell.</p>
+     *
+     * <p>An empty variable says nothing that an absent one does not, so it is not passed on. What
+     * the build was actually given, empty or not, still arrives: this drops only what the daemon
+     * kept after the build that set it had finished.</p>
+     */
+    private static void withoutEmptyVariables(Map<String, String> environment) {
+        environment.entrySet().removeIf { Map.Entry<String, String> variable ->
+            !variable.value
+        }
+    }
+
+    /**
+     * Refuses to start a run that could not be ended properly, before it is started.
+     *
+     * <p>The cache is written as the training JVM exits normally, so the run has to be asked to
+     * stop rather than killed. {@link Process#destroy()} asks on POSIX and kills on Windows, where
+     * it is {@code TerminateProcess} and no shutdown runs -- so on Windows the run would be
+     * exercised in full, killed, and leave no cache, and the build would fail at the end saying the
+     * cache was not written rather than saying why it could not be.</p>
+     */
+    private static void refuseWhereTheRunCannotBeAskedToStop() {
+        String os = System.getProperty('os.name', '')
+        if (os.toLowerCase(Locale.ROOT).contains('win')) {
+            throw new GradleException('Training an AOT cache needs the training run to be asked to ' +
+                    'stop, and on Windows a child process can only be killed -- which writes no ' +
+                    'cache. Train on Linux or macOS, or set grails.aotCache.enabled to false.')
+        }
+    }
+
+    /**
+     * Refuses on a JDK that has no cache to write, before the run is started.
+     *
+     * <p>{@code -XX:AOTCacheOutput} arrived in JDK 25. An earlier JDK does not recognise it and
+     * stops immediately, so the run would be started, fail at once, and be reported as having
+     * ended before it started serving -- which reads as a broken application rather than as the
+     * wrong JDK, and sends whoever is reading the build into a log of the application's own
+     * start-up that never happened.</p>
+     *
+     * <p>Asked of the JDK the training will run on, which is the project's toolchain rather than
+     * whichever one is running Gradle.</p>
+     */
+    private void refuseWhereTheJdkCannotWriteACache() {
+        String version = javaVersion.get()
+        Integer major = majorVersionOf(version)
+        if (major != null && major < MINIMUM_JAVA_VERSION) {
+            throw new GradleException("Training an AOT cache needs Java ${MINIMUM_JAVA_VERSION} or " +
+                    "later, and the toolchain this would train on is ${version}. Point the project " +
+                    'toolchain at a newer JDK, or set grails.aotCache.enabled to false.')
+        }
+    }
+
+    /**
+     * The feature version of a runtime version string, or {@code null} where it does not begin with
+     * one. Unreadable rather than old: a version this cannot parse is left to the run to answer for,
+     * since refusing over it would fail a build on a JDK that may be perfectly capable.
+     */
+    private static Integer majorVersionOf(String version) {
+        int digits = 0
+        while (digits < version.length() && Character.isDigit(version.charAt(digits))) {
+            digits++
+        }
+        digits > 0 ? Integer.valueOf(version.substring(0, digits)) : null
+    }
+
+    /**
+     * Writes down what the cache was made from.
+     *
+     * <p>A cache is read only by the JDK build that wrote it, against the archive it was trained on,
+     * with the arguments it was trained with. A JVM given one made from anything else declines it and
+     * starts as it would have anyway -- so what is lost is the speed, silently, and this is what
+     * tells a deployment which of those it has.</p>
+     */
+    private void describe(File cache, File archive) {
+        Properties properties = new Properties()
+        properties.setProperty('cache.file', cache.name)
+        properties.setProperty('cache.bytes', String.valueOf(cache.length()))
+        properties.setProperty('application.archive', archive.name)
+        properties.setProperty('application.sha256', sha256(archive))
+        properties.setProperty('training.arguments', jvmArguments.get().join(' '))
+        properties.setProperty('training.paths', paths.get().join(' '))
+        properties.setProperty('java.vendor', javaVendor.get())
+        properties.setProperty('java.runtime.version', javaVersion.get())
+        properties.setProperty('os.name', System.getProperty('os.name', ''))
+        properties.setProperty('os.arch', System.getProperty('os.arch', ''))
+        metadataFile.get().asFile.withOutputStream { OutputStream out ->
+            properties.store(out, 'What this AOT cache was trained from')
+        }
+    }
+
+    private static String sha256(File file) {
+        MessageDigest digest = MessageDigest.getInstance('SHA-256')
+        file.withInputStream { InputStream input ->
+            byte[] buffer = new byte[8192]
+            int read
+            while ((read = input.read(buffer)) != -1) {
+                digest.update(buffer, 0, read)
+            }
+        }
+        digest.digest().encodeHex().toString()
+    }
+
+    /**
+     * Waits for the application to start serving, and stops waiting if the run ends first --
+     * otherwise a run that fails immediately is waited on for the whole timeout.
+     *
+     * <p>Either the run says it started or its port answers. The message is Spring Boot's and is
+     * the earlier and clearer of the two, but it is an INFO log an application is free to turn off
+     * or reword; a port that accepts a connection is the application's own doing and cannot be
+     * configured away while it is still an application worth training.</p>
+     */
+    private void awaitStarted(Process process, File output) {
+        long deadline = System.currentTimeMillis() + Duration.ofSeconds(startTimeoutSeconds.get()).toMillis()
+        while (System.currentTimeMillis() < deadline) {
+            if (!process.isAlive()) {
+                throw new GradleException('The training run ended before it started serving.' +
+                        whyItEnded(output) + " What it printed is in ${output}")
+            }
+            if (output.isFile() && output.text.contains('Started ')) {

Review Comment:
   `03638e7424`. Added `StartupLog`, used by both tasks, matching `Started .+ in [\d.]+ seconds`.
   
   It also reads only what has been added since the last poll, decodes UTF-8 explicitly, and matches whole lines, so a line still being written is left rather than cut in half. Added `StartupLogSpec` covering `Started ServerConnector@...`, `Started Server@...`, `Tomcat started on port 8080`, and a Spring Boot line arriving in two halves.
   



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/TraceNativeMetadataTask.groovy:
##########
@@ -0,0 +1,484 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.aot
+
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.time.Duration
+import java.util.concurrent.TimeUnit
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+import groovy.transform.CompileStatic
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.work.DisableCachingByDefault
+
+/**
+ * Runs the application under GraalVM's tracing agent and writes down the reflection it did.
+ *
+ * <p>{@code GenerateNativeMetadataTask} records an application's own artefacts by reading the build
+ * output, which needs no run and misses nothing of the application's. What it cannot know is the
+ * framework's own reflection along a request path -- a controller method reached through Groovy's
+ * dispatch, a conversion asked for while binding a form -- because none of that is in the
+ * application's classes. An image built without it starts, serves its home page, and fails on the
+ * request that first takes such a path.</p>
+ *
+ * <p>The agent records exactly that, and records only what ran. So the paths are declared rather
+ * than discovered, and what is not listed is not covered:</p>
+ *
+ * <pre>
+ * grails {
+ *     nativeMetadata {
+ *         paths = ['/', '/login', '/book', '/book/create']
+ *         forms = ['/book/create']
+ *     }
+ * }
+ * </pre>
+ *
+ * <p>A form is asked for, read, and submitted with the fields it declares, because posting fewer
+ * than it declares records less than the application does. A checkbox is a string on the wire and a
+ * boolean on the domain class, so a form submitted without one never asks the conversion service
+ * anything -- and an image built from that trace fails the first time someone ticks a box.</p>
+ *
+ * <p>Forms are submitted before paths are asked for, and the session one establishes is carried
+ * through the rest of the trace. A page behind a login is reached by listing the login form, which
+ * makes the pages named after it reachable:</p>
+ *
+ * <pre>
+ * grails {
+ *     nativeMetadata {
+ *         forms = ['/login?username=admin&amp;password=secret', '/book/create']
+ *         paths = ['/', '/book']
+ *     }
+ * }
+ * </pre>
+ *
+ * <p>Where a page carries more than one form -- a layout's search or sign-out beside the page's own
+ * -- the one declaring the most fields is submitted, and which it was is reported. A page whose
+ * wanted form is not the fullest names it: {@code '/book/create#bookForm'}.</p>
+ *
+ * <p>Written to the application's sources rather than to the build directory: what an image was
+ * built from should be reviewable, and a trace is only as good as the paths someone thought of.</p>
+ *
+ * @since 8.0
+ */
+@CompileStatic
+@DisableCachingByDefault(because = 'Records what a run of the application did, which is not an output of its inputs')
+abstract class TraceNativeMetadataTask extends DefaultTask {
+
+    /** How the agent is asked for, and the only way its output can be merged with what is there. */
+    private static final String AGENT = 'native-image-agent'
+
+    /** The names the agent library goes by, one of which is beside a GraalVM's java. */
+    private static final List<String> AGENT_LIBRARIES = [
+            'libnative-image-agent.dylib', 'libnative-image-agent.so', 'native-image-agent.dll'
+    ]
+
+    private static final Pattern FORM = Pattern.compile(/(?is)<form\b[^>]*>.*?<\/form>/)
+    private static final Pattern ACTION = Pattern.compile(/(?i)\baction\s*=\s*"([^"]*)"/)
+    private static final Pattern ID = Pattern.compile(/(?i)\bid\s*=\s*"([^"]+)"/)
+    private static final Pattern FIELD = Pattern.compile(/(?is)<(?:input|select|textarea)\b[^>]*>/)
+    private static final Pattern NAME = Pattern.compile(/(?i)\bname\s*=\s*"([^"]+)"/)
+    private static final Pattern VALUE = Pattern.compile(/(?i)\bvalue\s*=\s*"([^"]*)"/)
+    private static final Pattern TYPE = Pattern.compile(/(?i)\btype\s*=\s*"([^"]+)"/)
+
+    /**
+     * Carries the session from one request to the next, which is what makes a form that
+     * authenticates worth submitting: the pages that follow it are only reachable once it has been.
+     *
+     * <p>Its own cookie store rather than the JVM's. The store belongs to the client, so nothing is
+     * left behind in a daemon that outlives the build and two traces at once cannot share one.</p>
+     */
+    private HttpClient client
+
+    /** The archive to run, which has to be the one the image will be built from. */
+    @InputFile
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract RegularFileProperty getArchiveFile()
+
+    /**
+     * A java from a GraalVM. The agent ships with GraalVM rather than with a JDK, and an application
+     * built for an image is compiled for GraalVM's Java -- so the JDK that runs the build is usually
+     * neither, and running the trace on it fails at load or refuses the class file.
+     */
+    @Input
+    abstract Property<String> getJavaExecutable()
+
+    @Input
+    abstract ListProperty<String> getJvmArguments()
+
+    /** Paths to ask for. */
+    @Input
+    abstract ListProperty<String> getPaths()
+
+    /** Pages whose form is to be filled in and submitted. */
+    @Input
+    abstract ListProperty<String> getForms()
+
+    @Input
+    abstract Property<Integer> getPort()
+
+    @Input
+    abstract Property<Integer> getStartTimeoutSeconds()
+
+    /**
+     * Where the agent merges what it recorded. Not declared as an output: it is in the application's
+     * sources, and a directory Gradle believes it owns is a directory Gradle will delete.
+     */
+    @Internal
+    abstract DirectoryProperty getOutputDirectory()
+
+    @TaskAction
+    void trace() {
+        File java = new File(javaExecutable.get())
+        File metadata = outputDirectory.get().asFile
+        metadata.mkdirs()
+        refuseWithoutAgent(java)
+
+        List<String> command = []
+        command << java.absolutePath
+        command << "-agentlib:${AGENT}=config-merge-dir=${metadata.absolutePath}".toString()
+        command.addAll(jvmArguments.get())
+        command << '-jar' << archiveFile.get().asFile.absolutePath
+        command << "--server.port=${port.get()}".toString()
+
+        File output = new File(temporaryDir, 'trace.log')
+        Process process = new ProcessBuilder(command)
+                .directory(archiveFile.get().asFile.parentFile)
+                .redirectErrorStream(true)
+                .redirectOutput(output)
+                .start()
+
+        client = HttpClient.newBuilder()

Review Comment:
   `cb76065a1e`. Closed in the `finally` alongside `stop(process)`, field nulled. Corrected the javadoc that claimed nothing was left behind in the daemon.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "codeconsole (via GitHub)" <gi...@apache.org>.
codeconsole commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3792529178


##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -1115,6 +1265,146 @@ ${importStatements}
         }
     }
 
+    /**
+     * Wires up the cache the JDK can write for an application, so the next start reads what a
+     * training run worked out rather than working it out again.
+     *
+     * <p>Three steps, because the cache is only usable against the layout it was trained on: the
+     * archive is extracted, the extracted application is run and asked for its pages, and what the
+     * run recorded is left beside it. An application asks for this with
+     * {@code grails.aotCache.enabled}, and says which of its pages matter.</p>
+     */
+    protected void configureAotCache(Project project) {
+        AotCacheExtension extension = ((ExtensionAware) project.extensions.getByName('grails'))
+                .extensions.create('aotCache', AotCacheExtension)
+        extension.enabled.convention(false)
+        extension.paths.convention([])
+        extension.jvmArguments.convention(['-Dspring.aot.enabled=true', '-Dgrails.env=production'])
+        extension.port.convention(TRAINING_PORT)
+        extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS)
+
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            TaskProvider<?> bootJar = project.tasks.named('bootJar')
+            Provider<Directory> application = project.layout.buildDirectory.dir('aot-cache/application')
+            Provider<JavaLauncher> launcher = trainingLauncher(project)
+
+            TaskProvider<ExtractApplicationTask> extract = project.tasks.register(
+                    'extractAotCacheApplication', ExtractApplicationTask) { ExtractApplicationTask task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Extracts the application, which is the form the cache is read against'
+                task.onlyIf { extension.enabled.get() }
+                task.archiveFile.set(archiveFileOf(bootJar))
+                // Mapped rather than read, so the JDK the toolchain resolves to is not provisioned
+                // by every build that merely reads this project.
+                task.javaExecutable.set(launcher.map { JavaLauncher java -> java.executablePath.asFile.absolutePath })
+                task.destination.set(application)
+            }
+
+            project.tasks.register('trainAotCache', TrainAotCacheTask) { TrainAotCacheTask task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Runs the application once so the JDK can write down what starting it needs'
+                task.onlyIf { extension.enabled.get() }
+                task.dependsOn(extract)
+                task.applicationDirectory.set(application)
+                task.archiveFileName.set(archiveFileOf(bootJar).map { RegularFile file -> file.asFile.name })
+                // Beside the extracted application rather than inside it. Inside, the cache and its
+                // metadata land in the directory this task declares as its input, so writing them
+                // changes that input and the task can never be up to date -- every build would run
+                // the application again to record what the last one already recorded.
+                Provider<Directory> beside = project.layout.buildDirectory.dir('aot-cache')
+                task.cacheFile.set(beside.map { Directory dir -> dir.file("${project.name}.aot") })
+                task.metadataFile.set(beside.map { Directory dir -> dir.file('aot-cache.properties') })
+                task.javaExecutable.set(launcher.map { JavaLauncher java -> java.executablePath.asFile.absolutePath })
+                // Recorded from the JDK that will run the training, not the one running the build.
+                // A cache is read only by the JDK build that wrote it, and this is what a deployment
+                // checks that against -- so naming the wrong one is worse than naming none.
+                task.javaVersion.set(launcher.map { JavaLauncher java -> java.metadata.jvmVersion })
+                task.javaVendor.set(launcher.map { JavaLauncher java -> java.metadata.vendor })
+                task.jvmArguments.set(extension.jvmArguments)
+                task.paths.set(extension.paths)
+                task.port.set(extension.port)
+                task.startTimeoutSeconds.set(extension.startTimeoutSeconds)
+            }
+        }
+    }
+
+    /**
+     * The archive a task should consume, as something to be resolved when it runs.
+     *
+     * <p>A provider rather than a file: asking the task for its archive resolves the task, and a
+     * task resolved while the build is configured is one every build pays for whether or not it
+     * asked for an archive.</p>
+     */
+    private static Provider<RegularFile> archiveFileOf(TaskProvider<?> bootJar) {
+        bootJar.flatMap { Task task -> (Provider<RegularFile>) task.property('archiveFile') }
+    }
+
+    /**
+     * The JDK the cache will be trained on, which is the project's toolchain where it declares one.
+     *
+     * <p>A cache is read only by the JDK build that wrote it, so training has to happen on the JDK
+     * the application is compiled for rather than whichever one happens to be running Gradle. A
+     * project on the Java 21 baseline with a Java 25 toolchain compiles with 25 and would otherwise
+     * have trained with 21, where the cache options do not exist -- and the failure it reported was
+     * that the training run ended before it started serving.</p>
+     *
+     * <p>Where no toolchain is declared this resolves to the JDK running the build, which is also
+     * the one that compiled the application.</p>
+     */
+    private static Provider<JavaLauncher> trainingLauncher(Project project) {
+        JavaToolchainService toolchains = project.extensions.getByType(JavaToolchainService)
+        JavaPluginExtension java = project.extensions.getByType(JavaPluginExtension)
+        toolchains.launcherFor(java.toolchain)
+    }
+
+    /**
+     * Records the application's own classes and pages so a native image keeps them usable. The build
+     * output already names both, so an application does not have to be traced to be buildable.
+     */
+    protected void configureNativeMetadata(Project project) {
+        // Only where an image is actually being built. The metadata is read by nothing else, and
+        // generating it means reading the compiled classes and the pages compiled into every
+        // dependency -- so wiring it into processResources unconditionally made a build that only
+        // wanted to write a resource compile its sources and resolve its whole runtime classpath
+        // first, which a project that had declared no repositories could not do.
+        project.pluginManager.withPlugin(NATIVE_IMAGE_PLUGIN) {
+            configureNativeMetadataTask(project)
+        }
+    }
+
+    private void configureNativeMetadataTask(Project project) {
+        SourceSet sourceSet = SourceSets.findMainSourceSet(project)
+
+        TaskProvider<GenerateNativeMetadataTask> metadataTask = project.tasks.register(
+                'generateNativeMetadata', GenerateNativeMetadataTask) { GenerateNativeMetadataTask task ->
+            task.group = BasePlugin.BUILD_GROUP
+            task.description = 'Records the application classes and pages a native image must keep'
+            // The classes directory is written by more than one task, so the dependency has to be
+            // stated. It is stated against the compilation rather than the classes task, because
+            // the classes task also runs processResources, which consumes this task's output.
+            task.dependsOn(project.tasks.named(sourceSet.compileJavaTaskName))
+            ['compileGroovy', 'copyAstClasses'].each { String name ->
+                if (project.tasks.findByName(name)) {
+                    task.dependsOn(project.tasks.named(name))
+                }
+            }
+            task.classesDirs.from(sourceSet.output.classesDirs)
+            task.pageClassesDirs.from(project.layout.buildDirectory.dir('gsp-classes/main'))

Review Comment:
   `709989ae37`. Removed `processResources.from(metadataTask)`. The task now depends on `compileGroovyPages` via `pluginManager.withPlugin` on the GSP plugin id, and its output is consumed by `bootJar` into `BOOT-INF/classes` — where `GroovyPagePlugin` already puts the compiled pages — and by the native binary classpath via `graalvmNative.binaries.configureEach { classpath(...) }`.
   
   Source-set output does not work: `classes` builds those, which restores the cycle from the other side.
   
   Added a `native-metadata-ordering` fixture and two tests in `GrailsNativeImageDefaultsSpec`: `compileGroovyPages` precedes `generateNativeMetadata` in the `bootJar` graph, and `processResources` no longer depends on the task while `bootJar` does. Both fail against the previous wiring.
   



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -1115,6 +1265,146 @@ ${importStatements}
         }
     }
 
+    /**
+     * Wires up the cache the JDK can write for an application, so the next start reads what a
+     * training run worked out rather than working it out again.
+     *
+     * <p>Three steps, because the cache is only usable against the layout it was trained on: the
+     * archive is extracted, the extracted application is run and asked for its pages, and what the
+     * run recorded is left beside it. An application asks for this with
+     * {@code grails.aotCache.enabled}, and says which of its pages matter.</p>
+     */
+    protected void configureAotCache(Project project) {
+        AotCacheExtension extension = ((ExtensionAware) project.extensions.getByName('grails'))
+                .extensions.create('aotCache', AotCacheExtension)
+        extension.enabled.convention(false)
+        extension.paths.convention([])
+        extension.jvmArguments.convention(['-Dspring.aot.enabled=true', '-Dgrails.env=production'])
+        extension.port.convention(TRAINING_PORT)
+        extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS)
+
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            TaskProvider<?> bootJar = project.tasks.named('bootJar')
+            Provider<Directory> application = project.layout.buildDirectory.dir('aot-cache/application')
+            Provider<JavaLauncher> launcher = trainingLauncher(project)
+
+            TaskProvider<ExtractApplicationTask> extract = project.tasks.register(
+                    'extractAotCacheApplication', ExtractApplicationTask) { ExtractApplicationTask task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Extracts the application, which is the form the cache is read against'
+                task.onlyIf { extension.enabled.get() }
+                task.archiveFile.set(archiveFileOf(bootJar))
+                // Mapped rather than read, so the JDK the toolchain resolves to is not provisioned
+                // by every build that merely reads this project.
+                task.javaExecutable.set(launcher.map { JavaLauncher java -> java.executablePath.asFile.absolutePath })
+                task.destination.set(application)
+            }
+
+            project.tasks.register('trainAotCache', TrainAotCacheTask) { TrainAotCacheTask task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Runs the application once so the JDK can write down what starting it needs'
+                task.onlyIf { extension.enabled.get() }
+                task.dependsOn(extract)
+                task.applicationDirectory.set(application)
+                task.archiveFileName.set(archiveFileOf(bootJar).map { RegularFile file -> file.asFile.name })
+                // Beside the extracted application rather than inside it. Inside, the cache and its
+                // metadata land in the directory this task declares as its input, so writing them
+                // changes that input and the task can never be up to date -- every build would run
+                // the application again to record what the last one already recorded.
+                Provider<Directory> beside = project.layout.buildDirectory.dir('aot-cache')
+                task.cacheFile.set(beside.map { Directory dir -> dir.file("${project.name}.aot") })

Review Comment:
   `0021e820a5`. `String cacheName = project.name` hoisted out of the `beside.map { }` closure.
   



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -1115,6 +1265,146 @@ ${importStatements}
         }
     }
 
+    /**
+     * Wires up the cache the JDK can write for an application, so the next start reads what a
+     * training run worked out rather than working it out again.
+     *
+     * <p>Three steps, because the cache is only usable against the layout it was trained on: the
+     * archive is extracted, the extracted application is run and asked for its pages, and what the
+     * run recorded is left beside it. An application asks for this with
+     * {@code grails.aotCache.enabled}, and says which of its pages matter.</p>
+     */
+    protected void configureAotCache(Project project) {
+        AotCacheExtension extension = ((ExtensionAware) project.extensions.getByName('grails'))
+                .extensions.create('aotCache', AotCacheExtension)
+        extension.enabled.convention(false)
+        extension.paths.convention([])
+        extension.jvmArguments.convention(['-Dspring.aot.enabled=true', '-Dgrails.env=production'])
+        extension.port.convention(TRAINING_PORT)
+        extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS)
+
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            TaskProvider<?> bootJar = project.tasks.named('bootJar')
+            Provider<Directory> application = project.layout.buildDirectory.dir('aot-cache/application')
+            Provider<JavaLauncher> launcher = trainingLauncher(project)
+
+            TaskProvider<ExtractApplicationTask> extract = project.tasks.register(
+                    'extractAotCacheApplication', ExtractApplicationTask) { ExtractApplicationTask task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Extracts the application, which is the form the cache is read against'
+                task.onlyIf { extension.enabled.get() }
+                task.archiveFile.set(archiveFileOf(bootJar))
+                // Mapped rather than read, so the JDK the toolchain resolves to is not provisioned
+                // by every build that merely reads this project.
+                task.javaExecutable.set(launcher.map { JavaLauncher java -> java.executablePath.asFile.absolutePath })
+                task.destination.set(application)
+            }
+
+            project.tasks.register('trainAotCache', TrainAotCacheTask) { TrainAotCacheTask task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Runs the application once so the JDK can write down what starting it needs'
+                task.onlyIf { extension.enabled.get() }
+                task.dependsOn(extract)
+                task.applicationDirectory.set(application)
+                task.archiveFileName.set(archiveFileOf(bootJar).map { RegularFile file -> file.asFile.name })
+                // Beside the extracted application rather than inside it. Inside, the cache and its
+                // metadata land in the directory this task declares as its input, so writing them
+                // changes that input and the task can never be up to date -- every build would run
+                // the application again to record what the last one already recorded.
+                Provider<Directory> beside = project.layout.buildDirectory.dir('aot-cache')
+                task.cacheFile.set(beside.map { Directory dir -> dir.file("${project.name}.aot") })
+                task.metadataFile.set(beside.map { Directory dir -> dir.file('aot-cache.properties') })
+                task.javaExecutable.set(launcher.map { JavaLauncher java -> java.executablePath.asFile.absolutePath })
+                // Recorded from the JDK that will run the training, not the one running the build.
+                // A cache is read only by the JDK build that wrote it, and this is what a deployment
+                // checks that against -- so naming the wrong one is worse than naming none.
+                task.javaVersion.set(launcher.map { JavaLauncher java -> java.metadata.jvmVersion })
+                task.javaVendor.set(launcher.map { JavaLauncher java -> java.metadata.vendor })
+                task.jvmArguments.set(extension.jvmArguments)
+                task.paths.set(extension.paths)
+                task.port.set(extension.port)
+                task.startTimeoutSeconds.set(extension.startTimeoutSeconds)
+            }
+        }
+    }
+
+    /**
+     * The archive a task should consume, as something to be resolved when it runs.
+     *
+     * <p>A provider rather than a file: asking the task for its archive resolves the task, and a
+     * task resolved while the build is configured is one every build pays for whether or not it
+     * asked for an archive.</p>
+     */
+    private static Provider<RegularFile> archiveFileOf(TaskProvider<?> bootJar) {
+        bootJar.flatMap { Task task -> (Provider<RegularFile>) task.property('archiveFile') }
+    }
+
+    /**
+     * The JDK the cache will be trained on, which is the project's toolchain where it declares one.
+     *
+     * <p>A cache is read only by the JDK build that wrote it, so training has to happen on the JDK
+     * the application is compiled for rather than whichever one happens to be running Gradle. A
+     * project on the Java 21 baseline with a Java 25 toolchain compiles with 25 and would otherwise
+     * have trained with 21, where the cache options do not exist -- and the failure it reported was
+     * that the training run ended before it started serving.</p>
+     *
+     * <p>Where no toolchain is declared this resolves to the JDK running the build, which is also
+     * the one that compiled the application.</p>
+     */
+    private static Provider<JavaLauncher> trainingLauncher(Project project) {
+        JavaToolchainService toolchains = project.extensions.getByType(JavaToolchainService)
+        JavaPluginExtension java = project.extensions.getByType(JavaPluginExtension)
+        toolchains.launcherFor(java.toolchain)
+    }
+
+    /**
+     * Records the application's own classes and pages so a native image keeps them usable. The build
+     * output already names both, so an application does not have to be traced to be buildable.
+     */
+    protected void configureNativeMetadata(Project project) {
+        // Only where an image is actually being built. The metadata is read by nothing else, and
+        // generating it means reading the compiled classes and the pages compiled into every
+        // dependency -- so wiring it into processResources unconditionally made a build that only
+        // wanted to write a resource compile its sources and resolve its whole runtime classpath
+        // first, which a project that had declared no repositories could not do.
+        project.pluginManager.withPlugin(NATIVE_IMAGE_PLUGIN) {
+            configureNativeMetadataTask(project)
+        }
+    }
+
+    private void configureNativeMetadataTask(Project project) {
+        SourceSet sourceSet = SourceSets.findMainSourceSet(project)
+
+        TaskProvider<GenerateNativeMetadataTask> metadataTask = project.tasks.register(
+                'generateNativeMetadata', GenerateNativeMetadataTask) { GenerateNativeMetadataTask task ->
+            task.group = BasePlugin.BUILD_GROUP
+            task.description = 'Records the application classes and pages a native image must keep'
+            // The classes directory is written by more than one task, so the dependency has to be
+            // stated. It is stated against the compilation rather than the classes task, because
+            // the classes task also runs processResources, which consumes this task's output.
+            task.dependsOn(project.tasks.named(sourceSet.compileJavaTaskName))
+            ['compileGroovy', 'copyAstClasses'].each { String name ->
+                if (project.tasks.findByName(name)) {

Review Comment:
   `0021e820a5`. `project.tasks.names.contains(name)`.
   



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -836,6 +869,36 @@ ${importStatements}
                 it.destinationDirectory = project.layout.buildDirectory.dir('assetCompile/assets')
             }
         }
+        configureAssetsOnTheClasspath(project)
+    }
+
+    /**
+     * Packages the compiled assets where an executable jar can read them.
+     *
+     * <p>The asset pipeline plugin puts them at the root of whatever archive is built, which is
+     * where a war serves its web content from and is therefore right for a war. An executable jar
+     * has no web content: it serves assets by reading them off the classpath, and its classpath is
+     * {@code BOOT-INF/classes} -- so the same assets, at the same place, in a jar rather than a war,
+     * are packaged but unreachable, and every asset a page asks for is a 404 while the page itself
+     * renders. Adding them under the classpath directory is what makes them found.</p>
+     *
+     * <p>Only for {@code bootJar}. A war already serves them from the root, and putting them on its
+     * classpath as well would ship the same bytes twice.</p>
+     */
+    private void configureAssetsOnTheClasspath(Project project) {
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            // Matched by the task the pipeline registers rather than by the plugin that registers
+            // it: the asset pipeline's plugin id has changed once already, and the task name has
+            // not. Matched rather than looked up, so nothing depends on which plugin was applied
+            // first and no task is resolved to find out.
+            FileCollection compiledAssets = project.files(
+                    project.tasks.matching { Task task -> task.name == ASSET_COMPILE_TASK })

Review Comment:
   `e68da7d3db`. Used the `project.provider { }` over `tasks.names` option. The container is no longer in `bootJar`'s input files, and resolving them realizes only `assetCompile` rather than running the predicate against every registered task.
   
   Not the plugin-id route: `AssetClasspathPackagingSpec` registers a stand-in `assetCompile` because the asset-pipeline plugin is not on the test classpath, so keying off the id would leave that wiring unexercised. `project` is still reachable from the closure.
   



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -83,6 +93,22 @@ import javax.inject.Inject
 @CompileStatic
 class GrailsGradlePlugin implements Plugin<Project> {
 
+    private static final String NATIVE_IMAGE_PLUGIN = 'org.graalvm.buildtools.native'
+
+    private static final String SPRING_BOOT_PLUGIN = 'org.springframework.boot'
+
+    private static final String ASSET_COMPILE_TASK = 'assetCompile'
+
+    /** Where an executable jar reads its classpath from, and so where assets have to be to be found. */
+    private static final String CLASSPATH_ASSETS_PATH = 'BOOT-INF/classes/assets'
+
+    private static final int TRAINING_PORT = 18080

Review Comment:
   `3890b3466e`. Added `RunPort.refuseWhereTaken(port, what, setting)`, which binds the port before the run starts and fails with the port and the setting that names it. Called from `TrainAotCacheTask` (`grails.aotCache.port`) and `TraceNativeMetadataTask` (`grails.nativeMetadata.port`). Added `RunPortSpec`.
   
   Bind-check rather than `--server.port=0`, which would remove a documented setting. It does not close the race between check and launch.
   



##########
grails-data-neo4j/grails-plugin/src/main/groovy/grails/neo4j/bootstrap/Neo4jDataStoreSpringInitializer.groovy:
##########
@@ -75,17 +74,27 @@ class Neo4jDataStoreSpringInitializer extends AbstractDatastoreInitializer {
             callable.delegate = delegate
             callable.call()
 
-            ApplicationEventPublisher eventPublisher
-            if (beanDefinitionRegistry instanceof ConfigurableApplicationContext) {
-                eventPublisher = new ConfigurableApplicationContextEventPublisher((ConfigurableApplicationContext) beanDefinitionRegistry)
-            } else {
-                eventPublisher = new DefaultApplicationEventPublisher()
-            }
+            // Registered rather than built here, so what the datastore holds is a reference the
+            // container can build. Holding the publisher itself puts a live object in the
+            // definition, and generating code for a definition means writing out what it holds --
+            // which a publisher bound to a running context is not.
+            //
+            // The choice is made here rather than through AbstractDatastoreInitializer, because this
+            // build resolves that class from a released grails-datamapping-core rather than from
+            // the source beside it, and a method added there is not one this can call.
+            grailsDatastoreEventPublisher(beanDefinitionRegistry instanceof ConfigurableApplicationContext
+                    ? ConfigurableApplicationContextEventPublisher
+                    : DefaultApplicationEventPublisher)
             final boolean isRecentGrailsVersion = GrailsVersion.isAtLeastMajorMinor(3, 3)
             neo4jConnectionSourceFactory(Neo4jConnectionSourceFactory) { bean ->
                 bean.autowire = true
             }
-            neo4jDatastore(Neo4jDatastore, configuration, ref("neo4jConnectionSourceFactory"), eventPublisher, collectMappedClasses(DATASTORE_TYPE))
+            // The configuration is held rather than named. Naming the environment instead is what
+            // lets a definition be generated without writing the build machine's own settings into
+            // it, but that is asked through a method added to AbstractDatastoreInitializer, and this
+            // build resolves that class from a released grails-datamapping-core rather than from the
+            // source beside it -- so the call compiles here and goes missing at run time.
+            neo4jDatastore(Neo4jDatastore, configuration, ref("neo4jConnectionSourceFactory"), ref('grailsDatastoreEventPublisher'), collectMappedClasses(DATASTORE_TYPE))

Review Comment:
   `1ece28fbbb`. `getBeanDefinitions` calls `refuseWhereTheConfigurationWouldBeWrittenOut()`, which throws `IllegalStateException` when `AheadOfTimeProcessing.generatingCode`, naming Neo4j as not yet AOT-processable.
   
   Neither Neo4j change is compiled by anything: `grails-data-neo4j` is not in the root `settings.gradle`, is not an `includeBuild`, is referenced by no workflow, and its own build fails on `org.apache.grails:grails-gradle-plugins:6.1.2`, which does not exist. It pins `grailsVersion=6.0.0`, `groovyVersion=3.0.25`, `springBootVersion=2.7.18`, `springVersion=5.3.31`, `spockVersion=2.1-groovy-3.0`, `servletApiVersion=4.0.1`; 7 source files import `javax.*`, none import `jakarta.*`. So `processAot` is not currently reachable for Neo4j at all.
   



##########
grails-data-neo4j/grails-plugin/src/main/groovy/grails/neo4j/bootstrap/Neo4jDataStoreSpringInitializer.groovy:
##########
@@ -75,17 +74,27 @@ class Neo4jDataStoreSpringInitializer extends AbstractDatastoreInitializer {
             callable.delegate = delegate
             callable.call()
 
-            ApplicationEventPublisher eventPublisher
-            if (beanDefinitionRegistry instanceof ConfigurableApplicationContext) {
-                eventPublisher = new ConfigurableApplicationContextEventPublisher((ConfigurableApplicationContext) beanDefinitionRegistry)
-            } else {
-                eventPublisher = new DefaultApplicationEventPublisher()
-            }
+            // Registered rather than built here, so what the datastore holds is a reference the
+            // container can build. Holding the publisher itself puts a live object in the
+            // definition, and generating code for a definition means writing out what it holds --
+            // which a publisher bound to a running context is not.
+            //
+            // The choice is made here rather than through AbstractDatastoreInitializer, because this
+            // build resolves that class from a released grails-datamapping-core rather than from
+            // the source beside it, and a method added there is not one this can call.
+            grailsDatastoreEventPublisher(beanDefinitionRegistry instanceof ConfigurableApplicationContext

Review Comment:
   `1ece28fbbb`. The condition now also tests `resourcePatternResolver.resourceLoader instanceof ConfigurableApplicationContext`, so it agrees with `findEventPublisherClass`.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5321029254

   ## ✅ All tests passed ✅
   
   🏷️ Commit: 6d468648a8d4ab03082090733c2a750b1b790b89
   ▶️ Tests:  24585 executed
   ⚪️ Checks: 81/81 completed
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5270246924

   ## 🚨 TestLens detected 1 failed test 🚨
   
   Here is what you can do:
   
   1) Inspect the test failures carefully.
   2) If you are convinced that some of the tests are flaky, you can mute them below.
   3) Finally, trigger a rerun by checking the rerun checkbox.
   
   ### Test Summary
   
   #### [CI / Functional Tests \(Java 21, indy\=true, shard 1\)](https://github.com/apache/grails-core/actions/runs/31622264961/job/94200063518?pr=16094) > :grails-test-examples-app1:integrationTest
   
   | Test | Runs | Flakiness |
   |---|---|--:|
   | RedirectWithAndWithoutParamsFunctionalSpec > Params are not added to the url after a redirect even if they are passed to the redirect | ❌ | [1%&nbsp;🟡](https://github.com/apache/grails-core/issues/16030) |
   
   🏷️ Commit: cb374b1c3a05744224bb199228cfcdb140ab5010
   ▶️ Tests:  7717 executed
   🟡 Checks: 23/72 completed
   
   ### Test Failures
   
   <details>
   
   <summary><strong>RedirectWithAndWithoutParamsFunctionalSpec > Params are not added to the url after a redirect even if they are passed to the redirect</strong> (:grails-test-examples-app1:integrationTest in <a href="https://github.com/apache/grails-core/actions/runs/31622264961/job/94200063518?pr=16094">CI / Functional Tests (Java 21, indy=true, shard 1)</a>)</summary>
   
   ```
   Condition not satisfied:
   
   pageSource.contains('"id":')
   |          |
   |          false
   <html><head></head><body><form action="save" method="post">
       <input type="text" name="name">
       <input type="submit">
   </form>
   </body></html>
   
   	at functionaltests.RedirectWithAndWithoutParamsFunctionalSpec.$tt__$spock_feature_1_0(RedirectWithAndWithoutParamsFunctionalSpec.groovy:41)
   	at functionaltests.RedirectWithAndWithoutParamsFunctionalSpec.Params are not added to the url after a redirect even if they are passed to the redirect_closure1(RedirectWithAndWithoutParamsFunctionalSpec.groovy)
   	at grails.gorm.transactions.GrailsTransactionTemplate$1.doInTransaction(GrailsTransactionTemplate.groovy:72)
   	at org.springframework.transaction.support.TransactionTemplate.execute(TransactionTemplate.java:137)
   	at grails.gorm.transactions.GrailsTransactionTemplate.executeAndRollback(GrailsTransactionTemplate.groovy:69)
   	at functionaltests.RedirectWithAndWithoutParamsFunctionalSpec.Params are not added to the url after a redirect even if they are passed to the redirect(RedirectWithAndWithoutParamsFunctionalSpec.groovy)
   ```
   
   </details>
   
   ### Muted Tests
   > [!NOTE]
   > Checks are currently running using the configuration below.
   
   Select tests to mute in this pull request:
   
   🔲 RedirectWithAndWithoutParamsFunctionalSpec > Params are not added to the url after a redirect even if they are passed to the redirect <!-- uniqueId=[engine:spock]/[spec:functionaltests.RedirectWithAndWithoutParamsFunctionalSpec]/[feature:$spock_feature_1_0] -->
   
   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](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5277236682

   ## 🚨 TestLens detected 1 failed test 🚨
   
   Here is what you can do:
   
   1) Inspect the test failures carefully.
   2) If you are convinced that some of the tests are flaky, you can mute them below.
   3) Finally, trigger a rerun by checking the rerun checkbox.
   
   ### Test Summary
   
   #### [CI / Functional Tests \(Java 25, indy\=false, shard 1\)](https://github.com/apache/grails-core/actions/runs/31675907909/job/94370424248?pr=16094) > :grails-test-examples-gorm:integrationTest
   
   | Test | Runs | Flakiness |
   |---|---|--:|
   | FieldsValidationSpec > author email validation shows error for invalid format | ❌ | 0%&nbsp;🟢 |
   
   🏷️ Commit: 2eb6d04f46754318a7cde52b91c1055068edd7e7
   ▶️ Tests:  13303 executed
   🟡 Checks: 24/72 completed
   
   ### Test Failures
   
   <details>
   
   <summary><strong>FieldsValidationSpec > author email validation shows error for invalid format</strong> (:grails-test-examples-gorm:integrationTest in <a href="https://github.com/apache/grails-core/actions/runs/31675907909/job/94370424248?pr=16094">CI / Functional Tests (Java 25, indy=false, shard 1)</a>)</summary>
   
   ```
   geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
   	at geb.waiting.Wait.waitFor(Wait.groovy:128)
   	at geb.waiting.PotentiallyWaitingExecutor.execute(PotentiallyWaitingExecutor.groovy:31)
   	at geb.Page.verifyThisPageAtOnly(Page.groovy:424)
   	at geb.Page.getAtVerificationResult(Page.groovy:217)
   	at geb.Page.verifyAt(Page.groovy:188)
   	at geb.Browser.doAt(Browser.groovy:1208)
   	at geb.Browser.at(Browser.groovy:410)
   	at geb.Browser.to(Browser.groovy:566)
   	at geb.Browser.to(Browser.groovy:543)
   	at geb.Browser.to(Browser.groovy:532)
   	at grails.plugin.geb.support.delegate.BrowserDelegate$Trait$Helper.to(BrowserDelegate.groovy:160)
   	at grails.plugin.geb.support.delegate.BrowserDelegate$Trait$Helper.to(BrowserDelegate.groovy:159)
   	at gorm.FieldsValidationSpec.author email validation shows error for invalid format(FieldsValidationSpec.groovy:80)
   Caused by: Assertion failed: 
   
   title?.contains('Author') || $('h1').text()?.contains('Author')
   |      |                  |  |       |       |
   |      false              |  |       'Error' false
   'Error'                   |  [[[RemoteWebDriver: chrome on linux (80e0b7dd1ef2150f070505d9b3b080af)] -> css selector: h1]]
                             false
   
   	at gorm.pages.AuthorCreatePage._clinit__closure1(AuthorPages.groovy:40)
   	at gorm.pages.AuthorCreatePage._clinit__closure1(AuthorPages.groovy)
   	at geb.waiting.Wait.waitFor(Wait.groovy:117)
   	... 12 more
   ```
   
   </details>
   
   ### Muted Tests
   > [!NOTE]
   > Checks are currently running using the configuration below.
   
   Select tests to mute in this pull request:
   
   🔲 FieldsValidationSpec > author email validation shows error for invalid format <!-- uniqueId=[engine:spock]/[spec:gorm.FieldsValidationSpec]/[feature:$spock_feature_1_0] -->
   
   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](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "codeconsole (via GitHub)" <gi...@apache.org>.
codeconsole commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5290853175

   The failures are not snapshot resolution. `:grails-test-examples-scaffolding:compileGroovyPages` fails:
   
   ```
   gsp_grails_test_examples_scaffolding_usershow_gsp.groovy: 28: [Static type checking] - The variable [entityName] is undeclared.
   gsp_grails_test_examples_scaffolding_usershow_gsp.groovy: 70: [Static type checking] - The variable [controllerName] is undeclared.
   ```
   
   Expanding the scaffolding templates at build time so the GSP compiler can precompile them rests on those templates being compilable that way, and they are not: `entityName` is declared by `g:set` as the page renders, and `controllerName` comes from the request. Neither is visible to a static compile. That failed the GSP compilation for every project that scaffolds, and took the 652 tests with it.
   
   Removed in e93d383, together with the `@{ model=... }` directives that had been added to the shipped scaffolding templates to work around it — those changed what every application renders at run time in order to serve a build-time feature.
   
   Making a scaffolded view precompilable means making the templates say what they read, which changes what scaffolding renders for everyone and is its own piece of work. Nothing here needs it: precompiled scaffolded views matter to a native image, which this PR does not yet deliver.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5228666979

   ## 🚨 TestLens detected 1 failed test 🚨
   
   Here is what you can do:
   
   1) Inspect the test failures carefully.
   2) If you are convinced that some of the tests are flaky, you can mute them below.
   3) Finally, trigger a rerun by checking the rerun checkbox.
   
   ### Test Summary
   
   #### [CI / Build Grails-Core \(windows-latest, 25\)](https://github.com/apache/grails-core/actions/runs/31282784489/job/93166665699?pr=16094) > :grails-core:test
   
   | Test | Runs | Flakiness |
   |---|---|--:|
   | GrailsBannerNativeMarkSpec > nothing is drawn over, wherever it is read | ❌ | 0%&nbsp;🟢 |
   
   🏷️ Commit: 184821249d1d5646e66ee86a578fd3729c458587
   ▶️ Tests:  14632 executed
   🟡 Checks: 23/57 completed
   
   ### Test Failures
   
   <details>
   
   <summary><strong>GrailsBannerNativeMarkSpec > nothing is drawn over, wherever it is read</strong> (:grails-core:test in <a href="https://github.com/apache/grails-core/actions/runs/31282784489/job/93166665699?pr=16094">CI / Build Grails-Core (windows-latest, 25)</a>)</summary>
   
   ```
   Condition not satisfied:
   
   !output.contains('\r')
   ||      |
   ||      true
   | 
   |>                                                                            <
   |>                            ____           _ _                              <
   |>                           / ___|_ __ __ _(_) |___                          <
   |>                          | |  _| '__/ _` | | / __|                         <
   |>                          | |_| | | | (_| | | \__ \                         <
   |>                           \____|_|  \__,_|_|_|___/                         <
   |>                          https://grails.apache.org                         <
   |>                                                                            <
   |                                 N A T I V E
   |             app: unknown | JVM: BellSoft 25.0.4 | Groovy: 5.0.8              
   |                      Spring Boot: 4.1.0 | Spring: 7.0.8                      
   false
   
   	at grails.boot.GrailsBannerNativeMarkSpec.nothing is drawn over, wherever it is read(GrailsBannerNativeMarkSpec.groovy:134)
   ```
   
   </details>
   
   ### Muted Tests
   > [!NOTE]
   > Checks are currently running using the configuration below.
   
   Select tests to mute in this pull request:
   
   🔲 GrailsBannerNativeMarkSpec > nothing is drawn over, wherever it is read <!-- uniqueId=[engine:spock]/[spec:grails.boot.GrailsBannerNativeMarkSpec]/[feature:$spock_feature_0_7] -->
   
   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](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "codeconsole (via GitHub)" <gi...@apache.org>.
codeconsole commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3797125542


##########
dependencies.gradle:
##########


Review Comment:
   Yes — `6d468648a8`. `asset-pipeline-gradle.version` and `asset-pipeline-bom.version` both 5.2.0-M1 -> 5.2.0-M3.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#issuecomment-5309633277

   ## ✅ All tests passed ✅
   
   🏷️ Commit: 63a462a77674cf3b9c93e83fcdbfdfe4dcc3f72d
   ▶️ Tests:  70386 executed
   ⚪️ Checks: 81/81 completed
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3793836260


##########
dependencies.gradle:
##########


Review Comment:
   Assume you'll be updating this version in this PR?  I've released your changes on the asset pipeline ... 



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3792091499


##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -1115,6 +1265,146 @@ ${importStatements}
         }
     }
 
+    /**
+     * Wires up the cache the JDK can write for an application, so the next start reads what a
+     * training run worked out rather than working it out again.
+     *
+     * <p>Three steps, because the cache is only usable against the layout it was trained on: the
+     * archive is extracted, the extracted application is run and asked for its pages, and what the
+     * run recorded is left beside it. An application asks for this with
+     * {@code grails.aotCache.enabled}, and says which of its pages matter.</p>
+     */
+    protected void configureAotCache(Project project) {
+        AotCacheExtension extension = ((ExtensionAware) project.extensions.getByName('grails'))
+                .extensions.create('aotCache', AotCacheExtension)
+        extension.enabled.convention(false)
+        extension.paths.convention([])
+        extension.jvmArguments.convention(['-Dspring.aot.enabled=true', '-Dgrails.env=production'])
+        extension.port.convention(TRAINING_PORT)
+        extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS)
+
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            TaskProvider<?> bootJar = project.tasks.named('bootJar')
+            Provider<Directory> application = project.layout.buildDirectory.dir('aot-cache/application')
+            Provider<JavaLauncher> launcher = trainingLauncher(project)
+
+            TaskProvider<ExtractApplicationTask> extract = project.tasks.register(
+                    'extractAotCacheApplication', ExtractApplicationTask) { ExtractApplicationTask task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Extracts the application, which is the form the cache is read against'
+                task.onlyIf { extension.enabled.get() }
+                task.archiveFile.set(archiveFileOf(bootJar))
+                // Mapped rather than read, so the JDK the toolchain resolves to is not provisioned
+                // by every build that merely reads this project.
+                task.javaExecutable.set(launcher.map { JavaLauncher java -> java.executablePath.asFile.absolutePath })
+                task.destination.set(application)
+            }
+
+            project.tasks.register('trainAotCache', TrainAotCacheTask) { TrainAotCacheTask task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Runs the application once so the JDK can write down what starting it needs'
+                task.onlyIf { extension.enabled.get() }
+                task.dependsOn(extract)
+                task.applicationDirectory.set(application)
+                task.archiveFileName.set(archiveFileOf(bootJar).map { RegularFile file -> file.asFile.name })
+                // Beside the extracted application rather than inside it. Inside, the cache and its
+                // metadata land in the directory this task declares as its input, so writing them
+                // changes that input and the task can never be up to date -- every build would run
+                // the application again to record what the last one already recorded.
+                Provider<Directory> beside = project.layout.buildDirectory.dir('aot-cache')
+                task.cacheFile.set(beside.map { Directory dir -> dir.file("${project.name}.aot") })

Review Comment:
   `project` is captured in a provider that is evaluated after configuration.
   
   `beside.map { Directory dir -> dir.file("${project.name}.aot") }` reads `project.name` when the provider is queried — at property finalization or execution — so the closure carries the `Project` into the task's serialized state. That is the same class of problem as the `doFirst` block from the last round, just deferred through a `map` instead.
   
   `org.gradle.configuration-cache=false` in this repo's `gradle.properties`, so CI will not catch it; a consumer building with the cache on will. Hoisting `String archiveName = project.name` outside the closure fixes it.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -1115,6 +1265,146 @@ ${importStatements}
         }
     }
 
+    /**
+     * Wires up the cache the JDK can write for an application, so the next start reads what a
+     * training run worked out rather than working it out again.
+     *
+     * <p>Three steps, because the cache is only usable against the layout it was trained on: the
+     * archive is extracted, the extracted application is run and asked for its pages, and what the
+     * run recorded is left beside it. An application asks for this with
+     * {@code grails.aotCache.enabled}, and says which of its pages matter.</p>
+     */
+    protected void configureAotCache(Project project) {
+        AotCacheExtension extension = ((ExtensionAware) project.extensions.getByName('grails'))
+                .extensions.create('aotCache', AotCacheExtension)
+        extension.enabled.convention(false)
+        extension.paths.convention([])
+        extension.jvmArguments.convention(['-Dspring.aot.enabled=true', '-Dgrails.env=production'])
+        extension.port.convention(TRAINING_PORT)
+        extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS)
+
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            TaskProvider<?> bootJar = project.tasks.named('bootJar')
+            Provider<Directory> application = project.layout.buildDirectory.dir('aot-cache/application')
+            Provider<JavaLauncher> launcher = trainingLauncher(project)
+
+            TaskProvider<ExtractApplicationTask> extract = project.tasks.register(
+                    'extractAotCacheApplication', ExtractApplicationTask) { ExtractApplicationTask task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Extracts the application, which is the form the cache is read against'
+                task.onlyIf { extension.enabled.get() }
+                task.archiveFile.set(archiveFileOf(bootJar))
+                // Mapped rather than read, so the JDK the toolchain resolves to is not provisioned
+                // by every build that merely reads this project.
+                task.javaExecutable.set(launcher.map { JavaLauncher java -> java.executablePath.asFile.absolutePath })
+                task.destination.set(application)
+            }
+
+            project.tasks.register('trainAotCache', TrainAotCacheTask) { TrainAotCacheTask task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Runs the application once so the JDK can write down what starting it needs'
+                task.onlyIf { extension.enabled.get() }
+                task.dependsOn(extract)
+                task.applicationDirectory.set(application)
+                task.archiveFileName.set(archiveFileOf(bootJar).map { RegularFile file -> file.asFile.name })
+                // Beside the extracted application rather than inside it. Inside, the cache and its
+                // metadata land in the directory this task declares as its input, so writing them
+                // changes that input and the task can never be up to date -- every build would run
+                // the application again to record what the last one already recorded.
+                Provider<Directory> beside = project.layout.buildDirectory.dir('aot-cache')
+                task.cacheFile.set(beside.map { Directory dir -> dir.file("${project.name}.aot") })
+                task.metadataFile.set(beside.map { Directory dir -> dir.file('aot-cache.properties') })
+                task.javaExecutable.set(launcher.map { JavaLauncher java -> java.executablePath.asFile.absolutePath })
+                // Recorded from the JDK that will run the training, not the one running the build.
+                // A cache is read only by the JDK build that wrote it, and this is what a deployment
+                // checks that against -- so naming the wrong one is worse than naming none.
+                task.javaVersion.set(launcher.map { JavaLauncher java -> java.metadata.jvmVersion })
+                task.javaVendor.set(launcher.map { JavaLauncher java -> java.metadata.vendor })
+                task.jvmArguments.set(extension.jvmArguments)
+                task.paths.set(extension.paths)
+                task.port.set(extension.port)
+                task.startTimeoutSeconds.set(extension.startTimeoutSeconds)
+            }
+        }
+    }
+
+    /**
+     * The archive a task should consume, as something to be resolved when it runs.
+     *
+     * <p>A provider rather than a file: asking the task for its archive resolves the task, and a
+     * task resolved while the build is configured is one every build pays for whether or not it
+     * asked for an archive.</p>
+     */
+    private static Provider<RegularFile> archiveFileOf(TaskProvider<?> bootJar) {
+        bootJar.flatMap { Task task -> (Provider<RegularFile>) task.property('archiveFile') }
+    }
+
+    /**
+     * The JDK the cache will be trained on, which is the project's toolchain where it declares one.
+     *
+     * <p>A cache is read only by the JDK build that wrote it, so training has to happen on the JDK
+     * the application is compiled for rather than whichever one happens to be running Gradle. A
+     * project on the Java 21 baseline with a Java 25 toolchain compiles with 25 and would otherwise
+     * have trained with 21, where the cache options do not exist -- and the failure it reported was
+     * that the training run ended before it started serving.</p>
+     *
+     * <p>Where no toolchain is declared this resolves to the JDK running the build, which is also
+     * the one that compiled the application.</p>
+     */
+    private static Provider<JavaLauncher> trainingLauncher(Project project) {
+        JavaToolchainService toolchains = project.extensions.getByType(JavaToolchainService)
+        JavaPluginExtension java = project.extensions.getByType(JavaPluginExtension)
+        toolchains.launcherFor(java.toolchain)
+    }
+
+    /**
+     * Records the application's own classes and pages so a native image keeps them usable. The build
+     * output already names both, so an application does not have to be traced to be buildable.
+     */
+    protected void configureNativeMetadata(Project project) {
+        // Only where an image is actually being built. The metadata is read by nothing else, and
+        // generating it means reading the compiled classes and the pages compiled into every
+        // dependency -- so wiring it into processResources unconditionally made a build that only
+        // wanted to write a resource compile its sources and resolve its whole runtime classpath
+        // first, which a project that had declared no repositories could not do.
+        project.pluginManager.withPlugin(NATIVE_IMAGE_PLUGIN) {
+            configureNativeMetadataTask(project)
+        }
+    }
+
+    private void configureNativeMetadataTask(Project project) {
+        SourceSet sourceSet = SourceSets.findMainSourceSet(project)
+
+        TaskProvider<GenerateNativeMetadataTask> metadataTask = project.tasks.register(
+                'generateNativeMetadata', GenerateNativeMetadataTask) { GenerateNativeMetadataTask task ->
+            task.group = BasePlugin.BUILD_GROUP
+            task.description = 'Records the application classes and pages a native image must keep'
+            // The classes directory is written by more than one task, so the dependency has to be
+            // stated. It is stated against the compilation rather than the classes task, because
+            // the classes task also runs processResources, which consumes this task's output.
+            task.dependsOn(project.tasks.named(sourceSet.compileJavaTaskName))
+            ['compileGroovy', 'copyAstClasses'].each { String name ->
+                if (project.tasks.findByName(name)) {
+                    task.dependsOn(project.tasks.named(name))
+                }
+            }
+            task.classesDirs.from(sourceSet.output.classesDirs)
+            task.pageClassesDirs.from(project.layout.buildDirectory.dir('gsp-classes/main'))

Review Comment:
   **Blocker.** `generateNativeMetadata` can never see the pages.
   
   `pageClassesDirs` points at `build/gsp-classes/main`, which is written by `compileGroovyPages`. That task declares `dependsOn(tasks.named('classes'))` (`GroovyPagePlugin.groovy:100-105`), `classes` depends on `processResources`, and `processResources.from(metadataTask)` two lines below puts `generateNativeMetadata` ahead of it. So the ordering is
   
   ```
   generateNativeMetadata -> processResources -> classes -> compileGroovyPages
   ```
   
   and the directory this reads is always empty on a clean build, stale otherwise. Recording the compiled pages is one of the two things the task exists for, and it silently records none of them: an image built from this metadata is missing every page, which shows up as a page not found at runtime rather than as anything at build time.
   
   It is also an undeclared implicit dependency on another task's output, declared `@InputFiles` on a `@CacheableTask` — Gradle 9 reports that as a validation problem once the producer is reachable.
   
   No ordering satisfies both directions, so this needs rewiring rather than a `dependsOn`: the pages have to be recorded by something that runs after `compileGroovyPages` and feeds `bootJar`/`processAot` directly, not through `processResources`.
   
   Worth noting nothing exercises this. `configureNativeMetadata` is gated on `org.graalvm.buildtools.native`, only `native-defaults-on` applies that plugin, and `GrailsNativeImageDefaultsSpec` only runs `inspectDefaults` — so the task is registered in a test and never executed in one.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -1115,6 +1265,146 @@ ${importStatements}
         }
     }
 
+    /**
+     * Wires up the cache the JDK can write for an application, so the next start reads what a
+     * training run worked out rather than working it out again.
+     *
+     * <p>Three steps, because the cache is only usable against the layout it was trained on: the
+     * archive is extracted, the extracted application is run and asked for its pages, and what the
+     * run recorded is left beside it. An application asks for this with
+     * {@code grails.aotCache.enabled}, and says which of its pages matter.</p>
+     */
+    protected void configureAotCache(Project project) {
+        AotCacheExtension extension = ((ExtensionAware) project.extensions.getByName('grails'))
+                .extensions.create('aotCache', AotCacheExtension)
+        extension.enabled.convention(false)
+        extension.paths.convention([])
+        extension.jvmArguments.convention(['-Dspring.aot.enabled=true', '-Dgrails.env=production'])
+        extension.port.convention(TRAINING_PORT)
+        extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS)
+
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            TaskProvider<?> bootJar = project.tasks.named('bootJar')
+            Provider<Directory> application = project.layout.buildDirectory.dir('aot-cache/application')
+            Provider<JavaLauncher> launcher = trainingLauncher(project)
+
+            TaskProvider<ExtractApplicationTask> extract = project.tasks.register(
+                    'extractAotCacheApplication', ExtractApplicationTask) { ExtractApplicationTask task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Extracts the application, which is the form the cache is read against'
+                task.onlyIf { extension.enabled.get() }
+                task.archiveFile.set(archiveFileOf(bootJar))
+                // Mapped rather than read, so the JDK the toolchain resolves to is not provisioned
+                // by every build that merely reads this project.
+                task.javaExecutable.set(launcher.map { JavaLauncher java -> java.executablePath.asFile.absolutePath })
+                task.destination.set(application)
+            }
+
+            project.tasks.register('trainAotCache', TrainAotCacheTask) { TrainAotCacheTask task ->
+                task.group = BasePlugin.BUILD_GROUP
+                task.description = 'Runs the application once so the JDK can write down what starting it needs'
+                task.onlyIf { extension.enabled.get() }
+                task.dependsOn(extract)
+                task.applicationDirectory.set(application)
+                task.archiveFileName.set(archiveFileOf(bootJar).map { RegularFile file -> file.asFile.name })
+                // Beside the extracted application rather than inside it. Inside, the cache and its
+                // metadata land in the directory this task declares as its input, so writing them
+                // changes that input and the task can never be up to date -- every build would run
+                // the application again to record what the last one already recorded.
+                Provider<Directory> beside = project.layout.buildDirectory.dir('aot-cache')
+                task.cacheFile.set(beside.map { Directory dir -> dir.file("${project.name}.aot") })
+                task.metadataFile.set(beside.map { Directory dir -> dir.file('aot-cache.properties') })
+                task.javaExecutable.set(launcher.map { JavaLauncher java -> java.executablePath.asFile.absolutePath })
+                // Recorded from the JDK that will run the training, not the one running the build.
+                // A cache is read only by the JDK build that wrote it, and this is what a deployment
+                // checks that against -- so naming the wrong one is worse than naming none.
+                task.javaVersion.set(launcher.map { JavaLauncher java -> java.metadata.jvmVersion })
+                task.javaVendor.set(launcher.map { JavaLauncher java -> java.metadata.vendor })
+                task.jvmArguments.set(extension.jvmArguments)
+                task.paths.set(extension.paths)
+                task.port.set(extension.port)
+                task.startTimeoutSeconds.set(extension.startTimeoutSeconds)
+            }
+        }
+    }
+
+    /**
+     * The archive a task should consume, as something to be resolved when it runs.
+     *
+     * <p>A provider rather than a file: asking the task for its archive resolves the task, and a
+     * task resolved while the build is configured is one every build pays for whether or not it
+     * asked for an archive.</p>
+     */
+    private static Provider<RegularFile> archiveFileOf(TaskProvider<?> bootJar) {
+        bootJar.flatMap { Task task -> (Provider<RegularFile>) task.property('archiveFile') }
+    }
+
+    /**
+     * The JDK the cache will be trained on, which is the project's toolchain where it declares one.
+     *
+     * <p>A cache is read only by the JDK build that wrote it, so training has to happen on the JDK
+     * the application is compiled for rather than whichever one happens to be running Gradle. A
+     * project on the Java 21 baseline with a Java 25 toolchain compiles with 25 and would otherwise
+     * have trained with 21, where the cache options do not exist -- and the failure it reported was
+     * that the training run ended before it started serving.</p>
+     *
+     * <p>Where no toolchain is declared this resolves to the JDK running the build, which is also
+     * the one that compiled the application.</p>
+     */
+    private static Provider<JavaLauncher> trainingLauncher(Project project) {
+        JavaToolchainService toolchains = project.extensions.getByType(JavaToolchainService)
+        JavaPluginExtension java = project.extensions.getByType(JavaPluginExtension)
+        toolchains.launcherFor(java.toolchain)
+    }
+
+    /**
+     * Records the application's own classes and pages so a native image keeps them usable. The build
+     * output already names both, so an application does not have to be traced to be buildable.
+     */
+    protected void configureNativeMetadata(Project project) {
+        // Only where an image is actually being built. The metadata is read by nothing else, and
+        // generating it means reading the compiled classes and the pages compiled into every
+        // dependency -- so wiring it into processResources unconditionally made a build that only
+        // wanted to write a resource compile its sources and resolve its whole runtime classpath
+        // first, which a project that had declared no repositories could not do.
+        project.pluginManager.withPlugin(NATIVE_IMAGE_PLUGIN) {
+            configureNativeMetadataTask(project)
+        }
+    }
+
+    private void configureNativeMetadataTask(Project project) {
+        SourceSet sourceSet = SourceSets.findMainSourceSet(project)
+
+        TaskProvider<GenerateNativeMetadataTask> metadataTask = project.tasks.register(
+                'generateNativeMetadata', GenerateNativeMetadataTask) { GenerateNativeMetadataTask task ->
+            task.group = BasePlugin.BUILD_GROUP
+            task.description = 'Records the application classes and pages a native image must keep'
+            // The classes directory is written by more than one task, so the dependency has to be
+            // stated. It is stated against the compilation rather than the classes task, because
+            // the classes task also runs processResources, which consumes this task's output.
+            task.dependsOn(project.tasks.named(sourceSet.compileJavaTaskName))
+            ['compileGroovy', 'copyAstClasses'].each { String name ->
+                if (project.tasks.findByName(name)) {

Review Comment:
   `findByName` realizes the task, which is what `named`/`matching` exist to avoid — and it is the pattern the `assetCompile` thread moved away from earlier in this same PR.
   
   `project.tasks.names.contains(name)` answers the same question without creating anything.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/ExtractApplicationTask.groovy:
##########
@@ -0,0 +1,88 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.aot
+
+import javax.inject.Inject
+
+import groovy.transform.CompileStatic
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.FileSystemOperations
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.process.ExecOperations
+
+/**
+ * Unpacks an executable jar into the form an AOT cache is read against.
+ *
+ * <p>An executable jar loads its dependencies through a nested-jar class loader; the extracted form
+ * loads them as ordinary jars on the classpath. A cache records the class path it saw, so only the
+ * second is a layout a later run can reuse one against.</p>
+ *
+ * <p>The work is done through injected services rather than through the project, so that nothing is
+ * reached at execution time that the configuration cache forbids.</p>
+ *
+ * @since 8.0
+ */
+@CacheableTask
+@CompileStatic
+abstract class ExtractApplicationTask extends DefaultTask {
+
+    /** The archive to unpack, which has to be the one the cache will be trained against. */
+    @InputFile
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract RegularFileProperty getArchiveFile()
+
+    /**
+     * The java to unpack with. Its {@code jarmode} does the extracting, so it is the JDK the
+     * application was built for rather than whichever one is running Gradle.
+     */
+    @Input
+    abstract Property<String> getJavaExecutable()

Review Comment:
   An absolute JDK path as `@Input` on a `@CacheableTask` puts the machine into the cache key, so this task can never hit the cache on CI or on another developer's machine — which is most of what the annotation is for.
   
   `GroovyPageForkCompileTask` in this same PR solves it correctly with `@Nested Property<JavaLauncher>` (see its javadoc); the launcher's relevant metadata is then what is fingerprinted rather than the path. Same approach here.



##########
grails-data-neo4j/grails-plugin/src/main/groovy/grails/neo4j/bootstrap/Neo4jDataStoreSpringInitializer.groovy:
##########
@@ -75,17 +74,27 @@ class Neo4jDataStoreSpringInitializer extends AbstractDatastoreInitializer {
             callable.delegate = delegate
             callable.call()
 
-            ApplicationEventPublisher eventPublisher
-            if (beanDefinitionRegistry instanceof ConfigurableApplicationContext) {
-                eventPublisher = new ConfigurableApplicationContextEventPublisher((ConfigurableApplicationContext) beanDefinitionRegistry)
-            } else {
-                eventPublisher = new DefaultApplicationEventPublisher()
-            }
+            // Registered rather than built here, so what the datastore holds is a reference the
+            // container can build. Holding the publisher itself puts a live object in the
+            // definition, and generating code for a definition means writing out what it holds --
+            // which a publisher bound to a running context is not.
+            //
+            // The choice is made here rather than through AbstractDatastoreInitializer, because this
+            // build resolves that class from a released grails-datamapping-core rather than from
+            // the source beside it, and a method added there is not one this can call.
+            grailsDatastoreEventPublisher(beanDefinitionRegistry instanceof ConfigurableApplicationContext

Review Comment:
   `grailsDatastoreEventPublisher` is now registered under the same name by four initializers — Hibernate 5, Hibernate 7, Mongo and this one — so in an application with more than one datastore, whichever drains last defines it for all of them.
   
   For the other three that is harmless: they resolve the class through `AbstractDatastoreInitializer.findEventPublisherClass`, which falls back to `resourcePatternResolver.resourceLoader` and so agrees. This one tests only `beanDefinitionRegistry instanceof ConfigurableApplicationContext`, so it can pick `DefaultApplicationEventPublisher` where the shared method would have picked the context-backed one. A Hibernate + Neo4j application can therefore end up with Hibernate publishing through a no-op: GORM events stop firing and auto-timestamping stops working, with nothing logged.
   
   Either give the bean a per-datastore name, or make the choice here agree with `findEventPublisherClass`.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -83,6 +93,22 @@ import javax.inject.Inject
 @CompileStatic
 class GrailsGradlePlugin implements Plugin<Project> {
 
+    private static final String NATIVE_IMAGE_PLUGIN = 'org.graalvm.buildtools.native'
+
+    private static final String SPRING_BOOT_PLUGIN = 'org.springframework.boot'
+
+    private static final String ASSET_COMPILE_TASK = 'assetCompile'
+
+    /** Where an executable jar reads its classpath from, and so where assets have to be to be found. */
+    private static final String CLASSPATH_ASSETS_PATH = 'BOOT-INF/classes/assets'
+
+    private static final int TRAINING_PORT = 18080

Review Comment:
   Fixed ports with no bind check.
   
   `serving()` (`TrainAotCacheTask.groovy:319-329`, and the same in `TraceNativeMetadataTask.groovy:458-468`) only opens a TCP connection to `localhost:<port>`. Anything already listening on 18080 or 18081 — a second build on the same CI agent, a developer's own server, a leftover process from a cancelled build — makes `awaitStarted` return immediately, and the task then trains a cache or records a trace against an unrelated application. It reports success.
   
   Either bind the port first and fail if it is taken, or start with `--server.port=0` and read the chosen port out of the application's output.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisher.groovy:
##########
@@ -32,14 +34,26 @@ import org.springframework.context.ConfigurableApplicationContext
  * @since 6.0
  */
 @CompileStatic
-class ConfigurableApplicationContextEventPublisher implements ConfigurableApplicationEventPublisher {
+class ConfigurableApplicationContextEventPublisher implements ConfigurableApplicationEventPublisher, ApplicationContextAware {
 
-    final ConfigurableApplicationContext applicationContext
+    ConfigurableApplicationContext applicationContext
+
+    /**
+     * Takes the context from the container. A bean definition built this way holds no
+     * already-constructed object, which is what allows it to be processed ahead of time.
+     */
+    ConfigurableApplicationContextEventPublisher() {
+    }
 
     ConfigurableApplicationContextEventPublisher(ConfigurableApplicationContext applicationContext) {
         this.applicationContext = applicationContext
     }
 
+    @Override
+    void setApplicationContext(ApplicationContext applicationContext) {

Review Comment:
   Two things to tighten here.
   
   The cast is unchecked: any `ApplicationContext` that is not `ConfigurableApplicationContext` throws `ClassCastException` out of a container callback. Narrow it with an `instanceof` and leave the field null otherwise, or implement the interface only where the cast is guaranteed.
   
   The bigger one is that nothing guards against the field staying null. `AbstractDatastoreInitializer.findEventPublisherClass` selects this class when *either* the registry or `resourcePatternResolver.resourceLoader` is a `ConfigurableApplicationContext`, but the bean is built by whoever owns the registry. Where the registry is a bare `DefaultListableBeanFactory` and only the resource loader is a context, `ApplicationContextAware` is never applied and the first `publishEvent` NPEs. The old `findEventPublisher` covered that case by passing the resolved context explicitly.
   
   Also worth noting the field went from `final` to a mutable property, so its publication is no longer safe.



##########
grails-data-neo4j/grails-plugin/src/main/groovy/grails/neo4j/bootstrap/Neo4jDataStoreSpringInitializer.groovy:
##########
@@ -75,17 +74,27 @@ class Neo4jDataStoreSpringInitializer extends AbstractDatastoreInitializer {
             callable.delegate = delegate
             callable.call()
 
-            ApplicationEventPublisher eventPublisher
-            if (beanDefinitionRegistry instanceof ConfigurableApplicationContext) {
-                eventPublisher = new ConfigurableApplicationContextEventPublisher((ConfigurableApplicationContext) beanDefinitionRegistry)
-            } else {
-                eventPublisher = new DefaultApplicationEventPublisher()
-            }
+            // Registered rather than built here, so what the datastore holds is a reference the
+            // container can build. Holding the publisher itself puts a live object in the
+            // definition, and generating code for a definition means writing out what it holds --
+            // which a publisher bound to a running context is not.
+            //
+            // The choice is made here rather than through AbstractDatastoreInitializer, because this
+            // build resolves that class from a released grails-datamapping-core rather than from
+            // the source beside it, and a method added there is not one this can call.
+            grailsDatastoreEventPublisher(beanDefinitionRegistry instanceof ConfigurableApplicationContext
+                    ? ConfigurableApplicationContextEventPublisher
+                    : DefaultApplicationEventPublisher)
             final boolean isRecentGrailsVersion = GrailsVersion.isAtLeastMajorMinor(3, 3)
             neo4jConnectionSourceFactory(Neo4jConnectionSourceFactory) { bean ->
                 bean.autowire = true
             }
-            neo4jDatastore(Neo4jDatastore, configuration, ref("neo4jConnectionSourceFactory"), eventPublisher, collectMappedClasses(DATASTORE_TYPE))
+            // The configuration is held rather than named. Naming the environment instead is what
+            // lets a definition be generated without writing the build machine's own settings into
+            // it, but that is asked through a method added to AbstractDatastoreInitializer, and this
+            // build resolves that class from a released grails-datamapping-core rather than from the
+            // source beside it -- so the call compiles here and goes missing at run time.
+            neo4jDatastore(Neo4jDatastore, configuration, ref("neo4jConnectionSourceFactory"), ref('grailsDatastoreEventPublisher'), collectMappedClasses(DATASTORE_TYPE))

Review Comment:
   This is the leak `configurationReference()` was written to prevent, still open for Neo4j.
   
   The comment explains why the call cannot be made here, and that reasoning is sound — but the consequence is that a Neo4j application running `processAot` writes the build machine's `PropertyResolver`, environment variables and all, into its generated source. Credentials among them. That is a worse outcome than not being AOT-processable, and nothing tells the user it happened.
   
   Since the fix is not available in this build, please make it fail rather than generate: `if (AheadOfTimeProcessing.generatingCode) throw ...` naming Neo4j as not yet AOT-processable. An unsupported combination that says so is fine; one that quietly ships the builder's environment is not.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -836,6 +869,36 @@ ${importStatements}
                 it.destinationDirectory = project.layout.buildDirectory.dir('assetCompile/assets')
             }
         }
+        configureAssetsOnTheClasspath(project)
+    }
+
+    /**
+     * Packages the compiled assets where an executable jar can read them.
+     *
+     * <p>The asset pipeline plugin puts them at the root of whatever archive is built, which is
+     * where a war serves its web content from and is therefore right for a war. An executable jar
+     * has no web content: it serves assets by reading them off the classpath, and its classpath is
+     * {@code BOOT-INF/classes} -- so the same assets, at the same place, in a jar rather than a war,
+     * are packaged but unreachable, and every asset a page asks for is a 404 while the page itself
+     * renders. Adding them under the classpath directory is what makes them found.</p>
+     *
+     * <p>Only for {@code bootJar}. A war already serves them from the root, and putting them on its
+     * classpath as well would ship the same bytes twice.</p>
+     */
+    private void configureAssetsOnTheClasspath(Project project) {
+        project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+            // Matched by the task the pipeline registers rather than by the plugin that registers
+            // it: the asset pipeline's plugin id has changed once already, and the task name has
+            // not. Matched rather than looked up, so nothing depends on which plugin was applied
+            // first and no task is resolved to find out.
+            FileCollection compiledAssets = project.files(
+                    project.tasks.matching { Task task -> task.name == ASSET_COMPILE_TASK })

Review Comment:
   This holds a live `TaskCollection` inside a `ConfigurableFileCollection` that becomes part of `bootJar`'s `@InputFiles` — so `TaskContainer`, and through it `Project`, is reachable from `bootJar`'s state, and resolving the collection force-realizes every task in the container.
   
   I take the point from the earlier thread that `matching {}.configureEach {}` fires too late; this is about what the collection *holds*, not about when it fires. `project.provider { }` over `tasks.names`, or `withType`/`named` guarded by `pluginManager.withPlugin` on the asset-pipeline plugin id, gets the same laziness without capturing the container.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/TraceNativeMetadataTask.groovy:
##########
@@ -0,0 +1,484 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.aot
+
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.time.Duration
+import java.util.concurrent.TimeUnit
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+import groovy.transform.CompileStatic
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.work.DisableCachingByDefault
+
+/**
+ * Runs the application under GraalVM's tracing agent and writes down the reflection it did.
+ *
+ * <p>{@code GenerateNativeMetadataTask} records an application's own artefacts by reading the build
+ * output, which needs no run and misses nothing of the application's. What it cannot know is the
+ * framework's own reflection along a request path -- a controller method reached through Groovy's
+ * dispatch, a conversion asked for while binding a form -- because none of that is in the
+ * application's classes. An image built without it starts, serves its home page, and fails on the
+ * request that first takes such a path.</p>
+ *
+ * <p>The agent records exactly that, and records only what ran. So the paths are declared rather
+ * than discovered, and what is not listed is not covered:</p>
+ *
+ * <pre>
+ * grails {
+ *     nativeMetadata {
+ *         paths = ['/', '/login', '/book', '/book/create']
+ *         forms = ['/book/create']
+ *     }
+ * }
+ * </pre>
+ *
+ * <p>A form is asked for, read, and submitted with the fields it declares, because posting fewer
+ * than it declares records less than the application does. A checkbox is a string on the wire and a
+ * boolean on the domain class, so a form submitted without one never asks the conversion service
+ * anything -- and an image built from that trace fails the first time someone ticks a box.</p>
+ *
+ * <p>Forms are submitted before paths are asked for, and the session one establishes is carried
+ * through the rest of the trace. A page behind a login is reached by listing the login form, which
+ * makes the pages named after it reachable:</p>
+ *
+ * <pre>
+ * grails {
+ *     nativeMetadata {
+ *         forms = ['/login?username=admin&amp;password=secret', '/book/create']
+ *         paths = ['/', '/book']
+ *     }
+ * }
+ * </pre>
+ *
+ * <p>Where a page carries more than one form -- a layout's search or sign-out beside the page's own
+ * -- the one declaring the most fields is submitted, and which it was is reported. A page whose
+ * wanted form is not the fullest names it: {@code '/book/create#bookForm'}.</p>
+ *
+ * <p>Written to the application's sources rather than to the build directory: what an image was
+ * built from should be reviewable, and a trace is only as good as the paths someone thought of.</p>
+ *
+ * @since 8.0
+ */
+@CompileStatic
+@DisableCachingByDefault(because = 'Records what a run of the application did, which is not an output of its inputs')
+abstract class TraceNativeMetadataTask extends DefaultTask {
+
+    /** How the agent is asked for, and the only way its output can be merged with what is there. */
+    private static final String AGENT = 'native-image-agent'
+
+    /** The names the agent library goes by, one of which is beside a GraalVM's java. */
+    private static final List<String> AGENT_LIBRARIES = [
+            'libnative-image-agent.dylib', 'libnative-image-agent.so', 'native-image-agent.dll'
+    ]
+
+    private static final Pattern FORM = Pattern.compile(/(?is)<form\b[^>]*>.*?<\/form>/)
+    private static final Pattern ACTION = Pattern.compile(/(?i)\baction\s*=\s*"([^"]*)"/)
+    private static final Pattern ID = Pattern.compile(/(?i)\bid\s*=\s*"([^"]+)"/)
+    private static final Pattern FIELD = Pattern.compile(/(?is)<(?:input|select|textarea)\b[^>]*>/)
+    private static final Pattern NAME = Pattern.compile(/(?i)\bname\s*=\s*"([^"]+)"/)
+    private static final Pattern VALUE = Pattern.compile(/(?i)\bvalue\s*=\s*"([^"]*)"/)
+    private static final Pattern TYPE = Pattern.compile(/(?i)\btype\s*=\s*"([^"]+)"/)
+
+    /**
+     * Carries the session from one request to the next, which is what makes a form that
+     * authenticates worth submitting: the pages that follow it are only reachable once it has been.
+     *
+     * <p>Its own cookie store rather than the JVM's. The store belongs to the client, so nothing is
+     * left behind in a daemon that outlives the build and two traces at once cannot share one.</p>
+     */
+    private HttpClient client
+
+    /** The archive to run, which has to be the one the image will be built from. */
+    @InputFile
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract RegularFileProperty getArchiveFile()
+
+    /**
+     * A java from a GraalVM. The agent ships with GraalVM rather than with a JDK, and an application
+     * built for an image is compiled for GraalVM's Java -- so the JDK that runs the build is usually
+     * neither, and running the trace on it fails at load or refuses the class file.
+     */
+    @Input
+    abstract Property<String> getJavaExecutable()
+
+    @Input
+    abstract ListProperty<String> getJvmArguments()
+
+    /** Paths to ask for. */
+    @Input
+    abstract ListProperty<String> getPaths()
+
+    /** Pages whose form is to be filled in and submitted. */
+    @Input
+    abstract ListProperty<String> getForms()
+
+    @Input
+    abstract Property<Integer> getPort()
+
+    @Input
+    abstract Property<Integer> getStartTimeoutSeconds()
+
+    /**
+     * Where the agent merges what it recorded. Not declared as an output: it is in the application's
+     * sources, and a directory Gradle believes it owns is a directory Gradle will delete.
+     */
+    @Internal
+    abstract DirectoryProperty getOutputDirectory()
+
+    @TaskAction
+    void trace() {
+        File java = new File(javaExecutable.get())
+        File metadata = outputDirectory.get().asFile
+        metadata.mkdirs()
+        refuseWithoutAgent(java)
+
+        List<String> command = []
+        command << java.absolutePath
+        command << "-agentlib:${AGENT}=config-merge-dir=${metadata.absolutePath}".toString()
+        command.addAll(jvmArguments.get())
+        command << '-jar' << archiveFile.get().asFile.absolutePath
+        command << "--server.port=${port.get()}".toString()
+
+        File output = new File(temporaryDir, 'trace.log')
+        Process process = new ProcessBuilder(command)
+                .directory(archiveFile.get().asFile.parentFile)
+                .redirectErrorStream(true)
+                .redirectOutput(output)
+                .start()
+
+        client = HttpClient.newBuilder()

Review Comment:
   The `HttpClient` is stored in a task field and never closed. It is `AutoCloseable` on 21+, and it owns a selector thread and an executor — so both outlive the build inside the daemon, one set per task instance.
   
   The javadoc at :113-119 says nothing is left behind in a daemon that outlives the build, which is the thing that happens. Closing it in a `finally` around the tracing run, or building it per run in a try-with-resources, settles both.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/TrainAotCacheTask.groovy:
##########
@@ -0,0 +1,376 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.aot
+
+import java.security.MessageDigest
+import java.time.Duration
+
+import groovy.transform.CompileStatic
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputDirectory
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.work.DisableCachingByDefault
+
+/**
+ * Runs the application once so the JDK can write down what starting it needs.
+ *
+ * <p>The run is the point. A cache records the classes loaded and linked, and the profiles of the
+ * methods that ran, so the next start reads them rather than working them out again -- which means
+ * what the next start is <em>fast at</em> is whatever this run did. A run that only refreshes the
+ * context leaves every request path to be worked out on the day.</p>
+ *
+ * <p>So the application is started, asked for the pages an application is asked for, and then asked
+ * to stop. It has to stop of its own accord: the cache is written as the JVM exits, and a run that is
+ * killed writes nothing.</p>
+ *
+ * @since 8.0
+ */
+@CompileStatic
+@DisableCachingByDefault(because = 'Runs the application and records what it did, which is not reproducible')
+abstract class TrainAotCacheTask extends DefaultTask {
+
+    /**
+     * The extracted application: the cache is only usable against the layout it was trained on.
+     *
+     * <p>Compared by what is in it and where each file sits within it, not by where the directory
+     * itself is. Without saying so, Gradle takes the absolute path to be part of the input and
+     * refuses to validate the task at all -- and a build that did run would key its result to a
+     * path, so the same application checked out somewhere else, or built on CI, would agree about
+     * everything and still share nothing.</p>
+     */
+    @InputDirectory
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract DirectoryProperty getApplicationDirectory()
+
+    @Input
+    abstract Property<String> getArchiveFileName()
+
+    @OutputFile
+    abstract RegularFileProperty getCacheFile()
+
+    @Input
+    abstract Property<String> getJavaExecutable()
+
+    /** The version of the JDK above, which is the JDK the cache will only ever be readable by. */
+    @Input
+    abstract Property<String> getJavaVersion()
+
+    @Input
+    abstract Property<String> getJavaVendor()
+
+    /** Given to the training run, and to be given to every run that reads the cache. */
+    @Input
+    abstract ListProperty<String> getJvmArguments()
+
+    /** The paths to ask for, so their methods are profiled rather than met for the first time later. */
+    @Input
+    abstract ListProperty<String> getPaths()
+
+    @Input
+    abstract Property<Integer> getPort()
+
+    @Input
+    abstract Property<Integer> getStartTimeoutSeconds()
+
+    /** Written beside the cache, so what the cache was made from can be checked before it is used. */
+    @OutputFile
+    abstract RegularFileProperty getMetadataFile()
+
+    /** The first JDK that can write one. Before it, {@code -XX:AOTCacheOutput} is not an option. */
+    private static final int MINIMUM_JAVA_VERSION = 25
+
+    @TaskAction
+    void train() {
+        refuseWhereTheRunCannotBeAskedToStop()
+        refuseWhereTheJdkCannotWriteACache()
+        File directory = applicationDirectory.get().asFile
+        File cache = cacheFile.get().asFile
+        cache.delete()
+
+        List<String> command = []
+        command << javaExecutable.get()
+        command << "-XX:AOTCacheOutput=${cache.absolutePath}".toString()
+        command.addAll(jvmArguments.get())
+        command << '-jar' << archiveFileName.get()
+        command << "--server.port=${port.get()}".toString()
+
+        File output = new File(temporaryDir, 'training.log')
+        ProcessBuilder builder = new ProcessBuilder(command)
+                .directory(directory)
+                .redirectErrorStream(true)
+                .redirectOutput(output)
+        withoutEmptyVariables(builder.environment())
+        Process process = builder.start()
+        try {
+            awaitStarted(process, output)
+            exercise()
+        }
+        finally {
+            stop(process)
+        }
+        if (!cache.isFile()) {
+            throw new GradleException("The training run wrote no cache. What it printed is in ${output}")
+        }
+        describe(cache, new File(directory, archiveFileName.get()))
+        logger.lifecycle('Trained {} ({} MB) over {} paths',
+                cache.name, (cache.length() / (1024 * 1024)) as long, paths.get().size())
+    }
+
+    /**
+     * Drops the variables that are present but empty from what the run will inherit.
+     *
+     * <p>The run inherits the daemon's environment, and the daemon's is not the one the build was
+     * started from. A daemon that once ran a build with a variable set keeps the name afterwards
+     * and empties the value, so a later build started from a shell that never mentioned it hands
+     * the run {@code SOME_VARIABLE=""} all the same.</p>
+     *
+     * <p>Spring Boot binds an environment variable over the application's own configuration, and
+     * relaxed binding means {@code GRAILS_MONGODB_URL} is {@code grails.mongodb.url}. So an empty
+     * leftover replaced a configured value with nothing, and the training run failed on a property
+     * the application had set correctly -- reporting it against a name nobody had typed, in a
+     * build that had run cleanly minutes earlier from a different shell.</p>
+     *
+     * <p>An empty variable says nothing that an absent one does not, so it is not passed on. What
+     * the build was actually given, empty or not, still arrives: this drops only what the daemon
+     * kept after the build that set it had finished.</p>
+     */
+    private static void withoutEmptyVariables(Map<String, String> environment) {
+        environment.entrySet().removeIf { Map.Entry<String, String> variable ->
+            !variable.value
+        }
+    }
+
+    /**
+     * Refuses to start a run that could not be ended properly, before it is started.
+     *
+     * <p>The cache is written as the training JVM exits normally, so the run has to be asked to
+     * stop rather than killed. {@link Process#destroy()} asks on POSIX and kills on Windows, where
+     * it is {@code TerminateProcess} and no shutdown runs -- so on Windows the run would be
+     * exercised in full, killed, and leave no cache, and the build would fail at the end saying the
+     * cache was not written rather than saying why it could not be.</p>
+     */
+    private static void refuseWhereTheRunCannotBeAskedToStop() {
+        String os = System.getProperty('os.name', '')
+        if (os.toLowerCase(Locale.ROOT).contains('win')) {
+            throw new GradleException('Training an AOT cache needs the training run to be asked to ' +
+                    'stop, and on Windows a child process can only be killed -- which writes no ' +
+                    'cache. Train on Linux or macOS, or set grails.aotCache.enabled to false.')
+        }
+    }
+
+    /**
+     * Refuses on a JDK that has no cache to write, before the run is started.
+     *
+     * <p>{@code -XX:AOTCacheOutput} arrived in JDK 25. An earlier JDK does not recognise it and
+     * stops immediately, so the run would be started, fail at once, and be reported as having
+     * ended before it started serving -- which reads as a broken application rather than as the
+     * wrong JDK, and sends whoever is reading the build into a log of the application's own
+     * start-up that never happened.</p>
+     *
+     * <p>Asked of the JDK the training will run on, which is the project's toolchain rather than
+     * whichever one is running Gradle.</p>
+     */
+    private void refuseWhereTheJdkCannotWriteACache() {
+        String version = javaVersion.get()
+        Integer major = majorVersionOf(version)
+        if (major != null && major < MINIMUM_JAVA_VERSION) {
+            throw new GradleException("Training an AOT cache needs Java ${MINIMUM_JAVA_VERSION} or " +
+                    "later, and the toolchain this would train on is ${version}. Point the project " +
+                    'toolchain at a newer JDK, or set grails.aotCache.enabled to false.')
+        }
+    }
+
+    /**
+     * The feature version of a runtime version string, or {@code null} where it does not begin with
+     * one. Unreadable rather than old: a version this cannot parse is left to the run to answer for,
+     * since refusing over it would fail a build on a JDK that may be perfectly capable.
+     */
+    private static Integer majorVersionOf(String version) {
+        int digits = 0
+        while (digits < version.length() && Character.isDigit(version.charAt(digits))) {
+            digits++
+        }
+        digits > 0 ? Integer.valueOf(version.substring(0, digits)) : null
+    }
+
+    /**
+     * Writes down what the cache was made from.
+     *
+     * <p>A cache is read only by the JDK build that wrote it, against the archive it was trained on,
+     * with the arguments it was trained with. A JVM given one made from anything else declines it and
+     * starts as it would have anyway -- so what is lost is the speed, silently, and this is what
+     * tells a deployment which of those it has.</p>
+     */
+    private void describe(File cache, File archive) {
+        Properties properties = new Properties()
+        properties.setProperty('cache.file', cache.name)
+        properties.setProperty('cache.bytes', String.valueOf(cache.length()))
+        properties.setProperty('application.archive', archive.name)
+        properties.setProperty('application.sha256', sha256(archive))
+        properties.setProperty('training.arguments', jvmArguments.get().join(' '))
+        properties.setProperty('training.paths', paths.get().join(' '))
+        properties.setProperty('java.vendor', javaVendor.get())
+        properties.setProperty('java.runtime.version', javaVersion.get())
+        properties.setProperty('os.name', System.getProperty('os.name', ''))
+        properties.setProperty('os.arch', System.getProperty('os.arch', ''))
+        metadataFile.get().asFile.withOutputStream { OutputStream out ->
+            properties.store(out, 'What this AOT cache was trained from')
+        }
+    }
+
+    private static String sha256(File file) {
+        MessageDigest digest = MessageDigest.getInstance('SHA-256')
+        file.withInputStream { InputStream input ->
+            byte[] buffer = new byte[8192]
+            int read
+            while ((read = input.read(buffer)) != -1) {
+                digest.update(buffer, 0, read)
+            }
+        }
+        digest.digest().encodeHex().toString()
+    }
+
+    /**
+     * Waits for the application to start serving, and stops waiting if the run ends first --
+     * otherwise a run that fails immediately is waited on for the whole timeout.
+     *
+     * <p>Either the run says it started or its port answers. The message is Spring Boot's and is
+     * the earlier and clearer of the two, but it is an INFO log an application is free to turn off
+     * or reword; a port that accepts a connection is the application's own doing and cannot be
+     * configured away while it is still an application worth training.</p>
+     */
+    private void awaitStarted(Process process, File output) {
+        long deadline = System.currentTimeMillis() + Duration.ofSeconds(startTimeoutSeconds.get()).toMillis()
+        while (System.currentTimeMillis() < deadline) {
+            if (!process.isAlive()) {
+                throw new GradleException('The training run ended before it started serving.' +
+                        whyItEnded(output) + " What it printed is in ${output}")
+            }
+            if (output.isFile() && output.text.contains('Started ')) {

Review Comment:
   `contains('Started ')` matches well before the application is ready.
   
   Jetty logs `Started ServerConnector@...` and `Started Server@...` during start-up, and there are several other `Started ` lines depending on the container. Training or tracing that begins there records a partially started application, which is silently a worse cache and a thinner trace.
   
   Spring Boot's own line is `Started <AppName> in <n> seconds`; anchoring on `Started ` plus ` in ` and ` seconds` is enough, and an actuator health probe is better still.
   
   Minor, in the same method: the whole log is re-read every 250 ms with the platform default charset.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org