spring-projects / spring-projects/spring-framework
AOP-proxied Kotlin suspending function returning `kotlin.Result` intermittently double-wraps the return value under concurrency (ClassCastException)
@sdeleuze is already working on this.
Since Aug 24, 2026.
- Dominant language
- Java
- Stars
- 60.2k
- Forks
- 38.8k
- Avg merge
- 5d 2h
- Merged PRs (30d)
- 27
Description
Thank you for all your work on Spring — I use Spring Boot every day and really appreciate it. While vibe coding, I came across behavior that appears to be a bug, so I'm reporting it here. If it turns out not to be a bug, please feel free to close this issue. Sorry for the trouble, and thank you for taking the time to look at it.
Affects: Spring Framework 7.0.8 (observed in a Spring Boot 4.0.7 application; reproduced standalone with spring-context:7.0.8 only)
Description
When a Kotlin suspend fun returning kotlin.Result<T> (a value class) is invoked through a Spring AOP proxy, the caller intermittently receives a double-wrapped Result<Result<T>> instead of Result<T>. The inlined getOrNull() / getOrThrow() unboxing then fails with:
java.lang.ClassCastException: class kotlin.Result cannot be cast to class java.lang.String
All four of the following ingredients are required — removing any one of them makes the problem disappear (verified by elimination):
- A Spring AOP proxy around the bean — JDK or CGLIB, even with a single pass-through
MethodInterceptor { it.proceed() }. (Real-world trigger in our app:PersistenceExceptionTranslationPostProcessorproxying@Repository-annotated R2DBC repositories because JPA is also on the classpath.) - The proxied method is a
suspend funreturningkotlin.Result<T>. - Invocations mix synchronous fast-path completions and genuine suspensions on the same method (e.g. an in-memory cache: hit returns synchronously, miss awaits a
Mono). - Concurrent invocations (>= 2 coroutines). Single-threaded sequential calls never fail.
The failure is probabilistic (roughly 1 in a few hundred calls in the reproducer; in our application it hit almost every time three background coroutines raced on a cold cache), which suggests a race in the reflective suspending-invocation bridge (AopUtils$KotlinDelegate.invokeSuspendingFunction → CoroutinesUtils.invokeSuspendingFunction) around the boxing of value-class return values. That this code path is involved is visible when running the reproducer without kotlin-reflect on the classpath: every proxied call fails with NoClassDefFoundError: kotlin/reflect/full/KClassifiers from CoroutinesUtils.<clinit> via AopUtils.java:385/359.
Reproducer
Minimal self-contained project (about 70 lines, no Spring Boot, no database): https://github.com/Popbrain/spring-aop-suspend-result-repro (./gradlew run)
Core of the reproducer:
interface CachedRepo {
suspend fun find(key: String, id: String): Result<String?>
fun flush()
}
open class CachedRepoImpl : CachedRepo {
private val cache = ConcurrentHashMap<String, List<String>>()
override fun flush() = cache.clear()
override suspend fun find(key: String, id: String): Result<String?> = runCatching {
val values = cache[key]
?: Mono.delay(Duration.ofMillis(3))
.thenReturn(listOf("value-a", "value-b"))
.awaitSingle()
.also { cache[key] = it } // miss -> genuine suspension
values.firstOrNull { it == id } // hit -> synchronous completion
}
}
fun main() {
val pf = ProxyFactory(CachedRepoImpl())
pf.isProxyTargetClass = true // also fails with JDK proxy
pf.addAdvice(MethodInterceptor { it.proceed() })
val repo = pf.proxy as CachedRepo
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
runBlocking {
for (round in 1..2000) {
repo.flush()
(1..3).map {
scope.launch {
repeat(6) { k ->
// getOrNull() compiles to `value as String?` -> CCE when double-wrapped
repo.find("key-$round", if (k % 2 == 0) "value-a" else "nope").getOrNull()
}
}
}.joinAll()
}
}
}
Typical output (usually within the first rounds):
java.lang.ClassCastException: class kotlin.Result cannot be cast to class java.lang.String
at ReproKt$main$2$1$1.invokeSuspend(Repro.kt:66)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
...
Stack trace from the original application (Spring Boot 4.0.7, @Repository R2DBC data source proxied by PersistenceExceptionTranslationAdvisor, three background coroutines running concurrently):
java.lang.ClassCastException: class kotlin.Result cannot be cast to class com.example.SomeEntity
at com.example.SomeDataProviderImpl.findByCode$suspendImpl(SomeDataProvider.kt:88)
at com.example.SomeDataProviderImpl$findByCode$1.invokeSuspend(SomeDataProvider.kt)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
...
Environment
- Spring Framework 7.0.8 / Spring Boot 4.0.7
- Kotlin 2.2.21
- kotlinx-coroutines 1.10.2 (
kotlinx-coroutines-reactor) - Reactor Core 3.8.6
- JDK 21 (Amazon Corretto), macOS & Linux
Workaround
Avoid proxying such beans, e.g. replace @Repository with @Component on classes exposing suspend functions returning kotlin.Result, or disable persistence exception translation (spring.persistence.exceptiontranslation.enabled=false).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.