[ 
https://issues.apache.org/jira/browse/GROOVY-12046?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18084570#comment-18084570
 ] 

ASF GitHub Bot commented on GROOVY-12046:
-----------------------------------------

daniellansun opened a new pull request, #2572:
URL: https://github.com/apache/groovy/pull/2572

   …a supertype) instead of the receiver's runtime class, breaking the 
GroovyObject.invokeMethod MOP fallback




> 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
>            Priority: Major
>         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