Description
BridgeMethodResolver.findBridgedMethod leaves a synthetic bridge method
unresolved for a generic Kotlin suspend override when the class hierarchy
contains two non-bridge implementations of that method.
This affects Spring AOP in two ways:
- With a class-based proxy, invoking the method through a supertype reference
throws AopInvocationException before a transaction starts.
- With an interface-based proxy, the invocation succeeds but the
@Transactional attribute is not applied.
The equivalent hierarchy with only one implementation resolves correctly.
Minimal sample
Reproducer:
https://github.com/kylequera/spring-suspend-bridge-repro
The sample uses only Spring Framework, Kotlin, coroutines, and a counting
ReactiveTransactionManager. It requires no database and no Spring Boot
application.
Run:
GRADLE_USER_HOME="$PWD/.gradle-home" \
./gradlew test -PspringVersion=7.0.8 --no-daemon
The hierarchy is intentionally domain-neutral:
interface RootValue
interface SpecializedValue : RootValue
class SampleValue : SpecializedValue
interface Processor<T : RootValue> {
suspend fun <S : T> process(value: S): S
}
abstract class SpecializedProcessor<T : SpecializedValue> : Processor<T> {
override suspend fun <S : T> process(value: S): S = value
}
open class TransactionalProcessor : SpecializedProcessor<SampleValue>() {
@Transactional
override suspend fun <S : SampleValue> process(value: S): S =
super.process(value)
}
TransactionalProcessor contains the real
process(SampleValue, Continuation) method and synthetic bridges for
process(SpecializedValue, Continuation) and
process(RootValue, Continuation).
Expected behavior
BridgeMethodResolver.findBridgedMethod should resolve either synthetic bridge
to the corresponding non-bridge TransactionalProcessor.process method.
Spring AOP should consequently find the transaction attribute and invoke the
Kotlin suspending function correctly, independent of whether the call is made
through the concrete class or the generic interface.
Actual behavior
For the RootValue bridge,
BridgeMethodResolver.findBridgedMethod(bridge) returns an equal bridge method
whose isBridge property is still true.
With class-based proxying, a call through Processor<SampleValue> fails with
this exception chain:
org.springframework.aop.AopInvocationException:
AOP configuration seems to be invalid: tried calling method
[public java.lang.Object example.bridge.TransactionalProcessor.process(
example.bridge.RootValue, kotlin.coroutines.Continuation)] on target
[example.bridge.TransactionalProcessor]
Caused by: java.lang.IllegalArgumentException:
Failed to get Kotlin function for method:
public java.lang.Object example.bridge.TransactionalProcessor.process(
example.bridge.RootValue, kotlin.coroutines.Continuation)
The counting transaction manager records zero transaction starts.
With interface-based proxying, the same call returns successfully, but the
counting transaction manager again records zero transaction starts and zero
commits.
The single-implementation control resolves its bridge to the non-bridge method
and records one transaction start and one commit.
Analysis
resolveBridgeMethod finds more than one non-bridge candidate, so it cannot use
the single-candidate shortcut and calls searchCandidates.
checkResolvedTypeMatch resolves the Kotlin-declared return type to the
specialized value type, then compares it with the candidate suspend method's
JVM return type, which is Object. The return-type check rejects each
candidate. Since the generic parameter signatures at the two implementation
levels also differ, the same-signature fallback does not select a candidate,
and the original bridge is returned.
This appears to be the multi-implementation counterpart of
#33045.
That issue's single-implementation case is fixed, but the candidate-search path
used here remains affected. Kotlin reflection does not expose synthetic bridge
methods as Kotlin functions; see
KT-20768.
Tested versions
- Spring Framework 6.2.15
- Spring Framework 6.2.19
- Spring Framework 7.0.8
- Kotlin 2.1.0
- Gradle 8.10.2
- JDK 21
The behavior is identical across all three tested Spring Framework versions.
Description
BridgeMethodResolver.findBridgedMethodleaves a synthetic bridge methodunresolved for a generic Kotlin
suspendoverride when the class hierarchycontains two non-bridge implementations of that method.
This affects Spring AOP in two ways:
throws
AopInvocationExceptionbefore a transaction starts.@Transactionalattribute is not applied.The equivalent hierarchy with only one implementation resolves correctly.
Minimal sample
Reproducer:
https://github.com/kylequera/spring-suspend-bridge-repro
The sample uses only Spring Framework, Kotlin, coroutines, and a counting
ReactiveTransactionManager. It requires no database and no Spring Bootapplication.
Run:
The hierarchy is intentionally domain-neutral:
TransactionalProcessorcontains the realprocess(SampleValue, Continuation)method and synthetic bridges forprocess(SpecializedValue, Continuation)andprocess(RootValue, Continuation).Expected behavior
BridgeMethodResolver.findBridgedMethodshould resolve either synthetic bridgeto the corresponding non-bridge
TransactionalProcessor.processmethod.Spring AOP should consequently find the transaction attribute and invoke the
Kotlin suspending function correctly, independent of whether the call is made
through the concrete class or the generic interface.
Actual behavior
For the
RootValuebridge,BridgeMethodResolver.findBridgedMethod(bridge)returns an equal bridge methodwhose
isBridgeproperty is stilltrue.With class-based proxying, a call through
Processor<SampleValue>fails withthis exception chain:
The counting transaction manager records zero transaction starts.
With interface-based proxying, the same call returns successfully, but the
counting transaction manager again records zero transaction starts and zero
commits.
The single-implementation control resolves its bridge to the non-bridge method
and records one transaction start and one commit.
Analysis
resolveBridgeMethodfinds more than one non-bridge candidate, so it cannot usethe single-candidate shortcut and calls
searchCandidates.checkResolvedTypeMatchresolves the Kotlin-declared return type to thespecialized value type, then compares it with the candidate suspend method's
JVM return type, which is
Object. The return-type check rejects eachcandidate. Since the generic parameter signatures at the two implementation
levels also differ, the same-signature fallback does not select a candidate,
and the original bridge is returned.
This appears to be the multi-implementation counterpart of
#33045.
That issue's single-implementation case is fixed, but the candidate-search path
used here remains affected. Kotlin reflection does not expose synthetic bridge
methods as Kotlin functions; see
KT-20768.
Tested versions
The behavior is identical across all three tested Spring Framework versions.