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 297ae6046fd..8a0bc7fc888 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy @@ -240,7 +240,9 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { def springConfig = new DefaultRuntimeSpringConfiguration() def application = grailsApplication - Holders.setGrailsApplication(application) + if (!earlyPluginRegistrationRan) { + Holders.setGrailsApplication(application) + } if (!earlyPluginRegistrationRan) { // first register plugin beans; when the early phase ran they were diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java b/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java index e4c8ac44153..d7a3baacc6b 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java @@ -26,6 +26,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanInitializationException; import org.springframework.beans.factory.BeanRegistrar; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.BeanDefinitionRegistry; @@ -41,6 +42,7 @@ import org.springframework.core.io.Resource; import org.springframework.util.ClassUtils; +import grails.config.Settings; import grails.core.DefaultGrailsApplication; import grails.core.GrailsApplication; import grails.core.GrailsApplicationClass; @@ -123,6 +125,9 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t // The initializing flag is a system property, so a leak on failure poisons every subsequent // context in the same JVM (test forks especially). Reset it if anything below throws; the // success path leaves it set and resets on refresh via the listener added at the end. + GrailsApplication previousGrailsApplication = null; + GrailsApplication publishedGrailsApplication = null; + boolean publishedToLegacyHolder = false; Environment.setInitializing(true); try { DefaultGrailsApplication grailsApplication = new DefaultGrailsApplication(); @@ -147,6 +152,21 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t } RuntimeSpringConfiguration springConfig = new DefaultRuntimeSpringConfiguration(); + boolean legacyHoldersDuringDoWithSpring = applicationContext.getEnvironment() + .getProperty(Settings.LEGACY_HOLDERS_DURING_DO_WITH_SPRING, Boolean.class, false); + // This opt-in shim preserves Grails 7 compatibility for plugins that read Holders in + // doWithSpring. It is disabled by default because that global publication is unsafe + // during early plugin registration; plugins should migrate to injected dependencies. + if (legacyHoldersDuringDoWithSpring) { + LOG.warn("legacy doWithSpring compatibility shim is enabled. Migrate closures away from Holders access and remove '{}'", + Settings.LEGACY_HOLDERS_DURING_DO_WITH_SPRING); + previousGrailsApplication = Holders.replaceGrailsApplication(grailsApplication); + publishedGrailsApplication = grailsApplication; + publishedToLegacyHolder = true; + } + else { + previousGrailsApplication = Holders.findGrailsApplicationFallback(); + } pluginManager.doRuntimeConfiguration(springConfig); springConfig.registerBeansWithRegistry(registry); applyBeanRegistrars(pluginManager, registry); @@ -155,16 +175,27 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, grailsApplication); beanFactory.registerSingleton(GrailsPluginManager.BEAN_NAME, pluginManager); beanFactory.registerSingleton(EARLY_REGISTRATION_COMPLETE_BEAN_NAME, Boolean.TRUE); - Holders.setGrailsApplication(grailsApplication); // GrailsApplicationPostProcessor resets the initializing flag on refresh, but it is not // present in every context that runs this phase — reset here as well so the flag does not // leak once the context is up. applicationContext.addApplicationListener(this); + if (!publishedToLegacyHolder) { + Holders.restoreGrailsApplication(previousGrailsApplication, grailsApplication); + } } - catch (RuntimeException | Error e) { + catch (Throwable e) { Environment.setInitializing(false); - throw e; + if (publishedToLegacyHolder) { + Holders.restoreGrailsApplicationAfterFailure(publishedGrailsApplication, previousGrailsApplication); + } + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (e instanceof Error error) { + throw error; + } + throw new BeanInitializationException("Early Grails plugin registration failed", e); } } diff --git a/grails-core/src/main/groovy/grails/config/Settings.groovy b/grails-core/src/main/groovy/grails/config/Settings.groovy index 6acf468f8b8..8a4310faa22 100644 --- a/grails-core/src/main/groovy/grails/config/Settings.groovy +++ b/grails-core/src/main/groovy/grails/config/Settings.groovy @@ -73,6 +73,15 @@ interface Settings { */ String PLUGIN_EXCLUDES = 'grails.plugin.excludes' + /** + * Whether legacy {@code doWithSpring} closures can access the application through + * {@code Holders}. Defaults to {@code false}; enable only while migrating those closures away + * from global {@code Holders} access. + * + * @since 8.0 + */ + String LEGACY_HOLDERS_DURING_DO_WITH_SPRING = 'grails.legacy.holdersDuringDoWithSpring' + /** * Whether to include the jsessionid in the rendered links **/ diff --git a/grails-core/src/main/groovy/grails/util/Holders.java b/grails-core/src/main/groovy/grails/util/Holders.java index d83d4703fdb..c0824c46b5a 100644 --- a/grails-core/src/main/groovy/grails/util/Holders.java +++ b/grails-core/src/main/groovy/grails/util/Holders.java @@ -18,10 +18,16 @@ */ package grails.util; +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; import java.lang.reflect.InvocationTargetException; import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -61,7 +67,10 @@ public class Holders { createServletContextsHolder(); } - private static GrailsApplication applicationSingleton; // TODO remove + private static final AtomicReference applicationSingleton = new AtomicReference<>(); // TODO remove + private static final Object applicationSingletonMonitor = new Object(); + private static final ReferenceQueue failedApplications = new ReferenceQueue<>(); + private static final Map failedApplicationPredecessors = new HashMap<>(); private Holders() { // static only @@ -80,7 +89,7 @@ public static void clear() { servletContexts.set(null); } applicationDiscoveryStrategies.clear(); - applicationSingleton = null; + clearGrailsApplicationState(); } public static void setServletContext(final Object servletContext) { @@ -129,7 +138,7 @@ public static GrailsApplication findApplication() { return grailsApplication; } } - return applicationSingleton; + return findGrailsApplicationFallback(); } public static GrailsApplication getGrailsApplication() { @@ -138,8 +147,155 @@ public static GrailsApplication getGrailsApplication() { return grailsApplication; } + /** + * Returns the fallback Grails application without invoking discovery strategies. + * + * @return the fallback application, or {@code null} + */ + public static GrailsApplication findGrailsApplicationFallback() { + synchronized (applicationSingletonMonitor) { + drainFailedApplications(); + return applicationSingleton.get(); + } + } + + /** + * Sets the fallback Grails application without invoking discovery strategies. + * A previously failed application is replaced by its first nonfailed predecessor. + * + * @param application the fallback application, or {@code null} + */ public static void setGrailsApplication(GrailsApplication application) { - applicationSingleton = application; + synchronized (applicationSingletonMonitor) { + drainFailedApplications(); + applicationSingleton.set(firstNonfailedApplication(application)); + } + } + + /** + * Replaces the fallback Grails application without invoking discovery strategies. + * + *

A previously failed requested application is replaced by its first nonfailed predecessor. + * + * @param application the new fallback application, or {@code null} + * @return the previous fallback application + */ + public static GrailsApplication replaceGrailsApplication(GrailsApplication application) { + synchronized (applicationSingletonMonitor) { + drainFailedApplications(); + return applicationSingleton.getAndSet(firstNonfailedApplication(application)); + } + } + + /** + * Restores a fallback Grails application only if no other publisher has replaced it. + * + *

The ownership comparison retains identity semantics. A previously failed application + * requested for restoration is replaced by its first nonfailed predecessor. + * + * @param expected the fallback application owned by the caller + * @param application the fallback application to restore, or {@code null} + * @return {@code true} when the fallback was restored + */ + public static boolean restoreGrailsApplication(GrailsApplication expected, GrailsApplication application) { + synchronized (applicationSingletonMonitor) { + drainFailedApplications(); + return applicationSingleton.compareAndSet(expected, firstNonfailedApplication(application)); + } + } + + /** + * Records a failed fallback publication and restores its first nonfailed predecessor if the + * caller still owns the fallback. The failure record is retained when another publisher is + * currently visible, allowing that publisher's later restore to skip the failed application. + * Discovery strategies are not invoked. + * + *

The failure key uses weak identity semantics. The predecessor remains strongly reachable + * only while the failed application is reachable, directly or through another live failure + * chain. + * + * @param expected the failed fallback application owned by the caller, never {@code null} + * @param previous the predecessor that was visible before publication, or {@code null} + * @return {@code true} when the failed fallback was still visible and was restored + */ + public static boolean restoreGrailsApplicationAfterFailure(GrailsApplication expected, GrailsApplication previous) { + synchronized (applicationSingletonMonitor) { + drainFailedApplications(); + Assert.notNull(expected, "Expected failed GrailsApplication must not be null"); + failedApplicationPredecessors.put(new WeakIdentityKey(expected, failedApplications), previous); + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + visited.add(expected); + GrailsApplication application = firstNonfailedApplication(previous, visited); + return applicationSingleton.compareAndSet(expected, application); + } + } + + private static GrailsApplication firstNonfailedApplication(GrailsApplication application) { + return firstNonfailedApplication(application, Collections.newSetFromMap(new IdentityHashMap<>())); + } + + private static GrailsApplication firstNonfailedApplication( + GrailsApplication application, Set visited) { + GrailsApplication candidate = application; + while (candidate != null) { + if (!visited.add(candidate)) { + // A publication chain cannot legitimately cycle. Terminate it both to avoid + // resurrection and to prevent strong predecessor values retaining the cycle. + failedApplicationPredecessors.put(new WeakIdentityKey(candidate, failedApplications), null); + return null; + } + WeakIdentityKey lookupKey = new WeakIdentityKey(candidate); + if (!failedApplicationPredecessors.containsKey(lookupKey)) { + return candidate; + } + candidate = failedApplicationPredecessors.get(lookupKey); + } + return null; + } + + private static void drainFailedApplications() { + WeakIdentityKey failedApplication; + while ((failedApplication = (WeakIdentityKey) failedApplications.poll()) != null) { + failedApplicationPredecessors.remove(failedApplication); + } + } + + /** + * Weak map key whose hash and equality use Grails application identity rather than + * application-defined equality. Cleared keys compare only to themselves so queue removal + * remains reliable without allowing unrelated cleared references to compare equal. + */ + private static final class WeakIdentityKey extends WeakReference { + + private final int identityHashCode; + + private WeakIdentityKey(GrailsApplication application) { + super(application); + identityHashCode = System.identityHashCode(application); + } + + private WeakIdentityKey( + GrailsApplication application, ReferenceQueue referenceQueue) { + super(application, referenceQueue); + identityHashCode = System.identityHashCode(application); + } + + @Override + public int hashCode() { + return identityHashCode; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof WeakIdentityKey otherKey)) { + return false; + } + GrailsApplication application = get(); + return application != null && application == otherKey.get(); + } } public static void setConfig(Config config) { @@ -202,13 +358,23 @@ public static GrailsPluginManager currentPluginManager() { public static void reset() { setPluginManager(null); - setGrailsApplication(null); - setServletContext(null); + clearGrailsApplicationState(); + if (servletContexts != null) { + setServletContext(null); + } setPluginManager(null); setPluginManagerInCreation(false); setConfig(null); } + private static void clearGrailsApplicationState() { + synchronized (applicationSingletonMonitor) { + drainFailedApplications(); + applicationSingleton.set(null); + failedApplicationPredecessors.clear(); + } + } + private static T get(Holder holder, String type) { return get(holder, type, false); } diff --git a/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json index 50ba004db15..f38bd4761fc 100644 --- a/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json +++ b/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json @@ -12,6 +12,10 @@ "name": "grails.bootstrap", "description": "Core Properties" }, + { + "name": "grails.legacy", + "description": "Legacy Compatibility" + }, { "name": "grails.spring", "description": "Core Properties" @@ -96,6 +100,12 @@ "description": "Whether to skip execution of BootStrap.groovy classes on startup.", "defaultValue": false }, + { + "name": "grails.legacy.holdersDuringDoWithSpring", + "type": "java.lang.Boolean", + "description": "Whether legacy doWithSpring closures can access the promoted GrailsApplication through Holders during early plugin registration. Enable only while migrating legacy plugins away from global Holders access.", + "defaultValue": false + }, { "name": "grails.spring.bean.packages", "type": "java.util.List", diff --git a/grails-core/src/test-cli/groovy/org/apache/grails/core/cli/ConfigReportCommandSpec.groovy b/grails-core/src/test-cli/groovy/org/apache/grails/core/cli/ConfigReportCommandSpec.groovy index 8fb83112d11..30f28d21161 100644 --- a/grails-core/src/test-cli/groovy/org/apache/grails/core/cli/ConfigReportCommandSpec.groovy +++ b/grails-core/src/test-cli/groovy/org/apache/grails/core/cli/ConfigReportCommandSpec.groovy @@ -382,6 +382,16 @@ class ConfigReportCommandSpec extends Specification { profileEntry.description != null profileEntry.description.length() > 0 metadataResult.groupDescriptions.get('grails') == 'Core Properties' + + and: "legacy compatibility metadata retains its migration guidance" + ConfigReportCommand.ConfigPropertyMetadata legacyEntry = metadataResult.properties.find { ConfigReportCommand.ConfigPropertyMetadata property -> property.name == 'grails.legacy.holdersDuringDoWithSpring' } + legacyEntry.name == 'grails.legacy.holdersDuringDoWithSpring' + legacyEntry.type == 'java.lang.Boolean' + legacyEntry.defaultValue == false + legacyEntry.description != null + legacyEntry.description.length() > 0 + legacyEntry.description.toLowerCase().contains('migrat') + metadataResult.groupDescriptions.get('grails.legacy') == 'Legacy Compatibility' } def "escapeAsciidoc handles null and empty strings"() { diff --git a/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy index 4a65e7f80c8..0f6e4f3b790 100644 --- a/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy +++ b/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy @@ -18,18 +18,29 @@ */ package grails.boot.config +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import org.slf4j.LoggerFactory import org.springframework.beans.factory.BeanRegistrar import org.springframework.beans.factory.BeanRegistry +import org.springframework.beans.factory.BeanInitializationException import org.springframework.beans.factory.support.BeanDefinitionOverrideException import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.context.ApplicationContext import org.springframework.context.annotation.AnnotationConfigApplicationContext import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.core.env.MapPropertySource import org.springframework.core.env.StandardEnvironment +import grails.config.Settings import grails.core.DefaultGrailsApplication import grails.core.GrailsApplication import grails.plugins.DefaultGrailsPluginManager @@ -39,6 +50,7 @@ import grails.util.Environment import grails.util.Holders import org.apache.grails.core.plugins.DefaultPluginDiscovery import org.apache.grails.core.plugins.PluginDiscovery +import org.grails.core.support.GrailsApplicationDiscoveryStrategy import spock.lang.Specification /** @@ -81,6 +93,9 @@ class EarlyPluginRegistrationOrderingSpec extends Specification { and: 'the environment initializing flag was reset on refresh' !Environment.isInitializing() + and: 'the application promoted to the completed context is published to Holders' + Holders.findApplication().is(ctx.getBean(GrailsApplication.APPLICATION_ID, GrailsApplication)) + cleanup: ctx.close() Holders.clear() @@ -237,9 +252,159 @@ class EarlyPluginRegistrationOrderingSpec extends Specification { Environment.setInitializing(false) } + void 'a Grails 7-style plugin cannot access grailsApplication from Holders during doWithSpring when the legacy Holder shim property is absent'() { + given: 'a legacy plugin that reads Holders.grailsApplication from its doWithSpring closure' + def ctx = new AnnotationConfigApplicationContext() + def warningAppender = attachEarlyRegistrationWarningAppender() + registerDiscovery(ctx, EarlyOrderingHoldersLegacyGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the context refreshes through the real early registration path' + ctx.refresh() + + then: 'the modern lifecycle remains the default' + def failure = thrown(IllegalArgumentException) + failure.message == 'GrailsApplication not found' + EarlyOrderingHoldersLegacyGrailsPlugin.grailsApplicationSeen == null + + and: 'the compatibility warning is not emitted when the shim property is absent' + legacyHoldersWarnings(warningAppender).isEmpty() + + cleanup: + detachEarlyRegistrationWarningAppender(warningAppender) + ctx.close() + } + + void 'a Grails 7-style plugin cannot access grailsApplication from Holders during doWithSpring when the legacy Holder shim property is false'() { + given: 'a legacy plugin with the legacy Holder shim explicitly disabled' + def ctx = new AnnotationConfigApplicationContext() + def warningAppender = attachEarlyRegistrationWarningAppender() + setLegacyHoldersDuringDoWithSpring(ctx, false) + registerDiscovery(ctx, EarlyOrderingHoldersLegacyGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the context refreshes through the real early registration path' + ctx.refresh() + + then: 'the modern lifecycle remains the default' + def failure = thrown(IllegalArgumentException) + failure.message == 'GrailsApplication not found' + EarlyOrderingHoldersLegacyGrailsPlugin.grailsApplicationSeen == null + + and: 'the compatibility warning is not emitted when the shim is explicitly disabled' + legacyHoldersWarnings(warningAppender).isEmpty() + + cleanup: + detachEarlyRegistrationWarningAppender(warningAppender) + ctx.close() + } + + void 'the default-off lifecycle does not replace a preexisting Holder during doWithSpring'() { + given: 'a previous application remains in the process-global Holder' + def previousApplication = new DefaultGrailsApplication() + Holders.setGrailsApplication(previousApplication) + def ctx = new AnnotationConfigApplicationContext() + registerDiscovery(ctx, EarlyOrderingHoldersLegacyGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the new context refreshes without the compatibility shim' + ctx.refresh() + + then: 'the plugin sees the untouched previous Holder rather than the application being initialized' + EarlyOrderingHoldersLegacyGrailsPlugin.grailsApplicationSeen.is(previousApplication) + + and: 'the new application is published only after successful early registration' + Holders.findApplication().is(ctx.getBean(GrailsApplication.APPLICATION_ID)) + + cleanup: + ctx.close() + } + + void 'a default-off context does not overwrite a newer Holder publisher during late promotion'() { + given: 'a default-off context whose plugin pauses during doWithSpring' + EarlyOrderingLatePromotionOwnershipGrailsPlugin.reset() + Holders.clear() + def ctx = new AnnotationConfigApplicationContext() + registerDiscovery(ctx, EarlyOrderingLatePromotionOwnershipGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + def refreshFailure = new AtomicReference() + Thread refreshThread = Thread.start { + try { + ctx.refresh() + } + catch (Throwable failure) { + refreshFailure.set(failure) + } + } + + when: 'a newer application is published while early registration is blocked' + assert EarlyOrderingLatePromotionOwnershipGrailsPlugin.entered.await(5, TimeUnit.SECONDS) + def newerApplication = new DefaultGrailsApplication() + Holders.setGrailsApplication(newerApplication) + EarlyOrderingLatePromotionOwnershipGrailsPlugin.release.countDown() + refreshThread.join(5000) + + then: 'the refresh succeeds without replacing the newer publisher during its late promotion' + !refreshThread.alive + refreshFailure.get() == null + def contextApplication = ctx.getBean(GrailsApplication.APPLICATION_ID, GrailsApplication) + !newerApplication.is(contextApplication) + Holders.findApplication().is(newerApplication) + !Holders.findApplication().is(contextApplication) + + and: 'the default-off plugin never observed the context application through Holders' + EarlyOrderingLatePromotionOwnershipGrailsPlugin.holderApplicationSeen.get() == null + + when: 'the downstream application postprocessor handles the already-promoted context' + def postProcessor = new GrailsApplicationPostProcessor( + null, + ctx, + ctx.getBean(PluginDiscovery.BEAN_NAME, PluginDiscovery) + ) + postProcessor.loadExternalBeans = false + postProcessor.postProcessBeanDefinitionRegistry(ctx.beanFactory) + + then: 'it preserves the ownership decision made during early registration' + Holders.findGrailsApplicationFallback().is(newerApplication) + + cleanup: + EarlyOrderingLatePromotionOwnershipGrailsPlugin.release.countDown() + refreshThread?.join(5000) + assert !refreshThread?.alive + ctx?.close() + Holders.clear() + Environment.setInitializing(false) + EarlyOrderingLatePromotionOwnershipGrailsPlugin.reset() + } + + void 'a Grails 7-style plugin can access the promoted grailsApplication from Holders during doWithSpring when the legacy Holder shim property is true'() { + given: 'a legacy plugin with the legacy Holder shim enabled' + def ctx = new AnnotationConfigApplicationContext() + def warningAppender = attachEarlyRegistrationWarningAppender() + setLegacyHoldersDuringDoWithSpring(ctx, true) + registerDiscovery(ctx, EarlyOrderingHoldersLegacyGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the context refreshes through the real early registration path' + ctx.refresh() + + then: 'the plugin observed the exact Grails application instance promoted to the context' + EarlyOrderingHoldersLegacyGrailsPlugin.grailsApplicationSeen.is(ctx.getBean(GrailsApplication.APPLICATION_ID)) + + and: 'the compatibility warning is emitted exactly once' + legacyHoldersWarnings(warningAppender).size() == 1 + + cleanup: + detachEarlyRegistrationWarningAppender(warningAppender) + ctx.close() + } + void 'the initializing flag is reset when the early phase throws'() { given: 'a plugin whose doWithSpring throws while the early phase drains it' + def previousApplication = new DefaultGrailsApplication() + Holders.setGrailsApplication(previousApplication) def ctx = new AnnotationConfigApplicationContext() + setLegacyHoldersDuringDoWithSpring(ctx, true) registerDiscovery(ctx, EarlyOrderingThrowingGrailsPlugin) new GrailsPluginLifecycleInitializer().initialize(ctx) @@ -252,12 +417,297 @@ class EarlyPluginRegistrationOrderingSpec extends Specification { and: '...but the initializing flag (a system property) was reset, not leaked to later contexts' !Environment.isInitializing() + and: 'the Grails application Holder was restored, not replaced by the failed context' + Holders.findApplication().is(previousApplication) + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + + void 'a default-off late singleton registration failure restores the prior Holder application'() { + given: 'a prior Holder application and a plugin whose runtime configuration completes before promotion fails' + def previousApplication = new DefaultGrailsApplication() + Holders.setGrailsApplication(previousApplication) + def ctx = new AnnotationConfigApplicationContext() + ctx.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, new Object()) + registerDiscovery(ctx, EarlyOrderingRuntimeConfiguredGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the context fails to promote its application because grailsApplication is already registered' + ctx.refresh() + + then: 'plugin runtime configuration completed before the late failure' + thrown(Exception) + EarlyOrderingRuntimeConfiguredGrailsPlugin.runtimeConfigured + + and: 'the failed application was not leaked through global state' + Holders.replaceGrailsApplication(null).is(previousApplication) + !Environment.isInitializing() + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + + void 'an Error from a shim-enabled plugin propagates unchanged after global state is restored'() { + given: 'a prior Holder application and a plugin that throws a specific Error' + def previousApplication = new DefaultGrailsApplication() + def expectedFailure = new AssertionError('error from doWithSpring') + Holders.setGrailsApplication(previousApplication) + EarlyOrderingErrorThrowingGrailsPlugin.failure = expectedFailure + def ctx = new AnnotationConfigApplicationContext() + setLegacyHoldersDuringDoWithSpring(ctx, true) + registerDiscovery(ctx, EarlyOrderingErrorThrowingGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the context refreshes' + ctx.refresh() + + then: 'the original Error instance propagates without wrapping' + def failure = thrown(Error) + failure.is(expectedFailure) + + and: 'the prior Holder application and initializing state are restored' + Holders.findApplication().is(previousApplication) + !Environment.isInitializing() + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + + void 'failure restoration does not invoke Grails application discovery strategies'() { + given: 'a prior fallback application, a broken discovery strategy, and a throwing plugin' + def previousApplication = new DefaultGrailsApplication() + Holders.setGrailsApplication(previousApplication) + def throwingStrategy = new EarlyOrderingThrowingApplicationDiscoveryStrategy() + Holders.addApplicationDiscoveryStrategy(throwingStrategy) + def ctx = new AnnotationConfigApplicationContext() + setLegacyHoldersDuringDoWithSpring(ctx, true) + registerDiscovery(ctx, EarlyOrderingThrowingGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the plugin fails after the new application is published' + ctx.refresh() + + then: 'the plugin failure propagates without consulting discovery or leaking global state' + thrown(Exception) + throwingStrategy.invocationCount.get() == 0 + !Environment.isInitializing() + Holders.replaceGrailsApplication(null).is(previousApplication) + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + + void 'a checked doWithSpring failure is wrapped after restoring global state'() { + given: 'a prior application and a plugin whose doWithSpring sneaky-throws a checked exception' + def previousApplication = new DefaultGrailsApplication() + Holders.setGrailsApplication(previousApplication) + def ctx = new AnnotationConfigApplicationContext() + setLegacyHoldersDuringDoWithSpring(ctx, true) + registerDiscovery(ctx, EarlyOrderingCheckedThrowingGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the context refreshes' + ctx.refresh() + + then: 'the checked failure is wrapped and both process-wide values are restored' + def failure = thrown(BeanInitializationException) + failure.cause.message == 'checked failure from doWithSpring' + !Environment.isInitializing() + Holders.findApplication().is(previousApplication) + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + + void 'a failed context does not overwrite a newer Grails application publisher'() { + given: 'a plugin that replaces the fallback application before its startup fails' + def ctx = new AnnotationConfigApplicationContext() + setLegacyHoldersDuringDoWithSpring(ctx, true) + registerDiscovery(ctx, EarlyOrderingCompetingPublisherGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the context refreshes and fails after the competing publication' + ctx.refresh() + + then: 'rollback leaves the newer publisher in place' + thrown(Exception) + Holders.replaceGrailsApplication(null).is(EarlyOrderingCompetingPublisherGrailsPlugin.competitor) + !Environment.isInitializing() + cleanup: ctx.close() Holders.clear() Environment.setInitializing(false) } + void 'conditional fallback restoration cannot overwrite a concurrent publisher'() { + given: 'an owned fallback, a value to restore, and a newer competing application' + def ownedApplication = new DefaultGrailsApplication() + def previousApplication = new DefaultGrailsApplication() + def competingApplication = new DefaultGrailsApplication() + Holders.setGrailsApplication(ownedApplication) + def ready = new CountDownLatch(2) + def start = new CountDownLatch(1) + def finished = new CountDownLatch(2) + + and: 'two publishers ready to race' + def restoreThread = Thread.start { + ready.countDown() + start.await() + Holders.restoreGrailsApplication(ownedApplication, previousApplication) + finished.countDown() + } + def publishThread = Thread.start { + ready.countDown() + start.await() + Holders.setGrailsApplication(competingApplication) + finished.countDown() + } + + when: 'rollback and the competing publication start together' + assert ready.await(5, TimeUnit.SECONDS) + start.countDown() + + then: 'the newer publication wins regardless of operation order' + finished.await(5, TimeUnit.SECONDS) + Holders.replaceGrailsApplication(null).is(competingApplication) + + cleanup: + start.countDown() + restoreThread.join(5000) + publishThread.join(5000) + Holders.clear() + } + + void 'a temporary plain competitor preserves the stacked failed-context rollback sequence P to A to B to C to B to P'() { + given: 'a prior application and two shim-enabled contexts paused after publishing A then B' + def previousApplication = new DefaultGrailsApplication() + Holders.setGrailsApplication(previousApplication) + def refreshes = startStackedRefreshes(true, true) + def firstApplication = EarlyOrderingStackedFailureGrailsPlugin.firstPromotedApplication.get() + def secondApplication = EarlyOrderingStackedFailureGrailsPlugin.secondPromotedApplication.get() + def temporaryApplication = new DefaultGrailsApplication() + + expect: 'each context captured the application it published through Holders' + firstApplication != null + secondApplication != null + !firstApplication.is(secondApplication) + Holders.findApplication().is(secondApplication) + + when: 'C replaces B, A fails while C is visible, C restores B, and then B fails' + def replacedApplication = Holders.replaceGrailsApplication(temporaryApplication) + refreshes.releaseFirstAndJoin() + def applicationWhileTemporaryPublisherIsVisible = Holders.findApplication() + Holders.restoreGrailsApplication(temporaryApplication, replacedApplication) + def applicationAfterTemporaryPublisherRestores = Holders.findApplication() + refreshes.releaseSecondAndJoin() + + then: 'the public Holder operations follow the exact sequence and both contexts throw' + replacedApplication.is(secondApplication) + applicationWhileTemporaryPublisherIsVisible.is(temporaryApplication) + applicationAfterTemporaryPublisherRestores.is(secondApplication) + refreshes.firstFailure.get() != null + refreshes.secondFailure.get() != null + Holders.replaceGrailsApplication(null).is(previousApplication) + !Environment.isInitializing() + + cleanup: + refreshes?.releaseAllAndJoin() + refreshes?.closeContexts() + Holders.clear() + Environment.setInitializing(false) + EarlyOrderingStackedFailureGrailsPlugin.reset() + } + + void 'when B fails before A, both failed shim-enabled contexts restore the original Holder application'() { + given: 'a prior application and two contexts paused after publishing A then B' + def previousApplication = new DefaultGrailsApplication() + Holders.setGrailsApplication(previousApplication) + def refreshes = startStackedRefreshes(true, true) + def firstApplication = EarlyOrderingStackedFailureGrailsPlugin.firstPromotedApplication.get() + + when: 'B fails and restores A before A fails' + refreshes.releaseSecondAndJoin() + def applicationAfterSecondFailure = Holders.findApplication() + refreshes.releaseFirstAndJoin() + + then: 'both refreshes throw and the final Holder application is P' + refreshes.firstFailure.get() != null + refreshes.secondFailure.get() != null + applicationAfterSecondFailure.is(firstApplication) + Holders.replaceGrailsApplication(null).is(previousApplication) + !Environment.isInitializing() + + cleanup: + refreshes?.releaseAllAndJoin() + refreshes?.closeContexts() + Holders.clear() + Environment.setInitializing(false) + EarlyOrderingStackedFailureGrailsPlugin.reset() + } + + void 'when A succeeds while B owns Holders and B then fails, A is the final Holder application'() { + given: 'two contexts paused after publishing A then B, with A succeeding and B failing' + def refreshes = startStackedRefreshes(false, true) + def firstApplication = EarlyOrderingStackedFailureGrailsPlugin.firstPromotedApplication.get() + def secondApplication = EarlyOrderingStackedFailureGrailsPlugin.secondPromotedApplication.get() + + when: 'A completes while B remains the visible Holder application, then B fails' + refreshes.releaseFirstAndJoin() + def applicationAfterFirstSuccess = Holders.findApplication() + refreshes.releaseSecondAndJoin() + + then: 'only B throws and its rollback restores A' + refreshes.firstFailure.get() == null + refreshes.secondFailure.get() != null + applicationAfterFirstSuccess.is(secondApplication) + Holders.replaceGrailsApplication(null).is(firstApplication) + !Environment.isInitializing() + + cleanup: + refreshes?.releaseAllAndJoin() + refreshes?.closeContexts() + Holders.clear() + Environment.setInitializing(false) + EarlyOrderingStackedFailureGrailsPlugin.reset() + } + + void 'when A fails while B owns Holders and B then succeeds, B is the final Holder application'() { + given: 'two contexts paused after publishing A then B, with A failing and B succeeding' + def refreshes = startStackedRefreshes(true, false) + def secondApplication = EarlyOrderingStackedFailureGrailsPlugin.secondPromotedApplication.get() + + when: 'A fails while B is visible, then B completes successfully' + refreshes.releaseFirstAndJoin() + def applicationAfterFirstFailure = Holders.findApplication() + refreshes.releaseSecondAndJoin() + + then: 'only A throws and B remains published' + refreshes.firstFailure.get() != null + refreshes.secondFailure.get() == null + applicationAfterFirstFailure.is(secondApplication) + Holders.replaceGrailsApplication(null).is(secondApplication) + !Environment.isInitializing() + + cleanup: + refreshes?.releaseAllAndJoin() + refreshes?.closeContexts() + Holders.clear() + Environment.setInitializing(false) + EarlyOrderingStackedFailureGrailsPlugin.reset() + } + private static void registerDiscovery(AnnotationConfigApplicationContext ctx, Class pluginClass) { def discovery = new DefaultPluginDiscovery([pluginClass] as Class[]) discovery.loadPluginsFromClasspath = false @@ -265,6 +715,81 @@ class EarlyPluginRegistrationOrderingSpec extends Specification { ctx.beanFactory.registerSingleton(PluginDiscovery.BEAN_NAME, discovery) } + private static void setLegacyHoldersDuringDoWithSpring(AnnotationConfigApplicationContext ctx, boolean enabled) { + ctx.environment.propertySources.addFirst(new MapPropertySource('legacyHoldersDuringDoWithSpring', [ + (Settings.LEGACY_HOLDERS_DURING_DO_WITH_SPRING): enabled.toString() + ])) + } + + private static EarlyOrderingStackedRefreshes startStackedRefreshes(boolean firstFails, boolean secondFails) { + EarlyOrderingStackedFailureGrailsPlugin.configure(firstFails, secondFails) + def firstContext = stackedFailureContext() + def secondContext = stackedFailureContext() + def firstFailure = new AtomicReference() + def secondFailure = new AtomicReference() + def firstRefresh = Thread.start { + try { + firstContext.refresh() + } + catch (Throwable failure) { + firstFailure.set(failure) + } + } + assert EarlyOrderingStackedFailureGrailsPlugin.firstConfigured.await(5, TimeUnit.SECONDS) + def secondRefresh = Thread.start { + try { + secondContext.refresh() + } + catch (Throwable failure) { + secondFailure.set(failure) + } + } + assert EarlyOrderingStackedFailureGrailsPlugin.secondConfigured.await(5, TimeUnit.SECONDS) + new EarlyOrderingStackedRefreshes(firstContext, secondContext, firstRefresh, secondRefresh, firstFailure, secondFailure) + } + + private static AnnotationConfigApplicationContext stackedFailureContext() { + def ctx = new AnnotationConfigApplicationContext() + setLegacyHoldersDuringDoWithSpring(ctx, true) + registerDiscovery(ctx, EarlyOrderingStackedFailureGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + ctx + } + + private static ListAppender attachEarlyRegistrationWarningAppender() { + def appender = new ListAppender() + appender.start() + earlyRegistrationLogger().addAppender(appender) + appender + } + + private static void detachEarlyRegistrationWarningAppender(ListAppender appender) { + earlyRegistrationLogger().detachAppender(appender) + appender.stop() + } + + private static List legacyHoldersWarnings(ListAppender appender) { + appender.list.findAll { ILoggingEvent event -> + event.formattedMessage.contains(Settings.LEGACY_HOLDERS_DURING_DO_WITH_SPRING) && + event.formattedMessage.contains('legacy doWithSpring compatibility shim') + } + } + + private static Logger earlyRegistrationLogger() { + LoggerFactory.getLogger(GrailsEarlyPluginRegistrationPostProcessor) as Logger + } + + void cleanup() { + Holders.clear() + Environment.setInitializing(false) + EarlyOrderingCountingGrailsPlugin.INSTANCE_COUNT.set(0) + EarlyOrderingHoldersLegacyGrailsPlugin.grailsApplicationSeen = null + EarlyOrderingRuntimeConfiguredGrailsPlugin.runtimeConfigured = false + EarlyOrderingErrorThrowingGrailsPlugin.failure = null + EarlyOrderingStackedFailureGrailsPlugin.reset() + EarlyOrderingLatePromotionOwnershipGrailsPlugin.reset() + } + private static Class getEarlyOrderingPluginClass() { new GroovyClassLoader(EarlyPluginRegistrationOrderingSpec.classLoader).parseClass(''' class EarlyOrderingGrailsPlugin { @@ -370,6 +895,219 @@ class EarlyOrderingThrowingGrailsPlugin extends Plugin { } } +class EarlyOrderingRuntimeConfiguredGrailsPlugin extends Plugin { + + static boolean runtimeConfigured + + def version = '1.0' + + @Override + Closure doWithSpring() { + { -> + runtimeConfigured = true + runtimeConfigurationBean(EarlyOrderingPluginResolver) + } + } +} + +class EarlyOrderingLatePromotionOwnershipGrailsPlugin extends Plugin { + + static CountDownLatch entered + static CountDownLatch release + static final AtomicReference holderApplicationSeen = new AtomicReference<>() + + static void reset() { + entered = new CountDownLatch(1) + release = new CountDownLatch(1) + holderApplicationSeen.set(null) + } + + def version = '1.0' + + @Override + Closure doWithSpring() { + { -> + holderApplicationSeen.set(Holders.findApplication()) + entered.countDown() + if (!release.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException('timed out waiting to release late promotion ownership test') + } + } + } +} + +class EarlyOrderingErrorThrowingGrailsPlugin extends Plugin { + + static Error failure + + def version = '1.0' + + @Override + Closure doWithSpring() { + { -> throw failure } + } +} + +class EarlyOrderingThrowingApplicationDiscoveryStrategy implements GrailsApplicationDiscoveryStrategy { + + final AtomicInteger invocationCount = new AtomicInteger() + + @Override + GrailsApplication findGrailsApplication() { + invocationCount.incrementAndGet() + throw new IllegalStateException('broken Grails application discovery') + } + + @Override + ApplicationContext findApplicationContext() { + null + } +} + +class EarlyOrderingCheckedThrowingGrailsPlugin extends Plugin { + + def version = '1.0' + + @Override + Closure doWithSpring() { + { -> throw new Exception('checked failure from doWithSpring') } + } +} + +class EarlyOrderingCompetingPublisherGrailsPlugin extends Plugin { + + static final GrailsApplication competitor = new DefaultGrailsApplication() + + def version = '1.0' + + @Override + Closure doWithSpring() { + { -> + Holders.setGrailsApplication(competitor) + throw new IllegalStateException('failure after competing publication') + } + } +} + +class EarlyOrderingStackedFailureGrailsPlugin extends Plugin { + + static CountDownLatch firstConfigured + static CountDownLatch secondConfigured + static CountDownLatch releaseFirst + static CountDownLatch releaseSecond + static final AtomicInteger invocationCount = new AtomicInteger() + static final AtomicReference firstPromotedApplication = new AtomicReference<>() + static final AtomicReference secondPromotedApplication = new AtomicReference<>() + static boolean firstFails + static boolean secondFails + + static void configure(boolean firstFailure, boolean secondFailure) { + reset() + firstFails = firstFailure + secondFails = secondFailure + } + + static void reset() { + firstConfigured = new CountDownLatch(1) + secondConfigured = new CountDownLatch(1) + releaseFirst = new CountDownLatch(1) + releaseSecond = new CountDownLatch(1) + invocationCount.set(0) + firstPromotedApplication.set(null) + secondPromotedApplication.set(null) + firstFails = false + secondFails = false + } + + def version = '1.0' + + @Override + Closure doWithSpring() { + { -> + if (invocationCount.incrementAndGet() == 1) { + firstPromotedApplication.set(Holders.findApplication()) + firstConfigured.countDown() + if (!releaseFirst.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException('timed out waiting to release the first context') + } + if (firstFails) { + throw new IllegalStateException('first context failed') + } + } + else { + secondPromotedApplication.set(Holders.findApplication()) + secondConfigured.countDown() + if (!releaseSecond.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException('timed out waiting to release the second context') + } + if (secondFails) { + throw new IllegalStateException('second context failed') + } + } + } + } +} + +class EarlyOrderingStackedRefreshes { + + final AnnotationConfigApplicationContext firstContext + final AnnotationConfigApplicationContext secondContext + final Thread firstRefresh + final Thread secondRefresh + final AtomicReference firstFailure + final AtomicReference secondFailure + + EarlyOrderingStackedRefreshes( + AnnotationConfigApplicationContext firstContext, + AnnotationConfigApplicationContext secondContext, + Thread firstRefresh, + Thread secondRefresh, + AtomicReference firstFailure, + AtomicReference secondFailure) { + this.firstContext = firstContext + this.secondContext = secondContext + this.firstRefresh = firstRefresh + this.secondRefresh = secondRefresh + this.firstFailure = firstFailure + this.secondFailure = secondFailure + } + + void releaseFirstAndJoin() { + EarlyOrderingStackedFailureGrailsPlugin.releaseFirst.countDown() + firstRefresh.join(5000) + assert !firstRefresh.alive + } + + void releaseSecondAndJoin() { + EarlyOrderingStackedFailureGrailsPlugin.releaseSecond.countDown() + secondRefresh.join(5000) + assert !secondRefresh.alive + } + + void releaseAllAndJoin() { + EarlyOrderingStackedFailureGrailsPlugin.releaseFirst.countDown() + EarlyOrderingStackedFailureGrailsPlugin.releaseSecond.countDown() + firstRefresh.join(5000) + secondRefresh.join(5000) + assert !firstRefresh.alive + assert !secondRefresh.alive + } + + void closeContexts() { + firstContext.close() + secondContext.close() + } +} + +class EarlyOrderingHoldersLegacyGrailsPlugin { + + static GrailsApplication grailsApplicationSeen + + def version = '1.0' + + def doWithSpring = { grailsApplicationSeen = Holders.grailsApplication } +} + class EarlyOrderingAppResolver { } diff --git a/grails-core/src/test/groovy/grails/util/HoldersSpec.groovy b/grails-core/src/test/groovy/grails/util/HoldersSpec.groovy new file mode 100644 index 00000000000..6dd6f56f740 --- /dev/null +++ b/grails-core/src/test/groovy/grails/util/HoldersSpec.groovy @@ -0,0 +1,244 @@ +/* + * 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.util + +import java.lang.ref.Reference +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +import grails.core.GrailsApplication +import spock.lang.Specification + +class HoldersSpec extends Specification { + + void cleanup() { + Holders.clear() + } + + def "restores the prior fallback when its exact owner fails"() { + given: + GrailsApplication previous = Mock() + GrailsApplication expected = Mock() + Holders.setGrailsApplication(previous) + GrailsApplication capturedPrevious = Holders.replaceGrailsApplication(expected) + + when: + boolean restored = Holders.restoreGrailsApplicationAfterFailure(expected, capturedPrevious) + + then: + restored + Holders.findApplication().is(previous) + } + + def "restoring a visible successor skips its failed captured fallback"() { + given: + GrailsApplication previous = Mock() + GrailsApplication failed = Mock() + GrailsApplication successor = Mock() + Holders.setGrailsApplication(previous) + GrailsApplication failedPrevious = Holders.replaceGrailsApplication(failed) + GrailsApplication successorPrevious = Holders.replaceGrailsApplication(successor) + + when: "the owner fails after a successor has become visible" + boolean restoredFailed = Holders.restoreGrailsApplicationAfterFailure(failed, failedPrevious) + boolean restoredSuccessor = Holders.restoreGrailsApplication(successor, successorPrevious) + + then: + !restoredFailed + restoredSuccessor + Holders.findApplication().is(previous) + } + + def "a later failure skips an already failed fallback"() { + given: + GrailsApplication previous = Mock() + GrailsApplication first = Mock() + GrailsApplication second = Mock() + Holders.setGrailsApplication(previous) + GrailsApplication firstPrevious = Holders.replaceGrailsApplication(first) + GrailsApplication secondPrevious = Holders.replaceGrailsApplication(second) + + when: + boolean restoredFirst = Holders.restoreGrailsApplicationAfterFailure(first, firstPrevious) + boolean restoredSecond = Holders.restoreGrailsApplicationAfterFailure(second, secondPrevious) + + then: + !restoredFirst + restoredSecond + Holders.findApplication().is(previous) + } + + def "failures in reverse order restore each owner to the nonfailed predecessor"() { + given: + GrailsApplication previous = Mock() + GrailsApplication first = Mock() + GrailsApplication second = Mock() + Holders.setGrailsApplication(previous) + GrailsApplication firstPrevious = Holders.replaceGrailsApplication(first) + GrailsApplication secondPrevious = Holders.replaceGrailsApplication(second) + + when: + boolean restoredSecond = Holders.restoreGrailsApplicationAfterFailure(second, secondPrevious) + boolean restoredFirst = Holders.restoreGrailsApplicationAfterFailure(first, firstPrevious) + + then: + restoredSecond + restoredFirst + Holders.findApplication().is(previous) + } + + def "set and replace cannot resurrect a failed application"() { + given: + GrailsApplication previous = Mock() + GrailsApplication failed = Mock() + Holders.setGrailsApplication(previous) + GrailsApplication failedPrevious = Holders.replaceGrailsApplication(failed) + Holders.restoreGrailsApplicationAfterFailure(failed, failedPrevious) + + when: + Holders.setGrailsApplication(failed) + GrailsApplication replaced = Holders.replaceGrailsApplication(failed) + + then: + replaced.is(previous) + Holders.findApplication().is(previous) + } + + def "ordinary replacement and restoration retain exact identity-CAS semantics"() { + given: + GrailsApplication previous = Mock() + GrailsApplication replacement = Mock() + GrailsApplication other = Mock() + Holders.setGrailsApplication(previous) + + when: + GrailsApplication replaced = Holders.replaceGrailsApplication(replacement) + boolean restoredWithOtherOwner = Holders.restoreGrailsApplication(other, previous) + boolean restoredWithExactOwner = Holders.restoreGrailsApplication(replacement, previous) + + then: + replaced.is(previous) + !restoredWithOtherOwner + restoredWithExactOwner + Holders.findApplication().is(previous) + } + + def "clear removes fallback and failure tombstones"() { + given: + GrailsApplication previous = Mock() + GrailsApplication failed = Mock() + Holders.setGrailsApplication(previous) + GrailsApplication failedPrevious = Holders.replaceGrailsApplication(failed) + Holders.restoreGrailsApplicationAfterFailure(failed, failedPrevious) + + when: + Holders.clear() + GrailsApplication cleared = Holders.findApplication() + Holders.setGrailsApplication(failed) + + then: + cleared == null + Holders.findApplication().is(failed) + } + + def "reset removes fallback and failure tombstones"() { + given: + GrailsApplication previous = Mock() + GrailsApplication failed = Mock() + Holders.setGrailsApplication(previous) + GrailsApplication failedPrevious = Holders.replaceGrailsApplication(failed) + Holders.restoreGrailsApplicationAfterFailure(failed, failedPrevious) + + when: + Holders.reset() + GrailsApplication reset = Holders.findApplication() + Holders.setGrailsApplication(failed) + + then: + reset == null + Holders.findApplication().is(failed) + } + + def "findApplication drains queued failed fallback tombstones"() { + given: + FailedReferences references = createFailedTombstoneWithVisibleCurrentApplication() + + when: "the failed application has no remaining strong test reference" + Reference failed = awaitCollection(references.failedQueue) + + then: + failed?.is(references.failed) + + when: "a steady-state fallback read drains the queued tombstone" + GrailsApplication current = Holders.findApplication() + Reference previous = awaitCollection(references.previousQueue) + + then: + current != null + previous?.is(references.previous) + } + + private static FailedReferences createFailedTombstoneWithVisibleCurrentApplication() { + Holders.clear() + GrailsApplication previous = application() + GrailsApplication failed = application() + Holders.setGrailsApplication(previous) + GrailsApplication failedPrevious = Holders.replaceGrailsApplication(failed) + Holders.setGrailsApplication(application()) + assert !Holders.restoreGrailsApplicationAfterFailure(failed, failedPrevious) + new FailedReferences(failed, previous) + } + + private static GrailsApplication application() { + java.lang.reflect.Proxy.newProxyInstance(GrailsApplication.classLoader, [GrailsApplication] as Class[], { proxy, method, arguments -> + if (method.name == 'equals') { + return proxy.is(arguments[0]) + } + if (method.name == 'hashCode') { + return System.identityHashCode(proxy) + } + null + } as java.lang.reflect.InvocationHandler) as GrailsApplication + } + + private static Reference awaitCollection(ReferenceQueue queue) { + for (int attempt = 0; attempt < 50; attempt++) { + System.gc() + System.runFinalization() + System.identityHashCode(new byte[1024 * 1024]) + Reference reference = queue.poll() + if (reference != null) { + return reference + } + } + null + } + + private static class FailedReferences { + final ReferenceQueue failedQueue = new ReferenceQueue<>() + final ReferenceQueue previousQueue = new ReferenceQueue<>() + final WeakReference failed + final WeakReference previous + + FailedReferences(GrailsApplication failed, GrailsApplication previous) { + this.failed = new WeakReference<>(failed, failedQueue) + this.previous = new WeakReference<>(previous, previousQueue) + } + } +} diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 2d777e80ccb..a6990fbcee0 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1472,13 +1472,31 @@ A plugin that wanted to replace a Boot-provided bean therefore had to rely on be In Grails 8 the plugin lifecycle is unified and retimed: plugin bean definitions — both `doWithSpring` and the new `beanRegistrar()` hook — are drained into the bean definition registry *before* Spring Boot expands its auto-configurations. The practical effects are: * **Plugin beans win `@ConditionalOnMissingBean` races.** When a plugin registers a bean, a Boot auto-configuration bean of the same name or type that is guarded by `@ConditionalOnMissingBean` now backs off in favor of the plugin's bean. Plugins no longer need to override or remove Boot's defaults after the fact. -* **A single plugin manager and `GrailsApplication`.** The plugin manager and the `grailsApplication` are built once, early in the context lifecycle, and reused for the rest of startup, so plugins are loaded by a single manager pass rather than a throwaway pass followed by the real one. `Holders.grailsApplication` is available earlier than before. +* **A single plugin manager and `GrailsApplication`.** The plugin manager and the `grailsApplication` are built once, early in the context lifecycle, and reused for the rest of startup, so plugins are loaded by a single manager pass rather than a throwaway pass followed by the real one. * **Artefact discovery happens earlier.** Controllers, services, domain classes and other artefacts are discovered before plugin beans are registered, because core plugins iterate the application's artefacts while defining beans. The set of discovered artefacts is unchanged — only the timing moved. * **Side-effectful `doWithSpring` closures still run once**, just earlier. Closures that read `grailsApplication.config` continue to work; the configuration is fully loaded (including plugin-contributed configuration) before any bean-definition code runs. * **The application class is instantiated a second time during early artefact discovery.** A throwaway instance is created solely to compute the application's class scan; the real lifecycle bean is still created by Spring. An application-class constructor with side effects therefore runs twice per boot, so keep application-class constructors free of side effects. * **Plugins that inspect the bean definition registry see a pre-auto-configuration registry.** A `doWithSpring` closure that checks for the presence of a bean definition (for example `dispatcherServlet` as a "web application" signal) now runs before Spring Boot has registered its auto-configured beans, so such a check returns `false` where it previously returned `true`. Plugin code should test a registration-order-independent signal instead — for example whether the application context is a web application context. * **The `annotationHandlerMapping` and `annotationHandlerAdapter` beans were removed.** These duplicated the request-mapping infrastructure that Spring's own MVC configuration already provides and were never consulted at runtime, but with plugin beans now registering first the bare duplicate adapter started shadowing Spring's fully configured one. `@Controller` annotated beans continue to be handled by Spring Boot's standard MVC infrastructure. +===== 33.1 Legacy `Holders.grailsApplication` Access During `doWithSpring` + +By default, Grails does not publish the application being initialized to `Holders.grailsApplication` while a plugin's `doWithSpring` closure runs. In the normal one-application-per-JVM lifecycle no application is therefore available there. A preexisting global Holder from another context is left untouched, so plugins must not use `Holders` to identify the application being initialized. Spring is still wiring bean definitions at this stage and the new application lifecycle is not ready. + +Some Grails 7 plugins, including audit-logging 6.0.0, read `Holders.grailsApplication` from `doWithSpring`. As a temporary migration aid, enable the legacy shim in `application.yml`: + +[source,yaml] +.application.yml +---- +grails: + legacy: + holdersDuringDoWithSpring: true +---- + +The shim exposes only the promoted application during legacy `doWithSpring` execution. It does not make the lifecycle generally ready, and Grails logs a warning when it is used. Treat it as transitional, deprecated-style compatibility help while the plugin is migrated. + +New plugins should use the `Environment`, configuration, or injected dependencies instead of `Holders`. Migrate `doWithSpring` registrations to `beanRegistrar()` where appropriate. + **The bean builder DSL is deprecated.** `doWithSpring()` on `GrailsApplicationLifeCycle` and `grails.plugins.Plugin` is deprecated as of Grails 8 — it continues to work, but the recommended way to register beans is the new `beanRegistrar()` hook, which returns a Spring Framework `org.springframework.beans.factory.BeanRegistrar`. Before (deprecated bean builder DSL):