From d593bd2bbf0fdf9e5aa44cf4ccd6349b9f6b3a01 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Tue, 4 Aug 2026 17:37:32 -0500
Subject: [PATCH 001/115] Register the GSP codecs settings and servlet without
live instances
filteringCodecsByContentTypeSettings took the live GrailsApplication as a
constructor argument, and groovyPagesServlet took a new GroovyPagesServlet().
Spring AOT's ValueCodeGenerator cannot emit code for an arbitrary object, so
processAot aborted the entire run:
UnsupportedTypeValueCodeGenerationException:
Code generation does not support grails.core.DefaultGrailsApplication
Both now use forms the generator understands: a reference by bean name, as
errorsViewStackTracePrinter directly above already does, and an inner bean
definition. This was the first processAot failure for a stock web application
on 7.2.1, 8.0.0-M4 and 8.0.0-SNAPSHOT alike. Further blockers remain behind it,
so this does not by itself make an application AOT-processable.
Constructing the servlet through the registry rather than with new means it now
passes through bean post-processing. Its pluginManager is unaffected -
initFrameworkServlet already autowires the servlet's properties by type, which
covers that setter - but a post-processor whose pointcut matched the servlet
would hand ServletRegistrationBean a proxy in its place. No pointcut in the
framework does.
---
.../org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy
index 3d1b0ae61b6..f3a08dbe0a1 100644
--- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy
+++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/GroovyPagesGrailsPlugin.groovy
@@ -265,9 +265,9 @@ class GroovyPagesGrailsPlugin extends Plugin {
}
errorsViewStackTracePrinter(ErrorsViewStackTracePrinter, ref('grailsResourceLocator'))
- filteringCodecsByContentTypeSettings(FilteringCodecsByContentTypeSettings, application)
+ filteringCodecsByContentTypeSettings(FilteringCodecsByContentTypeSettings, ref('grailsApplication'))
- groovyPagesServlet(ServletRegistrationBean, new GroovyPagesServlet(), '*.gsp') {
+ groovyPagesServlet(ServletRegistrationBean, bean(GroovyPagesServlet), '*.gsp') {
if (Environment.isDevelopmentMode()) {
initParameters = [showSource: '1']
}
From ff515d199aa142184ba62226a1ef4fa5afbd242e Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Tue, 4 Aug 2026 17:47:29 -0500
Subject: [PATCH 002/115] Do not add a ConfigurationClassPostProcessor to an
AOT context
CoreGrailsPlugin registers a second ConfigurationClassPostProcessor so that
@Configuration beans contributed by plugins through doWithSpring - which
arrive after Spring's own processor has finished - still get parsed.
An AOT-optimized context has no ConfigurationClassPostProcessor at all:
the configuration classes were parsed at build time and their bean
definitions are in the generated initializer. Registering one there parses
them a second time, and the re-parse collides 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 bean definition with the
configuration class as its bean class, so the re-parse rediscovers
CoreAutoConfiguration under the bean name propertySourcesPlaceholderConfigurer
and then collides with itself. The processor is skipped when
AotDetector.useGeneratedArtifacts() reports generated artifacts are in use;
behaviour without AOT is unchanged.
This was reachable only after the GSP live-instance beans were fixed, since
processAot did not previously get far enough to produce an initializer.
---
.../groovy/org/grails/plugins/CoreGrailsPlugin.groovy | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy b/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy
index c65796ecde2..3824e08ff2f 100644
--- a/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy
+++ b/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy
@@ -21,6 +21,7 @@ package org.grails.plugins
import groovy.transform.CompileStatic
import org.springframework.aop.config.AopConfigUtils
+import org.springframework.aot.AotDetector
import org.springframework.beans.factory.BeanRegistrar
import org.springframework.beans.factory.BeanRegistry
import org.springframework.beans.factory.config.CustomEditorConfigurer
@@ -129,8 +130,13 @@ class CoreGrailsPlugin extends Plugin {
GrailsApplication application = grailsApplication
Config config = application.config
- // enable post-processing of @Configuration beans defined by plugins
- registry.registerBean('grailsConfigurationClassPostProcessor', ConfigurationClassPostProcessor)
+ // enable post-processing of @Configuration beans defined by plugins. An AOT-optimized
+ // context has no ConfigurationClassPostProcessor of its own: the configuration classes
+ // were parsed at build time and their beans are already in the generated initializer,
+ // so registering one here would parse them a second time.
+ if (!AotDetector.useGeneratedArtifacts()) {
+ registry.registerBean('grailsConfigurationClassPostProcessor', ConfigurationClassPostProcessor)
+ }
registry.registerBean('grailsBeanOverrideConfigurer', MapBasedSmartPropertyOverrideConfigurer) {
it.supplier {
From 38e941fecd6b60daa347eab6b1926fec2d649848 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Tue, 4 Aug 2026 18:12:40 -0500
Subject: [PATCH 003/115] Keep abstract bean definitions out of AOT processing
An abstract bean definition is a template: it carries property values for
children to inherit and is never instantiated. Spring's bean-definition code
generator has no representation for one - BeanDefinitionPropertiesCodeGenerator
emits lazyInit, primary, scope, role and synthetic, but not the abstract flag,
and nothing filters abstract definitions out beforehand. The definition is
regenerated as a concrete bean of type Object still carrying the template's
properties, and the context fails applying them:
BeanCreationException: Error creating bean with name
'abstractGrailsResourceLocator': Invalid property 'searchLocations' of bean
class [java.lang.Object]
CoreGrailsPlugin contributes exactly such a template through
AbstractResourceLocatorPostProcessor, and it is public surface - third-party
plugins inherit from it with bean.parent, asset-pipeline's assetResourceLocator
among them - so it cannot simply be removed.
A BeanRegistrationExcludeFilter registered in META-INF/spring/aot.factories
keeps every abstract definition out of generation. Children are unaffected,
because AOT generates them from their merged definition with inherited values
already folded in; a definition contributed dynamically still finds its parent,
since the post-processor that registers the template runs during refresh in an
AOT context as it does in any other.
With this an AOT-processed application starts. Its bean definitions differ from
a normal boot only by the annotation-processing infrastructure AOT replaces:
the configuration, autowired and common-annotation processors, Boot's shared
metadata reader factory, and grailsConfigurationClassPostProcessor.
---
.../AbstractBeanDefinitionExcludeFilter.java | 48 +++++++++++
.../resources/META-INF/spring/aot.factories | 2 +
...ractBeanDefinitionExcludeFilterSpec.groovy | 79 +++++++++++++++++++
3 files changed, 129 insertions(+)
create mode 100644 grails-core/src/main/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilter.java
create mode 100644 grails-core/src/main/resources/META-INF/spring/aot.factories
create mode 100644 grails-core/src/test/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilterSpec.groovy
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilter.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilter.java
new file mode 100644
index 00000000000..58808c10676
--- /dev/null
+++ b/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;
+
+import org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter;
+import org.springframework.beans.factory.support.RegisteredBean;
+
+/**
+ * Keeps abstract bean definitions out of ahead-of-time processing.
+ *
+ *
An abstract definition is a template: it carries property values for children to inherit
+ * and is never instantiated. Spring's bean-definition code generator has no representation for
+ * that — it emits neither the abstract flag nor a bean class, so the definition is regenerated
+ * as a concrete bean of type {@code Object} carrying the template's properties, which fails as
+ * soon as the context applies them.
+ *
+ *
Children are unaffected: ahead-of-time processing generates them from their merged
+ * definition, so inherited values are already folded in and no parent is needed at runtime.
+ * A definition contributed dynamically still finds its parent, because the post-processor that
+ * registers the template runs during refresh in an ahead-of-time context as it does in any other.
+ *
+ * @since 8.0
+ * @see org.grails.spring.beans.AbstractResourceLocatorPostProcessor
+ */
+public class AbstractBeanDefinitionExcludeFilter implements BeanRegistrationExcludeFilter {
+
+ @Override
+ public boolean isExcludedFromAotProcessing(RegisteredBean registeredBean) {
+ return registeredBean.getMergedBeanDefinition().isAbstract();
+ }
+
+}
diff --git a/grails-core/src/main/resources/META-INF/spring/aot.factories b/grails-core/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..2b6b9870fe9
--- /dev/null
+++ b/grails-core/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter=\
+org.grails.spring.beans.aot.AbstractBeanDefinitionExcludeFilter
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilterSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilterSpec.groovy
new file mode 100644
index 00000000000..c0b0ee9de52
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/AbstractBeanDefinitionExcludeFilterSpec.groovy
@@ -0,0 +1,79 @@
+/*
+ * 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 spock.lang.Specification
+
+import org.springframework.beans.factory.support.DefaultListableBeanFactory
+import org.springframework.beans.factory.support.GenericBeanDefinition
+import org.springframework.beans.factory.support.RegisteredBean
+
+import org.grails.spring.beans.AbstractResourceLocatorPostProcessor
+
+class AbstractBeanDefinitionExcludeFilterSpec extends Specification {
+
+ DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory()
+ AbstractBeanDefinitionExcludeFilter filter = new AbstractBeanDefinitionExcludeFilter()
+
+ void 'an abstract definition is excluded from AOT processing'() {
+ given: 'a classless template definition carrying only inherited property values'
+ def definition = new GenericBeanDefinition()
+ definition.abstract = true
+ definition.propertyValues.add('searchLocations', ['/some/location'])
+ beanFactory.registerBeanDefinition('abstractParent', definition)
+
+ expect:
+ filter.isExcludedFromAotProcessing(RegisteredBean.of(beanFactory, 'abstractParent'))
+ }
+
+ void 'a concrete definition is left to AOT processing'() {
+ given:
+ def definition = new GenericBeanDefinition()
+ definition.beanClass = String
+ beanFactory.registerBeanDefinition('concrete', definition)
+
+ expect:
+ !filter.isExcludedFromAotProcessing(RegisteredBean.of(beanFactory, 'concrete'))
+ }
+
+ void 'a child inheriting from an abstract parent is left to AOT processing'() {
+ given: 'the parent template and a child naming it'
+ def parent = new GenericBeanDefinition()
+ parent.abstract = true
+ parent.propertyValues.add('searchLocations', ['/some/location'])
+ beanFactory.registerBeanDefinition('abstractParent', parent)
+
+ def child = new GenericBeanDefinition()
+ child.beanClass = StringBuilder
+ child.parentName = 'abstractParent'
+ beanFactory.registerBeanDefinition('child', child)
+
+ expect: 'the child is generated from its merged definition, so it needs no parent at runtime'
+ !filter.isExcludedFromAotProcessing(RegisteredBean.of(beanFactory, 'child'))
+ }
+
+ void 'the resource locator template the core plugin contributes is excluded'() {
+ given:
+ new AbstractResourceLocatorPostProcessor(['/base']).postProcessBeanDefinitionRegistry(beanFactory)
+
+ expect:
+ filter.isExcludedFromAotProcessing(
+ RegisteredBean.of(beanFactory, AbstractResourceLocatorPostProcessor.BEAN_NAME))
+ }
+}
From b3811adb7565c0790d7b3fa8af1d887442ac1372 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Tue, 4 Aug 2026 18:30:37 -0500
Subject: [PATCH 004/115] Let the container supply the SiteMesh view resolver's
servlet context
SiteMeshViewResolver now implements ServletContextAware, so the bean-definition
wrap no longer passes the servlet context as a constructor argument. That
reference was to a bean the container only registers once the web server has
started, which has no bean definition for AOT to resolve, and it failed
processAot for every application with GSP on the classpath:
AotBeanProcessingException: Error processing bean with name 'jspViewResolver'
Caused by: NoSuchBeanDefinitionException: No bean named 'servletContext' available
GrailsSiteMeshViewResolver gains the matching three-argument constructor and
reads the context through the inherited accessor rather than keeping its own
copy. The four-argument constructor stays for callers that build the resolver
directly, which is how the instance-level post-processor still creates it.
This was the last blocker: a stock Grails web application now completes
processAot and starts with spring.aot.enabled=true. Its bean definitions differ
from a normal boot only by the annotation-processing infrastructure AOT
replaces - the configuration, autowired and common-annotation processors,
Boot's shared metadata reader factory, and grailsConfigurationClassPostProcessor.
The sitemesh version is moved to 3.3.0-SNAPSHOT because the change it depends on
is not in 3.3.0-M3. It must be pinned to a released version before this merges.
---
dependencies.gradle | 4 ++--
.../sitemesh3/GrailsSiteMeshViewResolver.java | 12 +++++++++---
2 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/dependencies.gradle b/dependencies.gradle
index 29bc2978822..ad18672d592 100644
--- a/dependencies.gradle
+++ b/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',
+ 'spring-webmvc-sitemesh.version': '3.3.0-SNAPSHOT',
// Spring Boot 4 no longer manages spring-retry; pin it here so the
// grails-shell-cli SpringRetryCompilerAutoConfiguration's unversioned
// reference resolves and consumer apps using @Retryable get a known version.
diff --git a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java
index a537f3df971..7b6b38d50c2 100644
--- a/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java
+++ b/grails-gsp/grails-sitemesh3/src/main/java/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolver.java
@@ -37,7 +37,14 @@ public class GrailsSiteMeshViewResolver extends SiteMeshViewResolver {
private final ContentProcessor contentProcessor;
private final DecoratorSelector decoratorSelector;
- private final ServletContext servletContext;
+
+ public GrailsSiteMeshViewResolver(ViewResolver innerViewResolver,
+ ContentProcessor contentProcessor,
+ DecoratorSelector decoratorSelector) {
+ super(innerViewResolver, contentProcessor, decoratorSelector);
+ this.contentProcessor = contentProcessor;
+ this.decoratorSelector = decoratorSelector;
+ }
public GrailsSiteMeshViewResolver(ViewResolver innerViewResolver,
ContentProcessor contentProcessor,
@@ -46,7 +53,6 @@ public GrailsSiteMeshViewResolver(ViewResolver innerViewResolver,
super(innerViewResolver, contentProcessor, decoratorSelector, servletContext);
this.contentProcessor = contentProcessor;
this.decoratorSelector = decoratorSelector;
- this.servletContext = servletContext;
}
@Override
@@ -54,7 +60,7 @@ protected SiteMeshView createSiteMeshView(View innerView) {
// Forward-based JSP inner views are switched to include dispatch by
// SiteMeshViewResolver.prepareForBufferedRender (keyed on
// DispatchMode) before this hook runs.
- return new GrailsSiteMeshView(innerView, contentProcessor, decoratorSelector, servletContext,
+ return new GrailsSiteMeshView(innerView, contentProcessor, decoratorSelector, getServletContext(),
getInnerViewResolver());
}
}
From bc537807e332ba021f6a332a51bc63f7418c724a Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Tue, 4 Aug 2026 19:34:58 -0500
Subject: [PATCH 005/115] Document ahead-of-time processing and cover it with
tests
AOT support is opt-in and needs configuration that is not guessable - the Spring
Boot AOT plugin, and generation in production mode, without which the url
mappings holder takes its reload-mode proxy shape and generation fails naming a
bean unrelated to anything in the application. The deployment guide now covers
enabling it, what changes at runtime, and the limitations: registrar-contributed
beans are not generated, definitions holding live objects cannot be generated,
abstract definitions are excluded, and AOT alone does not make an application
native-image ready.
Two layers of coverage, because the failures differ in kind.
CoreGrailsPluginAotSpec runs the real generator over the core plugin's bean
definitions, so a definition holding a live instance fails the build. It also
covers the configuration class post-processor being registered normally and
withheld when generated artifacts are in use, which no existing test reached. A
fourth case registers a definition holding a live instance and asserts
generation rejects it, so the check above cannot pass vacuously.
Generation succeeding does not mean the application starts, and the
post-processor condition is a runtime-only behaviour. The grails-test-examples
aot application therefore runs processAot and then starts the packaged jar with
spring.aot.enabled=true, asserting the beans an AOT context must still contain.
Its check task fails the build if either half regresses.
---
.../plugins/CoreGrailsPluginAotSpec.groovy | 146 ++++++++++++++++++
.../en/guide/deployment/deploymentAot.adoc | 101 ++++++++++++
grails-doc/src/en/guide/toc.yml | 1 +
...eMeshViewResolverServletContextSpec.groovy | 105 +++++++++++++
grails-test-examples/aot/build.gradle | 71 +++++++++
.../aot/grails-app/conf/application.yml | 5 +
.../grails-app/init/aot/Application.groovy | 59 +++++++
settings.gradle | 2 +
8 files changed, 490 insertions(+)
create mode 100644 grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy
create mode 100644 grails-doc/src/en/guide/deployment/deploymentAot.adoc
create mode 100644 grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverServletContextSpec.groovy
create mode 100644 grails-test-examples/aot/build.gradle
create mode 100644 grails-test-examples/aot/grails-app/conf/application.yml
create mode 100644 grails-test-examples/aot/grails-app/init/aot/Application.groovy
diff --git a/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy b/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy
new file mode 100644
index 00000000000..84ce4e450fc
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy
@@ -0,0 +1,146 @@
+/*
+ * 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.plugins
+
+import org.springframework.aot.AotDetector
+import org.springframework.aot.generate.ClassNameGenerator
+import org.springframework.aot.generate.DefaultGenerationContext
+import org.springframework.aot.generate.InMemoryGeneratedFiles
+import org.springframework.beans.factory.BeanRegistrar
+import org.springframework.beans.factory.support.BeanRegistryAdapter
+import org.springframework.beans.factory.support.GenericBeanDefinition
+import org.springframework.context.aot.ApplicationContextAotGenerator
+import org.springframework.context.support.GenericApplicationContext
+import org.springframework.core.SpringProperties
+import org.springframework.javapoet.ClassName
+
+import grails.core.DefaultGrailsApplication
+import grails.core.GrailsApplication
+import grails.plugins.DefaultGrailsPluginManager
+import grails.plugins.GrailsPlugin
+import grails.plugins.GrailsPluginManager
+import org.apache.grails.core.plugins.DefaultPluginDiscovery
+import spock.lang.Specification
+
+/**
+ * Covers the core plugin's behaviour under Spring's ahead-of-time processing: the bean definitions
+ * it contributes must be expressible as generated code, and the configuration class post-processor
+ * it registers must stand down where the context already has its bean definitions generated.
+ */
+class CoreGrailsPluginAotSpec extends Specification {
+
+ GenericApplicationContext context = new GenericApplicationContext()
+
+ void cleanup() {
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, null)
+ context.close()
+ }
+
+ /**
+ * Mirrors the registrar phase of {@code GrailsApplicationPostProcessor}: every enabled plugin's
+ * {@link BeanRegistrar} applied against the registry through the same adapter the runtime uses.
+ */
+ private void applyCorePluginRegistrar() {
+ GrailsApplication application = new DefaultGrailsApplication()
+ application.applicationContext = context
+ application.initialise()
+
+ def discovery = new DefaultPluginDiscovery([CoreGrailsPlugin] as Class>[])
+ discovery.loadPluginsFromClasspath = false
+ discovery.init(context.environment)
+
+ GrailsPluginManager pluginManager = new DefaultGrailsPluginManager(application, discovery)
+ context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application)
+ context.beanFactory.registerSingleton(GrailsPluginManager.BEAN_NAME, pluginManager)
+ pluginManager.loadPlugins()
+
+ for (GrailsPlugin plugin : pluginManager.allPlugins) {
+ BeanRegistrar registrar = plugin.beanRegistrar
+ if (registrar != null) {
+ new BeanRegistryAdapter(context, context, context.environment, registrar.getClass())
+ .register(registrar)
+ }
+ }
+ }
+
+ void 'the configuration class post-processor is registered when generated artifacts are not in use'() {
+ given:
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false')
+
+ when:
+ applyCorePluginRegistrar()
+
+ then: 'plugin-contributed @Configuration beans still need parsing at runtime'
+ context.containsBeanDefinition('grailsConfigurationClassPostProcessor')
+ }
+
+ void 'the configuration class post-processor is withheld when generated artifacts are in use'() {
+ given: 'the flag an AOT-optimized application is started with'
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'true')
+
+ when:
+ applyCorePluginRegistrar()
+
+ then: 'the configuration classes were parsed at build time, so parsing them again would ' +
+ 'collide with the definitions already generated'
+ !context.containsBeanDefinition('grailsConfigurationClassPostProcessor')
+ }
+
+ void 'the core plugin bean definitions can be generated ahead of time'() {
+ given:
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false')
+ applyCorePluginRegistrar()
+
+ and:
+ def generationContext = new DefaultGenerationContext(
+ new ClassNameGenerator(ClassName.get('org.grails.aot.test', 'CoreAotTest')),
+ new InMemoryGeneratedFiles())
+
+ when: 'the context is processed exactly as the processAot build task processes it'
+ new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext)
+
+ then: 'no definition holds a value the generator cannot express as code -- a live instance ' +
+ 'passed as a constructor argument or property value would fail here'
+ noExceptionThrown()
+ }
+
+ void 'a definition holding a live instance fails generation'() {
+ given:
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false')
+ applyCorePluginRegistrar()
+
+ and: 'the shape this plugin must avoid: an already-constructed object as a constructor argument'
+ def definition = new GenericBeanDefinition()
+ definition.beanClass = StringBuilder
+ definition.constructorArgumentValues.addIndexedArgumentValue(0, new DefaultGrailsApplication())
+ context.registerBeanDefinition('holdsALiveInstance', definition)
+
+ and:
+ def generationContext = new DefaultGenerationContext(
+ new ClassNameGenerator(ClassName.get('org.grails.aot.test', 'LiveInstanceAotTest')),
+ new InMemoryGeneratedFiles())
+
+ when:
+ new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext)
+
+ then: 'proving the preceding check is capable of failing'
+ Exception e = thrown()
+ e.message.contains('holdsALiveInstance')
+ }
+}
diff --git a/grails-doc/src/en/guide/deployment/deploymentAot.adoc b/grails-doc/src/en/guide/deployment/deploymentAot.adoc
new file mode 100644
index 00000000000..1a902b20a58
--- /dev/null
+++ b/grails-doc/src/en/guide/deployment/deploymentAot.adoc
@@ -0,0 +1,101 @@
+////
+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.
+////
+
+Spring's ahead-of-time (AOT) processing moves bean wiring from startup to build time. Instead of scanning the classpath, reading annotations and evaluating conditions on every boot, the build generates Java source that registers the bean definitions directly, and the application runs that.
+
+Two things follow from it: startup does less work, and the application becomes eligible for a GraalVM native image, which cannot perform the classpath scanning and reflection that normal startup relies on.
+
+AOT is opt-in.
+
+
+=== Enabling AOT
+
+
+Apply the Spring Boot AOT plugin alongside the Grails plugins:
+
+[source,groovy]
+----
+apply plugin: 'org.springframework.boot.aot'
+----
+
+This adds a `processAot` task, which `bootJar` then runs as part of the build. Nothing about a normal `bootRun` or `bootJar` changes until the application is started with AOT enabled:
+
+[source,bash]
+----
+java -Dspring.aot.enabled=true -jar build/libs/myapp.jar
+----
+
+An application started this way logs `Starting AOT-processed Application` rather than `Starting Application`.
+
+
+=== Generate in production mode
+
+
+`processAot` builds the bean definitions that the packaged application will use, so it must run with the same environment those definitions are meant for:
+
+[source,groovy]
+----
+tasks.named('processAot') {
+ systemProperty 'grails.env', 'production'
+}
+----
+
+Without this the task runs in the development environment, where reloading is enabled and several beans take a different shape. The URL mappings holder, for example, becomes a proxy whose type cannot be determined without creating it, and generation fails with an error naming a bean that is unrelated to anything in the application:
+
+[source]
+----
+Field urlMappings in org.grails.web.mapping.servlet.UrlMappingsErrorPageCustomizer
+required a bean of type 'grails.web.mapping.UrlMappings' that could not be found.
+----
+
+Reload-mode beans have no meaning in a packaged artifact, so generating in production mode is correct as well as necessary.
+
+
+=== What AOT changes at runtime
+
+
+An AOT-processed application registers the same beans as a normal one. The difference is the annotation-processing infrastructure it no longer needs, since the work those components do has already been done:
+
+* `internalConfigurationAnnotationProcessor`
+* `internalAutowiredAnnotationProcessor`
+* `internalCommonAnnotationProcessor`
+* Spring Boot's shared metadata reader factory
+* Grails' own configuration class post-processor
+
+Application beans, plugin beans and auto-configuration beans are all present as usual.
+
+
+=== Limitations
+
+
+Beans contributed from a plugin's `beanRegistrar()` are not generated. Spring marks them as registrar-owned and skips them, and they are registered again at runtime when Grails applies the plugin registrars. They behave correctly, but the plugin scan that produces them still runs on every start, so they do not benefit from generation.
+
+Writing bean definitions that hold live objects will fail generation. A definition is a recipe rather than an instance, and there is no general way to generate code that reconstructs an arbitrary object, so a constructor argument or property value holding one cannot be processed:
+
+[source]
+----
+UnsupportedTypeValueCodeGenerationException:
+ Code generation does not support com.example.SomeService
+----
+
+Reference another bean by name instead, or express the collaborator as a nested bean definition, and the generator can emit both.
+
+Abstract bean definitions — templates that exist only to be inherited through `bean.parent` — are excluded from generation, because Spring has no representation for them in generated code. Definitions that inherit from one are unaffected: they are generated from their merged definition, with the inherited values already folded in.
+
+AOT processing on its own does not make an application ready for a native image. A native image additionally requires reachability metadata covering the resources and reflection an application performs at runtime.
diff --git a/grails-doc/src/en/guide/toc.yml b/grails-doc/src/en/guide/toc.yml
index b0e4a5c3ff4..6132ebf6094 100644
--- a/grails-doc/src/en/guide/toc.yml
+++ b/grails-doc/src/en/guide/toc.yml
@@ -393,5 +393,6 @@ deployment:
deploymentStandalone: Standalone
deploymentContainer: Container Deployment (e.g. Tomcat)
deploymentTasks: Deployment Configuration Tasks
+ deploymentAot: Ahead-of-Time Processing
contributing:
title: Contributing to Grails
diff --git a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverServletContextSpec.groovy b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverServletContextSpec.groovy
new file mode 100644
index 00000000000..3cc5b936503
--- /dev/null
+++ b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/GrailsSiteMeshViewResolverServletContextSpec.groovy
@@ -0,0 +1,105 @@
+/*
+ * 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.plugins.sitemesh3
+
+import jakarta.servlet.ServletContext
+
+import org.sitemesh.DecoratorSelector
+import org.sitemesh.SiteMeshContext
+import org.sitemesh.content.ContentProcessor
+import org.sitemesh.content.tagrules.TagBasedContentProcessor
+import org.sitemesh.content.tagrules.html.CoreHtmlTagRuleBundle
+import org.springframework.beans.factory.support.GenericBeanDefinition
+import org.springframework.mock.web.MockServletContext
+import org.springframework.web.context.support.GenericWebApplicationContext
+import org.springframework.web.servlet.View
+import org.springframework.web.servlet.view.InternalResourceViewResolver
+import spock.lang.Specification
+
+/**
+ * Covers the resolver being built without a servlet context argument and receiving one from the
+ * container instead. A plain bean factory applies no {@code ServletContextAware} callback, so the
+ * path only exists in a real web application context.
+ */
+class GrailsSiteMeshViewResolverServletContextSpec extends Specification {
+
+ MockServletContext servletContext = new MockServletContext()
+ GenericWebApplicationContext context = new GenericWebApplicationContext(servletContext)
+
+ void setup() {
+ def target = new GenericBeanDefinition()
+ target.beanClass = InternalResourceViewResolver
+ context.registerBeanDefinition('jspViewResolver', target)
+
+ // definitions rather than singletons: the post-processor stands down unless the SiteMesh
+ // collaborators are present as bean definitions
+ context.registerBeanDefinition('contentProcessor',
+ new GenericBeanDefinition(beanClass: CaptureAwareContentProcessor))
+ def selector = new GenericBeanDefinition(beanClass: Sitemesh3LayoutFinder, lazyInit: true)
+ selector.constructorArgumentValues.addIndexedArgumentValue(0, null)
+ context.registerBeanDefinition('decoratorSelector', selector)
+ }
+
+ void cleanup() {
+ context.close()
+ }
+
+ void 'the rewritten resolver takes its servlet context from the container'() {
+ given:
+ new Sitemesh3ViewResolverDefinitionPostProcessor().postProcessBeanDefinitionRegistry(context)
+
+ when:
+ context.refresh()
+
+ then: 'nothing declares a servletContext bean definition for it to reference'
+ !context.containsBeanDefinition('servletContext')
+
+ and:
+ def resolver = context.getBean('jspViewResolver', GrailsSiteMeshViewResolver)
+ resolver.servletContext.is(servletContext)
+ }
+
+ void 'the injected servlet context reaches the view the resolver produces'() {
+ given:
+ new Sitemesh3ViewResolverDefinitionPostProcessor().postProcessBeanDefinitionRegistry(context)
+ context.refresh()
+
+ when: 'a view is resolved, which is where the servlet context is read'
+ def resolver = context.getBean('jspViewResolver', GrailsSiteMeshViewResolver)
+ View view = resolver.resolveViewName('someView', Locale.ENGLISH)
+
+ then:
+ view instanceof GrailsSiteMeshView
+ ((GrailsSiteMeshView) view).servletContext.is(servletContext)
+ }
+
+ void 'a resolver built with an explicit servlet context still carries it'() {
+ given: 'the constructor callers outside the container callback use'
+ ContentProcessor processor = new TagBasedContentProcessor(new CoreHtmlTagRuleBundle())
+ DecoratorSelector selector = { content, ctx -> new String[0] }
+ ServletContext explicit = new MockServletContext()
+
+ when:
+ def resolver = new GrailsSiteMeshViewResolver(
+ new InternalResourceViewResolver(), processor, selector, explicit)
+
+ then:
+ resolver.servletContext.is(explicit)
+ }
+}
diff --git a/grails-test-examples/aot/build.gradle b/grails-test-examples/aot/build.gradle
new file mode 100644
index 00000000000..18fee968659
--- /dev/null
+++ b/grails-test-examples/aot/build.gradle
@@ -0,0 +1,71 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id 'org.apache.grails.buildsrc.properties'
+ id 'org.apache.grails.buildsrc.dependency-validator'
+ id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.vulnerability-scan'
+}
+
+version = '0.1'
+group = 'functionaltests'
+
+apply plugin: 'org.apache.grails.gradle.grails-web'
+apply plugin: 'org.apache.grails.gradle.grails-gsp'
+apply plugin: 'org.springframework.boot.aot'
+
+apply {
+ from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle')
+}
+
+dependencies {
+ implementation platform(project(':grails-bom'))
+
+ implementation 'org.apache.grails:grails-dependencies-starter-web'
+ implementation 'org.apache.grails:grails-sitemesh3'
+
+ testImplementation 'org.apache.grails:grails-testing-support-web'
+}
+
+// Generation reflects the environment the packaged application runs in. Under development,
+// reloading is enabled and beans such as the url mappings holder take a proxied shape that has
+// no meaning in a packaged artifact and cannot be generated.
+tasks.named('processAot') {
+ systemProperty 'grails.env', 'production'
+}
+
+// The application must also start from what was generated, which is the half that catches a
+// runtime-only regression - a context that generates cleanly can still fail to boot.
+tasks.register('aotStartupCheck', JavaExec) {
+ dependsOn tasks.named('bootJar')
+ group = 'verification'
+ description = 'Starts the packaged application with AOT enabled and asserts the context comes up'
+ classpath = files(tasks.named('bootJar').flatMap { it.archiveFile })
+ mainClass = 'org.springframework.boot.loader.launch.JarLauncher'
+ systemProperties([
+ 'spring.aot.enabled': 'true',
+ 'grails.env' : 'production',
+ ])
+ args = ['--aot-startup-check', '--server.port=0', '--spring.main.banner-mode=off']
+}
+
+tasks.named('check') {
+ dependsOn tasks.named('aotStartupCheck')
+}
diff --git a/grails-test-examples/aot/grails-app/conf/application.yml b/grails-test-examples/aot/grails-app/conf/application.yml
new file mode 100644
index 00000000000..c6cc7db64ab
--- /dev/null
+++ b/grails-test-examples/aot/grails-app/conf/application.yml
@@ -0,0 +1,5 @@
+grails:
+ profile: web
+info:
+ app:
+ name: aot
diff --git a/grails-test-examples/aot/grails-app/init/aot/Application.groovy b/grails-test-examples/aot/grails-app/init/aot/Application.groovy
new file mode 100644
index 00000000000..e56f66ce0ec
--- /dev/null
+++ b/grails-test-examples/aot/grails-app/init/aot/Application.groovy
@@ -0,0 +1,59 @@
+/*
+ * 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 aot
+
+import grails.boot.GrailsApp
+import grails.boot.config.GrailsAutoConfiguration
+
+import org.springframework.context.ConfigurableApplicationContext
+
+class Application extends GrailsAutoConfiguration {
+
+ /**
+ * With {@code --aot-startup-check} the application starts, asserts the beans an AOT-processed
+ * context must still contain, and exits. The build runs it that way against the packaged jar
+ * with {@code spring.aot.enabled=true}, so a context that generates cleanly but cannot boot
+ * fails the build rather than passing unnoticed.
+ */
+ static void main(String[] args) {
+ if (!args.contains('--aot-startup-check')) {
+ GrailsApp.run(Application, args)
+ return
+ }
+
+ ConfigurableApplicationContext context = (ConfigurableApplicationContext) GrailsApp.run(Application, args)
+ try {
+ assertBeanPresent(context, 'grailsApplication')
+ assertBeanPresent(context, 'filteringCodecsByContentTypeSettings')
+ assertBeanPresent(context, 'groovyPagesServlet')
+ assertBeanPresent(context, 'jspViewResolver')
+ assertBeanPresent(context, 'grailsUrlMappingsHolder')
+ }
+ finally {
+ context.close()
+ }
+ System.exit(0)
+ }
+
+ private static void assertBeanPresent(ConfigurableApplicationContext context, String name) {
+ if (!context.containsBean(name)) {
+ throw new IllegalStateException("AOT-processed context is missing the '${name}' bean")
+ }
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index ac25c7e025f..820ffbe9c3d 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -526,6 +526,7 @@ project(':grails-test-examples-redis').projectDir = new File(settingsDir, 'grail
// Functional Tests
include(
+ 'grails-test-examples-aot',
'grails-test-examples-app1',
'grails-test-examples-app2',
'grails-test-examples-app3',
@@ -573,6 +574,7 @@ include(
project(':grails-test-examples-async-events-pubsub-demo').projectDir = file('grails-test-examples/async-events-pubsub-demo')
project(':grails-test-examples-beans-dsl').projectDir = file('grails-test-examples/beans-dsl')
project(':grails-test-examples-beans-dsl-plugin').projectDir = file('grails-test-examples/beans-dsl-plugin')
+project(':grails-test-examples-aot').projectDir = file('grails-test-examples/aot')
project(':grails-test-examples-app1').projectDir = file('grails-test-examples/app1')
project(':grails-test-examples-app2').projectDir = file('grails-test-examples/app2')
project(':grails-test-examples-app3').projectDir = file('grails-test-examples/app3')
From 227d74695e74a18514ec6e617b2b88e623f70a87 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Wed, 5 Aug 2026 21:09:04 -0700
Subject: [PATCH 006/115] Register the GORM event publisher as a bean
definition
Spring AOT generates the bean definitions for a context at build time, and it can
only do so for beans the container knows how to build. A bean registered as an
already-constructed object has nothing to generate from, so the MongoDB datastore
could not be processed ahead of time.
The MongoDB initializer built the event publisher itself and passed the instance to
the datastore constructor. It now registers the publisher as a definition and refers
to it by name, so the container builds both.
ConfigurableApplicationContextEventPublisher could only be built by passing a context
to its constructor, which is the thing a definition cannot do. It now also takes the
context from the container through ApplicationContextAware, leaving the existing
constructor in place for callers outside a container.
Which publisher gets registered still depends on where GORM is being bootstrapped:
outside an application context the no-op publisher is used, as before, because the
context-aware one would never be given a context there and would fail on the first
event it published.
---
.../MongoDbDataStoreSpringInitializer.groovy | 20 ++---
...pringInitializerBeanDefinitionsSpec.groovy | 85 +++++++++++++++++++
...bleApplicationContextEventPublisher.groovy | 18 +++-
...pplicationContextEventPublisherSpec.groovy | 80 +++++++++++++++++
4 files changed, 191 insertions(+), 12 deletions(-)
create mode 100644 grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializerBeanDefinitionsSpec.groovy
create mode 100644 grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisherSpec.groovy
diff --git a/grails-data-mongodb/core/src/main/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializer.groovy b/grails-data-mongodb/core/src/main/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializer.groovy
index 68dfe37d573..48e83448920 100644
--- a/grails-data-mongodb/core/src/main/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializer.groovy
+++ b/grails-data-mongodb/core/src/main/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializer.groovy
@@ -22,7 +22,6 @@ import com.mongodb.client.MongoClient
import org.springframework.beans.factory.support.BeanDefinitionRegistry
import org.springframework.context.ApplicationContext
-import org.springframework.context.ApplicationEventPublisher
import org.springframework.context.ConfigurableApplicationContext
import org.springframework.context.support.GenericApplicationContext
import org.springframework.util.ClassUtils
@@ -89,25 +88,26 @@ class MongoDbDataStoreSpringInitializer extends AbstractDatastoreInitializer {
def callable = getCommonConfiguration(beanDefinitionRegistry, 'mongo')
callable.delegate = delegate
callable.call()
- ApplicationEventPublisher eventPublisher
- if (beanDefinitionRegistry instanceof ConfigurableApplicationContext) {
- eventPublisher = new ConfigurableApplicationContextEventPublisher((ConfigurableApplicationContext) beanDefinitionRegistry)
- }
- else if (resourcePatternResolver.resourceLoader instanceof ConfigurableApplicationContext) {
- eventPublisher = new ConfigurableApplicationContextEventPublisher((ConfigurableApplicationContext) resourcePatternResolver.resourceLoader)
+ // The publisher is registered as a definition rather than constructed here so that the
+ // datastore holds a reference the container can build, which is what lets the context
+ // be processed ahead of time. Outside an application context there is nothing to
+ // publish through, so the no-op publisher stands in as it did before.
+ if (beanDefinitionRegistry instanceof ConfigurableApplicationContext
+ || resourcePatternResolver.resourceLoader instanceof ConfigurableApplicationContext) {
+ grailsDatastoreEventPublisher(ConfigurableApplicationContextEventPublisher)
}
else {
- eventPublisher = new DefaultApplicationEventPublisher()
+ grailsDatastoreEventPublisher(DefaultApplicationEventPublisher)
}
if (mongo == null) {
mongoConnectionSourceFactory(MongoConnectionSourceFactory) { bean ->
bean.autowire = true
}
- mongoDatastore(MongoDatastore, configuration, ref('mongoConnectionSourceFactory'), eventPublisher, collectMappedClasses(DATASTORE_TYPE))
+ mongoDatastore(MongoDatastore, configuration, ref('mongoConnectionSourceFactory'), ref('grailsDatastoreEventPublisher'), collectMappedClasses(DATASTORE_TYPE))
mongo(mongoDatastore: 'getMongoClient')
}
else {
- mongoDatastore(MongoDatastore, mongo, configuration, eventPublisher, collectMappedClasses(DATASTORE_TYPE))
+ mongoDatastore(MongoDatastore, mongo, configuration, ref('grailsDatastoreEventPublisher'), collectMappedClasses(DATASTORE_TYPE))
}
mongoMappingContext(mongoDatastore: 'getMappingContext')
diff --git a/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializerBeanDefinitionsSpec.groovy b/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializerBeanDefinitionsSpec.groovy
new file mode 100644
index 00000000000..25d749146f6
--- /dev/null
+++ b/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializerBeanDefinitionsSpec.groovy
@@ -0,0 +1,85 @@
+/*
+ * 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 grails.mongodb.bootstrap
+
+import grails.persistence.Entity
+import org.grails.datastore.gorm.events.ConfigurableApplicationContextEventPublisher
+import org.grails.datastore.gorm.events.DefaultApplicationEventPublisher
+import org.springframework.beans.factory.config.RuntimeBeanReference
+import org.springframework.beans.factory.support.SimpleBeanDefinitionRegistry
+import org.springframework.context.support.GenericApplicationContext
+import spock.lang.Specification
+
+/**
+ * Covers how the datastore receives its event publisher. The definitions are inspected without
+ * refreshing the context, so no MongoDB is involved.
+ */
+class MongoDbDataStoreSpringInitializerBeanDefinitionsSpec extends Specification {
+
+ private static final String PUBLISHER = 'grailsDatastoreEventPublisher'
+
+ void 'inside an application context the publisher is registered as a definition'() {
+ given:
+ def registry = new GenericApplicationContext()
+
+ when:
+ new MongoDbDataStoreSpringInitializer(Person).configureForBeanDefinitionRegistry(registry)
+
+ then: 'a definition rather than an already-constructed object, so it can be processed ahead of time'
+ registry.containsBeanDefinition(PUBLISHER)
+ registry.getBeanDefinition(PUBLISHER).beanClassName ==
+ ConfigurableApplicationContextEventPublisher.name
+
+ cleanup:
+ registry.close()
+ }
+
+ void 'the datastore refers to the publisher by name rather than holding an instance'() {
+ given:
+ def registry = new GenericApplicationContext()
+
+ when:
+ new MongoDbDataStoreSpringInitializer(Person).configureForBeanDefinitionRegistry(registry)
+ def args = registry.getBeanDefinition('mongoDatastore').constructorArgumentValues
+
+ then:
+ args.genericArgumentValues*.value
+ .findAll { it instanceof RuntimeBeanReference }
+ .any { RuntimeBeanReference reference -> reference.beanName == PUBLISHER }
+
+ cleanup:
+ registry.close()
+ }
+
+ void 'outside an application context the no-op publisher is used instead'() {
+ given: 'a registry that is not a context, as when GORM is bootstrapped standalone'
+ def registry = new SimpleBeanDefinitionRegistry()
+
+ when:
+ new MongoDbDataStoreSpringInitializer(Person).configureForBeanDefinitionRegistry(registry)
+
+ then: 'the context-aware publisher would never be given a context here, and would fail on publish'
+ registry.getBeanDefinition(PUBLISHER).beanClassName == DefaultApplicationEventPublisher.name
+ }
+
+ @Entity
+ static class Person {
+ String name
+ }
+}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisher.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisher.groovy
index 91073cd1c07..bdfbfab1f98 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisher.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisher.groovy
@@ -21,6 +21,8 @@ package org.grails.datastore.gorm.events
import groovy.transform.CompileStatic
+import org.springframework.context.ApplicationContext
+import org.springframework.context.ApplicationContextAware
import org.springframework.context.ApplicationEvent
import org.springframework.context.ApplicationListener
import org.springframework.context.ConfigurableApplicationContext
@@ -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) {
+ this.applicationContext = (ConfigurableApplicationContext) applicationContext
+ }
+
@Override
void addApplicationListener(ApplicationListener extends ApplicationEvent> listener) {
this.applicationContext.addApplicationListener(listener)
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisherSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisherSpec.groovy
new file mode 100644
index 00000000000..592f3fbe713
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisherSpec.groovy
@@ -0,0 +1,80 @@
+/*
+ * 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.datastore.gorm.events
+
+import org.springframework.context.ApplicationListener
+import org.springframework.context.event.ContextRefreshedEvent
+import org.springframework.context.support.GenericApplicationContext
+import spock.lang.Specification
+
+/**
+ * Covers the publisher being built by the container rather than handed a context, which is what
+ * lets a datastore reference it as a bean definition instead of an already-constructed object.
+ */
+class ConfigurableApplicationContextEventPublisherSpec extends Specification {
+
+ GenericApplicationContext context = new GenericApplicationContext()
+
+ void cleanup() {
+ if (context.active) {
+ context.close()
+ }
+ }
+
+ void 'the container supplies the context to a publisher built without one'() {
+ given:
+ context.registerBean('grailsDatastoreEventPublisher', ConfigurableApplicationContextEventPublisher)
+
+ when:
+ context.refresh()
+ def publisher = context.getBean('grailsDatastoreEventPublisher',
+ ConfigurableApplicationContextEventPublisher)
+
+ then: 'nothing passed the context in, so only the container callback can have set it'
+ publisher.applicationContext.is(context)
+ }
+
+ void 'a publisher built that way still delivers events and listeners'() {
+ given:
+ context.registerBean('grailsDatastoreEventPublisher', ConfigurableApplicationContextEventPublisher)
+ context.refresh()
+ def publisher = context.getBean('grailsDatastoreEventPublisher',
+ ConfigurableApplicationContextEventPublisher)
+ def received = []
+
+ when:
+ publisher.addApplicationListener({ event -> received << event } as ApplicationListener)
+ def event = new ContextRefreshedEvent(context)
+ publisher.publishEvent(event)
+
+ then:
+ received == [event]
+ }
+
+ void 'the constructor taking a context keeps working'() {
+ given: 'the form callers outside the container still use'
+ context.refresh()
+
+ when:
+ def publisher = new ConfigurableApplicationContextEventPublisher(context)
+
+ then:
+ publisher.applicationContext.is(context)
+ }
+}
From 3206f6055440034066b18cc8d1de0c2b93c0b964 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Wed, 5 Aug 2026 21:34:50 -0700
Subject: [PATCH 007/115] Read the Spring Security version without nested
reflection
The welcome page shows the Spring Security version only when the dependency is
present, and reached it with Class.getMethod('getVersion').invoke(null). Calling
Method.invoke from a GSP expression goes through Groovy's dynamic dispatch, which
resolves to the private caller-sensitive overload the JDK added for core reflection.
A native image cannot supply the caller argument that overload requires and aborts
the process rather than raising an exception.
Spring's ReflectionUtils performs the invocation from Java, so Groovy never
dispatches on Method.invoke itself. The presence check is unchanged, which keeps the
page compiling for generated applications that did not select Spring Security.
Both copies of the page are updated: the one the profiles CLI writes into a new
application and the one grails-forge serves.
---
.../grails-forge-core/src/main/resources/gsp/index.gsp | 9 +++++++--
grails-profiles/web/skeleton/grails-app/views/index.gsp | 9 +++++++--
2 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp b/grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp
index 97362a9246a..add9c633cc9 100644
--- a/grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp
+++ b/grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp
@@ -2,6 +2,7 @@
<%@ page import="org.springframework.boot.SpringBootVersion"%>
<%@ page import="org.springframework.core.SpringVersion"%>
<%@ page import="org.springframework.util.ClassUtils"%>
+<%@ page import="org.springframework.util.ReflectionUtils"%>
${SpringVersion.getVersion()}
- <%-- Spring Security: only when the dependency is present --%>
+ <%-- Spring Security: only when the dependency is present. The call goes
+ through ReflectionUtils because invoking Method.invoke from Groovy
+ resolves to a caller-sensitive overload that a native image rejects. --%>
+
+ value="${springSecurityCoreVersionClass ? ReflectionUtils.invokeMethod(ReflectionUtils.findMethod(springSecurityCoreVersionClass, 'getVersion'), null) : null}"/>
- <%-- Spring Security: only when the dependency is present --%>
+ <%-- Spring Security: only when the dependency is present. The call goes
+ through ReflectionUtils because invoking Method.invoke from Groovy
+ resolves to a caller-sensitive overload that a native image rejects. --%>
+
+ value="${springSecurityCoreVersionClass ? ReflectionUtils.invokeMethod(ReflectionUtils.findMethod(springSecurityCoreVersionClass, 'getVersion'), null) : null}"/>
From 075c6488722296d8923759a0bfeb41737a37c2a2 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Wed, 5 Aug 2026 22:01:10 -0700
Subject: [PATCH 008/115] Add the license header to the AOT example
configuration
The file was added without one, which fails the release audit.
---
.../aot/grails-app/conf/application.yml | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/grails-test-examples/aot/grails-app/conf/application.yml b/grails-test-examples/aot/grails-app/conf/application.yml
index c6cc7db64ab..4314f81391d 100644
--- a/grails-test-examples/aot/grails-app/conf/application.yml
+++ b/grails-test-examples/aot/grails-app/conf/application.yml
@@ -1,3 +1,18 @@
+# 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.
+
grails:
profile: web
info:
From df347f19fa455d072192a76cc3e968c9c1a5ee1d Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Wed, 5 Aug 2026 23:43:17 -0700
Subject: [PATCH 009/115] Expand scaffolded views at build time
A scaffolded controller had no views of its own: the resolver expanded a template
into GSP source and compiled the result the first time each view was asked for.
That costs the first request, and a native image cannot do it at all, because
defining a class at run time is what an ahead-of-time image gives up.
The templates are now expanded during the build and compiled with the rest of the
views, so at run time they are found rather than produced. Only naming is
substituted, and the templates defer everything about the domain class to the field
tag libraries at render time, so this needs no GORM, no application context and no
loading of application classes: the controllers are read with ASM and the domain
class name is enough.
The generated pages are staged with the application's own before a single
compilation. Compiling them separately would produce a second gsp/views.properties,
and the archive tasks discard duplicates, so one of the two manifests would be lost
and the views it listed would never be found. A view the application declares is not
generated over, which keeps a hand-written page ahead of a scaffolded one as it is
at run time.
---
.../GenerateScaffoldedViewsTask.groovy | 245 ++++++++++++++++++
.../plugin/views/gsp/GroovyPagePlugin.groovy | 38 ++-
.../GenerateScaffoldedViewsTaskSpec.groovy | 205 +++++++++++++++
3 files changed, 487 insertions(+), 1 deletion(-)
create mode 100644 grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy
create mode 100644 grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTaskSpec.groovy
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy
new file mode 100644
index 00000000000..3b2e56f7af1
--- /dev/null
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTask.groovy
@@ -0,0 +1,245 @@
+/*
+ * 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.scaffolding
+
+import java.util.jar.JarEntry
+import java.util.jar.JarFile
+
+import groovy.text.GStringTemplateEngine
+import groovy.transform.CompileStatic
+import groovyjarjarasm.asm.AnnotationVisitor
+import groovyjarjarasm.asm.ClassReader
+import groovyjarjarasm.asm.ClassVisitor
+import groovyjarjarasm.asm.Opcodes
+import groovyjarjarasm.asm.Type
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Optional
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+
+/**
+ * Writes the views a scaffolded controller would otherwise generate on its first request.
+ *
+ *
Scaffolding expands a template into GSP source and compiles the result, and until now it did
+ * both when the view was first asked for. That costs the first request on the JVM, and a native
+ * image cannot do it at all: defining a class at runtime is exactly what an ahead-of-time image
+ * gives up. Expanding the templates here instead lets the ordinary GSP compiler precompile the
+ * result, so at runtime the views are found rather than produced.
+ *
+ *
Only naming is substituted -- the templates read {@code className} and {@code propertyName},
+ * and defer everything about the domain class to the field tag libraries at render time. That is
+ * why this needs no GORM, no application context and no loading of application classes: the
+ * controllers are read with ASM and the domain class name is enough.
+ *
+ *
A view the application already declares is never overwritten, which keeps the existing
+ * precedence: a hand-written {@code grails-app/views} page wins over a scaffolded one.
+ *
+ * @since 8.0
+ */
+@CacheableTask
+@CompileStatic
+abstract class GenerateScaffoldedViewsTask extends DefaultTask {
+
+ /** Descriptor of the annotation that marks a scaffolded controller. */
+ private static final String SCAFFOLD_ANNOTATION = 'Lgrails/plugin/scaffolding/annotation/Scaffold;'
+
+ /** Path within an artifact holding the scaffolding templates. */
+ private static final String TEMPLATE_PATH = 'META-INF/templates/scaffolding/'
+
+ /** The views scaffolding knows how to produce. */
+ private static final List VIEW_NAMES = ['index', 'create', 'edit', 'show']
+
+ /** Compiled application classes, searched for scaffolded controllers. */
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract ConfigurableFileCollection getClassesDirs()
+
+ /**
+ * The classpath the scaffolding templates are read from. The application's own
+ * {@code src/main/templates/scaffolding} takes precedence, matching the runtime lookup.
+ */
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract ConfigurableFileCollection getTemplateClasspath()
+
+ /** Application template overrides, normally {@code src/main/templates/scaffolding}. */
+ @InputFiles
+ @Optional
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract ConfigurableFileCollection getTemplateOverrides()
+
+ /** The application's own views; anything declared here is left alone. */
+ @InputFiles
+ @Optional
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract ConfigurableFileCollection getApplicationViews()
+
+ /** Where the generated views are written. */
+ @OutputDirectory
+ abstract DirectoryProperty getOutputDirectory()
+
+ @TaskAction
+ void generate() {
+ File outputDir = outputDirectory.get().asFile
+ outputDir.deleteDir()
+ outputDir.mkdirs()
+
+ Map templates = loadTemplates()
+ if (templates.isEmpty()) {
+ logger.info('No scaffolding templates on the classpath; nothing to generate')
+ return
+ }
+
+ Set declared = applicationViews.files
+ int written = 0
+ for (Map.Entry controller : findScaffoldedControllers()) {
+ String propertyName = controller.value
+ String className = capitalize(propertyName)
+ for (String viewName : VIEW_NAMES) {
+ String template = templates.get(viewName)
+ if (template == null) {
+ continue
+ }
+ // a view the application wrote itself already wins at runtime, so leaving it out
+ // keeps build-time and runtime resolution agreeing
+ if (declared.any { it.path.endsWith("views/${controller.key}/${viewName}.gsp".toString()) }) {
+ logger.info("Skipping ${controller.key}/${viewName}.gsp, the application declares it")
+ continue
+ }
+ File target = new File(outputDir, "${controller.key}/${viewName}.gsp")
+ target.parentFile.mkdirs()
+ target.text = expand(template, className, propertyName)
+ written++
+ }
+ }
+ logger.info("Generated ${written} scaffolded view(s)")
+ }
+
+ /** Expands a template the same way the runtime resolver does, with only the naming bound. */
+ private String expand(String template, String className, String propertyName) {
+ StringWriter out = new StringWriter()
+ new GStringTemplateEngine()
+ .createTemplate(template)
+ .make([className: className, propertyName: propertyName])
+ .writeTo(out)
+ out.toString()
+ }
+
+ /**
+ * Maps view name to template text, with the application's overrides winning over the templates
+ * a plugin contributes.
+ */
+ private Map loadTemplates() {
+ Map templates = [:]
+ for (File entry : templateClasspath.files) {
+ if (entry.isDirectory()) {
+ File dir = new File(entry, TEMPLATE_PATH)
+ if (dir.isDirectory()) {
+ dir.eachFileMatch(~/.*\.gsp/) { File f -> templates.putIfAbsent(baseName(f.name), f.text) }
+ }
+ }
+ else if (entry.name.endsWith('.jar') && entry.isFile()) {
+ new JarFile(entry).withCloseable { JarFile jar ->
+ for (JarEntry e : jar.entries()) {
+ if (e.name.startsWith(TEMPLATE_PATH) && e.name.endsWith('.gsp')) {
+ templates.putIfAbsent(baseName(e.name.substring(TEMPLATE_PATH.length())),
+ jar.getInputStream(e).getText('UTF-8'))
+ }
+ }
+ }
+ }
+ }
+ for (File override : templateOverrides.files) {
+ if (override.isFile() && override.name.endsWith('.gsp')) {
+ templates.put(baseName(override.name), override.text)
+ }
+ }
+ templates
+ }
+
+ /** Maps view directory name to the domain property name, for every {@code @Scaffold} controller. */
+ private Map findScaffoldedControllers() {
+ Map found = [:]
+ for (File dir : classesDirs.files) {
+ if (!dir.isDirectory()) {
+ continue
+ }
+ dir.eachFileRecurse { File f ->
+ if (!f.name.endsWith('Controller.class')) {
+ return
+ }
+ String domain = readScaffoldDomain(f)
+ if (domain != null) {
+ String controllerName = decapitalize(f.name - 'Controller.class')
+ found.put(controllerName, decapitalize(domain))
+ }
+ }
+ }
+ found
+ }
+
+ /**
+ * Returns the simple name of the domain class a controller scaffolds, or {@code null} when it is
+ * not scaffolded. Read with ASM so the application's classes are never loaded, which keeps the
+ * task independent of the runtime classpath.
+ */
+ private String readScaffoldDomain(File classFile) {
+ String domain = null
+ classFile.withInputStream { InputStream input ->
+ new ClassReader(input).accept(new ClassVisitor(Opcodes.ASM9) {
+ @Override
+ AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+ if (descriptor != SCAFFOLD_ANNOTATION) {
+ return null
+ }
+ return new AnnotationVisitor(Opcodes.ASM9) {
+ @Override
+ void visit(String name, Object value) {
+ // both @Scaffold(User) and @Scaffold(domain = User) name the domain class
+ if (value instanceof Type && (name == 'value' || name == 'domain')) {
+ String candidate = ((Type) value).className.tokenize('.').last()
+ if (candidate != 'Void') {
+ domain = candidate
+ }
+ }
+ }
+ }
+ }
+ }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES)
+ }
+ domain
+ }
+
+ private static String baseName(String fileName) {
+ fileName.endsWith('.gsp') ? fileName[0.. {
].findAll { it }
)
+ // Scaffolded views are expanded from their templates here and staged alongside the
+ // application's own, so a single compile produces a single gsp/views.properties. Compiling
+ // them separately would produce a second manifest, and the jar's duplicate handling would
+ // silently keep only one of them.
+ Provider stagedViews = project.layout.buildDirectory.dir('generated/views')
+ def generateScaffoldedViews = tasks.register(
+ 'generateScaffoldedViews', GenerateScaffoldedViewsTask) { GenerateScaffoldedViewsTask it ->
+ it.group = BasePlugin.BUILD_GROUP
+ it.description = 'Expands the views of scaffolded controllers so they can be precompiled'
+ it.classesDirs.from(classesDirs)
+ it.templateClasspath.from(project.configurations.named('compileClasspath'))
+ it.templateOverrides.from(
+ project.fileTree(project.layout.projectDirectory.dir('src/main/templates/scaffolding'))
+ .matching { PatternFilterable p -> p.include('*.gsp') })
+ it.applicationViews.from(
+ project.fileTree(project.layout.projectDirectory.dir('grails-app/views'))
+ .matching { PatternFilterable p -> p.include('**/*.gsp') })
+ it.outputDirectory.set(project.layout.buildDirectory.dir('generated/scaffolded-views'))
+ }
+
+ def stageGroovyPages = tasks.register('stageGroovyPages', Sync) { Sync it ->
+ it.description = 'Collects the application and scaffolded views for GSP compilation'
+ it.into(stagedViews)
+ it.from(project.layout.projectDirectory.dir('grails-app/views'))
+ it.from(generateScaffoldedViews)
+ // the application's own page wins, matching how the view resolvers are ordered
+ it.duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+ }
+
def compileGroovyPages = tasks.register('compileGroovyPages', GroovyPageForkCompileTask) {
it.destinationDirectory.set(destDir)
it.tmpDirPath = getTmpDirPath(project)
- it.source = project.layout.projectDirectory.dir('grails-app/views')
+ // resolved here because the setter takes a directory, not a provider: it has to set
+ // both srcDir and the SourceTask inputs, and setting srcDir alone compiles nothing
+ it.source = stagedViews.get()
it.serverpath.set('/WEB-INF/grails-app/views/')
it.classpath = allClasspath
+ it.dependsOn(stageGroovyPages)
}
def compileWebappGroovyPages = tasks.register('compileWebappGroovyPages', GroovyPageForkCompileTask) {
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTaskSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTaskSpec.groovy
new file mode 100644
index 00000000000..33ffe0736c5
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/scaffolding/GenerateScaffoldedViewsTaskSpec.groovy
@@ -0,0 +1,205 @@
+/*
+ * 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.scaffolding
+
+import java.util.jar.JarEntry
+import java.util.jar.JarOutputStream
+
+import groovyjarjarasm.asm.AnnotationVisitor
+import groovyjarjarasm.asm.ClassWriter
+import groovyjarjarasm.asm.Opcodes
+import groovyjarjarasm.asm.Type
+import spock.lang.Specification
+import spock.lang.TempDir
+
+import org.gradle.api.Project
+import org.gradle.testfixtures.ProjectBuilder
+
+class GenerateScaffoldedViewsTaskSpec extends Specification {
+
+ @TempDir
+ File projectDir
+
+ private File classesDir
+ private File templateJar
+
+ void setup() {
+ classesDir = new File(projectDir, 'classes')
+ classesDir.mkdirs()
+ templateJar = new File(projectDir, 'templates.jar')
+ writeTemplateJar(templateJar, [
+ index : 'list of ${propertyName} for ${className}',
+ create: 'create ${className}',
+ edit : 'edit ${className}',
+ show : 'show ${className}'])
+ }
+
+ /** A jar shaped like the one the scaffolding plugin publishes. */
+ private void writeTemplateJar(File jar, Map templates) {
+ new JarOutputStream(jar.newOutputStream()).withCloseable { JarOutputStream out ->
+ templates.each { String name, String body ->
+ out.putNextEntry(new JarEntry("META-INF/templates/scaffolding/${name}.gsp"))
+ out.write(body.bytes)
+ out.closeEntry()
+ }
+ }
+ }
+
+ /**
+ * Writes a class carrying {@code @Scaffold}, so the task reads a real annotation rather than a
+ * stand-in for one.
+ */
+ private void writeController(String controllerName, String domainClassName) {
+ ClassWriter writer = new ClassWriter(0)
+ writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, "com/example/${controllerName}", null,
+ 'java/lang/Object', null)
+ AnnotationVisitor annotation = writer.visitAnnotation(
+ 'Lgrails/plugin/scaffolding/annotation/Scaffold;', true)
+ annotation.visit('value', Type.getObjectType("com/example/${domainClassName}"))
+ annotation.visitEnd()
+ writer.visitEnd()
+ File target = new File(classesDir, "com/example/${controllerName}.class")
+ target.parentFile.mkdirs()
+ target.bytes = writer.toByteArray()
+ }
+
+ private void writePlainController(String controllerName) {
+ ClassWriter writer = new ClassWriter(0)
+ writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, "com/example/${controllerName}", null,
+ 'java/lang/Object', null)
+ writer.visitEnd()
+ File target = new File(classesDir, "com/example/${controllerName}.class")
+ target.parentFile.mkdirs()
+ target.bytes = writer.toByteArray()
+ }
+
+ private GenerateScaffoldedViewsTask task(List overrides = [], List views = []) {
+ Project project = ProjectBuilder.builder().withProjectDir(projectDir).build()
+ project.tasks.register('generateScaffoldedViews', GenerateScaffoldedViewsTask) {
+ GenerateScaffoldedViewsTask it ->
+ it.classesDirs.from(classesDir)
+ it.templateClasspath.from(templateJar)
+ it.templateOverrides.from(overrides)
+ it.applicationViews.from(views)
+ it.outputDirectory.set(new File(projectDir, 'out'))
+ }
+ project.tasks.named('generateScaffoldedViews', GenerateScaffoldedViewsTask).get()
+ }
+
+ private File generated(GenerateScaffoldedViewsTask task, String path) {
+ new File(task.outputDirectory.get().asFile, path)
+ }
+
+ void 'a scaffolded controller gets the full set of views'() {
+ given:
+ writeController('UserController', 'User')
+ def task = task()
+
+ when:
+ task.generate()
+
+ then:
+ ['index', 'create', 'edit', 'show'].every { generated(task, "user/${it}.gsp").exists() }
+ }
+
+ void 'the naming the templates read is substituted'() {
+ given:
+ writeController('UserController', 'User')
+ def task = task()
+
+ when:
+ task.generate()
+
+ then:
+ generated(task, 'user/index.gsp').text == 'list of user for User'
+ }
+
+ void 'a controller without the annotation is left alone'() {
+ given:
+ writePlainController('PlainController')
+ def task = task()
+
+ when:
+ task.generate()
+
+ then:
+ !generated(task, 'plain').exists()
+ }
+
+ void 'the domain class named by the annotation drives the naming, not the controller'() {
+ given: 'a controller whose name does not match the domain it scaffolds'
+ writeController('AccountController', 'Person')
+ def task = task()
+
+ when:
+ task.generate()
+
+ then:
+ generated(task, 'account/index.gsp').text == 'list of person for Person'
+ }
+
+ void 'an application template overrides the one a plugin contributes'() {
+ given:
+ writeController('UserController', 'User')
+ File overrides = new File(projectDir, 'templates')
+ overrides.mkdirs()
+ File custom = new File(overrides, 'index.gsp')
+ custom.text = 'custom ${className}'
+ def task = task([custom])
+
+ when:
+ task.generate()
+
+ then:
+ generated(task, 'user/index.gsp').text == 'custom User'
+ }
+
+ void 'a view the application declares is not generated over'() {
+ given: 'the application writes its own index page'
+ writeController('UserController', 'User')
+ File views = new File(projectDir, 'grails-app/views/user')
+ views.mkdirs()
+ File declared = new File(views, 'index.gsp')
+ declared.text = 'hand written'
+ def task = task([], [declared])
+
+ when:
+ task.generate()
+
+ then: 'the runtime prefers the declared page, so generating one would only shadow it'
+ !generated(task, 'user/index.gsp').exists()
+
+ and: 'the views it does not declare are still generated'
+ generated(task, 'user/create.gsp').exists()
+ }
+
+ void 'a stale view from a previous run does not survive'() {
+ given:
+ writeController('UserController', 'User')
+ def task = task()
+ task.generate()
+ File stale = generated(task, 'gone/index.gsp')
+ stale.parentFile.mkdirs()
+ stale.text = 'stale'
+
+ when:
+ task.generate()
+
+ then:
+ !stale.exists()
+ }
+}
From 55b7dcc425649b4a0d45b4fbb1a9e007d9c5535f Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 00:06:59 -0700
Subject: [PATCH 010/115] Register the request path's reflective API for
ahead-of-time images
Handling a request reaches the controller, URL mapping, data binding and content
negotiation APIs through Groovy's dynamic dispatch, which reads a type's declared
methods to choose an overload. An ahead-of-time image keeps only the members
something asks for, so these were stripped and dispatch failed at the point of use.
Leaving this to the tracing agent does not work, because the agent records only the
paths that were exercised. Each of these failed on a path an ordinary check misses:
content negotiation runs only for a request that states what it accepts, the method
check only for POST, PUT and DELETE, binding only for a request carrying data, and
the parameter accessor only once a mapping carries parameters. All four passed a
page walk and failed for a real visitor.
Each registrar sits in the module owning the API and names its types as strings,
registering only those present, so an application that does not use a given plugin
is unaffected. None of this varies between applications, which is why it belongs
with the framework rather than in every application's metadata.
---
.../aot/ControllerRuntimeHints.java | 68 +++++++++++++++++
.../resources/META-INF/spring/aot.factories | 2 +
.../aot/ControllerRuntimeHintsSpec.groovy | 73 +++++++++++++++++++
.../web/mime/aot/MimeTypeRuntimeHints.java | 56 ++++++++++++++
.../resources/META-INF/spring/aot.factories | 2 +
.../aot/DataBindingRuntimeHints.java | 61 ++++++++++++++++
.../resources/META-INF/spring/aot.factories | 2 +
.../mapping/aot/UrlMappingRuntimeHints.java | 60 +++++++++++++++
.../resources/META-INF/spring/aot.factories | 2 +
9 files changed, 326 insertions(+)
create mode 100644 grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHints.java
create mode 100644 grails-controllers/src/main/resources/META-INF/spring/aot.factories
create mode 100644 grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHintsSpec.groovy
create mode 100644 grails-mimetypes/src/main/groovy/org/grails/web/mime/aot/MimeTypeRuntimeHints.java
create mode 100644 grails-mimetypes/src/main/resources/META-INF/spring/aot.factories
create mode 100644 grails-web-databinding/src/main/groovy/org/grails/web/databinding/aot/DataBindingRuntimeHints.java
create mode 100644 grails-web-databinding/src/main/resources/META-INF/spring/aot.factories
create mode 100644 grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/aot/UrlMappingRuntimeHints.java
create mode 100644 grails-web-url-mappings/src/main/resources/META-INF/spring/aot.factories
diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHints.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHints.java
new file mode 100644
index 00000000000..e37c7730d98
--- /dev/null
+++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHints.java
@@ -0,0 +1,68 @@
+/*
+ * 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.plugins.web.controllers.aot;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.lang.Nullable;
+
+/**
+ * Registers the controller API a request is dispatched through.
+ *
+ *
A controller action reaches these through Groovy's dynamic dispatch, which reads a type's
+ * declared methods to choose an overload. An ahead-of-time image keeps only the members something
+ * asks for, so without these hints the methods are absent and dispatch fails at the point of use --
+ * on the request that first takes that path, rather than at start-up.
+ *
+ *
Recording this here rather than leaving it to a tracing agent matters because the agent only
+ * ever sees the paths a developer happened to exercise: the method check below is reached only by
+ * POST, PUT and DELETE, so a walk of an application's pages never records it and the failure
+ * appears the first time someone submits a form.
+ *
+ * @since 8.0
+ */
+public class ControllerRuntimeHints implements RuntimeHintsRegistrar {
+
+ /**
+ * Types Groovy dispatches on while handling a request. Named as strings, and registered only
+ * when present, so this stays correct for an application that does not use every plugin.
+ */
+ private static final String[] DISPATCHED_TYPES = {
+ "grails.artefact.Controller",
+ "grails.artefact.controller.support.AllowedMethodsHelper",
+ "grails.artefact.controller.support.RequestForwarder",
+ "grails.artefact.controller.support.ResponseRedirector",
+ "grails.artefact.controller.support.ResponseRenderer",
+ "grails.artefact.controller.RestResponder",
+ // a view asking who is logged in reaches the request's principal, and Groovy makes that
+ // call on the interface rather than the implementation the container supplies
+ "java.security.Principal"
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (String type : DISPATCHED_TYPES) {
+ hints.reflection().registerTypeIfPresent(classLoader, type,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_PUBLIC_METHODS,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
+ }
+ }
+}
diff --git a/grails-controllers/src/main/resources/META-INF/spring/aot.factories b/grails-controllers/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..a8cb0eb8035
--- /dev/null
+++ b/grails-controllers/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.plugins.web.controllers.aot.ControllerRuntimeHints
diff --git a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHintsSpec.groovy b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..8467a537142
--- /dev/null
+++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/aot/ControllerRuntimeHintsSpec.groovy
@@ -0,0 +1,73 @@
+/*
+ * 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.plugins.web.controllers.aot
+
+import grails.artefact.controller.support.AllowedMethodsHelper
+
+import org.springframework.aot.hint.MemberCategory
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.TypeReference
+import spock.lang.Specification
+
+/**
+ * Covers the controller API surviving into an ahead-of-time image. Without these hints the methods
+ * are stripped and dispatch fails on the request that first takes the path, which for the method
+ * check below means the first form submission rather than start-up.
+ */
+class ControllerRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ void setup() {
+ new ControllerRuntimeHints().registerHints(hints, getClass().classLoader)
+ }
+
+ private boolean registered(Class> type) {
+ def hint = hints.reflection().getTypeHint(TypeReference.of(type))
+ hint != null && hint.memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
+ void 'the method check a form submission reaches is registered'() {
+ expect: 'reached only by POST, PUT and DELETE, so a walk of an application never records it'
+ registered(AllowedMethodsHelper)
+ }
+
+ void 'the controller trait Groovy dispatches through is registered'() {
+ expect:
+ registered(grails.artefact.Controller)
+ }
+
+ void 'the response and forwarding support types are registered'() {
+ expect:
+ registered(grails.artefact.controller.support.ResponseRenderer)
+ registered(grails.artefact.controller.support.ResponseRedirector)
+ registered(grails.artefact.controller.support.RequestForwarder)
+ }
+
+ void 'a type absent from the classpath is skipped rather than failing the build'() {
+ given:
+ RuntimeHints empty = new RuntimeHints()
+
+ when: 'no class loader can resolve the named types'
+ new ControllerRuntimeHints().registerHints(empty, new URLClassLoader(new URL[0], null))
+
+ then:
+ noExceptionThrown()
+ }
+}
diff --git a/grails-mimetypes/src/main/groovy/org/grails/web/mime/aot/MimeTypeRuntimeHints.java b/grails-mimetypes/src/main/groovy/org/grails/web/mime/aot/MimeTypeRuntimeHints.java
new file mode 100644
index 00000000000..de6c2005a50
--- /dev/null
+++ b/grails-mimetypes/src/main/groovy/org/grails/web/mime/aot/MimeTypeRuntimeHints.java
@@ -0,0 +1,56 @@
+/*
+ * 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.web.mime.aot;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.lang.Nullable;
+
+/**
+ * Registers the content negotiation API.
+ *
+ *
Negotiation runs only for a request that states what it accepts. A browser always does;
+ * a bare command-line request does not, which is why the absence of these hints can pass an
+ * automated check and still fail for every real visitor.
+ *
+ * @since 8.0
+ */
+public class MimeTypeRuntimeHints implements RuntimeHintsRegistrar {
+
+ /**
+ * Types Groovy dispatches on. Named as strings, and registered only when present, so this stays
+ * correct for an application that does not use every plugin.
+ */
+ private static final String[] DISPATCHED_TYPES = {
+ "grails.web.mime.MimeType",
+ "org.grails.web.mime.DefaultAcceptHeaderParser",
+ "org.grails.web.mime.DefaultMimeUtility"
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (String type : DISPATCHED_TYPES) {
+ hints.reflection().registerTypeIfPresent(classLoader, type,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_PUBLIC_METHODS,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
+ }
+ }
+}
diff --git a/grails-mimetypes/src/main/resources/META-INF/spring/aot.factories b/grails-mimetypes/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..e6d118d9714
--- /dev/null
+++ b/grails-mimetypes/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.web.mime.aot.MimeTypeRuntimeHints
diff --git a/grails-web-databinding/src/main/groovy/org/grails/web/databinding/aot/DataBindingRuntimeHints.java b/grails-web-databinding/src/main/groovy/org/grails/web/databinding/aot/DataBindingRuntimeHints.java
new file mode 100644
index 00000000000..4c617da1598
--- /dev/null
+++ b/grails-web-databinding/src/main/groovy/org/grails/web/databinding/aot/DataBindingRuntimeHints.java
@@ -0,0 +1,61 @@
+/*
+ * 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.web.databinding.aot;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.lang.Nullable;
+
+/**
+ * Registers the data binding API a request is bound through.
+ *
+ *
Binding is reached only by a request that carries a body or parameters to bind, so a
+ * read-only walk of an application never records it and the absence shows up the first time
+ * a form is submitted.
+ *
+ * @since 8.0
+ */
+public class DataBindingRuntimeHints implements RuntimeHintsRegistrar {
+
+ /**
+ * Types Groovy dispatches on. Named as strings, and registered only when present, so this stays
+ * correct for an application that does not use every plugin.
+ */
+ private static final String[] DISPATCHED_TYPES = {
+ "grails.web.databinding.WebDataBinding",
+ "grails.web.databinding.DataBindingUtils",
+ "grails.databinding.DataBindingSource",
+ "grails.databinding.BindingHelper",
+ "grails.databinding.converters.ValueConverter",
+ // binding asks a target type whether it is an array, and Groovy makes that call
+ // reflectively on the Class object rather than directly
+ "java.lang.Class"
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (String type : DISPATCHED_TYPES) {
+ hints.reflection().registerTypeIfPresent(classLoader, type,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_PUBLIC_METHODS,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
+ }
+ }
+}
diff --git a/grails-web-databinding/src/main/resources/META-INF/spring/aot.factories b/grails-web-databinding/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..09045e2d0db
--- /dev/null
+++ b/grails-web-databinding/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.web.databinding.aot.DataBindingRuntimeHints
diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/aot/UrlMappingRuntimeHints.java b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/aot/UrlMappingRuntimeHints.java
new file mode 100644
index 00000000000..b716a43f78a
--- /dev/null
+++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/aot/UrlMappingRuntimeHints.java
@@ -0,0 +1,60 @@
+/*
+ * 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.web.mapping.aot;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.lang.Nullable;
+
+/**
+ * Registers the URL mapping API a request is dispatched through.
+ *
+ *
Resolving a request reads the matched mapping reflectively through Groovy, so the
+ * declared methods of these types have to survive into the image. The parameter accessor in
+ * particular is reached only once a mapping carries parameters, which a walk of an
+ * application's pages need not do.
+ *
+ * @since 8.0
+ */
+public class UrlMappingRuntimeHints implements RuntimeHintsRegistrar {
+
+ /**
+ * Types Groovy dispatches on. Named as strings, and registered only when present, so this stays
+ * correct for an application that does not use every plugin.
+ */
+ private static final String[] DISPATCHED_TYPES = {
+ "grails.web.mapping.UrlMappingInfo",
+ "grails.web.mapping.UrlMapping",
+ "grails.web.mapping.UrlMappings",
+ "grails.web.mapping.UrlCreator",
+ "grails.web.mapping.LinkGenerator",
+ "grails.web.mapping.UrlMappingData"
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (String type : DISPATCHED_TYPES) {
+ hints.reflection().registerTypeIfPresent(classLoader, type,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_PUBLIC_METHODS,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
+ }
+ }
+}
diff --git a/grails-web-url-mappings/src/main/resources/META-INF/spring/aot.factories b/grails-web-url-mappings/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..f1d12f9db9c
--- /dev/null
+++ b/grails-web-url-mappings/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.web.mapping.aot.UrlMappingRuntimeHints
From 3b3537d1546d304da0fbe01e0b318c8fdfd0f053 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 00:23:35 -0700
Subject: [PATCH 011/115] Register the framework's closures for ahead-of-time
images
Calling a closure goes through doCall, and Groovy reads its parameter types
reflectively to choose an overload. In an ahead-of-time image those are stripped
unless something asks for them, and the call then fails where the closure is used
rather than at start-up. The framework ships thousands across its plugins, so naming
them would be neither complete nor stable, and leaving it to each application means
every one of them rediscovers the same list.
They are found while the hints are written instead. That happens during the build,
on an ordinary JVM with the whole classpath, so scanning is available; it is only
the image that cannot do it.
Two kinds of closure are deliberately left out, both of which otherwise fail the
build rather than degrading at run time. One is a closure whose declaring class does
not resolve: the GSP compiler's task extends an Ant type absent at run time, and its
closures reach it through invokedynamic, so nothing in their own bytecode reveals
the dependency. The other is a closure naming an absent class in a method body: the
JSP closures link happily against a runtime with no JSP API, because loading a class
resolves its signatures and not the bodies the image analysis goes on to parse.
---
.../beans/aot/GrailsClosureRuntimeHints.java | 204 ++++++++++++++++++
.../resources/META-INF/spring/aot.factories | 3 +
.../aot/GrailsClosureRuntimeHintsSpec.groovy | 74 +++++++
3 files changed, 281 insertions(+)
create mode 100644 grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
create mode 100644 grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
new file mode 100644
index 00000000000..353cdba4f45
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
@@ -0,0 +1,204 @@
+/*
+ * 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.io.IOException;
+import java.io.InputStream;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.asm.ClassReader;
+import org.springframework.asm.SpringAsmInfo;
+import org.springframework.asm.ClassVisitor;
+import org.springframework.asm.MethodVisitor;
+import org.springframework.asm.Type;
+
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
+import org.springframework.core.type.classreading.MetadataReaderFactory;
+import org.springframework.lang.Nullable;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Registers the framework closures Groovy dispatches through.
+ *
+ *
Calling a closure goes through {@code doCall}, and Groovy reads its parameter types
+ * reflectively to choose an overload. In an ahead-of-time image those types are stripped unless
+ * something asks for them, and the call fails where the closure is used rather than at start-up.
+ * The framework ships thousands of closures across its plugins, so naming them individually would
+ * be neither complete nor stable.
+ *
+ *
They are found here instead, while the hints are being written. That happens during the build,
+ * on an ordinary JVM with the full classpath, so scanning is available -- it is only the image that
+ * cannot do it. A closure whose own bytecode does not resolve is skipped: the GSP compiler's Ant
+ * task is on the compile classpath but Ant is not on the runtime one, and registering it would make
+ * the image analysis parse a class whose supertype is absent, failing the build.
+ *
+ * @since 8.0
+ */
+public class GrailsClosureRuntimeHints implements RuntimeHintsRegistrar {
+
+ private static final Log logger = LogFactory.getLog(GrailsClosureRuntimeHints.class);
+
+ /** Closures live under the framework's own packages; an application's are registered elsewhere. */
+ private static final String[] PATTERNS = {
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "grails/**/*_closure*.class",
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/**/*_closure*.class"
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ ClassLoader loader = (classLoader != null) ? classLoader : ClassUtils.getDefaultClassLoader();
+ ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(loader);
+ MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver);
+ int registered = 0;
+ for (String pattern : PATTERNS) {
+ Resource[] resources;
+ try {
+ resources = resolver.getResources(pattern);
+ }
+ catch (IOException ex) {
+ logger.warn("Unable to scan for Grails closures matching " + pattern, ex);
+ continue;
+ }
+ for (Resource resource : resources) {
+ String className = classNameOf(metadataReaderFactory, resource);
+ if (className != null && resolves(className, resource, loader)) {
+ hints.reflection().registerTypeIfPresent(loader, className,
+ MemberCategory.INVOKE_DECLARED_METHODS,
+ MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
+ registered++;
+ }
+ }
+ }
+ logger.debug("Registered " + registered + " Grails closures for reflection");
+ }
+
+ @Nullable
+ private String classNameOf(MetadataReaderFactory factory, Resource resource) {
+ try {
+ return factory.getMetadataReader(resource).getClassMetadata().getClassName();
+ }
+ catch (IOException | RuntimeException ex) {
+ return null;
+ }
+ }
+
+ /**
+ * Whether every class the closure names resolves on this classpath.
+ *
+ *
Loading the class is not enough: that resolves its declaration and the types in its method
+ * signatures, but not the ones its bodies reference, and the image analysis parses those bodies.
+ * The GSP compiler's JSP closures link happily against a runtime with no JSP API and then fail
+ * the build, so the references are read directly instead.
+ */
+ private boolean resolves(String className, Resource resource, ClassLoader loader) {
+ // A closure cannot be reached unless the class declaring it can be: the GSP compiler's task
+ // extends an Ant type that is absent at run time, and its closures reach it through
+ // invokedynamic, so the reference is not visible in the bytecode read below.
+ int declaring = className.indexOf("$_");
+ if (declaring > 0 && !ClassUtils.isPresent(className.substring(0, declaring), loader)) {
+ logger.trace("Skipping closure whose declaring class does not resolve: " + className);
+ return false;
+ }
+ Set referenced = new LinkedHashSet<>();
+ try (InputStream input = resource.getInputStream()) {
+ new ClassReader(input).accept(new ReferenceCollector(referenced),
+ ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ }
+ catch (IOException | RuntimeException ex) {
+ return false;
+ }
+ for (String name : referenced) {
+ if (!ClassUtils.isPresent(name, loader)) {
+ logger.trace("Skipping closure referencing " + name + ", absent from this classpath");
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Collects the types a class names, in its own declaration and in the bodies of its methods. */
+ private static final class ReferenceCollector extends ClassVisitor {
+
+ private final Set referenced;
+
+ private ReferenceCollector(Set referenced) {
+ super(SpringAsmInfo.ASM_VERSION);
+ this.referenced = referenced;
+ }
+
+ private void add(@Nullable String internalName) {
+ if (internalName == null || internalName.startsWith("[")) {
+ return;
+ }
+ String className = ClassUtils.convertResourcePathToClassName(internalName);
+ // the JDK is always present, and skipping it keeps this to the classes that can be absent
+ if (!className.startsWith("java.") && !className.startsWith("jdk.")) {
+ referenced.add(className);
+ }
+ }
+
+ @Override
+ public void visit(int version, int access, String name, String signature, String superName,
+ String[] interfaces) {
+ add(superName);
+ if (interfaces != null) {
+ for (String each : interfaces) {
+ add(each);
+ }
+ }
+ }
+
+ @Override
+ public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
+ String[] exceptions) {
+ for (Type argument : Type.getArgumentTypes(descriptor)) {
+ add(argument.getSort() == Type.OBJECT ? argument.getInternalName() : null);
+ }
+ return new MethodVisitor(SpringAsmInfo.ASM_VERSION) {
+ @Override
+ public void visitTypeInsn(int opcode, String type) {
+ add(type);
+ }
+
+ @Override
+ public void visitMethodInsn(int opcode, String owner, String methodName,
+ String methodDescriptor, boolean isInterface) {
+ add(owner);
+ }
+
+ @Override
+ public void visitFieldInsn(int opcode, String owner, String fieldName,
+ String fieldDescriptor) {
+ add(owner);
+ }
+ };
+ }
+ }
+
+}
diff --git a/grails-core/src/main/resources/META-INF/spring/aot.factories b/grails-core/src/main/resources/META-INF/spring/aot.factories
index 2b6b9870fe9..3a2f1229ece 100644
--- a/grails-core/src/main/resources/META-INF/spring/aot.factories
+++ b/grails-core/src/main/resources/META-INF/spring/aot.factories
@@ -1,2 +1,5 @@
org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter=\
org.grails.spring.beans.aot.AbstractBeanDefinitionExcludeFilter
+
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.spring.beans.aot.GrailsClosureRuntimeHints
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..20690e9ad3f
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
@@ -0,0 +1,74 @@
+/*
+ * 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 org.springframework.aot.hint.MemberCategory
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.TypeReference
+import spock.lang.Specification
+
+/**
+ * Covers the framework's closures being found while the hints are written, rather than named one by
+ * one. Groovy reads {@code doCall}'s parameter types to choose an overload, so a closure missing
+ * from an image fails where it is used rather than at start-up.
+ */
+class GrailsClosureRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ private List registeredTypes() {
+ hints.reflection().typeHints().collect { it.type.name }
+ }
+
+ void 'the framework closures on the classpath are registered'() {
+ when:
+ new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader)
+
+ then: 'this module alone ships plenty, so a scan that found nothing would be broken'
+ registeredTypes().count { it.contains('_closure') } > 0
+ }
+
+ void 'a registered closure can have its parameter types read'() {
+ given:
+ new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader)
+
+ when:
+ def closure = registeredTypes().find { it.contains('_closure') }
+ def hint = hints.reflection().getTypeHint(TypeReference.of(closure))
+
+ then: 'that is the access Groovy needs to select an overload'
+ hint.memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
+ void 'only framework closures are registered'() {
+ when:
+ new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader)
+
+ then: 'an application registers its own; this must not reach outside the framework'
+ registeredTypes().every { it.startsWith('grails.') || it.startsWith('org.grails.') }
+ }
+
+ void 'a class loader that resolves nothing yields no hints rather than failing'() {
+ when:
+ new GrailsClosureRuntimeHints().registerHints(hints, new URLClassLoader(new URL[0], null))
+
+ then:
+ noExceptionThrown()
+ }
+}
From 842ce6f07b0f2610c4a1b6659c5442240fd8d95a Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 00:59:09 -0700
Subject: [PATCH 012/115] Record an application's own classes and pages for
ahead-of-time images
Grails reaches an application's artefacts reflectively, and a precompiled page is
looked up by the name recorded for its view, so a native image that keeps only the
members something asked for leaves both present but unusable. Until now an
application had to run under the tracing agent to discover them, which records only
the paths that were exercised: a page nobody visited during the trace is a page
missing from the image.
The build already knows the answer. The compiled classes are on disk and the pages
are named in the manifest the GSP compiler writes, so both are read directly. Pages a
plugin contributes are read from the manifest inside its artifact as well, because an
application renders those as readily as its own and they are not in its own build
output.
The closure registration is widened at the same time. A plugin declares its
descriptor in whatever package it chooses, and the bean definitions in that
descriptor are closures the container calls while the context is built, so scanning
only the framework's own packages missed them.
---
.../beans/aot/GrailsClosureRuntimeHints.java | 10 +-
.../aot/GrailsClosureRuntimeHintsSpec.groovy | 8 +-
.../aot/GenerateNativeMetadataTask.groovy | 188 ++++++++++++++++++
.../plugin/core/GrailsGradlePlugin.groovy | 26 +++
4 files changed, 227 insertions(+), 5 deletions(-)
create mode 100644 grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTask.groovy
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
index 353cdba4f45..35f5b9e5bed 100644
--- a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
@@ -64,10 +64,16 @@ public class GrailsClosureRuntimeHints implements RuntimeHintsRegistrar {
private static final Log logger = LogFactory.getLog(GrailsClosureRuntimeHints.class);
- /** Closures live under the framework's own packages; an application's are registered elsewhere. */
+ /**
+ * Where the framework's closures are found. The first two cover its own packages; the third
+ * covers plugin descriptors, which a plugin may declare in any package of its choosing -- the
+ * asset pipeline names its own {@code asset.pipeline}, and its bean definitions are closures
+ * the container calls while the context is built.
+ */
private static final String[] PATTERNS = {
ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "grails/**/*_closure*.class",
- ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/**/*_closure*.class"
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "org/grails/**/*_closure*.class",
+ ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + "**/*GrailsPlugin$*_closure*.class"
};
@Override
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
index 20690e9ad3f..f33f20cad4a 100644
--- a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
@@ -56,12 +56,14 @@ class GrailsClosureRuntimeHintsSpec extends Specification {
hint.memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS)
}
- void 'only framework closures are registered'() {
+ void 'only framework and plugin descriptor closures are registered'() {
when:
new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader)
- then: 'an application registers its own; this must not reach outside the framework'
- registeredTypes().every { it.startsWith('grails.') || it.startsWith('org.grails.') }
+ then: 'a plugin may sit in any package, but nothing else should be swept up'
+ registeredTypes().every {
+ it.startsWith('grails.') || it.startsWith('org.grails.') || it.contains('GrailsPlugin$')
+ }
}
void 'a class loader that resolves nothing yields no hints rather than failing'() {
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTask.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTask.groovy
new file mode 100644
index 00000000000..9174be9b393
--- /dev/null
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTask.groovy
@@ -0,0 +1,188 @@
+/*
+ * 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 groovy.json.JsonOutput
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Optional
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+
+/**
+ * Records the application's own classes so they survive into a native image.
+ *
+ *
Grails reaches an application's artefacts reflectively -- a controller's actions, a domain
+ * class's properties, a tag library's methods -- and a precompiled page is looked up by the name
+ * recorded for its view. A native image keeps only the members something asks for, so without this
+ * the classes are present but unusable.
+ *
+ *
The answer is already in the build output: the compiled classes are on disk and the pages are
+ * listed in the manifest the GSP compiler writes. Reading them here means an application does not
+ * have to run under the tracing agent to be buildable, which matters because the agent records only
+ * the paths that were exercised -- a page never visited during the trace is a page missing from the
+ * image.
+ *
+ * @since 8.0
+ */
+@CacheableTask
+@CompileStatic
+abstract class GenerateNativeMetadataTask extends DefaultTask {
+
+ /** Where the generated metadata is written within the artifact. */
+ static final String METADATA_PATH = 'META-INF/native-image/grails/reachability-metadata.json'
+
+ /** The manifest the GSP compiler writes, mapping a view to the class it compiled to. */
+ static final String VIEWS_MANIFEST = 'gsp/views.properties'
+
+ /** Compiled application classes. */
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract ConfigurableFileCollection getClassesDirs()
+
+ /** Compiled pages, including the manifest naming them. */
+ @InputFiles
+ @Optional
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract ConfigurableFileCollection getPageClassesDirs()
+
+ /**
+ * The classpath whose artifacts may carry their own pages. A plugin ships compiled pages and the
+ * manifest naming them, and an application renders those as readily as its own.
+ */
+ @InputFiles
+ @Optional
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract ConfigurableFileCollection getPageClasspath()
+
+ @OutputDirectory
+ abstract DirectoryProperty getOutputDirectory()
+
+ @TaskAction
+ void generate() {
+ Set types = [] as Set
+ types.addAll(applicationClasses())
+ types.addAll(pageClasses())
+ types.addAll(pageClassesFromClasspath())
+
+ List
*/
+ @CompileStatic
protected boolean isDevelopmentMode() {
if (SpringProperties.getFlag(AbstractAotProcessor.AOT_PROCESSING)) {
return false
From 0d0dc239d17ccded28414c4c2ad5ba928d2ba137 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 18:59:45 -0700
Subject: [PATCH 033/115] Do not read the application's own bean declarations a
second time
An application declares beans of its own in spring/resources.groovy, and these are
read while the artifacts are being generated: what they declare is written out as
code and registered from there. Reading them again at start-up registers them a
second time, and reading the Groovy one means compiling a script -- which is the
one thing an image cannot do.
So an application that has a spring/resources.groovy, as one generated by Forge
does, did not start at all:
Error loading spring/resources.groovy file: Classes cannot be defined at
runtime by default when using ahead-of-time Native Image compilation
Verified by a native image rather than by a test: the post-processor takes a
refreshed context and a plugin manager to construct, so nothing exercises it below
the level of an application starting, and a test that read the guard back off the
source would assert nothing about behaviour.
(cherry picked from commit c7e1fefff4cce61b3c354bd6a476f3bab8fdb155)
---
.../boot/config/GrailsApplicationPostProcessor.groovy | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy
index cbdbd415f45..c37840bfb5a 100644
--- a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy
+++ b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy
@@ -256,7 +256,12 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces
pluginManager.doRuntimeConfiguration(springConfig)
}
- if (loadExternalBeans) {
+ // Running on generated artifacts these beans are already registered: the application's own
+ // definitions were read while the artifacts were being generated and what they declared was
+ // written out as code. Reading them again would register them a second time, and reading
+ // the Groovy one means compiling a script -- which an image cannot do at all, so an
+ // application that has a spring/resources.groovy did not start.
+ if (loadExternalBeans && !AotDetector.useGeneratedArtifacts()) {
// now allow overriding via application
def context = application.mainContext
From 2b0824fef572d1aef21b6b6dfe28600ed0f8efc1 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 19:27:08 -0700
Subject: [PATCH 034/115] Write down the artefacts an application is made of
An application's artefacts are found two ways, and an image has neither: the
classpath is scanned for the classes under the application's own packages, which
needs a classpath to walk, and a list the compile-time transform builds as it goes
is consulted, which lives in a static field and is empty in anything the transform
did not itself compile.
So an image found no controllers, no domain classes and no URL mappings, and the
application failed to start on the first bean that wanted one. The only way to
start was to override classes() and name every artefact by hand -- a list to keep
in step with the application forever after, and one an application should not have
to know it needs.
They are found while the code is generated, on an ordinary JVM where both ways
work, so what was found is written into the generated code and read back by
classes(). An application declares nothing and keeps nothing in step.
Nothing is written down where nothing was found, so a plain Spring application
being generated is unaffected, and on an ordinary start the two ways are used as
they always were.
Verified on a generated application that declares no artefacts of its own: the
generated initializer carries all 32 of them, its own UrlMappings and User among
them, where before it carried none.
(cherry picked from commit 7b5c501d61b012f28d49ccd8704eb3994f2a6cea)
---
.../config/GrailsAutoConfiguration.groovy | 31 +++++
...BeanFactoryInitializationAotProcessor.java | 126 ++++++++++++++++++
.../resources/META-INF/spring/aot.factories | 3 +
...ctoryInitializationAotProcessorSpec.groovy | 120 +++++++++++++++++
4 files changed, 280 insertions(+)
create mode 100644 grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java
create mode 100644 grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy
diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy b/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy
index 6e800e43982..3ce991577b5 100644
--- a/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy
+++ b/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy
@@ -26,6 +26,8 @@ import org.springframework.aop.config.AopConfigUtils
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.context.annotation.Bean
+import org.springframework.aot.AotDetector
+import org.springframework.beans.factory.config.SingletonBeanRegistry
import org.springframework.core.io.support.PathMatchingResourcePatternResolver
import grails.boot.config.tools.ClassPathScanner
@@ -34,6 +36,7 @@ import grails.core.GrailsApplication
import grails.core.GrailsApplicationClass
import org.apache.grails.core.plugins.PluginDiscovery
import org.grails.spring.aop.autoproxy.GroovyAwareAspectJAwareAdvisorAutoProxyCreator
+import org.grails.spring.beans.aot.ArtefactClassesBeanFactoryInitializationAotProcessor
import org.grails.spring.aop.autoproxy.GroovyAwareInfrastructureAdvisorAutoProxyCreator
/**
@@ -78,6 +81,11 @@ class GrailsAutoConfiguration implements GrailsApplicationClass, ApplicationCont
* @return The classes that constitute the Grails application
*/
Collection classes() {
+ Collection written = artefactsWrittenDownAheadOfTime()
+ if (written != null) {
+ return written
+ }
+
if (limitScanningToApplication()) {
return ApplicationArtefactScanner.scanApplicationClasses(getClass(), packageNames())
}
@@ -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.
+ *
+ *
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.
+ *
+ *
They were found while the code was generated, on an ordinary JVM where both ways work, and
+ * left here.
+ */
+ protected Collection artefactsWrittenDownAheadOfTime() {
+ if (applicationContext == null || !AotDetector.useGeneratedArtifacts()) {
+ return null
+ }
+ Object written = applicationContext.autowireCapableBeanFactory instanceof SingletonBeanRegistry
+ ? ((SingletonBeanRegistry) applicationContext.autowireCapableBeanFactory)
+ .getSingleton(ArtefactClassesBeanFactoryInitializationAotProcessor.BEAN_NAME)
+ : null
+ written instanceof Class[] ? Arrays.asList((Class[]) written) : null
+ }
+
/**
* Whether classpath scanning should be limited to the application and not dependent JAR files. Users can override this method to enable more broad scanning
* at the cost of startup time.
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java
new file mode 100644
index 00000000000..31af8aab939
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java
@@ -0,0 +1,126 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+
+import javax.lang.model.element.Modifier;
+
+import org.springframework.aot.generate.GeneratedMethod;
+import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution;
+import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor;
+import org.springframework.beans.factory.aot.BeanFactoryInitializationCode;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.javapoet.CodeBlock;
+import org.springframework.lang.Nullable;
+
+import grails.core.GrailsApplication;
+
+/**
+ * Writes down the artefacts an application is made of, while they can still be found.
+ *
+ *
They are found two ways, and an image has neither. The classpath is scanned for the classes
+ * under the application's own packages, which needs a classpath to walk; and a list the compile-time
+ * transform builds as it goes is consulted, which lives in a static field of the transform and is
+ * therefore empty in anything the transform did not itself compile.
+ *
+ *
So an image found no controllers, no domain classes and no URL mappings, and the application
+ * failed to start on the first bean that wanted one. The only way an application could start was to
+ * override {@code classes()} and list its own artefacts by hand, which is a thing to keep in step
+ * with the application forever after.
+ *
+ *
Generation runs on an ordinary JVM with the full classpath, where both ways work. What they
+ * found is written into the generated code here, and read back by {@link
+ * grails.boot.config.GrailsAutoConfiguration#classes()}.
+ *
+ * @since 8.0
+ */
+public class ArtefactClassesBeanFactoryInitializationAotProcessor implements BeanFactoryInitializationAotProcessor {
+
+ /**
+ * Where the classes are left for {@code classes()} to find. A singleton rather than a bean
+ * definition, because it is read while the definitions are still being contributed.
+ */
+ public static final String BEAN_NAME = "grailsArtefactClasses";
+
+ @Override
+ @Nullable
+ public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) {
+ List> artefacts = artefactsOf(beanFactory);
+ if (artefacts.isEmpty()) {
+ return null;
+ }
+ return (generationContext, beanFactoryInitializationCode) ->
+ contribute(artefacts, beanFactoryInitializationCode);
+ }
+
+ /**
+ * The artefacts the application was found to be made of, or none if this is not a Grails
+ * application context -- a plain Spring one being generated has no {@code grailsApplication}.
+ */
+ private List> artefactsOf(ConfigurableListableBeanFactory beanFactory) {
+ List> artefacts = new ArrayList<>();
+ Object application = beanFactory.getSingleton(GrailsApplication.APPLICATION_ID);
+ if (!(application instanceof GrailsApplication grailsApplication)) {
+ return artefacts;
+ }
+ Class>[] allClasses = grailsApplication.getAllClasses();
+ if (allClasses == null) {
+ return artefacts;
+ }
+ for (Class> artefact : allClasses) {
+ if (artefact != null && isNamed(artefact)) {
+ artefacts.add(artefact);
+ }
+ }
+ return artefacts;
+ }
+
+ /**
+ * Whether the class can be written down and read back. One generated as the application ran --
+ * a proxy, or a script compiled from a string -- has a name that resolves to nothing next time.
+ */
+ private boolean isNamed(Class> artefact) {
+ return !artefact.isSynthetic() && !artefact.isAnonymousClass() &&
+ !artefact.isLocalClass() && artefact.getCanonicalName() != null;
+ }
+
+ private void contribute(List> artefacts, BeanFactoryInitializationCode beanFactoryInitializationCode) {
+ GeneratedMethod method = beanFactoryInitializationCode.getMethods()
+ .add("registerArtefactClasses", builder -> {
+ builder.addJavadoc("Register the artefacts this application is made of.");
+ builder.addModifiers(Modifier.PUBLIC, Modifier.STATIC);
+ builder.addParameter(ConfigurableListableBeanFactory.class,
+ BeanFactoryInitializationCode.BEAN_FACTORY_VARIABLE);
+ builder.addStatement("$L.registerSingleton($S, $L)",
+ BeanFactoryInitializationCode.BEAN_FACTORY_VARIABLE, BEAN_NAME, arrayOf(artefacts));
+ });
+ beanFactoryInitializationCode.addInitializer(method.toMethodReference());
+ }
+
+ /** The classes as an array literal, in the order they were found, so a run reads as a build did. */
+ private CodeBlock arrayOf(List> artefacts) {
+ CodeBlock.Builder array = CodeBlock.builder().add("new $T[] {", Class.class);
+ for (int i = 0; i < artefacts.size(); i++) {
+ array.add(i == 0 ? "$T.class" : ", $T.class", artefacts.get(i));
+ }
+ return array.add("}").build();
+ }
+}
diff --git a/grails-core/src/main/resources/META-INF/spring/aot.factories b/grails-core/src/main/resources/META-INF/spring/aot.factories
index 0d488193e1b..f263d9e0f9d 100644
--- a/grails-core/src/main/resources/META-INF/spring/aot.factories
+++ b/grails-core/src/main/resources/META-INF/spring/aot.factories
@@ -9,3 +9,6 @@ org.grails.spring.beans.aot.GroovyExtensionModuleRuntimeHints
org.springframework.beans.factory.aot.BeanRegistrationAotProcessor=\
org.grails.spring.beans.aot.AutowireModeBeanRegistrationAotProcessor,\
org.grails.spring.beans.aot.VarargsBeanRegistrationAotProcessor
+
+org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor=\
+org.grails.spring.beans.aot.ArtefactClassesBeanFactoryInitializationAotProcessor
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy
new file mode 100644
index 00000000000..308b7321ef3
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy
@@ -0,0 +1,120 @@
+/*
+ * 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 org.springframework.aot.generate.ClassNameGenerator
+import org.springframework.aot.generate.DefaultGenerationContext
+import org.springframework.aot.generate.GeneratedFiles
+import org.springframework.aot.generate.InMemoryGeneratedFiles
+import org.springframework.context.aot.ApplicationContextAotGenerator
+import org.springframework.context.support.GenericApplicationContext
+import org.springframework.javapoet.ClassName
+import spock.lang.Specification
+
+import grails.core.DefaultGrailsApplication
+import grails.core.GrailsApplication
+
+/**
+ * Covers the artefacts an application is made of being written down while they can still be found.
+ *
+ *
They are found by walking the classpath and by reading a list the compile-time transform builds
+ * as it goes, and an image has neither -- so it found no controllers, no domain classes and no URL
+ * mappings, and the application failed to start on the first bean that wanted one. The only way to
+ * start was for an application to name its own artefacts by hand.
+ */
+class ArtefactClassesBeanFactoryInitializationAotProcessorSpec extends Specification {
+
+ GenericApplicationContext context = new GenericApplicationContext()
+
+ void cleanup() {
+ context.close()
+ }
+
+ private String generatedSourceFor(Class>... artefacts) {
+ GrailsApplication application = new DefaultGrailsApplication(artefacts)
+ application.applicationContext = context
+ application.initialise()
+ context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application)
+
+ InMemoryGeneratedFiles generatedFiles = new InMemoryGeneratedFiles()
+ DefaultGenerationContext generationContext = new DefaultGenerationContext(
+ new ClassNameGenerator(ClassName.get('com.example', 'Subject')), generatedFiles)
+ new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext)
+ generationContext.writeGeneratedContent()
+
+ generatedFiles.getGeneratedFiles(GeneratedFiles.Kind.SOURCE)
+ .keySet()
+ .collect { generatedFiles.getGeneratedFileContent(GeneratedFiles.Kind.SOURCE, it) }
+ .join('\n')
+ }
+
+ void 'the artefacts are written into the generated code'() {
+ when:
+ String generated = generatedSourceFor(DemoController, DemoService)
+
+ then: 'so that an image has them without the application naming them itself'
+ generated.contains('registerSingleton("grailsArtefactClasses"')
+ generated.contains('DemoController.class')
+ generated.contains('DemoService.class')
+ }
+
+ void 'the registration is run as the bean factory is initialized'() {
+ when:
+ String generated = generatedSourceFor(DemoController)
+
+ then: 'they are read while the definitions are still being contributed, so they have to be ' +
+ 'there before any of them is'
+ generated.contains('registerArtefactClasses')
+ }
+
+ void 'a context that is not a Grails application contributes nothing'() {
+ given:
+ def processor = new ArtefactClassesBeanFactoryInitializationAotProcessor()
+
+ expect: 'a plain Spring application being generated has no grailsApplication to ask'
+ processor.processAheadOfTime(context.beanFactory) == null
+ }
+
+ void 'the classes that constitute the application are what is written down'() {
+ when: 'a Grails application holds these as the classes it is made of'
+ String generated = generatedSourceFor(DemoController, DemoService)
+
+ then: 'which is what classes() answers with, so a run reads what a build found'
+ generated.count('.class') >= 2
+ }
+
+ void 'an application with no artefacts contributes nothing'() {
+ given:
+ GrailsApplication application = new DefaultGrailsApplication()
+ application.applicationContext = context
+ application.initialise()
+ context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application)
+
+ expect: 'writing an empty array down would say the application has none, which is different ' +
+ 'from not having looked'
+ new ArtefactClassesBeanFactoryInitializationAotProcessor()
+ .processAheadOfTime(context.beanFactory) == null
+ }
+
+ static class DemoController {
+ }
+
+ static class DemoService {
+ }
+}
From 26f464bef9b684b178a2b98fe3209a04d1c85aa8 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 20:25:34 -0700
Subject: [PATCH 035/115] Leave the plugins' own artefacts to the plugins
Writing down every class the application holds wrote down the plugins' artefacts
too -- their tag libraries and codecs, which they register from themselves on
every start. The application then claimed them as its own and they were registered
twice, which for a codec is not the same as registering it once: a page rendered
through one asked for static text that the other had never been given, and every
page that renders any failed on a null.
What the plugins provide is asked of the plugins and left to them, so an
application writes down its own and nothing else -- for the application under test
its six, where before it was all thirty-two.
(cherry picked from commit 9f2e54e9d3227483ca1f33dbc513168450d2a06f)
---
...BeanFactoryInitializationAotProcessor.java | 30 ++++++++++++-
...ctoryInitializationAotProcessorSpec.groovy | 43 +++++++++++++++++++
2 files changed, 72 insertions(+), 1 deletion(-)
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java
index 31af8aab939..00c661f2fb8 100644
--- a/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessor.java
@@ -19,7 +19,10 @@
package org.grails.spring.beans.aot;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Set;
import javax.lang.model.element.Modifier;
@@ -32,6 +35,8 @@
import org.springframework.lang.Nullable;
import grails.core.GrailsApplication;
+import grails.plugins.GrailsPlugin;
+import grails.plugins.GrailsPluginManager;
/**
* Writes down the artefacts an application is made of, while they can still be found.
@@ -85,14 +90,37 @@ private List> artefactsOf(ConfigurableListableBeanFactory beanFactory)
if (allClasses == null) {
return artefacts;
}
+ Set> providedByPlugins = providedByPlugins(beanFactory);
for (Class> artefact : allClasses) {
- if (artefact != null && isNamed(artefact)) {
+ if (artefact != null && isNamed(artefact) && !providedByPlugins.contains(artefact)) {
artefacts.add(artefact);
}
}
return artefacts;
}
+ /**
+ * The artefacts the plugins bring with them, which are not the application's to declare.
+ *
+ *
They are registered from the plugins on every start, so writing them down here would have
+ * the application claim them as its own -- and a codec or a tag library registered twice, once
+ * as a plugin's and once as the application's, is not the same as registered once.
+ */
+ private Set> providedByPlugins(ConfigurableListableBeanFactory beanFactory) {
+ Set> provided = new LinkedHashSet<>();
+ Object manager = beanFactory.getSingleton(GrailsPluginManager.BEAN_NAME);
+ if (!(manager instanceof GrailsPluginManager pluginManager)) {
+ return provided;
+ }
+ for (GrailsPlugin plugin : pluginManager.getAllPlugins()) {
+ Class>[] artefacts = plugin.getProvidedArtefacts();
+ if (artefacts != null) {
+ provided.addAll(Arrays.asList(artefacts));
+ }
+ }
+ return provided;
+ }
+
/**
* Whether the class can be written down and read back. One generated as the application ran --
* a proxy, or a script compiled from a string -- has a name that resolves to nothing next time.
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy
index 308b7321ef3..dd2813dacc0 100644
--- a/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/ArtefactClassesBeanFactoryInitializationAotProcessorSpec.groovy
@@ -29,6 +29,8 @@ import spock.lang.Specification
import grails.core.DefaultGrailsApplication
import grails.core.GrailsApplication
+import grails.plugins.GrailsPlugin
+import grails.plugins.GrailsPluginManager
/**
* Covers the artefacts an application is made of being written down while they can still be found.
@@ -112,6 +114,47 @@ class ArtefactClassesBeanFactoryInitializationAotProcessorSpec extends Specifica
.processAheadOfTime(context.beanFactory) == null
}
+ void "what the plugins bring with them is not written down"() {
+ given: "a plugin manager whose plugins provide one of the classes the application holds"
+ GrailsApplication application = new DefaultGrailsApplication(DemoController, DemoService)
+ application.applicationContext = context
+ application.initialise()
+ context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application)
+ context.beanFactory.registerSingleton(GrailsPluginManager.BEAN_NAME,
+ Stub(GrailsPluginManager) {
+ getAllPlugins() >> ([Stub(GrailsPlugin) {
+ getProvidedArtefacts() >> ([DemoService] as Class[])
+ }] as GrailsPlugin[])
+ })
+
+ when:
+ def contribution = new ArtefactClassesBeanFactoryInitializationAotProcessor()
+ .processAheadOfTime(context.beanFactory)
+
+ then: "they are registered from the plugins on every start, and a codec or tag library " +
+ "registered twice is not the same as registered once"
+ contribution != null
+
+ and:
+ def written = writtenBy(contribution)
+ written.contains(DemoController)
+ !written.contains(DemoService)
+ }
+
+ /** The classes the contribution would write, read back off the code it generates. */
+ private List writtenBy(contribution) {
+ InMemoryGeneratedFiles files = new InMemoryGeneratedFiles()
+ DefaultGenerationContext generation = new DefaultGenerationContext(
+ new ClassNameGenerator(ClassName.get('com.example', 'Written')), files)
+ new ApplicationContextAotGenerator().processAheadOfTime(context, generation)
+ generation.writeGeneratedContent()
+ String source = files.getGeneratedFiles(GeneratedFiles.Kind.SOURCE)
+ .keySet()
+ .collect { files.getGeneratedFileContent(GeneratedFiles.Kind.SOURCE, it) }
+ .join('\n')
+ [DemoController, DemoService].findAll { source.contains(it.simpleName + '.class') }
+ }
+
static class DemoController {
}
From f3041206244aa84e0ba742c88c997ed69a087e66 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 20:25:34 -0700
Subject: [PATCH 036/115] Define the url mappings holder as reloadable only
where it can reload
Reloading swaps the mappings behind a proxy, and that proxy produces its
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:
Field urlMappings in UrlMappingsErrorPageCustomizer required a bean of type
'grails.web.mapping.UrlMappings' that could not be found
and the application did not start. Generation runs in the project directory, where
the surroundings look like development, so this is what an application got unless
it knew to generate its code with the environment set to production.
Reloading is now off while the code is being generated, whatever the machine
generating it looks like -- an image cannot reload anything in any case. The
surroundings still decide on an ordinary start.
The decision is its own method so that both answers can be tested; before, the
only way to reach it was to be in surroundings that reload.
(cherry picked from commit 5c77de08159229667d479244327d867d46039bd5)
---
.../mapping/UrlMappingsGrailsPlugin.groovy | 24 +++++++++-
.../UrlMappingsGrailsPluginSpec.groovy | 45 +++++++++++++++++++
2 files changed, 67 insertions(+), 2 deletions(-)
diff --git a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy b/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy
index 1824e739b36..24b7680c775 100644
--- a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy
+++ b/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy
@@ -30,6 +30,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.ApplicationContext
+import org.springframework.context.aot.AbstractAotProcessor
+import org.springframework.core.SpringProperties
import org.springframework.core.env.Environment
import org.springframework.web.filter.CorsFilter
@@ -117,8 +119,7 @@ class UrlMappingsGrailsPlugin extends Plugin {
grailsApplication.addArtefact(UrlMappingsArtefactHandler.TYPE, DefaultUrlMappings)
}
- boolean reloadEnabled = GrailsEnvironment.developmentMode ||
- GrailsEnvironment.current.reloadEnabled
+ boolean reloadEnabled = isReloadEnabled()
boolean corsFilterEnabled = environment.getProperty(Settings.SETTING_CORS_FILTER, Boolean, true)
// The url-mapping holder is a ProxyFactoryBean (reload mode) whose produced UrlMappings
@@ -155,6 +156,25 @@ class UrlMappingsGrailsPlugin extends Plugin {
}
}
+ /**
+ * Whether the mappings are to be reloadable, which decides how the holder is defined.
+ *
+ *
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.
+ */
+ protected boolean isReloadEnabled() {
+ !SpringProperties.getFlag(AbstractAotProcessor.AOT_PROCESSING) && environmentReloadable
+ }
+
+ /** Whether the surroundings are ones that reload, which only a run can answer. */
+ protected boolean isEnvironmentReloadable() {
+ GrailsEnvironment.developmentMode || GrailsEnvironment.current.reloadEnabled
+ }
+
@CompileStatic
private static UrlMappingsHolder createUrlMappingsHolder(ApplicationContext applicationContext) {
def factory = new UrlMappingsHolderFactoryBean(applicationContext: applicationContext)
diff --git a/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPluginSpec.groovy b/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPluginSpec.groovy
index 289100a3f0b..f4a97541762 100644
--- a/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPluginSpec.groovy
+++ b/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPluginSpec.groovy
@@ -23,6 +23,7 @@ import org.springframework.beans.factory.config.RuntimeBeanReference
import org.springframework.beans.factory.support.AbstractBeanDefinition
import org.springframework.beans.factory.support.BeanRegistryAdapter
import org.springframework.beans.factory.support.DefaultListableBeanFactory
+import org.springframework.context.aot.AbstractAotProcessor
import org.springframework.core.env.StandardEnvironment
import grails.core.GrailsApplication
@@ -103,6 +104,50 @@ class UrlMappingsGrailsPluginSpec extends Specification {
!registry.containsBeanDefinition('urlMappingsTargetSource')
}
+ void "reloading is off while the code is being generated"() {
+ given: "surroundings that would otherwise reload"
+ def plugin = new Reloading()
+
+ expect: "which is how the holder comes to be a proxy"
+ plugin.isReloadEnabled()
+
+ when: "the same surroundings, while the code is being generated"
+ System.setProperty(AbstractAotProcessor.AOT_PROCESSING, 'true')
+
+ then: "the proxy produces its UrlMappings through a target source rather than declaring the " +
+ "type, so nothing could be autowired by that type from a generated definition"
+ !plugin.isReloadEnabled()
+
+ cleanup:
+ System.clearProperty(AbstractAotProcessor.AOT_PROCESSING)
+ }
+
+ void "the surroundings still decide on an ordinary start"() {
+ given:
+ System.clearProperty(AbstractAotProcessor.AOT_PROCESSING)
+
+ expect: "generation is the only thing that overrides them"
+ new Reloading().isReloadEnabled()
+ !new NotReloading().isReloadEnabled()
+ }
+
+ /** Stands in for surroundings that reload, which a test JVM is not. */
+ static class Reloading extends UrlMappingsGrailsPlugin {
+
+ @Override
+ protected boolean isEnvironmentReloadable() {
+ true
+ }
+ }
+
+ static class NotReloading extends UrlMappingsGrailsPlugin {
+
+ @Override
+ protected boolean isEnvironmentReloadable() {
+ false
+ }
+ }
+
private static void applyRegistrar(DefaultListableBeanFactory beanFactory, GrailsApplication application) {
def plugin = new UrlMappingsGrailsPlugin(grailsApplication: application)
def registrar = plugin.beanRegistrar()
From 9ba4d149a1d6bc14e92c01225a14336c9a9b7aad Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 20:25:34 -0700
Subject: [PATCH 037/115] Carry what a page compiled at build time is read from
Compiling a page splits it: the code becomes a class, and the static text between
the code -- most of the page -- is written beside it as a resource that the class
reads as it renders, along with the line numbers mapping the generated code back
to the page.
An image carries a resource only when it has been asked to, and nothing asked for
these: they are named by a convention rather than by any code. Every page then
rendered with nothing where its text should be and reported a null from inside the
page's own generated code, which says nothing about a missing resource.
They are asked for here, so an application does not have to know the names of
files the compiler chose.
(cherry picked from commit 0235ecc06b2f3c92b7b4dc7708131e5b3939cf6c)
---
.../gsp/aot/PrecompiledPageRuntimeHints.java | 51 +++++++++++++++
.../resources/META-INF/spring/aot.factories | 2 +
.../PrecompiledPageRuntimeHintsSpec.groovy | 64 +++++++++++++++++++
3 files changed, 117 insertions(+)
create mode 100644 grails-gsp/core/src/main/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHints.java
create mode 100644 grails-gsp/core/src/main/resources/META-INF/spring/aot.factories
create mode 100644 grails-gsp/core/src/test/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHintsSpec.groovy
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHints.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHints.java
new file mode 100644
index 00000000000..2e10774ca24
--- /dev/null
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHints.java
@@ -0,0 +1,51 @@
+/*
+ * 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.gsp.aot;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.lang.Nullable;
+
+import org.grails.gsp.GroovyPageMetaInfo;
+
+/**
+ * Registers what a page compiled at build time is read from.
+ *
+ *
Compiling a page splits it: the code becomes a class, and the static text between the code --
+ * most of the page -- is written beside it as a resource, along with the line numbers that map the
+ * generated code back to the page it came from. The class reads that resource as it renders.
+ *
+ *
An image carries a resource only when it has been asked to, and nothing asked for these: they
+ * are named by a convention rather than by any code. The page then rendered with nothing where its
+ * text should be, and reported a null the page itself could not explain.
+ *
+ * @since 8.0
+ */
+public class PrecompiledPageRuntimeHints implements RuntimeHintsRegistrar {
+
+ /** Where the pages compiled at build time are listed, read to find them at all. */
+ private static final String VIEWS = "gsp/views.properties";
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ hints.resources().registerPattern(VIEWS);
+ hints.resources().registerPattern("*" + GroovyPageMetaInfo.HTML_DATA_POSTFIX);
+ hints.resources().registerPattern("*" + GroovyPageMetaInfo.LINENUMBERS_DATA_POSTFIX);
+ }
+}
diff --git a/grails-gsp/core/src/main/resources/META-INF/spring/aot.factories b/grails-gsp/core/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000000..178ec340883
--- /dev/null
+++ b/grails-gsp/core/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1,2 @@
+org.springframework.aot.hint.RuntimeHintsRegistrar=\
+org.grails.gsp.aot.PrecompiledPageRuntimeHints
diff --git a/grails-gsp/core/src/test/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHintsSpec.groovy b/grails-gsp/core/src/test/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..9eea0dbe87b
--- /dev/null
+++ b/grails-gsp/core/src/test/groovy/org/grails/gsp/aot/PrecompiledPageRuntimeHintsSpec.groovy
@@ -0,0 +1,64 @@
+/*
+ * 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.gsp.aot
+
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates
+import spock.lang.Specification
+
+/**
+ * Covers what a page compiled at build time is read from being carried into an image.
+ *
+ *
Compiling a page splits it: the code becomes a class, and the static text between the code --
+ * most of the page -- is written beside it as a resource that the class reads as it renders. An
+ * image carries a resource only when asked, and these are named by convention rather than by any
+ * code, so nothing asked. Every page then rendered with nothing where its text should be.
+ */
+class PrecompiledPageRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ void setup() {
+ new PrecompiledPageRuntimeHints().registerHints(hints, getClass().classLoader)
+ }
+
+ void 'the static text of a compiled page is carried'() {
+ expect: 'without it the page renders empty and reports a null it cannot explain'
+ RuntimeHintsPredicates.resource()
+ .forResource('gsp_demo_indexgsp_html.data')
+ .test(hints)
+ }
+
+ void 'the line numbers that map generated code back to the page are carried'() {
+ expect:
+ RuntimeHintsPredicates.resource()
+ .forResource('gsp_demo_indexgsp_linenumbers.data')
+ .test(hints)
+ }
+
+ void 'the list of the pages compiled at build time is carried'() {
+ expect: 'read to find the compiled pages at all'
+ RuntimeHintsPredicates.resource().forResource('gsp/views.properties').test(hints)
+ }
+
+ void 'an unrelated resource is not carried'() {
+ expect: 'the patterns name the compiled pages rather than everything'
+ !RuntimeHintsPredicates.resource().forResource('application.yml').test(hints)
+ }
+}
From feb740f097406a5f2a3baa6a2bf9a2f13caa5041 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 20:58:48 -0700
Subject: [PATCH 038/115] Ship what an image needs with the framework rather
than asking for it
Building an application into an image needed the application to know things about
Groovy and about Grails that are not its to know: that Groovy's plugin factory and
Grails' build settings both read their surroundings the first time they are
touched, and so must be left until the image runs; that Groovy reads a table of
the methods it adds to every type, and its version, and the descriptors naming its
extensions, as it starts; and that Grails reads a listing to find its plugins at
all.
None of it was discoverable. Leaving out the two initializations gave a
NullPointerException from inside Groovy's metaclass registry before any
application code ran, and leaving out the resources gave a page that rendered
empty or a plugin that was never found -- in each case naming something the
application had never heard of.
The initializations ship as native-image.properties, because when a class is
initialized is a property of the image and nothing in the Spring hint model
expresses it. The resources ship as hints, beside the code that reads them. Both
travel in the jar, so this holds for a build of any kind rather than for one
Gradle configuration.
The application under test now declares none of it and builds and runs the same:
0.715s to start, no overridden definitions, every page served.
(cherry picked from commit 9c3f7c6cea8bc4d44965c746aeeacfe8b90ea78b)
---
.../beans/aot/GrailsClosureRuntimeHints.java | 10 +++++++
.../GroovyExtensionModuleRuntimeHints.java | 19 ++++++++++++
.../grails-core/native-image.properties | 30 +++++++++++++++++++
.../aot/GrailsClosureRuntimeHintsSpec.groovy | 9 ++++++
...oovyExtensionModuleRuntimeHintsSpec.groovy | 15 ++++++++++
5 files changed, 83 insertions(+)
create mode 100644 grails-core/src/main/resources/META-INF/native-image/org.apache.grails/grails-core/native-image.properties
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
index 68b972ea5b8..55881d9f1f0 100644
--- a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
@@ -51,12 +51,21 @@
* task is on the compile classpath but Ant is not on the runtime one, and registering it would make
* the image analysis parse a class whose supertype is absent, failing the build.
*
+ *
Where the plugins are listed is carried into the image as well, since that is read to find
+ * them at all and an image carries a resource only when it has been asked to.
+ *
* @since 8.0
*/
public class GrailsClosureRuntimeHints implements RuntimeHintsRegistrar {
private static final Log logger = LogFactory.getLog(GrailsClosureRuntimeHints.class);
+ /**
+ * Where the plugins are listed, read to find them at all. Written out rather than taken from
+ * {@code FactoriesLoaderSupport}, whose constant is a Groovy property and so not visible here.
+ */
+ private static final String PLUGIN_LISTING = "META-INF/grails.factories";
+
/**
* Where the framework's closures are found. The first two cover its own packages; the third
* covers plugin descriptors, which a plugin may declare in any package of its choosing -- the
@@ -71,6 +80,7 @@ public class GrailsClosureRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ hints.resources().registerPattern(PLUGIN_LISTING);
ClassLoader loader = (classLoader != null) ? classLoader : ClassUtils.getDefaultClassLoader();
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(loader);
MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver);
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHints.java
index 397c4bad57a..e223b90d6df 100644
--- a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHints.java
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHints.java
@@ -55,6 +55,11 @@
*
Read from the descriptors while the hints are written, so a module added later is covered
* without being named here, whether it belongs to the framework, a plugin or the application.
*
+ *
The descriptors themselves are carried into the image too, along with the table of methods
+ * Groovy adds to every type. Both are read as the runtime starts, and an image carries a resource
+ * only when it has been asked to: without them Groovy cannot build its metaclasses at all, and
+ * fails before any application code runs.
+ *
* @since 8.0
*/
public class GroovyExtensionModuleRuntimeHints implements RuntimeHintsRegistrar {
@@ -72,8 +77,22 @@ public class GroovyExtensionModuleRuntimeHints implements RuntimeHintsRegistrar
/** The two kinds of extension a descriptor names: one extends instances, the other the type. */
private static final String[] CLASS_PROPERTIES = { "extensionClasses", "staticExtensionClasses" };
+ /**
+ * What the Groovy runtime reads as it starts: the table of the methods it adds to every type,
+ * the version it reports, and the descriptors naming the extensions above.
+ */
+ private static final String[] RESOURCES = {
+ "META-INF/dgminfo",
+ "META-INF/groovy-release-info.properties",
+ "META-INF/services/" + DESCRIPTOR_NAME,
+ "META-INF/groovy/" + DESCRIPTOR_NAME
+ };
+
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (String resource : RESOURCES) {
+ hints.resources().registerPattern(resource);
+ }
ClassLoader loader = (classLoader != null) ? classLoader : ClassUtils.getDefaultClassLoader();
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(loader);
Set extensionClasses = new LinkedHashSet<>();
diff --git a/grails-core/src/main/resources/META-INF/native-image/org.apache.grails/grails-core/native-image.properties b/grails-core/src/main/resources/META-INF/native-image/org.apache.grails/grails-core/native-image.properties
new file mode 100644
index 00000000000..966754ebc3c
--- /dev/null
+++ b/grails-core/src/main/resources/META-INF/native-image/org.apache.grails/grails-core/native-image.properties
@@ -0,0 +1,30 @@
+#
+# 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.
+#
+# Both of these read their surroundings the first time they are touched, so an image that
+# initialized them while it was being built would answer for the machine that built it.
+#
+# - Groovy's plugin factory decides how it reaches the JDK it is running on, and an image
+# initialized at build time carries a decision made against the build's JDK. Nothing then
+# starts: its metaclass registry throws a NullPointerException before any application code runs.
+# - Grails' build settings resolve the project directory, which exists only where the build ran.
+#
+# Args rather than hints because when a class is initialized is a property of the image, and
+# nothing in the Spring hint model expresses it.
+Args = --initialize-at-run-time=org.codehaus.groovy.vmplugin.VMPluginFactory \
+ --initialize-at-run-time=grails.util.BuildSettings
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
index f33f20cad4a..64ed1b98f80 100644
--- a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
@@ -20,6 +20,7 @@ package org.grails.spring.beans.aot
import org.springframework.aot.hint.MemberCategory
import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates
import org.springframework.aot.hint.TypeReference
import spock.lang.Specification
@@ -66,6 +67,14 @@ class GrailsClosureRuntimeHintsSpec extends Specification {
}
}
+ void 'where the plugins are listed is carried'() {
+ given:
+ new GrailsClosureRuntimeHints().registerHints(hints, getClass().classLoader)
+
+ expect: 'read to find the plugins at all, and an image carries a resource only when asked'
+ RuntimeHintsPredicates.resource().forResource('META-INF/grails.factories').test(hints)
+ }
+
void 'a class loader that resolves nothing yields no hints rather than failing'() {
when:
new GrailsClosureRuntimeHints().registerHints(hints, new URLClassLoader(new URL[0], null))
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHintsSpec.groovy
index 39da074843d..d73e1b14617 100644
--- a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHintsSpec.groovy
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GroovyExtensionModuleRuntimeHintsSpec.groovy
@@ -21,6 +21,7 @@ package org.grails.spring.beans.aot
import org.springframework.aot.hint.MemberCategory
import org.springframework.aot.hint.RuntimeHints
import org.springframework.aot.hint.TypeReference
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates
import spock.lang.Specification
/**
@@ -87,6 +88,20 @@ class GroovyExtensionModuleRuntimeHintsSpec extends Specification {
registeredTypes().findAll { it.endsWith('Extension') }.every { invocable(it) }
}
+ void 'what the Groovy runtime reads as it starts is carried'() {
+ expect: 'without these it cannot build its metaclasses, and fails before any application code'
+ resourceRegistered('META-INF/dgminfo')
+ resourceRegistered('META-INF/groovy-release-info.properties')
+
+ and: 'and the descriptors naming the extensions, which is how it finds them'
+ resourceRegistered('META-INF/services/org.codehaus.groovy.runtime.ExtensionModule')
+ resourceRegistered('META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule')
+ }
+
+ private boolean resourceRegistered(String resource) {
+ RuntimeHintsPredicates.resource().forResource(resource).test(hints)
+ }
+
void 'a class loader that resolves nothing yields no hints rather than failing'() {
given:
RuntimeHints empty = new RuntimeHints()
From 7d2bad0f02ce528a6d15e57f20fb17b2c3fc8e4a Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 21:19:38 -0700
Subject: [PATCH 039/115] Colour the output of an image that is being run at a
shell
Spring Boot decides whether to colour its output by asking for the console, and an
image answers that it has none even when it has one. So the same application whose
start-up is coloured under bootRun arrives plain once it is built, for a reason
that has nothing to do with where it is running, and the only way to get the
colour back was to know to pass spring.output.ansi.enabled.
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 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. That is why the terminal is read rather than the colour
simply forced on.
An application that has said either way keeps what it said, and a JVM is left to
Spring Boot, where asking for the console works.
Verified on a native image run at a terminal with nothing passed to it: dim
timestamps, green level, magenta pid, as under bootRun.
(cherry picked from commit c5eb3e8d4e1acf59da151123b0dda2053238380c)
---
.../GrailsEnvironmentPostProcessor.java | 46 +++++++++
...ilsEnvironmentPostProcessorAnsiSpec.groovy | 96 +++++++++++++++++++
2 files changed, 142 insertions(+)
create mode 100644 grails-core/src/test/groovy/grails/boot/config/GrailsEnvironmentPostProcessorAnsiSpec.groovy
diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java b/grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java
index 2650bb300be..79648fb61cd 100644
--- a/grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java
+++ b/grails-core/src/main/groovy/grails/boot/config/GrailsEnvironmentPostProcessor.java
@@ -22,6 +22,7 @@
import java.util.Collection;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -32,6 +33,7 @@
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
+import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.Resource;
@@ -74,8 +76,15 @@ public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 15;
}
+ /** Where Spring Boot reads whether to colour its output. */
+ private static final String ANSI_ENABLED = "spring.output.ansi.enabled";
+
+ /** Set in an image, and in nothing else. */
+ private static final String IMAGE_CODE = "org.graalvm.nativeimage.imagecode";
+
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
+ colourTheOutputOfAnImageThatHasATerminal(environment);
try {
PluginDiscovery pluginDiscovery = bootstrapContext.get(PluginDiscovery.class);
if (pluginDiscovery == null) {
@@ -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.
+ *
+ *
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.
+ *
+ *
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.
+ */
+ void colourTheOutputOfAnImageThatHasATerminal(ConfigurableEnvironment environment) {
+ if (!isImage() || environment.containsProperty(ANSI_ENABLED)) {
+ return;
+ }
+ String terminal = terminal();
+ if (terminal == null || terminal.isEmpty() || "dumb".equals(terminal)) {
+ return;
+ }
+ environment.getPropertySources().addLast(new MapPropertySource("grails.ansi.output",
+ Map.of(ANSI_ENABLED, "always")));
+ }
+
+ /** Whether this is running as an image, which only a run can answer. */
+ protected boolean isImage() {
+ return System.getProperty(IMAGE_CODE) != null;
+ }
+
+ /** What the environment names as the terminal, if it names one. */
+ protected String terminal() {
+ return System.getenv("TERM");
+ }
+
/**
* Loads plugin configuration files ({@code plugin.yml} or {@code plugin.groovy})
* in the topologically sorted order and adds them to the environment's property sources.
diff --git a/grails-core/src/test/groovy/grails/boot/config/GrailsEnvironmentPostProcessorAnsiSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/GrailsEnvironmentPostProcessorAnsiSpec.groovy
new file mode 100644
index 00000000000..c4b4b76b268
--- /dev/null
+++ b/grails-core/src/test/groovy/grails/boot/config/GrailsEnvironmentPostProcessorAnsiSpec.groovy
@@ -0,0 +1,96 @@
+/*
+ * 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 grails.boot.config
+
+import org.springframework.boot.bootstrap.DefaultBootstrapContext
+import org.springframework.core.env.MapPropertySource
+import org.springframework.core.env.StandardEnvironment
+import spock.lang.Specification
+
+/**
+ * Covers an image colouring its output when it is being watched at a terminal.
+ *
+ *
Spring Boot decides by asking for the console, and an image answers that it has none even when
+ * it has one -- so the same application whose start-up is coloured under bootRun arrives plain once
+ * it is built. What the environment names as the terminal is read instead, which an image does
+ * carry. It cannot tell output being watched from output being redirected -- nothing in an image
+ * can -- but it does tell a shell from a build, a container or a service manager.
+ */
+class GrailsEnvironmentPostProcessorAnsiSpec extends Specification {
+
+ private static final String ANSI = 'spring.output.ansi.enabled'
+
+ StandardEnvironment environment = new StandardEnvironment()
+
+ private String ansiAfter(boolean image, String terminal) {
+ new Processor(image, terminal).colourTheOutputOfAnImageThatHasATerminal(environment)
+ environment.getProperty(ANSI)
+ }
+
+ void 'an image at a terminal colours its output'() {
+ expect:
+ ansiAfter(true, 'xterm-256color') == 'always'
+ }
+
+ void 'an image where nothing names a terminal stays plain'() {
+ expect: 'a build, a container or a service manager, whose output is only ever read later'
+ ansiAfter(true, null) == null
+ }
+
+ void 'a terminal that cannot colour is left alone'() {
+ expect:
+ ansiAfter(true, 'dumb') == null
+ }
+
+ void 'running on a JVM is left to Spring Boot'() {
+ expect: 'where asking for the console works, and answers for pipes too'
+ ansiAfter(false, 'xterm-256color') == null
+ }
+
+ void 'an application that has said either way keeps what it said'() {
+ given:
+ environment.propertySources.addFirst(new MapPropertySource('test', [(ANSI): 'never']))
+
+ expect:
+ ansiAfter(true, 'xterm-256color') == 'never'
+ }
+
+ /** Stands in for the two things only a run can answer. */
+ static class Processor extends GrailsEnvironmentPostProcessor {
+
+ private final boolean image
+ private final String terminal
+
+ Processor(boolean image, String terminal) {
+ super(new DefaultBootstrapContext())
+ this.image = image
+ this.terminal = terminal
+ }
+
+ @Override
+ protected boolean isImage() {
+ image
+ }
+
+ @Override
+ protected String terminal() {
+ terminal
+ }
+ }
+}
From 79f1dccc59bcdb6c734659472aa1987413c93ed0 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 21:51:30 -0700
Subject: [PATCH 040/115] Show the banner art in the framework's colour
The art is printed as it is read, so an application starting at a terminal that
colours everything else about its output has a plain banner above it.
AnsiOutput writes the escapes only where colour has been enabled, so a terminal
that cannot colour, a redirected log, and an application that has turned colour
off all print exactly what they printed before.
The width the versions beneath are centred on is measured before the colour is
added, since the escapes are characters the terminal does not show and would
otherwise push the versions off centre by as many as the colour cost. There were
no tests for the banner; there are now, including one that reads the versions back
with the escapes stripped and finds them where they were.
(cherry picked from commit 03aaac246aff1276ea148069c1d2b0e46a4145d4)
---
.../groovy/grails/boot/GrailsBanner.groovy | 17 +++-
.../grails/boot/GrailsBannerColourSpec.groovy | 89 +++++++++++++++++++
2 files changed, 105 insertions(+), 1 deletion(-)
create mode 100644 grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy
diff --git a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy
index 434245a18a4..77aebfe029d 100644
--- a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy
+++ b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy
@@ -24,6 +24,8 @@ import groovy.transform.stc.ClosureParams
import groovy.transform.stc.SimpleType
import org.springframework.boot.Banner
+import org.springframework.boot.ansi.AnsiColor
+import org.springframework.boot.ansi.AnsiOutput
import org.springframework.boot.SpringBootVersion
import org.springframework.core.SpringVersion
import org.springframework.core.env.Environment
@@ -63,8 +65,10 @@ class GrailsBanner implements Banner {
bannerPaddingTop.times { out.println() }
if (shouldDisplayArt(environment)) {
def art = createBannerArt(environment)
+ // measured before colouring, so the escapes do not count towards the width the
+ // versions below are centred on
bannerWidth = longestLineLength(art) ?: FALLBACK_BANNER_WIDTH
- out.println(art)
+ out.println(colour(art))
artPaddingBottom.times { out.println() }
}
if (shouldDisplayVersions(environment)) {
@@ -74,6 +78,17 @@ class GrailsBanner implements Banner {
bannerPaddingBottom.times { out.println() }
}
+ /**
+ * The art in the framework's colour, or as it stands where colour is off.
+ *
+ *
{@link AnsiOutput} writes the escapes only where it has been enabled, so this is the same
+ * string on a terminal that cannot colour, in a redirected log, and wherever an application has
+ * turned colour off.
+ */
+ protected String colour(String art) {
+ AnsiOutput.toString(AnsiColor.YELLOW, art, AnsiColor.DEFAULT)
+ }
+
/**
* Creates the banner art to be displayed.
*
diff --git a/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy b/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy
new file mode 100644
index 00000000000..23831ac5ea9
--- /dev/null
+++ b/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy
@@ -0,0 +1,89 @@
+/*
+ * 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 grails.boot
+
+import org.springframework.boot.ansi.AnsiColor
+import org.springframework.boot.ansi.AnsiOutput
+import org.springframework.core.env.StandardEnvironment
+import spock.lang.Specification
+
+/**
+ * Covers the banner art being shown in the framework's colour.
+ *
+ *
The escapes are written only where colour has been enabled, and the width the versions beneath
+ * are centred on is measured before they are added -- otherwise they would count towards it and
+ * push the versions off centre by as many characters as the colour cost.
+ */
+class GrailsBannerColourSpec extends Specification {
+
+ GrailsBanner banner = new GrailsBanner()
+
+ void cleanup() {
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.DETECT)
+ }
+
+ private String printed() {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream()
+ banner.printBanner(new StandardEnvironment(), GrailsBannerColourSpec, new PrintStream(bytes))
+ bytes.toString()
+ }
+
+ void 'the art is yellow where colour is on'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+
+ expect:
+ banner.colour('art').startsWith(AnsiOutput.encode(AnsiColor.YELLOW))
+ }
+
+ void 'the art is left as it stands where colour is off'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.NEVER)
+
+ expect: 'a redirected log, or an application that has turned colour off'
+ banner.colour('art') == 'art'
+ }
+
+ void 'the banner carries the colour through to what is printed'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+
+ expect:
+ printed().contains(AnsiOutput.encode(AnsiColor.YELLOW))
+ }
+
+ void 'the versions stay where they were before the art was coloured'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.NEVER)
+ List plain = versionLines(printed())
+
+ when:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+ List coloured = versionLines(printed().replaceAll(/\[[0-9;]*m/, ''))
+
+ then: 'the width is measured before the escapes are added, so they do not count towards it'
+ coloured == plain
+ !plain.isEmpty()
+ }
+
+ /** The centred lines beneath the art, which is where a mismeasured width would show. */
+ private List versionLines(String output) {
+ output.readLines().findAll { it.contains('Grails:') || it.contains('Spring') }
+ }
+}
From af29982470285faa1ad94b1bb00e2745c05ee09b Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 22:55:57 -0700
Subject: [PATCH 041/115] Let an application choose the colour the banner art
is shown in
The colour was the framework's to pick, and an application that wanted another --
or wanted the banner left alone while everything else about its output is
coloured -- had no way to say so.
grails.banner.art.color takes a number for one of the 256 colours a terminal
offers, a name for one of the eight it has always had, or none to leave the art as
it stands. It sits beside grails.banner.art.file, which is where an application
already goes to change the art itself.
The default is amber rather than the plain yellow it was: one of the 256, chosen
to read as the framework's own rather than as a terminal's idea of yellow.
A value that is neither a number nor a name falls back to the default. Failing to
start over the colour of a banner would be a poor trade, and the banner itself
shows plainly that the value did not take.
(cherry picked from commit 6d2b7a9eecdaa0db6c7072483243fa2e2b0ea336)
---
.../groovy/grails/boot/GrailsBanner.groovy | 48 ++++++++++++---
.../grails/boot/GrailsBannerColourSpec.groovy | 59 +++++++++++++++++--
2 files changed, 95 insertions(+), 12 deletions(-)
diff --git a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy
index 77aebfe029d..3ad9004f1d1 100644
--- a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy
+++ b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy
@@ -24,7 +24,9 @@ import groovy.transform.stc.ClosureParams
import groovy.transform.stc.SimpleType
import org.springframework.boot.Banner
+import org.springframework.boot.ansi.Ansi8BitColor
import org.springframework.boot.ansi.AnsiColor
+import org.springframework.boot.ansi.AnsiElement
import org.springframework.boot.ansi.AnsiOutput
import org.springframework.boot.SpringBootVersion
import org.springframework.core.SpringVersion
@@ -45,6 +47,13 @@ class GrailsBanner implements Banner {
private static final int FALLBACK_BANNER_WIDTH = 0
private static final String DEFAULT_BANNER_FILE = 'grails-banner.txt'
+ private static final String ART_COLOR_PROPERTY = 'grails.banner.art.color'
+
+ /** One of the 256 colours a terminal offers: the amber the framework is shown in. */
+ private static final String DEFAULT_ART_COLOR = '214'
+
+ private static final String NO_COLOR = 'none'
+
String bannerFile = DEFAULT_BANNER_FILE
int bannerPaddingTop = 1
int bannerPaddingBottom = 1
@@ -68,7 +77,7 @@ class GrailsBanner implements Banner {
// measured before colouring, so the escapes do not count towards the width the
// versions below are centred on
bannerWidth = longestLineLength(art) ?: FALLBACK_BANNER_WIDTH
- out.println(colour(art))
+ out.println(colour(art, environment))
artPaddingBottom.times { out.println() }
}
if (shouldDisplayVersions(environment)) {
@@ -79,14 +88,39 @@ class GrailsBanner implements Banner {
}
/**
- * The art in the framework's colour, or as it stands where colour is off.
+ * The art in the configured colour, or as it stands where none is wanted.
*
- *
{@link AnsiOutput} writes the escapes only where it has been enabled, so this is the same
- * string on a terminal that cannot colour, in a redirected log, and wherever an application has
- * turned colour off.
+ *
{@link AnsiOutput} writes the escapes only where colour has been enabled, so this is the
+ * same string on a terminal that cannot colour, in a redirected log, and wherever an application
+ * has turned colour off.
*/
- protected String colour(String art) {
- AnsiOutput.toString(AnsiColor.YELLOW, art, AnsiColor.DEFAULT)
+ protected String colour(String art, Environment environment) {
+ AnsiElement colour = resolveArtColour(environment)
+ colour == null ? art : AnsiOutput.toString(colour, art, AnsiColor.DEFAULT)
+ }
+
+ /**
+ * The colour to show the art in, read from {@code grails.banner.art.color}.
+ *
+ *
Takes a number for one of the 256 colours a terminal offers, a name for one of the eight
+ * it has always had ({@code red}, {@code bright_blue}), or {@code none} to leave the art as it
+ * stands. A value that is neither falls back to the default rather than failing to start over
+ * the colour of a banner.
+ */
+ protected AnsiElement resolveArtColour(Environment environment) {
+ String configured = environment.getProperty(ART_COLOR_PROPERTY, String, DEFAULT_ART_COLOR)
+ if (!configured || configured.equalsIgnoreCase(NO_COLOR)) {
+ return null
+ }
+ if (configured.isInteger()) {
+ return Ansi8BitColor.foreground(configured.toInteger())
+ }
+ try {
+ return AnsiColor.valueOf(configured.toUpperCase())
+ }
+ catch (IllegalArgumentException ignored) {
+ return Ansi8BitColor.foreground(DEFAULT_ART_COLOR.toInteger())
+ }
}
/**
diff --git a/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy b/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy
index 23831ac5ea9..a7610849731 100644
--- a/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy
+++ b/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy
@@ -18,8 +18,10 @@
*/
package grails.boot
+import org.springframework.boot.ansi.Ansi8BitColor
import org.springframework.boot.ansi.AnsiColor
import org.springframework.boot.ansi.AnsiOutput
+import org.springframework.core.env.MapPropertySource
import org.springframework.core.env.StandardEnvironment
import spock.lang.Specification
@@ -34,22 +36,30 @@ class GrailsBannerColourSpec extends Specification {
GrailsBanner banner = new GrailsBanner()
+ StandardEnvironment environment = new StandardEnvironment()
+
void cleanup() {
AnsiOutput.setEnabled(AnsiOutput.Enabled.DETECT)
}
+ private void configured(String colour) {
+ environment.propertySources.addFirst(
+ new MapPropertySource('test', ['grails.banner.art.color': colour]))
+ }
+
private String printed() {
ByteArrayOutputStream bytes = new ByteArrayOutputStream()
- banner.printBanner(new StandardEnvironment(), GrailsBannerColourSpec, new PrintStream(bytes))
+ banner.printBanner(environment, GrailsBannerColourSpec, new PrintStream(bytes))
bytes.toString()
}
- void 'the art is yellow where colour is on'() {
+ void 'the art is the framework amber where nothing is configured'() {
given:
AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
expect:
- banner.colour('art').startsWith(AnsiOutput.encode(AnsiColor.YELLOW))
+ banner.colour('art', environment)
+ .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214)))
}
void 'the art is left as it stands where colour is off'() {
@@ -57,7 +67,46 @@ class GrailsBannerColourSpec extends Specification {
AnsiOutput.setEnabled(AnsiOutput.Enabled.NEVER)
expect: 'a redirected log, or an application that has turned colour off'
- banner.colour('art') == 'art'
+ banner.colour('art', environment) == 'art'
+ }
+
+ void 'an application chooses one of the 256 colours by number'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+ configured('45')
+
+ expect:
+ banner.colour('art', environment)
+ .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(45)))
+ }
+
+ void 'an application chooses one of the eight by name'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+ configured('bright_blue')
+
+ expect: 'read without regard to case, as configuration is written either way'
+ banner.colour('art', environment)
+ .startsWith(AnsiOutput.encode(AnsiColor.BRIGHT_BLUE))
+ }
+
+ void 'an application asks for no colour at all'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+ configured('none')
+
+ expect: 'colour everywhere else, and a banner left as it stands'
+ banner.colour('art', environment) == 'art'
+ }
+
+ void 'a colour that means nothing falls back rather than failing to start'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+ configured('chartreuse')
+
+ expect:
+ banner.colour('art', environment)
+ .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214)))
}
void 'the banner carries the colour through to what is printed'() {
@@ -65,7 +114,7 @@ class GrailsBannerColourSpec extends Specification {
AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
expect:
- printed().contains(AnsiOutput.encode(AnsiColor.YELLOW))
+ printed().contains(AnsiOutput.encode(Ansi8BitColor.foreground(214)))
}
void 'the versions stay where they were before the art was coloured'() {
From 181ac758a90d90fbc239d822889827a3ea273dfe Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 23:34:36 -0700
Subject: [PATCH 042/115] Address review: metadata only for a native build, and
four smaller things
Generating the native metadata was wired into processResources, so a build that
only wanted to write a resource compiled its sources and resolved its whole
runtime classpath first -- which a project that had declared no repositories could
not do. Four of the Gradle plugin's own tests failed on it. The metadata is read
by nothing but a native image, so it is generated only where one is being built.
A tag library the application had already declared was kept but still altered: any
existing definition with no autowiring had by-name autowiring put back on it,
which cannot tell a generated definition from one an application deliberately
declared that way. What a generated definition needs is carried into it while it
is generated, so nothing has to be put back afterwards, and an existing definition
is now left exactly as it was found.
A banner colour given as a number was passed on whatever it was, so -1 or 999
wrote an escape the terminal does not understand and then showed it in the banner.
Numbers are held to the 256 a terminal has, and anything else falls back.
grails.banner.art.color is documented beside the rest of the banner configuration
and declared in the configuration metadata, so an IDE offers it and says what it
takes.
The Hibernate datastore held its event publisher rather than referencing one, and
its connection-source definitions declared what they produce in a way that reads
back as a mismatch: a supplier typed for the DataSource that builds the factory
bean which produces it. Neither could be expressed as generated code, so a
Hibernate application could not be built ahead of time at all -- the first fails
generation, the second fails to compile what generation wrote.
(cherry picked from commit 4f8c2c99cee33bbf32f0c586349fe31da1a32909)
---
.../groovy/grails/boot/GrailsBanner.groovy | 11 +++++-
.../beans/aot/BeanRegistrarRuntimeHints.java | 14 ++++++-
...itional-spring-configuration-metadata.json | 38 ++++++++++++++-----
.../grails/boot/GrailsBannerColourSpec.groovy | 18 +++++++++
.../aot/BeanRegistrarRuntimeHintsSpec.groovy | 11 ++++++
...DatastoreConnectionSourcesRegistrar.groovy | 7 ++--
...HibernateDatastoreSpringInitializer.groovy | 16 +++++++-
.../conf/applicationClass/customizing.adoc | 8 ++++
.../plugin/core/GrailsGradlePlugin.groovy | 21 +++++++++-
.../TagLibBeanDefinitionsPostProcessor.groovy | 28 ++------------
...LibBeanDefinitionsPostProcessorSpec.groovy | 25 ++++++------
11 files changed, 143 insertions(+), 54 deletions(-)
diff --git a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy
index 3ad9004f1d1..73e5c268a03 100644
--- a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy
+++ b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy
@@ -113,16 +113,23 @@ class GrailsBanner implements Banner {
return null
}
if (configured.isInteger()) {
- return Ansi8BitColor.foreground(configured.toInteger())
+ int code = configured.toInteger()
+ // A terminal offers 256 of them, and anything else writes an escape it does not
+ // understand -- which shows as the escape itself, printed into the banner.
+ return code in 0..255 ? Ansi8BitColor.foreground(code) : defaultArtColour()
}
try {
return AnsiColor.valueOf(configured.toUpperCase())
}
catch (IllegalArgumentException ignored) {
- return Ansi8BitColor.foreground(DEFAULT_ART_COLOR.toInteger())
+ return defaultArtColour()
}
}
+ private AnsiElement defaultArtColour() {
+ Ansi8BitColor.foreground(DEFAULT_ART_COLOR.toInteger())
+ }
+
/**
* Creates the banner art to be displayed.
*
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHints.java
index 1b7400422c6..48ce66481b9 100644
--- a/grails-core/src/main/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHints.java
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHints.java
@@ -23,6 +23,9 @@
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.beans.factory.BeanRegistrar;
import org.springframework.beans.factory.BeanRegistry;
+import org.springframework.beans.factory.ListableBeanFactory;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.lang.Nullable;
/**
@@ -37,6 +40,10 @@
* first call and the context does not start, reporting a method of an interface that appears nowhere
* in the application -- a plugin declaring a bean with a specification, which most of them do.
*
+ *
The same is true of the registry the older bean DSL is handed: a plugin that still declares
+ * its beans that way asks whether one is already registered, and that call is made the same
+ * reflective way. It failed later than the others, once a datastore came to be configured.
+ *
*
The types are named here rather than scanned for, because they belong to Spring and are few.
*
* @since 8.0
@@ -48,7 +55,12 @@ public class BeanRegistrarRuntimeHints implements RuntimeHintsRegistrar {
BeanRegistrar.class,
BeanRegistry.class,
BeanRegistry.Spec.class,
- BeanRegistry.SupplierContext.class
+ BeanRegistry.SupplierContext.class,
+ // The registry the older bean DSL is handed, which the plugins that still use it call the
+ // same way: asking whether a bean is already there, registering one, naming an alias.
+ BeanDefinitionRegistry.class,
+ ListableBeanFactory.class,
+ ConfigurableListableBeanFactory.class
};
@Override
diff --git a/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
index ec339dce9a7..9ceecec922d 100644
--- a/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
+++ b/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -2,15 +2,21 @@
"properties": [
{
"name": "grails.banner.art.display",
- "description": "Whether to display the Grails banner art.",
+ "description": "Whether to display the Grails banner art.",
"type": "java.lang.Boolean",
"defaultValue": true
},
{
- "name": "grails.banner.art.file",
- "description": "The file path on the classpath to the Grails banner art to display.",
- "type": "java.lang.String",
- "defaultValue": "grails-banner.txt"
+ "name": "grails.banner.art.file",
+ "description": "The file path on the classpath to the Grails banner art to display.",
+ "type": "java.lang.String",
+ "defaultValue": "grails-banner.txt"
+ },
+ {
+ "name": "grails.banner.art.color",
+ "type": "java.lang.String",
+ "description": "The colour to show the banner art in, where the terminal colours output at all. A number selects one of the 256 colours a terminal offers; a name selects one of the eight it has always had, such as red or bright_blue; none leaves the art uncoloured. A value that is neither falls back to the default.",
+ "defaultValue": "214"
},
{
"name": "grails.banner.versions.display",
@@ -56,11 +62,23 @@
{
"name": "grails.i18n.localeResolver",
"values": [
- { "value": "session", "description": "Resolve the locale from the HTTP session (mutable; ?lang= switching works). The default." },
- { "value": "cookie", "description": "Resolve the locale from a cookie (mutable; ?lang= switching works)." },
- { "value": "acceptHeader", "description": "Resolve the locale from the Accept-Language header (read-only; ?lang= is ignored)." },
- { "value": "fixed", "description": "Use a fixed locale from grails.i18n.default.locale, falling back to the JVM default (read-only; ?lang= is ignored)." }
+ {
+ "value": "session",
+ "description": "Resolve the locale from the HTTP session (mutable; ?lang= switching works). The default."
+ },
+ {
+ "value": "cookie",
+ "description": "Resolve the locale from a cookie (mutable; ?lang= switching works)."
+ },
+ {
+ "value": "acceptHeader",
+ "description": "Resolve the locale from the Accept-Language header (read-only; ?lang= is ignored)."
+ },
+ {
+ "value": "fixed",
+ "description": "Use a fixed locale from grails.i18n.default.locale, falling back to the JVM default (read-only; ?lang= is ignored)."
+ }
]
}
]
-}
\ No newline at end of file
+}
diff --git a/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy b/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy
index a7610849731..13321c4ae2c 100644
--- a/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy
+++ b/grails-core/src/test/groovy/grails/boot/GrailsBannerColourSpec.groovy
@@ -109,6 +109,24 @@ class GrailsBannerColourSpec extends Specification {
.startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214)))
}
+ void 'a number outside the 256 a terminal has falls back'() {
+ given:
+ AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
+
+ expect: 'anything else writes an escape the terminal does not understand, which it then shows'
+ banner.colour('art', configuredWith('999'))
+ .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214)))
+ banner.colour('art', configuredWith('-1'))
+ .startsWith(AnsiOutput.encode(Ansi8BitColor.foreground(214)))
+ }
+
+ /** A fresh environment each time, so the two values above do not fight over one property source. */
+ private StandardEnvironment configuredWith(String colour) {
+ StandardEnvironment fresh = new StandardEnvironment()
+ fresh.propertySources.addFirst(new MapPropertySource('test', ['grails.banner.art.color': colour]))
+ fresh
+ }
+
void 'the banner carries the colour through to what is printed'() {
given:
AnsiOutput.setEnabled(AnsiOutput.Enabled.ALWAYS)
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHintsSpec.groovy
index 86af0cf6600..e0204de73ca 100644
--- a/grails-core/src/test/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHintsSpec.groovy
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/BeanRegistrarRuntimeHintsSpec.groovy
@@ -26,6 +26,9 @@ import org.springframework.aot.hint.TypeHint
import org.springframework.aot.hint.TypeReference
import org.springframework.beans.factory.BeanRegistrar
import org.springframework.beans.factory.BeanRegistry
+import org.springframework.beans.factory.ListableBeanFactory
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory
+import org.springframework.beans.factory.support.BeanDefinitionRegistry
import spock.lang.Specification
/**
@@ -63,6 +66,14 @@ class BeanRegistrarRuntimeHintsSpec extends Specification {
hintFor(BeanRegistrar)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS)
}
+ void 'the registry the older bean DSL is handed can be called'() {
+ expect: 'a plugin that still declares its beans that way asks whether one is already there'
+ hintFor(BeanDefinitionRegistry)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ hintFor(ListableBeanFactory)?.memberCategories?.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ hintFor(ConfigurableListableBeanFactory)?.memberCategories
+ ?.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
void 'the call a plugin actually makes is covered'() {
given: 'the three-argument form, which is what a bean with a specification uses'
def registerBean = BeanRegistry.getMethod('registerBean', String, Class, Consumer)
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/support/HibernateDatastoreConnectionSourcesRegistrar.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/support/HibernateDatastoreConnectionSourcesRegistrar.groovy
index 231da37c875..495b1c9367e 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/support/HibernateDatastoreConnectionSourcesRegistrar.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/support/HibernateDatastoreConnectionSourcesRegistrar.groovy
@@ -31,6 +31,7 @@ import org.springframework.beans.factory.config.ConstructorArgumentValues
import org.springframework.beans.factory.support.BeanDefinitionRegistry
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor
import org.springframework.beans.factory.support.RootBeanDefinition
+import org.springframework.core.ResolvableType
import org.springframework.core.Ordered
import org.springframework.transaction.PlatformTransactionManager
@@ -63,7 +64,7 @@ class HibernateDatastoreConnectionSourcesRegistrar implements BeanDefinitionRegi
if (!registry.containsBeanDefinition(dataSourceBeanName) && shouldConfigureDataSourceBean) {
def dataSourceBean = new RootBeanDefinition()
- dataSourceBean.setTargetType(DataSource)
+ dataSourceBean.setTargetType(ResolvableType.forClassWithGenerics(InstanceFactoryBean, DataSource))
dataSourceBean.setBeanClass(InstanceFactoryBean)
def args = new ConstructorArgumentValues()
String spel = "#{dataSourceConnectionSourceFactory.create('$dataSourceName', environment).source}".toString()
@@ -80,7 +81,7 @@ class HibernateDatastoreConnectionSourcesRegistrar implements BeanDefinitionRegi
String transactionManagerBeanName = "transactionManager$suffix"
def sessionFactoryBean = new RootBeanDefinition()
- sessionFactoryBean.setTargetType(SessionFactory)
+ sessionFactoryBean.setTargetType(ResolvableType.forClassWithGenerics(InstanceFactoryBean, SessionFactory))
sessionFactoryBean.setBeanClass(InstanceFactoryBean)
def args = new ConstructorArgumentValues()
args.addGenericArgumentValue("#{hibernateDatastore.getDatastoreForConnection('$dataSourceName').sessionFactory}".toString())
@@ -93,7 +94,7 @@ class HibernateDatastoreConnectionSourcesRegistrar implements BeanDefinitionRegi
)
def transactionManagerBean = new RootBeanDefinition()
- transactionManagerBean.setTargetType(PlatformTransactionManager)
+ transactionManagerBean.setTargetType(ResolvableType.forClassWithGenerics(InstanceFactoryBean, PlatformTransactionManager))
transactionManagerBean.setBeanClass(InstanceFactoryBean)
def txMgrArgs = new ConstructorArgumentValues()
txMgrArgs.addGenericArgumentValue("#{hibernateDatastore.getDatastoreForConnection('$dataSourceName').transactionManager}".toString())
diff --git a/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy b/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy
index bcd83fd0f9e..2be5b60d9d3 100644
--- a/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy
+++ b/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy
@@ -22,11 +22,14 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistry
import grails.spring.BeanBuilder
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationEventPublisher
+import org.springframework.context.ConfigurableApplicationContext
import org.springframework.context.support.GenericApplicationContext
import org.springframework.core.env.ConfigurableEnvironment
import org.springframework.core.env.PropertyResolver
import org.springframework.transaction.PlatformTransactionManager
+import org.grails.datastore.gorm.events.ConfigurableApplicationContextEventPublisher
+import org.grails.datastore.gorm.events.DefaultApplicationEventPublisher
import org.grails.datastore.gorm.bootstrap.AbstractDatastoreInitializer
import org.grails.datastore.gorm.jdbc.connections.CachedDataSourceConnectionSourceFactory
import org.grails.datastore.gorm.support.AbstractDatastorePersistenceContextInterceptor
@@ -168,6 +171,17 @@ class HibernateDatastoreSpringInitializer extends AbstractDatastoreInitializer {
Object configurationReference = configurationReference(beanDefinitionRegistry)
final boolean isGrailsPresent = isGrailsPresent()
def appContext = this.applicationContext
+ // 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.
+ if (beanDefinitionRegistry instanceof ConfigurableApplicationContext
+ || resourcePatternResolver.resourceLoader instanceof ConfigurableApplicationContext) {
+ grailsDatastoreEventPublisher(ConfigurableApplicationContextEventPublisher)
+ }
+ else {
+ grailsDatastoreEventPublisher(DefaultApplicationEventPublisher)
+ }
dataSourceConnectionSourceFactory(CachedDataSourceConnectionSourceFactory)
hibernateConnectionSourceFactory(HibernateConnectionSourceFactory, ref('hibernateBytecodeProvider'), persistentClasses as Class[]) { bean ->
bean.autowire = true
@@ -176,7 +190,7 @@ class HibernateDatastoreSpringInitializer extends AbstractDatastoreInitializer {
applicationContext = appContext
}
}
- hibernateDatastore(HibernateDatastore, configurationReference, hibernateConnectionSourceFactory, eventPublisher) { bean ->
+ hibernateDatastore(HibernateDatastore, configurationReference, hibernateConnectionSourceFactory, ref('grailsDatastoreEventPublisher')) { bean ->
bean.primary = true
}
sessionFactory(hibernateDatastore: 'getSessionFactory') { bean ->
diff --git a/grails-doc/src/en/guide/conf/applicationClass/customizing.adoc b/grails-doc/src/en/guide/conf/applicationClass/customizing.adoc
index 246639c6a62..359444fb1e0 100644
--- a/grails-doc/src/en/guide/conf/applicationClass/customizing.adoc
+++ b/grails-doc/src/en/guide/conf/applicationClass/customizing.adoc
@@ -93,6 +93,14 @@ grails:
art:
display: true # Whether to display the banner art (default: true).
file: 'banner.txt' # Path to a custom banner file on the classpath (default: null).
+ # The colour to show the art in, where the terminal colours output at all (default: 214).
+ # A number selects one of the 256 colours a terminal offers; a name selects one of the
+ # eight it has always had; 'none' leaves the art uncoloured. A value that is neither
+ # falls back to the default. Named colours are:
+ # black, red, green, yellow, blue, magenta, cyan, white
+ # bright_black, bright_red, bright_green, bright_yellow,
+ # bright_blue, bright_magenta, bright_cyan, bright_white
+ color: 214
versions:
display: true # Whether to display version information (default: true).
include:
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy
index d5223b25c52..df4f7f78c0d 100644
--- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy
@@ -84,6 +84,8 @@ import javax.inject.Inject
*/
@CompileStatic
class GrailsGradlePlugin implements Plugin {
+ private static final String NATIVE_IMAGE_PLUGIN = 'org.graalvm.buildtools.native'
+
private static final String CLI_PID_FILE_PROPERTY = 'grails.cli.pid.file'
private static final String RUN_APP_PID_FILE_NAME = 'run-app.pid'
@@ -1124,6 +1126,17 @@ ${importStatements}
* 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 metadataTask = project.tasks.register(
@@ -1141,7 +1154,13 @@ ${importStatements}
}
task.classesDirs.from(sourceSet.output.classesDirs)
task.pageClassesDirs.from(project.layout.buildDirectory.dir('gsp-classes/main'))
- task.pageClasspath.from(project.configurations.named('runtimeClasspath'))
+ // Leniently: the pages compiled into dependencies are worth finding, but not at the
+ // price of making every build that writes a resource resolve the whole runtime
+ // classpath first. A dependency that cannot be resolved contributes no pages rather
+ // than failing a build that never asked for a native image.
+ task.pageClasspath.from(project.configurations.named('runtimeClasspath').map { conf ->
+ conf.incoming.artifactView { view -> view.lenient(true) }.files
+ })
task.outputDirectory.set(project.layout.buildDirectory.dir('generated-resources/grails-native'))
}
diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessor.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessor.groovy
index 7c7f3268c0e..111f436141e 100644
--- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessor.groovy
+++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessor.groovy
@@ -21,7 +21,6 @@ package org.grails.plugins.web
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
-import org.springframework.beans.factory.config.BeanDefinition
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory
import org.springframework.beans.factory.support.AbstractBeanDefinition
import org.springframework.beans.factory.support.BeanDefinitionRegistry
@@ -47,8 +46,10 @@ import org.grails.core.artefact.gsp.TagLibArtefactHandler
*
*
The artefacts are read from the application rather than named individually, so a tag library
* belongs to whoever declared it: the application, another plugin, or one supplied through
- * {@code providedArtefacts}. An existing definition for the same name wins, which preserves the
- * ability to override a tag library.
+ * {@code providedArtefacts}. An existing definition for the same name wins untouched, which
+ * preserves the ability to override a tag library -- including one deliberately declared without
+ * by-name autowiring, which cannot be told apart from a generated one. What a generated definition
+ * needs is carried into it while it is generated, by the processor that reads the autowire mode.
*
* @since 8.0
*/
@@ -71,7 +72,6 @@ class TagLibBeanDefinitionsPostProcessor implements BeanDefinitionRegistryPostPr
continue
}
if (registry.containsBeanDefinition(beanName)) {
- restoreAutowiringByName(registry.getBeanDefinition(beanName), beanName)
continue
}
GenericBeanDefinition definition = new GenericBeanDefinition(
@@ -84,26 +84,6 @@ class TagLibBeanDefinitionsPostProcessor implements BeanDefinitionRegistryPostPr
}
}
- /**
- * Restores by-name autowiring on a definition that already exists.
- *
- *
A tag library takes some of its collaborators by name rather than by annotation, and
- * ahead-of-time processing does not carry that over: it generates the injection it can see from
- * the annotations and leaves the mode at none, so a message source or an asset resolver arrives
- * null. The mode is only ever raised, never lowered, so a definition that asks for something
- * else keeps it.
- */
- private void restoreAutowiringByName(BeanDefinition existing, String beanName) {
- if (!(existing instanceof AbstractBeanDefinition)) {
- return
- }
- AbstractBeanDefinition definition = (AbstractBeanDefinition) existing
- if (definition.autowireMode == AbstractBeanDefinition.AUTOWIRE_NO) {
- definition.autowireMode = AbstractBeanDefinition.AUTOWIRE_BY_NAME
- log.debug('Restored autowiring by name on tag library {}', beanName)
- }
- }
-
@Override
void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
}
diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessorSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessorSpec.groovy
index ff1d63b8d07..591e36bc703 100644
--- a/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessorSpec.groovy
+++ b/grails-gsp/plugin/src/test/groovy/org/grails/plugins/web/TagLibBeanDefinitionsPostProcessorSpec.groovy
@@ -80,30 +80,31 @@ class TagLibBeanDefinitionsPostProcessorSpec extends Specification {
registry.getBeanDefinition(DemoTagLib.name).is(generated)
}
- void 'autowiring by name is restored on a definition that lost it'() {
- given: 'the generator does not write the mode out, so it arrives as none'
- RootBeanDefinition generated = new RootBeanDefinition(DemoTagLib)
- generated.autowireMode = AbstractBeanDefinition.AUTOWIRE_NO
- registry.registerBeanDefinition(DemoTagLib.name, generated)
+ void 'a definition that declares no autowiring is left declaring none'() {
+ given: 'an application that deliberately took by-name autowiring off its own tag library'
+ RootBeanDefinition declared = new RootBeanDefinition(DemoTagLib)
+ declared.autowireMode = AbstractBeanDefinition.AUTOWIRE_NO
+ registry.registerBeanDefinition(DemoTagLib.name, declared)
when:
process()
- then:
- generated.autowireMode == AbstractBeanDefinition.AUTOWIRE_BY_NAME
+ then: 'which cannot be told apart from a generated one, so neither is touched -- what a ' +
+ 'generated definition needs is carried into it while it is generated'
+ declared.autowireMode == AbstractBeanDefinition.AUTOWIRE_NO
}
void 'a definition asking for something else keeps it'() {
given:
- RootBeanDefinition generated = new RootBeanDefinition(DemoTagLib)
- generated.autowireMode = AbstractBeanDefinition.AUTOWIRE_BY_TYPE
- registry.registerBeanDefinition(DemoTagLib.name, generated)
+ RootBeanDefinition declared = new RootBeanDefinition(DemoTagLib)
+ declared.autowireMode = AbstractBeanDefinition.AUTOWIRE_BY_TYPE
+ registry.registerBeanDefinition(DemoTagLib.name, declared)
when:
process()
- then: 'the mode is only ever raised, never lowered'
- generated.autowireMode == AbstractBeanDefinition.AUTOWIRE_BY_TYPE
+ then:
+ declared.autowireMode == AbstractBeanDefinition.AUTOWIRE_BY_TYPE
}
/** Recognised as a tag library by its name, which is what the artefact handler reads. */
From 7687f84639d9db222bcbe20bb781bafb538bb857 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 23:42:29 -0700
Subject: [PATCH 043/115] Leave a datastore's proxy handler alone
The core plugin registers a proxy handler for an application that has none, and
these registrars run after the doWithSpring drain -- so it was registered over the
one a GORM implementation had already declared, which is the one that knows how to
unwrap that datastore's proxies. A Hibernate application was left unwrapping
Hibernate proxies with the general case, and said so on every start by reporting
proxyHandler as an overridden definition.
It is registered only where nothing has declared one, the way the resource locator
already backs off to the one GSP declares.
(cherry picked from commit 2476b1b5b012964f817e34c30dca672c0984aea2)
---
.../grails/plugins/CoreGrailsPlugin.groovy | 21 +++++++++++++---
.../plugins/CoreGrailsPluginAotSpec.groovy | 24 +++++++++++++++++++
2 files changed, 42 insertions(+), 3 deletions(-)
diff --git a/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy b/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy
index 7cbdfedf6eb..bb7668aa486 100644
--- a/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy
+++ b/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy
@@ -131,10 +131,20 @@ class CoreGrailsPlugin extends Plugin {
* none, and is what the plugin's own processor is for.
*/
private static boolean hasConfigurationClassPostProcessor(GrailsApplication application) {
+ hasBeanDefinition(application, AnnotationConfigUtils.CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME)
+ }
+
+ /**
+ * Whether the context already has a definition under this name.
+ *
+ *
These registrars run after the {@code doWithSpring} drain, so a plugin that has already
+ * declared a bean under one of these names has declared the one that should stand: registering
+ * over it replaces something chosen for the application with the general case.
+ */
+ private static boolean hasBeanDefinition(GrailsApplication application, String beanName) {
ApplicationContext context = application.mainContext
context instanceof ConfigurableApplicationContext &&
- ((ConfigurableApplicationContext) context).beanFactory.containsBeanDefinition(
- AnnotationConfigUtils.CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME)
+ ((ConfigurableApplicationContext) context).beanFactory.containsBeanDefinition(beanName)
}
/**
@@ -218,7 +228,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')) {
+ registry.registerBean('proxyHandler', DefaultProxyHandler)
+ }
// an abstract parent definition, which registerBean cannot express since it always
// takes a class; third-party plugins inherit their search locations from it
diff --git a/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy b/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy
index 8c1d98f2c74..32395f089a5 100644
--- a/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy
+++ b/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginAotSpec.groovy
@@ -25,6 +25,7 @@ import org.springframework.aot.generate.InMemoryGeneratedFiles
import org.springframework.beans.factory.BeanRegistrar
import org.springframework.beans.factory.support.BeanRegistryAdapter
import org.springframework.beans.factory.support.GenericBeanDefinition
+import org.springframework.beans.factory.support.RootBeanDefinition
import org.springframework.context.annotation.AnnotationConfigUtils
import org.springframework.context.aot.ApplicationContextAotGenerator
import org.springframework.context.support.GenericApplicationContext
@@ -118,6 +119,29 @@ class CoreGrailsPluginAotSpec extends Specification {
context.containsBeanDefinition(AnnotationConfigUtils.CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME)
}
+ void "a datastore's own proxy handler is not replaced"() {
+ given: 'what a GORM implementation declares from doWithSpring, which runs earlier'
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false')
+ context.registerBeanDefinition('proxyHandler', new RootBeanDefinition(String))
+
+ when:
+ applyCorePluginRegistrar()
+
+ then: 'it knows how to unwrap that datastore\'s proxies, and this one does not'
+ context.getBeanDefinition('proxyHandler').beanClassName == String.name
+ }
+
+ void 'the proxy handler is registered where no datastore declared one'() {
+ given:
+ SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false')
+
+ when:
+ applyCorePluginRegistrar()
+
+ then:
+ context.containsBeanDefinition('proxyHandler')
+ }
+
void 'the core plugin bean definitions can be generated ahead of time'() {
given:
SpringProperties.setProperty(AotDetector.AOT_ENABLED, 'false')
From 813e1918da17f38fd61868a6984057426deb4447 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Thu, 6 Aug 2026 23:50:36 -0700
Subject: [PATCH 044/115] Let an image call the framework's own interfaces
A plugin descriptor is Groovy written largely without static compilation, so
reading a setting, asking the application about its artefacts, or asking the
plugin manager about a plugin are all resolved where the call is written rather
than when the descriptor is compiled -- and are therefore made reflectively.
An image keeps a method for that only when asked, and an application never names
these itself: they are the framework's, called from the framework's own
descriptors. So the image refused the call and reported a method of an interface
appearing nowhere in the application. The one that stopped a context from starting
was ConfigMap.getProperty, which is how nearly every plugin reads its settings.
Only the interfaces are named. What implements them is reached through them, and
registering the implementations would be registering most of the framework.
(cherry picked from commit 3d18e3417f543c99aa6d8a056c5fc678d5e5ad7d)
---
.../beans/aot/GrailsApiRuntimeHints.java | 70 +++++++++++++++++
.../resources/META-INF/spring/aot.factories | 1 +
.../aot/GrailsApiRuntimeHintsSpec.groovy | 77 +++++++++++++++++++
3 files changed, 148 insertions(+)
create mode 100644 grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java
create mode 100644 grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java
new file mode 100644
index 00000000000..517f21a6e57
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java
@@ -0,0 +1,70 @@
+/*
+ * 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 org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.lang.Nullable;
+
+import grails.config.Config;
+import grails.config.ConfigMap;
+import grails.core.GrailsApplication;
+import grails.core.GrailsClass;
+import grails.plugins.GrailsPlugin;
+import grails.plugins.GrailsPluginManager;
+
+/**
+ * Registers the framework's own interfaces, which a plugin reaches through dynamically.
+ *
+ *
A plugin descriptor is Groovy, and much of it is written without static compilation -- so
+ * reading a configuration value, asking the application for its artefacts, or asking the plugin
+ * manager about a plugin are all calls resolved where they are written rather than when the
+ * descriptor is compiled. Each is therefore made reflectively.
+ *
+ *
An image keeps a method for that only when something has asked it to, and an application never
+ * names these itself: they are the framework's, called from the framework's own descriptors. The
+ * image refuses the call where it is made, and what it reports is a method of an interface that
+ * appears nowhere in the application -- the last of them stopping a context from starting on
+ * {@code ConfigMap.getProperty}, which is how nearly every plugin reads its settings.
+ *
+ *
Only the interfaces are named. What implements them is reached through them, and registering
+ * the implementations would be registering most of the framework.
+ *
+ * @since 8.0
+ */
+public class GrailsApiRuntimeHints implements RuntimeHintsRegistrar {
+
+ /** What a descriptor written without static compilation calls on the framework. */
+ private static final Class>[] TYPES = {
+ Config.class,
+ ConfigMap.class,
+ GrailsApplication.class,
+ GrailsClass.class,
+ GrailsPlugin.class,
+ GrailsPluginManager.class
+ };
+
+ @Override
+ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
+ for (Class> type : TYPES) {
+ hints.reflection().registerType(type, MemberCategory.INVOKE_DECLARED_METHODS);
+ }
+ }
+}
diff --git a/grails-core/src/main/resources/META-INF/spring/aot.factories b/grails-core/src/main/resources/META-INF/spring/aot.factories
index f263d9e0f9d..c43038d0e7c 100644
--- a/grails-core/src/main/resources/META-INF/spring/aot.factories
+++ b/grails-core/src/main/resources/META-INF/spring/aot.factories
@@ -3,6 +3,7 @@ org.grails.spring.beans.aot.AbstractBeanDefinitionExcludeFilter
org.springframework.aot.hint.RuntimeHintsRegistrar=\
org.grails.spring.beans.aot.BeanRegistrarRuntimeHints,\
+org.grails.spring.beans.aot.GrailsApiRuntimeHints,\
org.grails.spring.beans.aot.GrailsClosureRuntimeHints,\
org.grails.spring.beans.aot.GroovyExtensionModuleRuntimeHints
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy
new file mode 100644
index 00000000000..ac42935e71d
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy
@@ -0,0 +1,77 @@
+/*
+ * 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 org.springframework.aot.hint.MemberCategory
+import org.springframework.aot.hint.RuntimeHints
+import org.springframework.aot.hint.TypeReference
+import spock.lang.Specification
+
+import grails.config.Config
+import grails.config.ConfigMap
+import grails.core.GrailsApplication
+import grails.plugins.GrailsPluginManager
+
+/**
+ * Covers the framework's own interfaces being callable from an image.
+ *
+ *
A plugin descriptor is Groovy written largely without static compilation, so reading a setting
+ * or asking the application about its artefacts is resolved where the call is written and made
+ * reflectively. An application never names these interfaces itself, so nothing else asks an image to
+ * keep them, and a context stopped starting on ConfigMap.getProperty -- how nearly every plugin
+ * reads its settings.
+ */
+class GrailsApiRuntimeHintsSpec extends Specification {
+
+ RuntimeHints hints = new RuntimeHints()
+
+ void setup() {
+ new GrailsApiRuntimeHints().registerHints(hints, getClass().classLoader)
+ }
+
+ private boolean invocable(Class> type) {
+ def hint = hints.reflection().getTypeHint(TypeReference.of(type))
+ hint != null && hint.memberCategories.contains(MemberCategory.INVOKE_DECLARED_METHODS)
+ }
+
+ void 'the configuration a plugin reads its settings from can be called'() {
+ expect:
+ invocable(ConfigMap)
+ invocable(Config)
+ }
+
+ void 'what a plugin asks about the application can be called'() {
+ expect:
+ invocable(GrailsApplication)
+ }
+
+ void 'what a plugin asks about the plugins can be called'() {
+ expect:
+ invocable(GrailsPluginManager)
+ }
+
+ void 'the call that stopped a context from starting is covered'() {
+ given: 'the form a plugin uses to read a setting with a type and a default'
+ def getProperty = ConfigMap.getMethod('getProperty', String, Class, Object)
+
+ expect:
+ getProperty.declaringClass == ConfigMap
+ invocable(ConfigMap)
+ }
+}
From 7a063e3a986bb30bab7fbfba19f17e0e8cf5a458 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Fri, 7 Aug 2026 00:00:25 -0700
Subject: [PATCH 045/115] Skip a closure whose surroundings cannot be loaded
A closure loads without the class it was written inside -- it extends Closure and
nothing else -- so asking only whether the closure loads lets one through whose
surroundings are absent. Registering it makes the image analyse it, and analysing
a closure means reading the method it was written in.
The Hibernate plugin ships a Spock specification for applications to write their
tests against, and ships it in its main jar, so it is on the runtime classpath of
every application that uses Hibernate whether or not that application tests with
Spock. One that does not has no spock.lang.Specification for the image to read,
and the build failed on a class the application never asked for:
Error encountered while parsing grails.test.hibernate.HibernateSpec$_setupSpec_closure5
Caused by: NoClassDefFoundError: spock/lang/Specification
The class the closure was written inside now has to load as well.
The test asks the guard directly. A class loader that hides Spock does not
reproduce this, because loading the enclosing class delegates to a parent that can
still see it -- which is worth knowing, since such a test passes while proving
nothing.
(cherry picked from commit 7d63e65b7aeb390e58aeb60b9fafd2305e6bb5fc)
---
.../beans/aot/GrailsClosureRuntimeHints.java | 20 ++++++++++++++++++-
.../aot/GrailsClosureRuntimeHintsSpec.groovy | 16 +++++++++++++++
2 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
index 55881d9f1f0..b47e925d585 100644
--- a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHints.java
@@ -114,7 +114,7 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader)
* a method body.
*/
private boolean registrable(String className, Resource resource, ClassLoader loader) {
- if (!RegistrableTypes.loads(className, loader)) {
+ if (!RegistrableTypes.loads(className, loader) || !enclosingLoads(className, loader)) {
return false;
}
try {
@@ -125,6 +125,24 @@ private boolean registrable(String className, Resource resource, ClassLoader loa
}
}
+ /**
+ * Whether the class the closure was written inside can be loaded.
+ *
+ *
A closure loads without it -- it extends {@code Closure} and nothing else -- so asking only
+ * about the closure lets one through whose surroundings are absent. Registering it makes the
+ * image analyse it, and analysing a closure means reading the method it was written in: the test
+ * support ships closures written inside a Spock specification, and an application that does not
+ * test with Spock has no {@code spock.lang.Specification} for the image to read, which fails the
+ * build rather than the closure.
+ */
+ boolean enclosingLoads(String className, ClassLoader loader) {
+ int closure = className.indexOf("$_");
+ if (closure < 0) {
+ return true;
+ }
+ return RegistrableTypes.loads(className.substring(0, closure), loader);
+ }
+
@Nullable
private String classNameOf(MetadataReaderFactory factory, Resource resource) {
try {
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
index 64ed1b98f80..c489ed120d7 100644
--- a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsClosureRuntimeHintsSpec.groovy
@@ -75,6 +75,22 @@ class GrailsClosureRuntimeHintsSpec extends Specification {
RuntimeHintsPredicates.resource().forResource('META-INF/grails.factories').test(hints)
}
+ void 'a closure is skipped when the class it was written inside cannot be loaded'() {
+ given:
+ def registrar = new GrailsClosureRuntimeHints()
+ ClassLoader loader = getClass().classLoader
+
+ expect: 'analysing a closure means reading the method it was written in, so a closure whose ' +
+ 'surroundings are absent fails the build rather than being left out of it'
+ !registrar.enclosingLoads('com.example.NotHere$_someMethod_closure1', loader)
+
+ and: 'one written inside a class that is present is kept'
+ registrar.enclosingLoads('org.grails.config.NavigableMap$_flattenKeys_closure3', loader)
+
+ and: 'a class that is not a closure has no surroundings to ask about'
+ registrar.enclosingLoads('org.grails.config.NavigableMap', loader)
+ }
+
void 'a class loader that resolves nothing yields no hints rather than failing'() {
when:
new GrailsClosureRuntimeHints().registerHints(hints, new URLClassLoader(new URL[0], null))
From 5846c43855a7539e5342717b5466244c4f1ed5e0 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Fri, 7 Aug 2026 00:16:16 -0700
Subject: [PATCH 046/115] Let an image call the Spring interfaces a plugin is
handed
A plugin descriptor is given the environment, the context and the resource loader,
and calls them the same dynamic way it calls the framework's own interfaces -- so
each call is made reflectively, and an application names none of them either.
Reading a setting from the environment stopped a Hibernate application from
starting in exactly the way reading one from the configuration stopped every
application: PropertyResolver.getProperty, from the datastore initializer.
(cherry picked from commit 6a30b0c96aac2dedde0f739d5573a780ebc1cf00)
---
.../beans/aot/GrailsApiRuntimeHints.java | 23 +++++++++++++++++--
.../aot/GrailsApiRuntimeHintsSpec.groovy | 11 +++++++++
2 files changed, 32 insertions(+), 2 deletions(-)
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java
index 517f21a6e57..75358b26a91 100644
--- a/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHints.java
@@ -21,6 +21,12 @@
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.Environment;
+import org.springframework.core.env.PropertyResolver;
+import org.springframework.core.io.ResourceLoader;
import org.springframework.lang.Nullable;
import grails.config.Config;
@@ -31,7 +37,8 @@
import grails.plugins.GrailsPluginManager;
/**
- * Registers the framework's own interfaces, which a plugin reaches through dynamically.
+ * Registers the interfaces a plugin reaches through dynamically: the framework's own, and the
+ * Spring ones it is handed.
*
*
A plugin descriptor is Groovy, and much of it is written without static compilation -- so
* reading a configuration value, asking the application for its artefacts, or asking the plugin
@@ -44,6 +51,11 @@
* appears nowhere in the application -- the last of them stopping a context from starting on
* {@code ConfigMap.getProperty}, which is how nearly every plugin reads its settings.
*
+ *
The Spring interfaces are here for the same reason rather than a different one: a descriptor is
+ * given the environment and the context and calls them the same dynamic way, and an application does
+ * not name them either. Reading a setting from the environment stopped a Hibernate application from
+ * starting in exactly the way reading one from the configuration stopped every application.
+ *
*
Only the interfaces are named. What implements them is reached through them, and registering
* the implementations would be registering most of the framework.
*
@@ -58,7 +70,14 @@ public class GrailsApiRuntimeHints implements RuntimeHintsRegistrar {
GrailsApplication.class,
GrailsClass.class,
GrailsPlugin.class,
- GrailsPluginManager.class
+ GrailsPluginManager.class,
+ // What a descriptor is handed by Spring and calls the same way
+ Environment.class,
+ ConfigurableEnvironment.class,
+ PropertyResolver.class,
+ ApplicationContext.class,
+ ConfigurableApplicationContext.class,
+ ResourceLoader.class
};
@Override
diff --git a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy
index ac42935e71d..2ff8daf8fb1 100644
--- a/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy
+++ b/grails-core/src/test/groovy/org/grails/spring/beans/aot/GrailsApiRuntimeHintsSpec.groovy
@@ -23,6 +23,10 @@ import org.springframework.aot.hint.RuntimeHints
import org.springframework.aot.hint.TypeReference
import spock.lang.Specification
+import org.springframework.context.ApplicationContext
+import org.springframework.core.env.Environment
+import org.springframework.core.env.PropertyResolver
+
import grails.config.Config
import grails.config.ConfigMap
import grails.core.GrailsApplication
@@ -66,6 +70,13 @@ class GrailsApiRuntimeHintsSpec extends Specification {
invocable(GrailsPluginManager)
}
+ void 'what Spring hands a plugin can be called'() {
+ expect: 'a descriptor is given these and calls them the same dynamic way'
+ invocable(PropertyResolver)
+ invocable(Environment)
+ invocable(ApplicationContext)
+ }
+
void 'the call that stopped a context from starting is covered'() {
given: 'the form a plugin uses to read a setting with a type and a default'
def getProperty = ConfigMap.getMethod('getProperty', String, Class, Object)
From ac091ad8a9a305396868ee14720229a26f534444 Mon Sep 17 00:00:00 2001
From: Scott Murphy Heiberg
Date: Fri, 7 Aug 2026 00:52:22 -0700
Subject: [PATCH 047/115] Use plain map-entry spacing in the native metadata
task
The aligned colons were a style violation, and nothing reads the file in a
way the alignment helped.
(cherry picked from commit 28c32101e9ac0dd61e91bd0483972b4db3422e65)
---
.../gradle/plugin/aot/GenerateNativeMetadataTask.groovy | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTask.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTask.groovy
index 9174be9b393..c7bb024106b 100644
--- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTask.groovy
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/GenerateNativeMetadataTask.groovy
@@ -88,10 +88,10 @@ abstract class GenerateNativeMetadataTask extends DefaultTask {
List