Skip to content

AOP-proxied Kotlin suspending function returning kotlin.Result intermittently double-wraps the return value under concurrency (ClassCastException) #37155

Description

@Popbrain

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):

  1. 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.)
  2. The proxied method is a suspend fun returning kotlin.Result<T>.
  3. 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).
  4. 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.invokeSuspendingFunctionCoroutinesUtils.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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    status: waiting-for-triageAn issue we've not yet triaged or decided ontheme: kotlinAn issue related to Kotlin support

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions