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: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to