Spring Boot: 3.4.0-M3

This is minimal code to reproduce:

package com.example.demo

import org.springframework.boot.ApplicationRunner
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@SpringBootApplication
class DemoApplication {
    @Bean
    fun runner(service: DemoService) = ApplicationRunner {
        try {
            println(service.results())
        } catch (e: DemoException) {
            println("exception: ${e.message}")
        }
    }
}

fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args)
}

class DemoException(message: String?) : Exception(message)

interface DemoService {
    fun results(): Map<String, Any>
}

@Service
class DemoServiceImpl : DemoService {
    @Transactional(readOnly = true)
    override fun results(): Map<String, Any> {
        throw DemoException("sww")
    }
}

Expected output:

exception: sww

Actual output:

java.lang.reflect.UndeclaredThrowableException: null
        at com.example.demo.DemoServiceImpl$$SpringCGLIB$$0.results(<generated>) ~[main/:na]
        at com.example.demo.DemoApplication.runner$lambda$0(DemoApplication.kt:15) ~[main/:na]
        at org.springframework.boot.SpringApplication.lambda$callRunner$4(SpringApplication.java:787) ~[spring-boot-3.4.0-M3.jar:3.4.0-M3]
        at org.springframework.util.function.ThrowingConsumer$1.acceptWithException(ThrowingConsumer.java:83) ~[spring-core-6.2.0-RC1.jar:6.2.0-RC1]
        at org.springframework.util.function.ThrowingConsumer.accept(ThrowingConsumer.java:60) ~[spring-core-6.2.0-RC1.jar:6.2.0-RC1]
        at org.springframework.util.function.ThrowingConsumer$1.accept(ThrowingConsumer.java:88) ~[spring-core-6.2.0-RC1.jar:6.2.0-RC1]
        at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:799) ~[spring-boot-3.4.0-M3.jar:3.4.0-M3]
        at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:787) ~[spring-boot-3.4.0-M3.jar:3.4.0-M3]
        at org.springframework.boot.SpringApplication.lambda$callRunners$3(SpringApplication.java:775) ~[spring-boot-3.4.0-M3.jar:3.4.0-M3]
        at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:184) ~[na:na]
        at java.base/java.util.stream.SortedOps$SizedRefSortingSink.end(SortedOps.java:357) ~[na:na]
        at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:510) ~[na:na]
        at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499) ~[na:na]
        at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151) ~[na:na]
        at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174) ~[na:na]
        at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234) ~[na:na]
        at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596) ~[na:na]
        at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:775) ~[spring-boot-3.4.0-M3.jar:3.4.0-M3]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:325) ~[spring-boot-3.4.0-M3.jar:3.4.0-M3]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1364) ~[spring-boot-3.4.0-M3.jar:3.4.0-M3]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1353) ~[spring-boot-3.4.0-M3.jar:3.4.0-M3]
        at com.example.demo.DemoApplicationKt.main(DemoApplication.kt:40) ~[main/:na]
Caused by: com.example.demo.DemoException: sww
        at com.example.demo.DemoServiceImpl.results(DemoApplication.kt:36) ~[main/:na]
        at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
        at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:359) ~[spring-aop-6.2.0-RC1.jar:6.2.0-RC1]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) ~[spring-aop-6.2.0-RC1.jar:6.2.0-RC1]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-6.2.0-RC1.jar:6.2.0-RC1]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:771) ~[spring-aop-6.2.0-RC1.jar:6.2.0-RC1]
        at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:380) ~[spring-tx-6.2.0-RC1.jar:6.2.0-RC1]
        at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-6.2.0-RC1.jar:6.2.0-RC1]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.2.0-RC1.jar:6.2.0-RC1]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:771) ~[spring-aop-6.2.0-RC1.jar:6.2.0-RC1]
        at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:724) ~[spring-aop-6.2.0-RC1.jar:6.2.0-RC1]

Comment From: wilkinsona

Thanks for the report. The proxying here is part of Spring Framework's support for @Transactional and is out of Spring Boot's control. We'll transfer this issue to the Framework team so that they can investigate.

Comment From: razubuddy

Thanks

Comment From: sdeleuze

I can't reproduce so please provide a self-contained reproduced (including the Maven or Gradle build) via an attached archive or a link to a repository.

Comment From: razubuddy

Sure, here is demo for issue reproduction https://github.com/razubuddy/demo

This is a generated application with Spring Boot 3.4.0-M3, it uses spring-framework 6.2.0-RC1

Comment From: razubuddy

I think this is regression which was fixed once in the past https://github.com/spring-projects/spring-framework/issues/23844

Comment From: sdeleuze

@jhoeller Looks like a regression caused by #32469, I guess we need to restore the special Kotlin code path in the new implementation. Any guidance from your side?

Comment From: jhoeller

@sdeleuze that special code path propagated the exception as-is, and we're always propagating exceptions as-is there now. Is the UndeclaredThrowableException maybe coming out of a different place within CGLIB where we need to make it Kotlin-friendly in our CGLIB fork?

As a side note: As of #32469, CglibMethodInvocation currently does not add anything, so could be replaced by a plain ReflectiveMethodInvocation. Unless we have to add any special checks to it for fixing this issue here.

Comment From: sdeleuze

@jhoeller Indeed, the related UndeclaredThrowableException likely comes from the bytecode generated by EmitUtils#wrap_undeclared_throwable invocation in UndeclaredThrowableTransformer. We may potentially have to adapt the one in InvocationHandlerGenerator too (not sure yet).

If I enable the Spring Boot AOT plugin to see what the CGLIB generated class looks, I see:

public final Map results() {
        try {
            MethodInterceptor var10000 = this.CGLIB$CALLBACK_0;
            if (var10000 == null) {
                CGLIB$BIND_CALLBACKS(this);
                var10000 = this.CGLIB$CALLBACK_0;
            }

            return var10000 != null ? (Map)var10000.intercept(this, CGLIB$results$0$Method, CGLIB$emptyArgs, CGLIB$results$0$Proxy) : super.results();
        } catch (Error | RuntimeException var1) {
            throw var1;
        } catch (Throwable var2) {
            throw new UndeclaredThrowableException(var2);
        }
    }

I guess we need to skip that EmitUtils#wrap_undeclared_throwable invocation for Kotlin, but how can we detect such information in CGLIB world? KotlinDetector dectect a Kotlin class via the presence of a kotlin.Metadata declared annotation at class level. I don't think org.springframework.cglib.core.ClassInfo currently provides that information, should we enrich it with that information via ClassEmitter -> ClassVisitor#visitAnnotation? Is it ok to just store a boolean to limit the memory impact compared to if we were storing all the class annotation information?

Comment From: jhoeller

@sdeleuze maybe we could do that Kotlin check in our CglibAopProxy class, conditionally calling enhancer.setStrategy(new ClassLoaderAwareGeneratorStrategy(classLoader)) instead of enhancer.setStrategy(new ClassLoaderAwareGeneratorStrategy(classLoader, undeclaredThrowableStrategy)) if KotlinDetector.isKotlinType(proxySuperClass)? That would avoid modifying the actual CGLIB code, applying a different generator strategy per target class instead.