From 6fc9704df98a7b90e975056116567aabc4bd76c5 Mon Sep 17 00:00:00 2001 From: Jiawei Wang Date: Sat, 1 Nov 2025 21:38:43 +0800 Subject: [PATCH] preventing unwanted eager instantiation and ensuring correct bean selection by exact definition --- .../micronaut/context/DefaultBeanContext.java | 14 +++++ .../io/micronaut/context/SingletonScope.java | 3 +- .../ContextBeanReplacementScopeTest.kt | 61 +++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 test-suite-kotlin-ksp/src/test/kotlin/io/micronaut/context/replacement/ContextBeanReplacementScopeTest.kt diff --git a/inject/src/main/java/io/micronaut/context/DefaultBeanContext.java b/inject/src/main/java/io/micronaut/context/DefaultBeanContext.java index 70037b2b179..1924b425ca9 100644 --- a/inject/src/main/java/io/micronaut/context/DefaultBeanContext.java +++ b/inject/src/main/java/io/micronaut/context/DefaultBeanContext.java @@ -2032,6 +2032,20 @@ protected void initializeContext( throw new BeanInstantiationException(MSG_BEAN_DEFINITION + (ref == null ? "" : ref.getName()) + MSG_COULD_NOT_BE_LOADED + e.getMessage(), e); } } + // Prune eager beans that are replaced by any replacement bean in the application, + // regardless of the replacement scope. + List> replacementTypes = new ArrayList<>(4); + for (BeanDefinitionProducer producer : beanDefinitionsClasses) { + BeanDefinition def = producer.getDefinitionIfEnabled(this); + if (def != null && def.getAnnotationMetadata().hasStereotype(REPLACES_ANN)) { + replacementTypes.add(def); + } + } + if (!replacementTypes.isEmpty()) { + //noinspection unchecked,rawtypes + eagerInit.removeIf(def -> checkIfReplacementExists(null, (List) replacementTypes, def)); + } + // Also apply local replacement filtering amongst eager beans themselves. filterReplacedBeans(null, eagerInit); OrderUtil.sortOrdered(eagerInit); for (BeanDefinition eagerInitDefinition : eagerInit) { diff --git a/inject/src/main/java/io/micronaut/context/SingletonScope.java b/inject/src/main/java/io/micronaut/context/SingletonScope.java index fb32a201ca5..15933d89427 100644 --- a/inject/src/main/java/io/micronaut/context/SingletonScope.java +++ b/inject/src/main/java/io/micronaut/context/SingletonScope.java @@ -248,7 +248,8 @@ BeanRegistration findBeanRegistration(@NonNull BeanDefinition beanDefi @Nullable Qualifier qualifier) { BeanRegistration beanRegistration = singletonByBeanDefinition.get(BeanDefinitionIdentity.of(beanDefinition)); if (beanRegistration == null) { - return findCachedSingletonBeanRegistration(beanType, qualifier); + // Do not fall back to cached registration by type/qualifier; we must respect the exact definition. + return null; } return beanRegistration; } diff --git a/test-suite-kotlin-ksp/src/test/kotlin/io/micronaut/context/replacement/ContextBeanReplacementScopeTest.kt b/test-suite-kotlin-ksp/src/test/kotlin/io/micronaut/context/replacement/ContextBeanReplacementScopeTest.kt new file mode 100644 index 00000000000..06d15baee2f --- /dev/null +++ b/test-suite-kotlin-ksp/src/test/kotlin/io/micronaut/context/replacement/ContextBeanReplacementScopeTest.kt @@ -0,0 +1,61 @@ +package io.micronaut.context.replacement + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Context +import io.micronaut.context.annotation.Replaces +import io.micronaut.context.annotation.Requires +import jakarta.inject.Singleton +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.util.concurrent.atomic.AtomicInteger + +interface ReplacedApi + +@Context +@Requires(property = "spec.name", value = "ContextBeanReplacementScopeTest") +open class OriginalContextApi : ReplacedApi { + companion object { + @JvmField + val created = AtomicInteger(0) + } + init { + created.incrementAndGet() + } +} + +@Singleton +@Replaces(OriginalContextApi::class) +@Requires(property = "spec.name", value = "ContextBeanReplacementScopeTest") +open class ReplacementSingletonApi : ReplacedApi { + companion object { + @JvmField + val created = AtomicInteger(0) + } + init { + created.incrementAndGet() + } +} + +class ContextBeanReplacementScopeTest { + + @Test + fun replacingContextBeanWithSingletonShouldPreventOriginalInstantiation() { + // Reset counters for isolation + OriginalContextApi.created.set(0) + ReplacementSingletonApi.created.set(0) + + val ctx = ApplicationContext.run(mapOf("spec.name" to "ContextBeanReplacementScopeTest")) + try { + // Expected: Replacing a @Context bean with a non-@Context bean should prevent eager instantiation + // Actual (bug): OriginalContextApi is still eagerly created at startup + assertEquals(0, OriginalContextApi.created.get(), "Original @Context bean should not be instantiated when replaced by a non-context bean") + + val api = ctx.getBean(ReplacedApi::class.java) + assertTrue(api is ReplacementSingletonApi, "Injected bean should be the replacement") + assertEquals(1, ReplacementSingletonApi.created.get(), "Replacement bean should be constructed once on demand") + assertEquals(0, OriginalContextApi.created.get(), "Original bean must not be constructed at all") + } finally { + ctx.close() + } + } +}