This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch GROOVY-12046
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 0ea6df003144960b972f35659ac8942ffb4ad4ca
Author: Daniel Sun <[email protected]>
AuthorDate: Sat May 30 21:50:11 2026 +0900

    GROOVY-12046: MissingMethodException reports the metaclass theClass (a 
supertype) instead of the receiver's runtime class, breaking the 
GroovyObject.invokeMethod MOP fallback
---
 src/main/java/groovy/lang/MetaClassImpl.java |  15 +++-
 src/test/groovy/bugs/Groovy12046.groovy      | 130 +++++++++++++++++++++++++++
 2 files changed, 144 insertions(+), 1 deletion(-)

diff --git a/src/main/java/groovy/lang/MetaClassImpl.java 
b/src/main/java/groovy/lang/MetaClassImpl.java
index 6d746e331b..ee9edb46d2 100644
--- a/src/main/java/groovy/lang/MetaClassImpl.java
+++ b/src/main/java/groovy/lang/MetaClassImpl.java
@@ -998,7 +998,20 @@ public class MetaClassImpl implements MetaClass, 
MutableMetaClass {
             }
         }
 
-        throw original != null ? original : new 
MissingMethodExceptionNoStack(methodName, theClass, arguments, false);
+        // GROOVY-12046: MissingMethodException reports the metaclass theClass 
(a supertype)
+        //                 instead of the receiver's runtime class, breaking 
the GroovyObject.invokeMethod MOP fallback.
+        //
+        // The MOP fallback guard in 
IndyGuardsFiltersAndSignatures.invokeGroovyObjectInvoker checks
+        // `receiver.getClass() == e.getType()` before delegating to 
GroovyObject.invokeMethod.
+        // A per-instance metaclass may have theClass set to a supertype of 
the actual receiver (a
+        // common pattern in mocking frameworks), so we must use the 
receiver's runtime class as the
+        // exception type in that case; otherwise the guard fails and the 
fallback is silently skipped.
+        Class<?> type = theClass;
+        if (!(instance instanceof Class) && type != instance.getClass() && 
type.isAssignableFrom(instance.getClass())
+                && lookupObjectMetaClass(instance) == this) {
+            type = instance.getClass();
+        }
+        throw original != null ? original : new 
MissingMethodExceptionNoStack(methodName, type, arguments, false);
     }
 
     /**
diff --git a/src/test/groovy/bugs/Groovy12046.groovy 
b/src/test/groovy/bugs/Groovy12046.groovy
new file mode 100644
index 0000000000..2b3e793648
--- /dev/null
+++ b/src/test/groovy/bugs/Groovy12046.groovy
@@ -0,0 +1,130 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package bugs
+
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+
+/**
+ * Regression tests for GROOVY-12046.
+ *
+ * <p>A mocking framework (e.g. Spock, Mockito) typically creates a runtime 
subclass ("proxy") and
+ * installs a per-instance {@link groovy.lang.MetaClassImpl} whose {@code 
theClass} points to the
+ * <em>parent</em> class.  When an unresolved method reaches
+ * {@code MetaClassImpl.invokeMissingMethod}, the thrown {@link 
groovy.lang.MissingMethodException}
+ * must carry the <em>receiver's runtime class</em> as its {@code type}, not 
{@code theClass}.
+ * The indy call-site handler in
+ * {@code IndyGuardsFiltersAndSignatures.invokeGroovyObjectInvoker} guards
+ * {@code receiver.getClass() == e.getType()} before delegating to
+ * {@link groovy.lang.GroovyObject#invokeMethod(String, Object)};  if the type 
is wrong the
+ * {@code invokeMethod} MOP fallback is silently skipped.</p>
+ */
+final class Groovy12046 {
+
+    // 
---------------------------------------------------------------------------
+    // Shared proxy infrastructure (inlined per assertScript isolation 
requirement)
+    // 
---------------------------------------------------------------------------
+
+    private static final String PROXY_SETUP = '''
+        import groovy.lang.GroovySystem
+        import groovy.lang.MetaClass
+        import groovy.lang.MetaClassImpl
+        import groovy.lang.MissingMethodException
+
+        // Simulates the subclass a mocking framework generates at runtime.
+        // The per-instance metaclass intentionally targets the *parent* 
ObjClass.
+        class Outer {
+            static class ObjClass {
+                String test(int a, int b) { "real:${a + b}" }
+            }
+            static class Proxy extends ObjClass {
+                private final MetaClass metaClass
+                Proxy() {
+                    metaClass = new 
MetaClassImpl(GroovySystem.metaClassRegistry, ObjClass)
+                    metaClass.initialize()
+                }
+                @Override MetaClass getMetaClass() { metaClass }
+                @Override Object invokeMethod(String name, Object args) { 
"FALLBACK(${name})" }
+            }
+        }
+    '''
+
+    /**
+     * The {@link groovy.lang.MissingMethodException#getType()} must equal the 
receiver's
+     * <em>runtime</em> class ({@code Proxy}), not the metaclass {@code 
theClass} ({@code ObjClass}).
+     * This is the contract consumed by {@code invokeGroovyObjectInvoker}'s 
type guard.
+     */
+    @Test
+    void testMissingMethodExceptionTypeIsReceiverRuntimeClass() {
+        assertScript PROXY_SETUP + '''
+            Outer.ObjClass client = new Outer.Proxy()
+            Object[] args = [123d, false] as Object[]
+
+            // Pre-condition: metaclass is deliberately bound to the parent 
class.
+            assert client.metaClass.theClass == Outer.ObjClass
+
+            // Calling through the metaclass should propagate a 
MissingMethodException whose
+            // type matches the actual runtime class, so that the indy guard 
can recognise it.
+            try {
+                client.metaClass.invokeMethod(Outer.ObjClass, client, 'test', 
args, false, false)
+                assert false : 'expected MissingMethodException'
+            } catch (MissingMethodException mme) {
+                assert mme.type == Outer.Proxy : "expected Proxy but got 
${mme.type}"
+            }
+        '''
+    }
+
+    /**
+     * An end-to-end call on a typed variable compiled with an indy call site 
must activate the
+     * {@link groovy.lang.GroovyObject#invokeMethod(String, Object)} MOP 
fallback when the
+     * target method is absent from the per-instance metaclass.
+     */
+    @Test
+    void testInvokeMethodMOPFallbackHonoredViaIndyCallSite() {
+        assertScript PROXY_SETUP + '''
+            Outer.ObjClass client = new Outer.Proxy()
+
+            // client.test(double, boolean) is not defined on ObjClass; the 
only match is the
+            // int,int overload.  The indy site must fall through to 
Proxy.invokeMethod().
+            assert client.test(123d, false) == 'FALLBACK(test)'
+        '''
+    }
+
+    /**
+     * Negative case: when the metaclass {@code theClass} <em>equals</em> the 
receiver's runtime
+     * class (the ordinary, non-proxy scenario) the exception type must still 
be {@code theClass}.
+     */
+    @Test
+    void testMissingMethodExceptionTypeIsTheClassForOrdinaryReceiver() {
+        assertScript '''
+            import groovy.lang.MissingMethodException
+
+            class Foo {}
+
+            Foo foo = new Foo()
+            try {
+                foo.metaClass.invokeMethod(Foo, foo, 'noSuchMethod', [] as 
Object[], false, false)
+                assert false : 'expected MissingMethodException'
+            } catch (MissingMethodException mme) {
+                assert mme.type == Foo : "expected Foo but got ${mme.type}"
+            }
+        '''
+    }
+}

Reply via email to