[
https://issues.apache.org/jira/browse/GROOVY-12046?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18084573#comment-18084573
]
ASF GitHub Bot commented on GROOVY-12046:
-----------------------------------------
github-actions[bot] commented on PR #2572:
URL: https://github.com/apache/groovy/pull/2572#issuecomment-4583019915
### JMH summary — classic (commit `dd0a988`)
Speedup vs trailing 90-day baseline on gh-pages. Higher = faster.
`1.00` = in line with history. Per-benchmark ratio, geomean within group.
Time-per-op units inverted so direction is consistent.
| Group | Speedup | n |
|--------|---------|---|
| bench | 1.000 × | 26 |
| core | 0.977 × | 77 |
| grails | 0.962 × | 80 |
<sub>Baseline: <code>dev/bench/jmh/<part>/classic/data.js</code> on
gh-pages, trailing 90 days. <a
href="https://apache.github.io/groovy/dev/bench/jmh/summary.html">Daily
dashboard</a> · <a
href="https://apache.github.io/groovy/dev/bench/jmh/">Per-suite raw
data</a></sub>
<!
> 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)