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

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new 8a51ac0dc0 GROOVY-12165: extend Closure.call fast path to 
multi-argument overrides
8a51ac0dc0 is described below

commit 8a51ac0dc00d4e2c05a57e8557ad1fae9cfe6697
Author: Paul King <[email protected]>
AuthorDate: Tue Jul 14 20:30:20 2026 +1000

    GROOVY-12165: extend Closure.call fast path to multi-argument overrides
    
    The cached fast path covered only zero- and one-argument call overrides
    (GROOVY-11911, GROOVY-12164), so every multi-argument closure call from
    Java -- all Map iteration, eachWithIndex, inject -- took full metaclass
    dispatch. Generalise the cache to an arity-indexed table (0..4) with
    per-argument instance-of guards: at each arity an all-Object override
    wins outright (the 11911 rule), otherwise the single unambiguous typed
    override dispatches when every argument is already an instance of its
    declared type (the 12164 rule), and anything else (coercion, null,
    same-arity overloads, static declarations, array params) falls through
    to the metaclass exactly as before. Map#each is ~3-3.6x faster, inject
    ~2x, eachWithIndex ~1.65x; one-arg calls also gain ~13% by spreading
    the argument array directly instead of re-wrapping it.
    
    GROOVY-12165: key the cache on doCall and guard it on the stock metaclass
    
        Per the closure-dispatch design discussion: call selects a doCall, so 
the
        arity-indexed cache is re-keyed from call overloads to the subclass's
        doCall declarations (hierarchy walk, most-derived override wins, vararg/
        static/bridge shapes and same-arity overloads decline to the metaclass).
        Declared call()/call(Object) overrides keep per-arity precedence
        (GROOVY-11911) -- DGM's closure.call(item) reaches such an override via
        plain virtual dispatch, so the varargs entry must agree -- while typed 
or
        multi-arg call overloads are no longer honoured, restoring 3.x-5.x
        behaviour (the ecosystem census found zero reliance: every real subclass
        either overrides call(Object...) or declares doCall). MethodClosure and
        CurriedClosure are excluded: MetaClassImpl has dedicated dispatch for
        them, so their doCall declarations are not what the MOP would select.
    
        The cache is now also semantically invisible: a mopPerturbed flag
        (checked at construction, latched by setMetaClass -- the only mutation
        path for the instance's metaclass field) plus the category quick-exit
        gate the fast path, so a per-instance metaclass or active category
        routes through invokeMethod as it did before the cache existed. The
        re-entry latch is scoped to the 11911 call-form targets only: doCall
        targets skip the ThreadLocal entirely, so nested closure calls inside a
        dispatched body keep their own fast path (sum-style shapes gain ~50%).
    
        Also keeps the GString-key coercion test honest (Copilot review): the
        key stays a GString so the guard genuinely fails and the metaclass
        coercion path is exercised.
---
 src/main/java/groovy/lang/Closure.java             | 290 +++++++++++++++------
 src/test/groovy/bugs/Groovy12164.groovy            |  18 +-
 src/test/groovy/bugs/Groovy12165.groovy            | 153 +++++++++++
 .../groovy/runtime/ClosureCallDoCallKeyTest.groovy | 130 +++++++++
 .../groovy/runtime/ClosureCallMopGuardTest.groovy  |  98 +++++++
 5 files changed, 600 insertions(+), 89 deletions(-)

diff --git a/src/main/java/groovy/lang/Closure.java 
b/src/main/java/groovy/lang/Closure.java
index cde84c068d..000f8b595e 100644
--- a/src/main/java/groovy/lang/Closure.java
+++ b/src/main/java/groovy/lang/Closure.java
@@ -45,6 +45,7 @@ import java.lang.reflect.Modifier;
 import java.util.ArrayDeque;
 import java.util.Collections;
 import java.util.Deque;
+import java.util.HashSet;
 import java.util.IdentityHashMap;
 import java.util.Map;
 import java.util.Set;
@@ -536,35 +537,48 @@ public abstract class Closure<V> extends 
GroovyObjectSupport implements Cloneabl
     @SuppressWarnings("unchecked")
     public V call(final Object... arguments) {
         Class<?> myClass = getClass();
-        if (myClass != Closure.class && arguments != null && 
!IN_CALL_FALLBACK.get()) {
+        if (myClass != Closure.class && arguments != null && mopUnperturbed()) 
{
             CallOverride override = CALL_OVERRIDES.get(myClass);
+            // call selects a doCall: the cache is keyed on the subclass's 
doCall declarations,
+            // arity-indexed (GROOVY-12165) so the shapes the GDK drives hard 
(Map#each,
+            // eachWithIndex, inject) dispatch directly. An argument that is 
already an instance
+            // of each declared parameter type dispatches directly; anything 
that needs Groovy
+            // coercion (GString -> String, number conversions, null) falls 
through to the
+            // metaclass below, which coerces exactly as before 
(GROOVY-12164). A subclass with
+            // no doCall keeps the GROOVY-11911 call()/call(Object) 
compatibility carve-out,
+            // whose targets alone need the re-entry latch (a custom call 
override may delegate
+            // back to call(...), which must not re-dispatch to the same 
target).
             Method target = null;
-            if (arguments.length == 1 && override.oneArg != null) {
-                target = override.oneArg;
-            } else if (arguments.length == 1 && override.oneArgTyped != null 
&& override.oneArgGuard.isInstance(arguments[0])) {
-                // GROOVY-12164: a closure with a typed parameter declares 
call(T)/doCall(T) rather
-                // than call(Object), which the lookup above cannot see -- 
without this branch every
-                // such call pays full metaclass dispatch. An argument that is 
already an instance of
-                // the declared type dispatches directly; anything that needs 
Groovy coercion
-                // (GString -> String, number conversions, null) falls through 
to the metaclass below,
-                // which coerces exactly as before.
-                target = override.oneArgTyped;
-            } else if (arguments.length == 0 && override.zeroArg != null) {
-                target = override.zeroArg;
+            int arity = arguments.length;
+            if (arity < CallOverride.ARITY_LIMIT) {
+                Method m = override.byArity[arity];
+                if (m != null && 
CallOverride.guardsPass(override.guards[arity], arguments)) {
+                    target = m;
+                }
             }
             if (target != null) {
-                IN_CALL_FALLBACK.set(Boolean.TRUE);
-                try {
-                    return (V) (arguments.length == 0
-                            ? target.invoke(this)
-                            : target.invoke(this, arguments[0]));
-                } catch (InvocationTargetException ite) {
-                    UncheckedThrow.rethrow(ite.getCause());
-                    return null; // unreachable statement
-                } catch (IllegalAccessException iae) {
-                    throw new GroovyRuntimeException(iae);
-                } finally {
-                    IN_CALL_FALLBACK.set(Boolean.FALSE);
+                if (!override.callForm[arity]) {
+                    // a doCall body: re-entry is ordinary recursion, no latch 
required
+                    try {
+                        return (V) target.invoke(this, arguments);
+                    } catch (InvocationTargetException ite) {
+                        UncheckedThrow.rethrow(ite.getCause());
+                        return null; // unreachable statement
+                    } catch (IllegalAccessException iae) {
+                        throw new GroovyRuntimeException(iae);
+                    }
+                } else if (!IN_CALL_FALLBACK.get()) {
+                    IN_CALL_FALLBACK.set(Boolean.TRUE);
+                    try {
+                        return (V) target.invoke(this, arguments);
+                    } catch (InvocationTargetException ite) {
+                        UncheckedThrow.rethrow(ite.getCause());
+                        return null; // unreachable statement
+                    } catch (IllegalAccessException iae) {
+                        throw new GroovyRuntimeException(iae);
+                    } finally {
+                        IN_CALL_FALLBACK.set(Boolean.FALSE);
+                    }
                 }
             }
         }
@@ -578,6 +592,39 @@ public abstract class Closure<V> extends 
GroovyObjectSupport implements Cloneabl
         }
     }
 
+    /**
+     * Whether something MOP-relevant is in play for this instance: a 
non-stock metaclass
+     * (replaced, wrapped or Expando — it must see {@code invokeMethod}). 
Checked once at
+     * construction and latched by {@link #setMetaClass}, which is the only 
way the instance's
+     * metaclass field can change — so reading one boolean here is equivalent 
to re-checking
+     * the metaclass class on every call, at a fraction of the cost on the hot 
dispatch lanes.
+     */
+    private transient boolean mopPerturbed = nonStockMetaClass();
+
+    private boolean nonStockMetaClass() {
+        MetaClass mc = super.getMetaClass();
+        Class<?> mcClass = (mc == null) ? null : mc.getClass();
+        return mcClass != 
org.codehaus.groovy.runtime.metaclass.ClosureMetaClass.class && mcClass != 
MetaClassImpl.class;
+    }
+
+    @Override
+    public void setMetaClass(final MetaClass metaClass) {
+        super.setMetaClass(metaClass);
+        // conservative: any explicitly assigned metaclass (even a stock one) 
routes via the MOP
+        mopPerturbed = true;
+    }
+
+    /**
+     * Whether nothing MOP-relevant is in play for this instance, so a cached 
direct
+     * dispatch path is semantically invisible: the metaclass is a stock one 
and no
+     * category is active on the current thread — the same conditions the 
classic
+     * call-site caches check (see {@code AbstractCallSite}). One boolean load 
and one
+     * quick-exit counter read, cheap enough for per-element dispatch lanes.
+     */
+    protected final boolean mopUnperturbed() {
+        return !mopPerturbed && 
!org.codehaus.groovy.runtime.GroovyCategorySupport.hasCategoryInCurrentThread();
+    }
+
     /**
      * Throws the supplied throwable as a runtime exception, wrapping checked 
throwables.
      *
@@ -1385,51 +1432,158 @@ public abstract class Closure<V> extends 
GroovyObjectSupport implements Cloneabl
 
     private static final class CallOverride {
         /**
-         * Marker instance indicating that no call override exists.
+         * Cached arities: {@code 0..ARITY_LIMIT-1}. Sized to cover every 
callback shape the GDK
+         * drives (iteration and inject/withIndex variants peak at three 
parameters, plus slack).
          */
-        static final CallOverride NONE = new CallOverride(null, null, null);
+        static final int ARITY_LIMIT = 5;
         /**
-         * Cached zero-argument call override.
+         * Marker instance indicating that no dispatch targets exist.
          */
-        final Method zeroArg;
+        static final CallOverride NONE = new CallOverride(new 
Method[ARITY_LIMIT], new Class[ARITY_LIMIT][], new boolean[ARITY_LIMIT]);
         /**
-         * Cached single-argument call override.
+         * Cached {@code doCall} targets indexed by parameter count, or null 
where absent,
+         * ambiguous, or inaccessible — {@code call} selects a {@code doCall}, 
so the cache is
+         * keyed on the {@code doCall} declarations themselves. At each arity 
an all-{@code Object}
+         * doCall wins outright; otherwise the single unambiguous doCall with 
declared parameter
+         * types is cached with guards (GROOVY-12164/12165). For a subclass 
declaring NO doCall at
+         * all, the GROOVY-11911 compatibility carve-out caches its {@code 
call()}/{@code call(Object)}
+         * overrides instead (the historical Java-written adapter shape).
          */
-        final Method oneArg;
+        final Method[] byArity;
         /**
-         * Cached single-argument call override with a declared (non-Object) 
parameter type,
-         * used only when {@link #oneArg} is absent (GROOVY-12164). A closure 
literal with a
-         * typed parameter generates {@code call(T)}/{@code doCall(T)} instead 
of
-         * {@code call(Object)}.
+         * Per-arity instance-of guards for {@link #byArity}: the declared 
parameter types
+         * (boxed for primitives), with null entries for {@code Object} 
parameters; a wholly
+         * null guard array means no checks at all. An argument that is not 
already an instance
+         * of its declared type needs Groovy coercion, which only the 
metaclass fallback provides.
          */
-        final Method oneArgTyped;
+        final Class<?>[][] guards;
         /**
-         * Instance-of guard for {@link #oneArgTyped}: the declared parameter 
type (boxed for a
-         * primitive). An argument that is not already an instance needs 
Groovy coercion, which
-         * only the metaclass fallback provides.
+         * Which cached targets are {@code call} forms (the 11911 shapes) 
rather than doCall
+         * bodies. Only those need the re-entry latch: a custom {@code call} 
override may
+         * delegate back to {@code call(...)}, which must not re-dispatch to 
the same target.
+         * doCall targets are user bodies — re-entry through them is ordinary 
recursion — so
+         * their dispatch skips the ThreadLocal entirely (and nested closure 
calls inside a
+         * dispatched body keep their own fast path).
          */
-        final Class<?> oneArgGuard;
+        final boolean[] callForm;
 
-        private CallOverride(Method zeroArg, Method oneArg, Method 
oneArgTyped) {
-            this.zeroArg = zeroArg;
-            this.oneArg = oneArg;
-            this.oneArgTyped = oneArgTyped;
-            this.oneArgGuard = (oneArgTyped == null) ? null : 
wrapperOf(oneArgTyped.getParameterTypes()[0]);
+        private CallOverride(Method[] byArity, Class<?>[][] guards, boolean[] 
callForm) {
+            this.byArity = byArity;
+            this.guards = guards;
+            this.callForm = callForm;
+        }
+
+        static boolean guardsPass(Class<?>[] guards, Object[] args) {
+            if (guards == null) return true; // all parameters are Object
+            for (int i = 0, n = args.length; i < n; i += 1) {
+                Class<?> g = guards[i];
+                if (g != null && !g.isInstance(args[i])) return false;
+            }
+            return true;
         }
 
         /**
-         * Finds call overrides declared by the supplied closure subclass.
+         * Finds the subclass's {@code doCall} declarations, one target per 
arity — {@code call}
+         * selects a {@code doCall}, so the cache is keyed on the doCall 
declarations themselves
+         * and any visibility the MOP would dispatch is accepted (the 
hierarchy is walked with
+         * {@code getDeclaredMethods}; an overridden signature keeps its 
most-derived form).
+         * Bridge methods are skipped (their erased twin is the real 
declaration), as are static
+         * declarations and methods with array-typed parameters (that shape 
belongs to vararg
+         * collection, which is metaclass work). Same-arity overloads beyond 
an all-Object one
+         * are ambiguous: the metaclass performs the selection.
+         * <p>
+         * Declared {@code call()}/{@code call(Object)} overrides (the 
GROOVY-11911 shapes) take
+         * precedence at their arities: DGM's {@code closure.call(item)} 
reaches such an override
+         * through plain virtual dispatch anyway, so the varargs entry must 
agree, and a
+         * decorating override must not be bypassed. Typed or multi-argument 
{@code call}
+         * overloads are NOT honoured — per the {@code Closure} contract, only 
doCall carries
+         * the body.
          *
          * @param type the closure subclass
-         * @return the resolved override metadata
+         * @return the resolved dispatch metadata
          */
         static CallOverride lookup(Class<?> type) {
             if (type == Closure.class) return NONE;
+            // The cache is only valid where it mirrors the MOP's selection. 
MetaClassImpl's
+            // invokeMethod has dedicated dispatch for these adapter types (a 
method pointer
+            // invokes its target method; a curried closure uncurries and 
re-enters), so their
+            // doCall declarations, where present, are NOT what the MOP would 
select — e.g.
+            // MethodClosure's vestigial doCall would mis-dispatch a 
class-owner method pointer.
+            if 
(org.codehaus.groovy.runtime.MethodClosure.class.isAssignableFrom(type)
+                    || 
org.codehaus.groovy.runtime.CurriedClosure.class.isAssignableFrom(type)) {
+                return NONE;
+            }
+            Method[] exact = new Method[ARITY_LIMIT];
+            Method[] typed = new Method[ARITY_LIMIT];
+            boolean[] typedAmbiguous = new boolean[ARITY_LIMIT];
+            Set<String> seen = new HashSet<>();
+            for (Class<?> c = type; c != null && c != Closure.class; c = 
c.getSuperclass()) {
+                for (Method m : c.getDeclaredMethods()) {
+                    if (!"doCall".equals(m.getName()) || m.isBridge() || 
Modifier.isStatic(m.getModifiers())) {
+                        continue;
+                    }
+                    Class<?>[] params = m.getParameterTypes();
+                    if (!seen.add(java.util.Arrays.toString(params))) 
continue; // overridden below: most-derived wins
+                    int arity = m.getParameterCount();
+                    if (arity >= ARITY_LIMIT) continue;
+                    boolean allObject = true, hasArray = false;
+                    for (Class<?> p : params) {
+                        if (p != Object.class) allObject = false;
+                        if (p.isArray()) hasArray = true;
+                    }
+                    if (hasArray) continue;
+                    if (allObject) {
+                        if (exact[arity] == null) exact[arity] = m;
+                    } else if (typed[arity] != null) {
+                        typedAmbiguous[arity] = true;
+                    } else {
+                        typed[arity] = m;
+                    }
+                }
+            }
+            Method[] byArity = new Method[ARITY_LIMIT];
+            Class<?>[][] guards = new Class[ARITY_LIMIT][];
+            boolean[] callForm = new boolean[ARITY_LIMIT];
+            boolean any = false;
+            // GROOVY-11911: a declared call()/call(Object) override takes 
precedence at its
+            // arity even when doCall methods exist — DGM's closure.call(item) 
reaches such an
+            // override through plain virtual dispatch anyway, so the varargs 
entry must agree
+            // (a decorating override would otherwise be silently bypassed on 
one form only).
             Method zero = findOverride(type);
+            if (zero != null) {
+                byArity[0] = zero;
+                callForm[0] = true;
+                any = true;
+            }
             Method one = findOverride(type, Object.class);
-            Method typed = (one == null) ? findTypedOneArgOverride(type) : 
null;
-            if (zero == null && one == null && typed == null) return NONE;
-            return new CallOverride(zero, one, typed);
+            if (one != null) {
+                byArity[1] = one;
+                callForm[1] = true;
+                any = true;
+            }
+            for (int arity = 0; arity < ARITY_LIMIT; arity += 1) {
+                if (byArity[arity] != null) continue; // a call-form override 
claimed this arity
+                Method m = (exact[arity] != null) ? exact[arity]
+                        : (typedAmbiguous[arity] ? null : typed[arity]);
+                if (m == null) continue;
+                try {
+                    m.setAccessible(true);
+                } catch (RuntimeException ignored) {
+                    // module/package access denied; fall through to MOP doCall
+                    continue;
+                }
+                byArity[arity] = m;
+                if (m != exact[arity]) {
+                    Class<?>[] params = m.getParameterTypes();
+                    Class<?>[] g = new Class[arity];
+                    for (int i = 0; i < arity; i += 1) {
+                        g[i] = (params[i] == Object.class) ? null : 
wrapperOf(params[i]);
+                    }
+                    guards[arity] = g;
+                }
+                any = true;
+            }
+            return any ? new CallOverride(byArity, guards, callForm) : NONE;
         }
 
         private static Method findOverride(Class<?> type, Class<?>... params) {
@@ -1450,42 +1604,6 @@ public abstract class Closure<V> extends 
GroovyObjectSupport implements Cloneabl
             }
         }
 
-        /**
-         * Finds the single unambiguous one-argument {@code call} override 
with a declared
-         * (non-Object) parameter type (GROOVY-12164). Array-typed parameters 
are skipped —
-         * that shape belongs to vararg collection, which is metaclass work — 
as are bridge
-         * methods (their erased twin is the real override) and overloaded 
typed overrides
-         * (ambiguous: the metaclass performs the selection).
-         *
-         * @param type the closure subclass
-         * @return the override, or null when absent, ambiguous, or 
inaccessible
-         */
-        private static Method findTypedOneArgOverride(Class<?> type) {
-            Method candidate = null;
-            for (Method m : type.getMethods()) {
-                // getMethods() includes public statics: a static call(T) is 
not an instance
-                // override (reflective invocation would ignore the receiver), 
so skip it and
-                // let the MOP fallback dispatch instance doCall as before
-                if (m.getParameterCount() != 1 || !"call".equals(m.getName()) 
|| m.isBridge()
-                        || Modifier.isStatic(m.getModifiers()) || 
m.getDeclaringClass() == Closure.class) {
-                    continue;
-                }
-                Class<?> param = m.getParameterTypes()[0];
-                if (param == Object.class || param.isArray()) continue;
-                if (candidate != null) return null; // overloaded: leave 
selection to the metaclass
-                candidate = m;
-            }
-            if (candidate != null) {
-                try {
-                    candidate.setAccessible(true);
-                } catch (RuntimeException ignored) {
-                    // module/package access denied; fall through to MOP doCall
-                    return null;
-                }
-            }
-            return candidate;
-        }
-
         private static Class<?> wrapperOf(Class<?> type) {
             if (!type.isPrimitive()) return type;
             if (type == int.class) return Integer.class;
diff --git a/src/test/groovy/bugs/Groovy12164.groovy 
b/src/test/groovy/bugs/Groovy12164.groovy
index a91b80e060..991da6305e 100644
--- a/src/test/groovy/bugs/Groovy12164.groovy
+++ b/src/test/groovy/bugs/Groovy12164.groovy
@@ -136,15 +136,27 @@ final class Groovy12164 {
 
     @Test
     void testTypedSubclassOverride() {
-        // a Closure subclass with a typed call override is served by the same 
cache
+        // call selects a doCall: a subclass declaring only a typed call(T) — 
and no doCall —
+        // is NOT dispatched on the Java/GDK entry (the Closure javadoc 
requires doCall); this
+        // matches 3.x-5.x behaviour. The dynamic path remains MOP-permissive 
and still finds
+        // the declared method (long-standing ClosureMetaClass behaviour).
         assertScript '''
+            import groovy.test.GroovyAssert
             class Doubler extends Closure<Integer> {
                 Doubler() { super(null) }
                 Integer call(Integer x) { x * 2 }
             }
             Closure<Integer> d = new Doubler()
-            assert d.call(21) == 42
-            assert [1, 2, 3].collect(d) == [2, 4, 6]
+            assert d.call(21) == 42 // dynamic dispatch: metaclass finds the 
declared method
+            GroovyAssert.shouldFail(MissingMethodException) {
+                [1, 2, 3].collect(d)  // Java entry: no doCall, no dispatch
+            }
+            // with a doCall the same shape is served by the cache on both 
paths
+            class Tripler extends Closure<Integer> {
+                Tripler() { super(null) }
+                Integer doCall(Integer x) { x * 3 }
+            }
+            assert [1, 2].collect(new Tripler()) == [3, 6]
         '''
     }
 
diff --git a/src/test/groovy/bugs/Groovy12165.groovy 
b/src/test/groovy/bugs/Groovy12165.groovy
new file mode 100644
index 0000000000..20af501c92
--- /dev/null
+++ b/src/test/groovy/bugs/Groovy12165.groovy
@@ -0,0 +1,153 @@
+/*
+ *  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
+
+/**
+ * {@code Closure.call}'s cached fast path covered only zero- and one-argument
+ * overrides (GROOVY-11911, GROOVY-12164), so every multi-argument closure call
+ * from Java — all {@code Map} iteration, {@code eachWithIndex}, {@code inject}
+ * — took full metaclass dispatch. GROOVY-12165 generalises the cache to an
+ * arity-indexed table (0..4) with per-argument instance-of guards: at each
+ * arity an all-{@code Object} override wins outright, otherwise the single
+ * unambiguous typed override dispatches when every argument is already an
+ * instance of its declared type, and anything else (coercion, null, ambiguity)
+ * falls through to the metaclass exactly as before. These tests pin both sides
+ * of the guards at the new arities; iteration goes through DGM, whose Java
+ * code invokes {@code closure.call(...)}, the path the cache serves.
+ */
+final class Groovy12165 {
+
+    @Test
+    void testMapIterationUntyped() {
+        assertScript '''
+            def m = [a: 1, b: 2, c: 3]
+            int sum = 0
+            def keys = []
+            m.each { k, v -> keys << k; sum += (int) v }
+            assert keys == ['a', 'b', 'c']
+            assert sum == 6
+            assert m.collect { k, v -> "$k$v".toString() } == ['a1', 'b2', 
'c3']
+        '''
+    }
+
+    @Test
+    void testMapIterationTyped() {
+        assertScript '''
+            def m = [a: 1, b: 2]
+            int sum = 0
+            m.each { String k, Integer v -> sum += v }
+            assert sum == 3
+            assert m.any { String k, Integer v -> v == 2 }
+            assert m.every { String k, Integer v -> v > 0 }
+        '''
+    }
+
+    @Test
+    void testEachWithIndexAndInject() {
+        assertScript '''
+            def seen = []
+            ['a', 'b'].eachWithIndex { x, i -> seen << "$i:$x".toString() }
+            assert seen == ['0:a', '1:b']
+            assert [1, 2, 3, 4].inject(0) { a, x -> (int) a + (int) x } == 10
+            assert [1, 2, 3].inject(1) { Integer a, Integer x -> a * x } == 6
+        '''
+    }
+
+    @Test
+    void testThreeArgumentClosure() {
+        assertScript '''
+            // Map#inject drives a three-parameter closure with (acc, key, 
value) from Java
+            assert [a: 1, b: 2, c: 3].inject(0) { acc, k, v -> (int) acc + 
(int) v } == 6
+            assert [a: 1, b: 2].inject(0) { Integer acc, String k, Integer v 
-> acc + v } == 3
+        '''
+    }
+
+    @Test
+    void testTypedGuardFallsThroughToCoercion() {
+        assertScript '''
+            // a GString key is NOT an instance of the String parameter type: 
the guard fails,
+            // the call falls through to the metaclass, and its coercion 
produces a real String
+            def name = 'a'
+            def m = [("k${name}"): 1]
+            assert m.keySet()[0] !instanceof String // premise: the key stays 
a GString
+            def out = []
+            m.each { String k, Integer v -> assert k.class == String; out << 
"$k=$v".toString() }
+            assert out == ['ka=1']
+            // null value is not an instance of Integer: metaclass path, 
arrives as null
+            def m2 = [x: null]
+            def seen = []
+            m2.each { String k, Integer v -> seen << v }
+            assert seen == [null]
+        '''
+    }
+
+    @Test
+    void testDefaultParametersPopulateMultipleArities() {
+        assertScript '''
+            def c = { Integer x, Integer y = 10, Integer z = 100 -> x + y + z }
+            assert c.call(1) == 111
+            assert c.call(1, 2) == 103
+            assert c.call(1, 2, 3) == 6
+        '''
+    }
+
+    @Test
+    void testSameArityOverloadsStayOnMetaclass() {
+        // two typed two-arg overrides are ambiguous: the cache must decline 
and the metaclass
+        // fallback dispatches doCall — Map#collect drives call(k, v) from Java
+        assertScript '''
+            class Picky extends Closure<String> {
+                Picky() { super(null) }
+                String call(Integer a, Integer b) { 'ints' }
+                String call(String a, String b) { 'strings' }
+                String doCall(Object a, Object b) { 'doCall' }
+            }
+            assert [(1): 2].collect(new Picky()) == ['doCall']
+        '''
+    }
+
+    @Test
+    void testStaticMultiArgCallDeclarationIsNotAnOverride() {
+        // a public static call(T, T) is not an instance override: dispatch 
keeps going
+        // through the metaclass to instance doCall (same rule as one-arg, 
GROOVY-12164);
+        // Map#collect drives the two-arg call(k, v) entry point from Java
+        assertScript '''
+            class TwoStatic extends Closure<String> {
+                TwoStatic() { super(null) }
+                static String call(Integer a, Integer b) { 'static' }
+                String doCall(Integer a, Integer b) { 'instance' }
+            }
+            assert [(1): 2].collect(new TwoStatic()) == ['instance']
+        '''
+    }
+
+    @Test
+    void testMultiParameterDestructuringPreserved() {
+        // a single List argument to a two-parameter closure still destructures
+        assertScript '''
+            def seen = []
+            [[1, 'a'], [2, 'b']].each { Integer n, String s -> seen << 
"$n$s".toString() }
+            assert seen == ['1a', '2b']
+        '''
+    }
+}
diff --git 
a/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallDoCallKeyTest.groovy 
b/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallDoCallKeyTest.groovy
new file mode 100644
index 0000000000..34aa41d54a
--- /dev/null
+++ 
b/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallDoCallKeyTest.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 org.codehaus.groovy.runtime
+
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+
+/**
+ * {@code Closure.call}'s cached fast path is keyed on the subclass's {@code 
doCall}
+ * declarations — "call selects a doCall" — rather than on {@code call} 
overloads,
+ * with the GROOVY-11911 {@code call()}/{@code call(Object)} carve-out kept for
+ * subclasses declaring no doCall at all. These tests pin the doCall keying 
for the
+ * shapes the ecosystem actually writes (a hand-written subclass with a typed 
doCall
+ * is the Gradle KotlinClosure0-3 pattern), the hierarchy walk, the vararg 
exclusion,
+ * ambiguity, and that nested closure calls inside a dispatched body keep 
their own
+ * fast path (the re-entry latch applies only to carve-out targets).
+ */
+final class ClosureCallDoCallKeyTest {
+
+    @Test
+    void typedDoCallAdapterIsServed() {
+        // the KotlinClosure1 shape: hand-written subclass, typed doCall, no 
call overloads
+        assertScript '''
+            class Adapter extends Closure<Integer> {
+                Adapter() { super(null) }
+                Integer doCall(Integer x) { x + 100 }
+            }
+            def a = new Adapter()
+            assert [1, 2].collect(a) == [101, 102]     // Java/GDK entry
+            assert a(5) == 105                          // dynamic entry
+            // guard fall-through: a non-Integer argument declines to the 
metaclass,
+            // which rejects it exactly as before
+            groovy.test.GroovyAssert.shouldFail(MissingMethodException) {
+                ['x'].collect(a)
+            }
+        '''
+    }
+
+    @Test
+    void protectedDoCallIsServed() {
+        // the MOP dispatches protected doCall declarations, so the cache must 
too
+        assertScript '''
+            class Guarded extends Closure<String> {
+                Guarded() { super(null) }
+                protected String doCall(String s) { 'p:' + s }
+            }
+            assert ['a', 'b'].collect(new Guarded()) == ['p:a', 'p:b']
+        '''
+    }
+
+    @Test
+    void varargDoCallStaysOnMetaclass() {
+        // the Geb InvocationForwarding shape: an array-typed parameter is 
vararg
+        // collection, which stays metaclass work — behaviour unchanged either 
way
+        assertScript '''
+            class Fwd extends Closure<Object> {
+                Fwd() { super(null) }
+                protected doCall(Object[] args) { args.toList() }
+            }
+            def f = new Fwd()
+            assert f.call(1, 2) == [1, 2]
+            assert [7].collect(f) == [[7]]
+        '''
+    }
+
+    @Test
+    void overriddenDoCallDispatchesMostDerived() {
+        // the hierarchy walk dedupes by signature: an override wins over its 
parent
+        assertScript '''
+            class Base extends Closure<String> {
+                Base() { super(null) }
+                String doCall(Integer x) { 'base:' + x }
+            }
+            class Derived extends Base {
+                @Override String doCall(Integer x) { 'derived:' + x }
+            }
+            assert [1].collect(new Derived()) == ['derived:1']
+            assert [1].collect(new Base()) == ['base:1']
+        '''
+    }
+
+    @Test
+    void ambiguousSameArityDoCallsDeclineToMetaclass() {
+        // two typed same-arity doCalls: selection is the metaclass's job; 
results must
+        // match dynamic dispatch
+        assertScript '''
+            class Picky extends Closure<String> {
+                Picky() { super(null) }
+                String doCall(Integer x) { 'int' }
+                String doCall(String x) { 'str' }
+            }
+            def p = new Picky()
+            assert [1].collect(p) == ['int']
+            assert ['a'].collect(p) == ['str']
+        '''
+    }
+
+    @Test
+    void nestedClosureCallsInsideDispatchedBodyKeepWorking() {
+        // the re-entry latch applies only to 11911 carve-out targets: a 
doCall body that
+        // itself drives closures through the GDK must behave identically (and 
keeps the
+        // fast path — previously the thread-global latch silently disabled it)
+        assertScript '''
+            def inner = { it * 2 }
+            def outer = { List xs -> xs.collect(inner).sum() }
+            assert [[1, 2, 3]].collect(outer) == [12]
+            // recursion through the cache is ordinary recursion
+            def fact
+            fact = { int n -> n <= 1 ? 1 : n * fact(n - 1) }
+            assert fact(5) == 120
+        '''
+    }
+}
diff --git 
a/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallMopGuardTest.groovy 
b/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallMopGuardTest.groovy
new file mode 100644
index 0000000000..1dcbb1df2f
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallMopGuardTest.groovy
@@ -0,0 +1,98 @@
+/*
+ *  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 org.codehaus.groovy.runtime
+
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+
+/**
+ * {@code Closure.call}'s cached direct-dispatch path must be semantically 
invisible:
+ * it may only be taken when nothing MOP-relevant is in play. These tests pin 
the
+ * guard — a per-instance metaclass whose {@code invokeMethod} intercepts
+ * {@code doCall} must be honoured on the Java/GDK entry ({@code 
closure.call(...)}
+ * from DGM), not just the dynamic path, for statically compiled closure 
classes
+ * (which declare typed doCall/call methods the cache serves). Without the 
guard,
+ * the cache invokes the target directly and the interception is silently 
skipped
+ * on one path but not the other.
+ */
+final class ClosureCallMopGuardTest {
+
+    private static final String INTERCEPTOR = '''
+        def intercept = { Closure target ->
+            def mc = new DelegatingMetaClass(target.metaClass) {
+                Object invokeMethod(Object object, String methodName, Object[] 
args) {
+                    if (methodName == 'doCall' || methodName == 'call') return 
'intercepted'
+                    super.invokeMethod(object, methodName, args)
+                }
+            }
+            mc.initialize()
+            target.metaClass = mc
+            target
+        }
+    '''
+
+    @Test
+    void perInstanceMetaclassInterceptsStaticallyCompiledClosureOnGdkPath() {
+        assertScript INTERCEPTOR + '''
+            import groovy.transform.CompileStatic
+            @CompileStatic
+            class D { Closure maker() { return { Integer x -> x * 2 } } }
+            def c = new D().maker()
+            assert [3].collect(c) == [6]           // unperturbed: fast path, 
real body
+            intercept(c)
+            assert c(3) == 'intercepted'           // dynamic path
+            assert [3].collect(c) == ['intercepted'] // Java/GDK path: the 
guard must route via the MOP
+        '''
+    }
+
+    @Test
+    void unperturbedInstancesAreUnaffectedByAnotherInstancesMetaclass() {
+        assertScript INTERCEPTOR + '''
+            import groovy.transform.CompileStatic
+            @CompileStatic
+            class D2 { Closure maker() { return { Integer x -> x + 1 } } }
+            def a = new D2().maker()
+            def b = new D2().maker()
+            intercept(a)
+            assert [1].collect(a) == ['intercepted']
+            assert [1].collect(b) == [2]           // b keeps the fast path
+        '''
+    }
+
+    @Test
+    void behaviourInsideCategoryBlocksIsPreserved() {
+        // A category on the current thread conservatively disables the direct 
path (matching
+        // AbstractCallSite); results must be identical either way. Note 
ClosureMetaClass has
+        // never consulted categories for doCall dispatch (verified 3.x-5.x), 
so the pin here
+        // is "unchanged behaviour", not interception.
+        assertScript '''
+            class ShoutCategory {
+                static String doCall(Closure self, Object arg) { 'category' }
+            }
+            def c = { it * 2 }
+            assert [3].collect(c) == [6]
+            use(ShoutCategory) {
+                assert c(3) == 6                  // dynamic path: unchanged 
(as in 3.x-5.x)
+                assert [3].collect(c) == [6]      // Java/GDK path via the MOP 
route: unchanged
+            }
+            assert [3].collect(c) == [6]          // fast path restored after 
the block
+        '''
+    }
+}


Reply via email to