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: PersistenceExceptionTranslationPostProcessor proxying @Repository-annotated R2DBC repositories because JPA is also on the classpath.)
- The proxied method is a
suspend fun returning kotlin.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).
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.8only)Description
When a Kotlin
suspend funreturningkotlin.Result<T>(a value class) is invoked through a Spring AOP proxy, the caller intermittently receives a double-wrappedResult<Result<T>>instead ofResult<T>. The inlinedgetOrNull()/getOrThrow()unboxing then fails with:All four of the following ingredients are required — removing any one of them makes the problem disappear (verified by elimination):
MethodInterceptor { it.proceed() }. (Real-world trigger in our app:PersistenceExceptionTranslationPostProcessorproxying@Repository-annotated R2DBC repositories because JPA is also on the classpath.)suspend funreturningkotlin.Result<T>.Mono).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 withoutkotlin-reflecton the classpath: every proxied call fails withNoClassDefFoundError: kotlin/reflect/full/KClassifiersfromCoroutinesUtils.<clinit>viaAopUtils.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:
Typical output (usually within the first rounds):
Stack trace from the original application (Spring Boot 4.0.7,
@RepositoryR2DBC data source proxied byPersistenceExceptionTranslationAdvisor, three background coroutines running concurrently):Environment
kotlinx-coroutines-reactor)Workaround
Avoid proxying such beans, e.g. replace
@Repositorywith@Componenton classes exposingsuspendfunctions returningkotlin.Result, or disable persistence exception translation (spring.persistence.exceptiontranslation.enabled=false).