spring-projects / spring-projects/spring-framework

`BridgeMethodResolver` does not resolve bridge methods for multi-level generic Kotlin suspend overrides

Open
#37,125 3 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

in: core status: waiting-for-triage theme: kotlin
Dominant language
Java
Stars
60.2k
Forks
38.8k
Avg merge
5d 2h
Merged PRs (30d)
27

Description

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:

  1. With a class-based proxy, invoking the method through a supertype reference
    throws AopInvocationException before a transaction starts.
  2. 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at BridgeMethodResolver.findBridgedMethod and follow resolveBridgeMethod, searchCandidates, and checkResolvedTypeMatch. Run the linked reproducer with the provided Gradle command to observe the bridge resolution and transaction behavior. Done means both synthetic bridges resolve to the non-bridge TransactionalProcessor.process method and the reproducer records the expected transaction start and commit.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, kotlin, spring
Domain
backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.