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)