Leonard Brünings created GROOVY-12046:
-----------------------------------------

             Summary: MissingMethodException reports the metaclass theClass (a 
supertype) instead of the receiver's runtime class, breaking the 
GroovyObject.invokeMethod MOP fallback
                 Key: GROOVY-12046
                 URL: https://issues.apache.org/jira/browse/GROOVY-12046
             Project: Groovy
          Issue Type: Bug
    Affects Versions: 6.0.0-alpha-1
            Reporter: Leonard Brünings
         Attachments: GroovyMopRepro.groovy, GroovyMopReproByteBuddy.groovy

This was discovered in the Spock Groovy 6 [integration 
branch|https://github.com/spockframework/spock/pull/2356].
The analysis was done by Claude, but it seems reasonable to me.
I've verified that the reproducers work.
h2. Summary

When a method call cannot be resolved on a {{GroovyObject}} {*}whose 
per-instance {{metaClass.theClass}} is a _supertype_ of the object's actual 
runtime class{*}, Groovy is supposed to fall back to 
{{GroovyObject.invokeMethod(String, Object)}} (the documented MOP contract).
 * *Groovy 5.0.6:* the fallback is honored — 
{{MetaClassImpl.invokeMethod(...)}} reports the *runtime class* as the 
{{MissingMethodException}} type, the invokedynamic recovery handler's guard 
{{receiver.getClass() == e.getType()}} matches, and 
{{GroovyObject.invokeMethod}} is invoked.
 * *Groovy 6.0.0-alpha-1 and 6.0.0-SNAPSHOT (master):* the same call throws 
{{MissingMethodException}} whose type is the metaclass's {*}{{theClass}} (the 
supertype){*}. The recovery guard no longer matches, so the 
{{GroovyObject.invokeMethod}} fallback is *never invoked* and the exception 
escapes.

This is the exact shape produced by a mocking framework's proxy (e.g. Spock / 
ByteBuddy): a generated subclass whose metaclass is that of the mocked 
supertype. It causes previously-passing code to throw 
{{MissingMethodException}} under Groovy 6.
h2. Affected versions
||Groovy||Result||
|5.0.6|(/) fallback honored (no exception / runtime-class type)|
|6.0.0-alpha-1|(x) {{MissingMethodException(type = supertype)}}|
|6.0.0-SNAPSHOT (master, build {{{}6.0.0-20260529.093309-717{}}})|(x) same as 
alpha-1|
h2. Reproducer A — minimal (pure Groovy, no third-party deps)
{code:groovy}
import groovy.lang.*

class Outer {                                    // mocked types are commonly 
nested
  static class ObjClass {                         // the "mocked type" / 
supertype
    String test(int a, int b) { "real:${a + b}" }
  }
  static class Proxy extends ObjClass {           // the "mock proxy" / runtime 
subclass
    private final MetaClass mc
    Proxy() { mc = new MetaClassImpl(GroovySystem.metaClassRegistry, ObjClass); 
mc.initialize() }
    @Override MetaClass getMetaClass() { mc }     // theClass == ObjClass (the 
SUPERTYPE)
    @Override Object invokeMethod(String name, Object args) { 
"FALLBACK(${name})" }
  }
}

Outer.ObjClass client = new Outer.Proxy()
def args = [123d, false] as Object[]             // (Double, Boolean) does not 
match test(int,int)

println "Groovy ${GroovySystem.version}"
println "runtime class      : ${client.getClass().simpleName}"          // Proxy
println "metaClass.theClass : ${client.getMetaClass().theClass.simpleName}"  // 
ObjClass (supertype)
println "direct invokeMethod: ${client.invokeMethod('test', args)}"     // 
FALLBACK(test)  (works on all versions)

try {
  client.getMetaClass().invokeMethod(Outer.ObjClass, client, "test", args, 
false, false)
  println "MetaClass.invokeMethod: returned (fallback honored)"
} catch (MissingMethodException e) {
  println "MetaClass.invokeMethod: THREW type=${e.type.simpleName}"
}
{code}
h3. Output

*Groovy 5.0.6*
{noformat}
runtime class      : Proxy
metaClass.theClass : ObjClass
direct invokeMethod: FALLBACK(test)
MetaClass.invokeMethod: THREW type=Proxy        <-- runtime class
{noformat}
*Groovy 6.0.0-alpha-1 / 6.0.0-SNAPSHOT*
{noformat}
runtime class      : Proxy
metaClass.theClass : ObjClass
direct invokeMethod: FALLBACK(test)
MetaClass.invokeMethod: THREW type=ObjClass     <-- supertype (regression)
{noformat}
Note the fallback target itself ({{{}client.invokeMethod(...){}}}) returns a 
value on *all* versions — only the _routing to it_ regressed.
h2. Reproducer B — end-to-end with a real ByteBuddy mock proxy ({{{}@Grab{}}}, 
no Spock)

This builds the proxy the same way a mocking framework does (ByteBuddy 
subclass, {{metaClass.theClass}} = the mocked supertype, 
{{{}@Internal{}}}-annotated {{invokeMethod}} fallback) and calls the unresolved 
method through a normal call site. It shows the regression directly in the 
thrown exception's type.
{code:groovy}
@Grab('net.bytebuddy:byte-buddy:1.18.8')
import net.bytebuddy.ByteBuddy
import net.bytebuddy.description.annotation.AnnotationDescription
import net.bytebuddy.dynamic.loading.ClassLoadingStrategy
import net.bytebuddy.implementation.MethodDelegation
import net.bytebuddy.implementation.bind.annotation.*
import static net.bytebuddy.matcher.ElementMatchers.named
import groovy.lang.*
import groovy.transform.Internal
import org.codehaus.groovy.runtime.InvokerHelper
import java.lang.reflect.Method

class Outer { static class ObjClass { String test(int a, int b) { "real:${a + 
b}" } } }

class MockInterceptor {
  static MetaClass mockMetaClass
  @RuntimeType
  static Object intercept(@This Object self, @Origin Method method, 
@AllArguments Object[] args) {
    if (method.name == 'getMetaClass') return mockMetaClass        // theClass 
== supertype
    if (method.name == 'invokeMethod') return "FALLBACK(${args[0]})"
    return null
  }
}

def mop = 
named("invokeMethod").or(named("getProperty")).or(named("setProperty"))
def proxyType = new 
ByteBuddy().subclass(Outer.ObjClass).name('Outer$ObjClass$ByteBuddyMock$1')
  .method(mop).intercept(MethodDelegation.to(MockInterceptor))
      .annotateMethod(AnnotationDescription.Builder.ofType(Internal).build())
  
.method(named("getMetaClass").or(named("test"))).intercept(MethodDelegation.to(MockInterceptor))
  .make().load(Outer.ObjClass.classLoader, 
ClassLoadingStrategy.Default.WRAPPER).loaded

Outer.ObjClass client = (Outer.ObjClass) 
proxyType.getDeclaredConstructor().newInstance()
MockInterceptor.mockMetaClass = InvokerHelper.getMetaClass(Outer.ObjClass)

try {
  println "Groovy ${GroovySystem.version}: returned ${client.test(123d, false)}"
} catch (MissingMethodException e) {
  println "Groovy ${GroovySystem.version}: threw 
MissingMethodException(type=${e.type.simpleName}) " +
          (e.type == client.getClass() ? "[RUNTIME class -> recovery matches]" 
: "[SUPERTYPE -> recovery fails: REGRESSION]")
}
{code}
h3. Output
{noformat}
Groovy 5.0.6                  : threw 
MissingMethodException(type=Outer$ObjClass$ByteBuddyMock$1) [RUNTIME class -> 
recovery matches]
Groovy 6.0.0-alpha-1          : threw MissingMethodException(type=ObjClass)     
                  [SUPERTYPE -> recovery fails: REGRESSION]
Groovy 6.0.0-SNAPSHOT         : threw MissingMethodException(type=ObjClass)     
                  [SUPERTYPE -> recovery fails: REGRESSION]
{noformat}
The exception type flips from the runtime proxy class (Groovy 5) to the 
supertype (Groovy 6). The runtime-class type is exactly what the 
{{invokeGroovyObjectInvoker}} guard requires; in a real mocking framework 
(where the proxy's {{invokeMethod}} is reached) this is the difference between 
a recovered call returning the mock default and an escaping 
{{{}MissingMethodException{}}}.
h2. Expected vs. actual
 * *Expected (Groovy 5 behavior):* {{MissingMethodException.getType()}} is the 
receiver's actual runtime class, so the documented "method not found -> 
{{{}GroovyObject.invokeMethod{}}}" fallback fires.
 * *Actual (Groovy 6):* {{MissingMethodException.getType()}} is the metaclass 
{{theClass}} (a supertype), so the fallback is skipped and the exception 
propagates.

h2. Root-cause analysis

The recovery that implements the {{GroovyObject.invokeMethod}} fallback for 
indy call sites is 
{{{}org.codehaus.groovy.vmplugin.v8.IndyGuardsFiltersAndSignatures.invokeGroovyObjectInvoker{}}}:
{code:java}
} else if (receiver.getClass() == e.getType() && e.getMethod().equals(name)) {
    // in case there's nothing else, invoke the object's own invokeMethod()
    return ((GroovyObject) receiver).invokeMethod(name, args);
}
{code}
This class is *byte-identical* between 5.0.6 and 6.0.0-alpha-1, so the 
regression is upstream of it, in {{{}groovy.lang.MetaClassImpl{}}}. The type 
carried by the thrown {{MissingMethodException}} changed:
 * *Groovy 5* throw path (real stack): {{MetaClassImpl.invokeMethod}} -> 
{{invokePropertyOrMissing}} -> {{invokeMissingMethod}} -> 
{{{}MissingMethodException(type = runtime class){}}}.
 * *Groovy 6* throws a {{MissingMethodExceptionNoStack(type = theClass)}} 
earlier in the reworked {{invokeMethod}} tail. The area was reworked under 
GROOVY-11823 (the new {{invokeOuterMethod}} / {{getNonClosureOuter}} handling 
and the {{invokePropertyOrMissing}} try/catch in 
{{{}MetaClassImpl.invokeMethod{}}}).

Because {{theClass}} (the supertype) {{!=}} {{receiver.getClass()}} (the 
runtime subclass), the recovery guard is {{false}} and the fallback is lost.
h2. Real-world impact (Spock)

This surfaced as a Spock test that passes on Groovy 5 and fails on Groovy 6. A 
Java mock of a Groovy class with a primitive-typed method, called with 
non-matching argument types, should be intercepted by the mock (returning the 
default {{{}null{}}}); under Groovy 6 it throws instead:
{code:groovy}
class ObjClass {                       // a (nested) Groovy class
  String test(int a, int b) { a + b }
}

def "non-matching call is intercepted, not dispatched"() {
  given:
  ObjClass client = Mock()

  when:
  def response = client.test(123d, false)   // (Double, Boolean) — matches no 
real signature

  then:
  0 * client.test(_ as int, _ as int)
  response == null
}
{code}
{noformat}
No signature of method: test for class: ...ObjClass is applicable for argument 
types:
  (Double, Boolean) values: [123.0, false]
Possible solutions: test(int, int), wait(), any(), dump(), every(), find()
{noformat}
The stack trace contains *no mock-framework frames* — the exception originates 
entirely in the Groovy call site / metaclass 
({{{}ScriptBytecodeAdapter.unwrap{}}} -> 
{{IndyGuardsFiltersAndSignatures.unwrap}} -> feature method).
h2. Environment
 * JDK: 21 (BellSoft)
 * Reproduced with Groovy 5.0.6 (pass), 6.0.0-alpha-1 (fail), 6.0.0-SNAPSHOT 
master build {{6.0.0-20260529.093309-717}} (fail).{{{}{}}}



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to