Skip to content

Commit aa7d150

Browse files
committed
add a reproducer for #11758
1 parent c97bfac commit aa7d150

File tree

1 file changed

+141
-0
lines changed

1 file changed

+141
-0
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package io.micronaut.reproduce
2+
3+
import io.micronaut.context.ApplicationContext
4+
import io.micronaut.context.annotation.Context
5+
import io.micronaut.context.annotation.Replaces
6+
import io.micronaut.context.annotation.Requires
7+
import io.micronaut.core.annotation.Order
8+
import jakarta.inject.Singleton
9+
import org.junit.jupiter.api.AfterEach
10+
import org.junit.jupiter.api.Test
11+
import kotlin.test.assertTrue
12+
13+
// Minimal, self-contained reproduction of the @Context / @Replaces nondeterministic behavior.
14+
// All beans are guarded by a test property so they do not affect other tests.
15+
16+
interface DemoService
17+
18+
@Context
19+
@Requires(property = "spec.name", value = "replace-context")
20+
open class OriginalDemoService : DemoService {
21+
companion object {
22+
@Volatile
23+
var created = 0
24+
}
25+
26+
init {
27+
created++
28+
// make sure construction side-effects are visible in logs
29+
System.err.println("[OriginalDemoService] created (count=$created)")
30+
}
31+
}
32+
33+
// Replacement intentionally does NOT use @Context. It should replace the OriginalDemoService
34+
// definition. If replacement does not replace the @Context scoped bean definition properly,
35+
// the OriginalDemoService may still be instantiated at startup (the bug under investigation).
36+
@Replaces(OriginalDemoService::class)
37+
@Singleton
38+
@Requires(property = "spec.name", value = "replace-context")
39+
open class ReplacementDemoService : DemoService {
40+
companion object {
41+
@Volatile
42+
var created = 0
43+
}
44+
45+
init {
46+
created++
47+
System.err.println("[ReplacementDemoService] created (count=$created)")
48+
}
49+
}
50+
51+
// Two eager consumers with explicit order to force different initialization sequences.
52+
// They are @Context so they initialize during context start.
53+
@Context
54+
@Order(-1000)
55+
@Requires(property = "spec.name", value = "replace-context")
56+
@Requires(property = "scenario", value = "early")
57+
class EarlyConsumer(bean: DemoService) {
58+
companion object {
59+
@Volatile
60+
var injected: DemoService? = null
61+
}
62+
63+
init {
64+
injected = bean
65+
System.err.println("[EarlyConsumer] injected -> ${'$'}{bean::class.qualifiedName}")
66+
}
67+
}
68+
69+
@Context
70+
@Order(100)
71+
@Requires(property = "spec.name", value = "replace-context")
72+
@Requires(property = "scenario", value = "late")
73+
class LateConsumer(bean: DemoService) {
74+
companion object {
75+
@Volatile
76+
var injected: DemoService? = null
77+
}
78+
79+
init {
80+
injected = bean
81+
System.err.println("[LateConsumer] injected -> ${'$'}{bean::class.qualifiedName}")
82+
}
83+
}
84+
85+
class ReplaceContextBeanSpec {
86+
87+
@AfterEach
88+
fun cleanup() {
89+
OriginalDemoService.created = 0
90+
ReplacementDemoService.created = 0
91+
EarlyConsumer.injected = null
92+
LateConsumer.injected = null
93+
}
94+
95+
@Test
96+
fun `replacement should prevent original @Context instantiation when consumer is early`() {
97+
val ctx = ApplicationContext.run(mapOf("spec.name" to "replace-context", "scenario" to "early"))
98+
try {
99+
// Collect diagnostics
100+
val originalCount = OriginalDemoService.created
101+
val replacementCount = ReplacementDemoService.created
102+
val injected = EarlyConsumer.injected
103+
104+
// If the replacement worked properly, the original @Context must not be instantiated at all
105+
// and the injected instance must be the ReplacementDemoService.
106+
if (originalCount != 0 || replacementCount != 1 || injected !is ReplacementDemoService) {
107+
throw AssertionError(
108+
"Early scenario: unexpected outcome:\n" +
109+
" OriginalDemoService.created=$originalCount\n" +
110+
" ReplacementDemoService.created=$replacementCount\n" +
111+
" EarlyConsumer.injected=${injected?.let { it::class.qualifiedName } ?: "<null>"}\n" +
112+
" (Expected: original.created=0, replacement.created=1, injected -> ReplacementDemoService)"
113+
)
114+
}
115+
} finally {
116+
ctx.close()
117+
}
118+
}
119+
120+
@Test
121+
fun `replacement should prevent original @Context instantiation when consumer is late`() {
122+
val ctx = ApplicationContext.run(mapOf("spec.name" to "replace-context", "scenario" to "late"))
123+
try {
124+
val originalCount = OriginalDemoService.created
125+
val replacementCount = ReplacementDemoService.created
126+
val injected = LateConsumer.injected
127+
128+
if (originalCount != 0 || replacementCount != 1 || injected !is ReplacementDemoService) {
129+
throw AssertionError(
130+
"Late scenario: unexpected outcome:\n" +
131+
" OriginalDemoService.created=$originalCount\n" +
132+
" ReplacementDemoService.created=$replacementCount\n" +
133+
" LateConsumer.injected=${injected?.let { it::class.qualifiedName } ?: "<null>"}\n" +
134+
" (Expected: original.created=0, replacement.created=1, injected -> ReplacementDemoService)"
135+
)
136+
}
137+
} finally {
138+
ctx.close()
139+
}
140+
}
141+
}

0 commit comments

Comments
 (0)