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}" + } + ''' + } +}
