You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/08/18 00:49:04 UTC

(groovy) branch master updated (889ae18448 -> 763cbbf2bd)

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

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


    from 889ae18448 GROOVY-12232: Wrap AIC cast arguments only when the generated constructor delegates to super dynamically
     new eda69e6b98 GROOVY-12263: Invoke cached Closure doCall targets via MethodHandle
     new 46cc512647 GROOVY-12263: Add test cases
     new 763cbbf2bd GROOVY-12263: Cache high-arity doCall targets as MethodHandle spreaders

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 src/main/java/groovy/lang/Closure.java             | 259 +++++++-
 .../groovy/lang/ClosureCallHandleTest.groovy       | 690 +++++++++++++++++++++
 .../groovy/lang/StringDoCallClosure.java}          |  34 +-
 3 files changed, 935 insertions(+), 48 deletions(-)
 create mode 100644 src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy
 copy src/test/{groovy/groovy/lang/BigIntegerNumberRangeTest.java => java/groovy/lang/StringDoCallClosure.java} (62%)


(groovy) 02/03: GROOVY-12263: Add test cases

Posted by pa...@apache.org.
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

commit 46cc5126476ea46dd07e6274e36fd37f61c2c250
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sun Aug 16 05:35:20 2026 +0900

    GROOVY-12263: Add test cases
---
 .../groovy/lang/ClosureCallHandleTest.groovy       | 319 ++++++++++++++++++++-
 src/test/java/groovy/lang/StringDoCallClosure.java |  46 +++
 2 files changed, 358 insertions(+), 7 deletions(-)

diff --git a/src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy b/src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy
index c7a598ceb5..6c4deb092d 100644
--- a/src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy
+++ b/src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy
@@ -18,6 +18,7 @@
  */
 package groovy.lang
 
+import org.codehaus.groovy.runtime.InvokerInvocationException
 import org.junit.jupiter.api.Test
 
 import java.lang.invoke.MethodHandle
@@ -52,9 +53,11 @@ final class ClosureCallHandleTest {
         def c = new Closure(this) {
             def doCall(String s) { s.toUpperCase() }
         }
-        assert c.call('ab') == 'AB'
+        // Java/GDK entry: Groovy c.call(...) is indy and binds to doCall, skipping guards
+        assert javaVarargsCall(c, 'ab') == 'AB'
         // GROOVY-12164: GString is not a String, so the MH/guard path must not fire
-        assert c.call("${'ab'}") == 'AB'
+        assert javaVarargsCall(c, "${'ab'}") == 'AB'
+        assert handleFor(c, 1) != null
     }
 
     @Test
@@ -137,7 +140,7 @@ final class ClosureCallHandleTest {
     @Test
     void 'arity at the cache limit is not cached'() {
         def five = { a, b, c, d, e -> a + b + c + d + e }
-        assert five.call(1, 2, 3, 4, 5) == 15
+        assert javaVarargsCall(five, 1, 2, 3, 4, 5) == 15
         assert cachedTarget(five, 5) == null
         assert handleFor(five, 5) == null
     }
@@ -148,14 +151,16 @@ final class ClosureCallHandleTest {
         def empty = new Closure(this) {
             void doCall() { side << 1 }
         }
-        assert empty.call() == null
+        assert javaVarargsCall(empty) == null
         assert side == [1]
+        assert handleFor(empty, 0) != null
 
         def boxed = new Closure(this) {
             int doCall(int x) { x + 1 }
         }
-        assert boxed.call(41) == 42
+        assert javaVarargsCall(boxed, 41) == 42
         assert [1, 2].collect(boxed) == [2, 3]
+        assert handleFor(boxed, 1) != null
     }
 
     @Test
@@ -191,8 +196,37 @@ final class ClosureCallHandleTest {
     @Test
     void 'curried and method-pointer closures stay on the metaclass path'() {
         def add = { a, b -> a + b }
-        assert add.curry(10).call(2) == 12
-        assert 'abc'.&substring.call(1) == 'bc'
+        def curried = add.curry(10)
+        assert javaVarargsCall(curried, 2) == 12
+        assert javaVarargsCall('abc'.&substring, 1) == 'bc'
+        assert handleFor(curried, 1) == null
+        assert handleFor('abc'.&substring, 1) == null
+    }
+
+    @Test
+    void 'invokeCached catch propagates throwers built as MethodHandles'() {
+        def c = { 1 }
+        Method method = findDoCall(c.getClass(), 0)
+        method.accessible = true
+        MethodHandle throwing = MethodHandles.dropArguments(
+                MethodHandles.throwException(Object, IllegalStateException)
+                        .bindTo(new IllegalStateException('mh-throw')),
+                0, Object)
+        def e = shouldFail(IllegalStateException) {
+            invokeCachedDirect(throwing, method, c, new Object[0])
+        }
+        assert e.message == 'mh-throw'
+    }
+
+    @Test
+    void 'invokeCached catch propagates NullPointerException from invokeHandle'() {
+        def c = { 1 }
+        Method method = findDoCall(c.getClass(), 0)
+        method.accessible = true
+        MethodHandle handle = handleFor(c, 0)
+        shouldFail(NullPointerException) {
+            invokeCachedDirect(handle, method, c, null)
+        }
     }
 
     @Test
@@ -245,6 +279,203 @@ final class ClosureCallHandleTest {
         assert invokeHandleDirect(handle, c, [1, 2, 3, 4, 5] as Object[]) == [1, 2, 3, 4, 5]
     }
 
+    @Test
+    void 'java varargs call hits invokeHandle for arities zero through four'() {
+        // Groovy c.call(a,b,...) is indy and binds to doCall; the MH switch is on
+        // Closure.call(Object...), which Java/GDK uses.
+        def zero = { 42 }
+        assert javaVarargsCall(zero) == 42
+        def one = { x -> x }
+        assert javaVarargsCall(one, 'x') == 'x'
+        def two = { a, b -> a + b }
+        assert javaVarargsCall(two, 1, 2) == 3
+        def three = { a, b, c -> a + b + c }
+        assert javaVarargsCall(three, 1, 2, 3) == 6
+        def four = { a, b, c, d -> a + b + c + d }
+        assert javaVarargsCall(four, 1, 2, 3, 4) == 10
+    }
+
+    @Test
+    void 'java varargs call rethrows body throwables from the handle'() {
+        def boom = { throw new IllegalStateException('via-java-call') }
+        def e = shouldFail(IllegalStateException) {
+            javaVarargsCall(boom)
+        }
+        assert e.message == 'via-java-call'
+
+        def inner = new RuntimeException('inner')
+        def iteBody = { throw new InvocationTargetException(inner) }
+        def ite = shouldFail(InvocationTargetException) {
+            javaVarargsCall(iteBody)
+        }
+        assert ite.cause.is(inner)
+
+        def io = { throw new IOException('via-java-call-io') }
+        def ioe = shouldFail(IOException) {
+            javaVarargsCall(io)
+        }
+        assert ioe.message == 'via-java-call-io'
+
+        def err = { throw new Error('via-java-call-err') }
+        def error = shouldFail(Error) {
+            javaVarargsCall(err)
+        }
+        assert error.message == 'via-java-call-err'
+    }
+
+    @Test
+    void 'public call uses Method invoke when the cached handle is cleared'() {
+        def c = { x -> "r:$x" }
+        assert javaVarargsCall(c, 'a') == 'r:a'
+        assert handleFor(c, 1) != null
+        clearHandle(c, 1)
+        assert handleFor(c, 1) == null
+        assert javaVarargsCall(c, 'b') == 'r:b'
+    }
+
+    @Test
+    void 'public call unwraps InvocationTargetException when the handle is cleared'() {
+        def c = { throw new IllegalStateException('cleared-handle') }
+        try {
+            javaVarargsCall(c)
+        } catch (IllegalStateException ignored) {
+            // warms CallOverride so byArity[0] is populated
+        }
+        clearHandle(c, 0)
+        def e = shouldFail(IllegalStateException) {
+            javaVarargsCall(c)
+        }
+        assert e.message == 'cleared-handle'
+    }
+
+    @Test
+    void 'subclass with no doCall or call override resolves to CallOverride NONE'() {
+        def c = new Closure(this) {}
+        assert callOverrideOf(c).is(callOverrideNone())
+        shouldFail(MissingMethodException) {
+            javaVarargsCall(c)
+        }
+        Method lookup = Class.forName('groovy.lang.Closure$CallOverride')
+                .getDeclaredMethod('lookup', Class)
+        lookup.accessible = true
+        assert lookup.invoke(null, Closure).is(callOverrideNone())
+    }
+
+    @Test
+    void 'null arguments array skips the cached handle and uses the metaclass'() {
+        def c = { 7 }
+        assert javaVarargsCall(c) == 7
+        Method call = Closure.getMethod('call', Object[].class)
+        assert call.invoke(c, new Object[]{null}) == 7
+    }
+
+    @Test
+    void 'lookup skips static array and ambiguous doCall shapes'() {
+        def skipped = new Closure(this) {
+            static Object doCall(String ignored) { 'static' }
+            static Object call(Object ignored) { 'static-call' }
+            Object extra() { 'not-doCall' }
+            def doCall(Object[] args) { args }
+            def doCall() { 1 }
+        }
+        assert javaVarargsCall(skipped) == 1
+        assert handleFor(skipped, 0) != null
+        assert cachedTarget(skipped, 1) == null
+
+        def mixed = new Closure(this) {
+            def doCall(Object a, String b) { "$a:$b" }
+        }
+        assert javaVarargsCall(mixed, 1, 'x') == '1:x'
+        assert javaVarargsCall(mixed, 1, "${'x'}") == '1:x'
+        assert handleFor(mixed, 2) != null
+
+        def ambiguous = new Closure(this) {
+            def doCall(String s) { "s:$s" }
+            def doCall(Integer i) { "i:$i" }
+        }
+        assert javaVarargsCall(ambiguous, 'a') == 's:a'
+        assert javaVarargsCall(ambiguous, 2) == 'i:2'
+        assert cachedTarget(ambiguous, 1) == null
+        assert handleFor(ambiguous, 1) == null
+    }
+
+    @Test
+    void 'lookup keeps the most-derived doCall and boxes every primitive guard'() {
+        def child = new ChildDoCall()
+        assert javaVarargsCall(child, 'z') == 'child:z'
+        assert handleFor(child, 1) != null
+
+        def covariant = new StringDoCallClosure()
+        assert StringDoCallClosure.declaredMethods.any { it.name == 'doCall' && it.bridge }
+        assert javaVarargsCall(covariant, 7) == '7'
+        assert handleFor(covariant, 1) != null
+        assert !cachedTarget(covariant, 1).bridge
+
+        def primitives = new PrimitiveDoCalls()
+        assert javaVarargsCall(primitives, 1) == 2
+        assert javaVarargsCall(primitives, 1L, 2L) == 3L
+        assert javaVarargsCall(primitives, true, false, true) == true
+        assert javaVarargsCall(primitives, 1.0d, 2.0d, 3.0d, 4.0d) == 10.0d
+        assert handleFor(primitives, 1) != null
+        assert handleFor(primitives, 2) != null
+        assert handleFor(primitives, 3) != null
+        assert handleFor(primitives, 4) != null
+
+        def more = new MorePrimitiveDoCalls()
+        assert javaVarargsCall(more, (char) 'A') == (char) 'A'
+        assert javaVarargsCall(more, (byte) 1, (byte) 2) == (byte) 3
+        assert javaVarargsCall(more, (short) 1, (short) 2, (short) 3) == (short) 6
+        assert javaVarargsCall(more, 1.0f, 2.0f, 3.0f, 4.0f) == 10.0f
+        assert handleFor(more, 1) != null
+        assert handleFor(more, 2) != null
+        assert handleFor(more, 3) != null
+        assert handleFor(more, 4) != null
+    }
+
+    @Test
+    void 'InvokerInvocationException from the metaclass is unwrapped'() {
+        def c = { 1 }
+        c.metaClass = new DelegatingMetaClass(c.metaClass) {
+            @Override
+            Object invokeMethod(Object object, String methodName, Object[] arguments) {
+                throw new InvokerInvocationException(new IllegalStateException('via-mop'))
+            }
+        }
+        def e = shouldFail(IllegalStateException) {
+            javaVarargsCall(c)
+        }
+        assert e.message == 'via-mop'
+    }
+
+    @Test
+    void 'an active category or replaced metaclass skips the cached handle'() {
+        def c = { x -> "v:$x" }
+        assert javaVarargsCall(c, 'a') == 'v:a'
+        use(HandleTestCategory) {
+            assert javaVarargsCall(c, 'b') == 'v:b'
+        }
+        c.metaClass = c.metaClass
+        assert javaVarargsCall(c, 'c') == 'v:c'
+    }
+
+    @Test
+    void 'lookup helpers handle non-closure types and unboxable leftovers'() {
+        Class<?> callOverride = Class.forName('groovy.lang.Closure$CallOverride')
+        Method lookup = callOverride.getDeclaredMethod('lookup', Class)
+        lookup.accessible = true
+        assert lookup.invoke(null, String).is(callOverrideNone())
+
+        Method findOverride = callOverride.getDeclaredMethod('findOverride', Class, Class[])
+        findOverride.accessible = true
+        assert findOverride.invoke(null, Closure, [String] as Class[]) == null
+
+        Method wrapperOf = callOverride.getDeclaredMethod('wrapperOf', Class)
+        wrapperOf.accessible = true
+        assert wrapperOf.invoke(null, void) == void
+        assert wrapperOf.invoke(null, String) == String
+        assert wrapperOf.invoke(null, float) == Float
+    }
+
     @Test
     void 'unreflect returns null when the method cannot be adapted'() {
         def hidden = new Object() {
@@ -257,6 +488,42 @@ final class ClosureCallHandleTest {
         assert unreflect.invoke(null, method) == null
     }
 
+    private static Object javaVarargsCall(Closure c, Object... args) {
+        Method m = Closure.getMethod('call', Object[].class)
+        try {
+            return m.invoke(c, new Object[]{args})
+        } catch (InvocationTargetException ite) {
+            throw ite.cause
+        }
+    }
+
+    private static Object callOverrideNone() {
+        def none = Class.forName('groovy.lang.Closure$CallOverride').getDeclaredField('NONE')
+        none.accessible = true
+        return none.get(null)
+    }
+
+    private static Object callOverrideOf(Closure closure) {
+        def table = Closure.getDeclaredField('CALL_OVERRIDES')
+        table.accessible = true
+        return table.get(null).get(closure.getClass())
+    }
+
+    private static void clearHandle(Closure closure, int arity) {
+        Object[] slots = handleSlots(closure)
+        slots[arity] = null
+    }
+
+    private static Object[] handleSlots(Closure closure) {
+        Class<?> callOverride = Class.forName('groovy.lang.Closure$CallOverride')
+        def table = Closure.getDeclaredField('CALL_OVERRIDES')
+        table.accessible = true
+        def override = table.get(null).get(closure.getClass())
+        def f = callOverride.getDeclaredField('handles')
+        f.accessible = true
+        return (Object[]) f.get(override)
+    }
+
     private static Object callOverrideField(Closure closure, String field, int arity) {
         Class<?> callOverride = Class.forName('groovy.lang.Closure$CallOverride')
         def table = Closure.getDeclaredField('CALL_OVERRIDES')
@@ -304,3 +571,41 @@ final class ClosureCallHandleTest {
         }
     }
 }
+
+class HandleTestCategory {
+    static Object identity(Object self) { self }
+}
+
+class ParentDoCall extends Closure {
+    ParentDoCall() { super(null) }
+
+    def doCall(Object o) { "parent:$o" }
+}
+
+class ChildDoCall extends ParentDoCall {
+    def doCall(Object o) { "child:$o" }
+}
+
+class PrimitiveDoCalls extends Closure {
+    PrimitiveDoCalls() { super(null) }
+
+    int doCall(int x) { x + 1 }
+
+    long doCall(long a, long b) { a + b }
+
+    boolean doCall(boolean a, boolean b, boolean c) { a || b || c }
+
+    double doCall(double a, double b, double c, double d) { a + b + c + d }
+}
+
+class MorePrimitiveDoCalls extends Closure {
+    MorePrimitiveDoCalls() { super(null) }
+
+    char doCall(char x) { x }
+
+    byte doCall(byte a, byte b) { (byte) (a + b) }
+
+    short doCall(short a, short b, short c) { (short) (a + b + c) }
+
+    float doCall(float a, float b, float c, float d) { a + b + c + d }
+}
diff --git a/src/test/java/groovy/lang/StringDoCallClosure.java b/src/test/java/groovy/lang/StringDoCallClosure.java
new file mode 100644
index 0000000000..7d3c8e1f5d
--- /dev/null
+++ b/src/test/java/groovy/lang/StringDoCallClosure.java
@@ -0,0 +1,46 @@
+/*
+ *  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 groovy.lang;
+
+/**
+ * Covariant {@code doCall} so javac emits a bridge. {@code CallOverride} must
+ * skip that bridge and cache the most-derived declaration.
+ */
+class ObjectDoCallClosure extends Closure<Object> {
+
+    ObjectDoCallClosure() {
+        super(null);
+    }
+
+    public Object doCall(final Object value) {
+        return value;
+    }
+}
+
+public final class StringDoCallClosure extends ObjectDoCallClosure {
+
+    public StringDoCallClosure() {
+        super();
+    }
+
+    @Override
+    public String doCall(final Object value) {
+        return String.valueOf(value);
+    }
+}


(groovy) 03/03: GROOVY-12263: Cache high-arity doCall targets as MethodHandle spreaders

Posted by pa...@apache.org.
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

commit 763cbbf2bd6d467f9c4aeeec4fe0ef70e06e17a7
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sun Aug 16 12:37:50 2026 +0900

    GROOVY-12263: Cache high-arity doCall targets as MethodHandle spreaders
    
    Build asSpreader handles once at CallOverride lookup for arity >= 5,
    and invokeExact the cached (Object, Object[])Object shape. Leave the
    specialised 0-4 invokeExact switch on the GDK each/collect path unchanged.
---
 src/main/java/groovy/lang/Closure.java             | 157 ++++++++++++++++++---
 .../groovy/lang/ClosureCallHandleTest.groovy       |  97 +++++++++++--
 2 files changed, 226 insertions(+), 28 deletions(-)

diff --git a/src/main/java/groovy/lang/Closure.java b/src/main/java/groovy/lang/Closure.java
index fdc5b294f6..8f0f4d611f 100644
--- a/src/main/java/groovy/lang/Closure.java
+++ b/src/main/java/groovy/lang/Closure.java
@@ -48,10 +48,13 @@ import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
 import java.util.ArrayDeque;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Deque;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.IdentityHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.BiConsumer;
@@ -560,6 +563,13 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
                 if (m != null && CallOverride.guardsPass(override.guards[arity], arguments)) {
                     target = m;
                 }
+            } else {
+                // High-arity doCall: cached asSpreader of type (Object, Object[])Object.
+                // The 0..ARITY_LIMIT-1 invokeExact switch is unchanged.
+                CallOverride.SpreadSlot slot = override.spreadFor(arity);
+                if (slot != null && CallOverride.guardsPass(slot.guards, arguments)) {
+                    return invokeCached(slot.handle, slot.method, this, arguments);
+                }
             }
             if (target != null) {
                 MethodHandle handle = override.handles[arity];
@@ -616,11 +626,12 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
     }
 
     /**
-     * {@code invokeExact} against a handle adapted to
-     * {@link MethodType#genericMethodType(int) genericMethodType(arity+1)}
-     * (fixed-arity {@code Object} receiver and arguments, {@code Object} return).
-     * Cases {@code 0..ARITY_LIMIT-1} match that type exactly; the spreader
-     * is the type-correct fallback if the limit grows without a matching case.
+     * {@code invokeExact} against a cached handle. Cases {@code 0..ARITY_LIMIT-1}
+     * match {@link MethodType#genericMethodType(int) genericMethodType(arity+1)}.
+     * The default is a handle already adapted to
+     * {@link MethodType#genericMethodType(int, boolean) genericMethodType(1, true)}
+     * ({@code (Object, Object[])Object}) at lookup — {@code asSpreader} is not
+     * applied per call.
      */
     private static Object invokeHandle(final MethodHandle handle, final Closure<?> self, final Object[] arguments) throws Throwable {
         switch (arguments.length) {
@@ -635,7 +646,7 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
             case 4:
                 return handle.invokeExact((Object) self, arguments[0], arguments[1], arguments[2], arguments[3]);
             default:
-                return handle.asSpreader(Object[].class, arguments.length).invokeExact((Object) self, arguments);
+                return handle.invokeExact((Object) self, arguments);
         }
     }
 
@@ -1490,14 +1501,22 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
     private static final class CallOverride {
         private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
         /**
-         * 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).
+         * Specialised {@code invokeExact} cutoff: cases {@code 0..ARITY_LIMIT-1} use a
+         * fixed-arity handle. Sized to cover every callback shape the GDK drives
+         * (iteration and inject/withIndex variants peak at three parameters, plus slack).
+         * Higher arities are cached separately as {@link #SPREADER_TYPE} handles.
          */
         static final int ARITY_LIMIT = 5;
+        /**
+         * Cached high-arity shape: {@code (Object, Object[])Object}. Built once at
+         * lookup with {@code asSpreader}; {@code invokeHandle} then {@code invokeExact}s it.
+         */
+        static final MethodType SPREADER_TYPE = MethodType.genericMethodType(1, true);
+        static final SpreadSlot[] NO_SPREAD = new SpreadSlot[0];
         /**
          * Marker instance indicating that no dispatch targets exist.
          */
-        static final CallOverride NONE = new CallOverride(new Method[ARITY_LIMIT], new Class[ARITY_LIMIT][], new boolean[ARITY_LIMIT], new MethodHandle[ARITY_LIMIT]);
+        static final CallOverride NONE = new CallOverride(new Method[ARITY_LIMIT], new Class[ARITY_LIMIT][], new boolean[ARITY_LIMIT], new MethodHandle[ARITY_LIMIT], NO_SPREAD);
         /**
          * 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
@@ -1530,14 +1549,49 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
          * for {@code invokeExact}, or null when the method cannot be adapted.
          * The call path prefers the handle so {@code Method.invoke} is not on
          * the GDK {@code each}/{@code collect} hot path.
+         * Slots {@code 0..ARITY_LIMIT-1} are {@code genericMethodType(arity+1)};
+         * higher arities live in {@link #spread}.
          */
         final MethodHandle[] handles;
+        /**
+         * Exact-arity {@code doCall} targets with {@code arity >= ARITY_LIMIT},
+         * each adapted to {@link #SPREADER_TYPE}. Empty when the class has none.
+         */
+        final SpreadSlot[] spread;
 
-        private CallOverride(Method[] byArity, Class<?>[][] guards, boolean[] callForm, MethodHandle[] handles) {
+        private CallOverride(Method[] byArity, Class<?>[][] guards, boolean[] callForm, MethodHandle[] handles, SpreadSlot[] spread) {
             this.byArity = byArity;
             this.guards = guards;
             this.callForm = callForm;
             this.handles = handles;
+            this.spread = spread;
+        }
+
+        /**
+         * One high-arity {@code doCall}: exact parameter count, optional 12164
+         * guards, and a lookup-time spreader handle.
+         */
+        static final class SpreadSlot {
+            final int arity;
+            final Method method;
+            final Class<?>[] guards;
+            final MethodHandle handle;
+
+            SpreadSlot(final int arity, final Method method, final Class<?>[] guards, final MethodHandle handle) {
+                this.arity = arity;
+                this.method = method;
+                this.guards = guards;
+                this.handle = handle;
+            }
+        }
+
+        SpreadSlot spreadFor(final int arity) {
+            for (SpreadSlot slot : spread) {
+                if (slot.arity == arity) {
+                    return slot;
+                }
+            }
+            return null;
         }
 
         static boolean guardsPass(Class<?>[] guards, Object[] args) {
@@ -1551,8 +1605,10 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
 
         /**
          * 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
+         * selects a {@code doCall}, so the cache is keyed on the doCall declarations themselves.
+         * Arities {@code 0..ARITY_LIMIT-1} use specialised {@code invokeExact}; higher arities
+         * are stored as {@link #SPREADER_TYPE} handles. 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
@@ -1583,6 +1639,9 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
             Method[] exact = new Method[ARITY_LIMIT];
             Method[] typed = new Method[ARITY_LIMIT];
             boolean[] typedAmbiguous = new boolean[ARITY_LIMIT];
+            Map<Integer, Method> highExact = null;
+            Map<Integer, Method> highTyped = null;
+            Set<Integer> highAmbiguous = null;
             Set<String> seen = new HashSet<>();
             for (Class<?> c = type; c != null && c != Closure.class; c = c.getSuperclass()) {
                 for (Method m : c.getDeclaredMethods()) {
@@ -1592,13 +1651,29 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
                     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 (arity >= ARITY_LIMIT) {
+                        if (highExact == null) {
+                            highExact = new HashMap<>();
+                            highTyped = new HashMap<>();
+                            highAmbiguous = new HashSet<>();
+                        }
+                        if (allObject) {
+                            highExact.putIfAbsent(arity, m);
+                        } else if (highExact.containsKey(arity)) {
+                            // all-Object at this arity already won, as for 0..ARITY_LIMIT-1
+                        } else if (highTyped.containsKey(arity)) {
+                            highAmbiguous.add(arity);
+                        } else {
+                            highTyped.put(arity, m);
+                        }
+                        continue;
+                    }
                     if (allObject) {
                         if (exact[arity] == null) exact[arity] = m;
                     } else if (typed[arity] != null) {
@@ -1650,22 +1725,66 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
                 }
                 any = true;
             }
-            if (!any) {
-                return NONE;
-            }
             MethodHandle[] handles = new MethodHandle[ARITY_LIMIT];
             for (int arity = 0; arity < ARITY_LIMIT; arity += 1) {
                 if (byArity[arity] != null) {
                     handles[arity] = unreflect(byArity[arity]);
                 }
             }
-            return new CallOverride(byArity, guards, callForm, handles);
+            SpreadSlot[] spread = buildSpread(highExact, highTyped, highAmbiguous);
+            if (spread.length > 0) {
+                any = true;
+            }
+            if (!any) {
+                return NONE;
+            }
+            return new CallOverride(byArity, guards, callForm, handles, spread);
+        }
+
+        private static SpreadSlot[] buildSpread(final Map<Integer, Method> highExact, final Map<Integer, Method> highTyped, final Set<Integer> highAmbiguous) {
+            if (highExact == null) {
+                return NO_SPREAD;
+            }
+            Set<Integer> arities = new HashSet<>(highExact.keySet());
+            arities.addAll(highTyped.keySet());
+            List<SpreadSlot> built = new ArrayList<>(arities.size());
+            for (Integer boxed : arities) {
+                int arity = boxed;
+                Method exact = highExact.get(boxed);
+                Method m = (exact != null) ? exact
+                        : (highAmbiguous.contains(boxed) ? null : highTyped.get(boxed));
+                if (m == null) {
+                    continue;
+                }
+                try {
+                    m.setAccessible(true);
+                } catch (RuntimeException ignored) {
+                    continue;
+                }
+                Class<?>[] g = null;
+                if (m != exact) {
+                    Class<?>[] params = m.getParameterTypes();
+                    g = new Class[arity];
+                    for (int i = 0; i < arity; i += 1) {
+                        g[i] = (params[i] == Object.class) ? null : wrapperOf(params[i]);
+                    }
+                }
+                built.add(new SpreadSlot(arity, m, g, unreflect(m)));
+            }
+            return built.isEmpty() ? NO_SPREAD : built.toArray(NO_SPREAD);
         }
 
         private static MethodHandle unreflect(final Method method) {
             try {
-                return LOOKUP.unreflect(method)
-                        .asType(MethodType.genericMethodType(method.getParameterCount() + 1));
+                int arity = method.getParameterCount();
+                MethodHandle handle = LOOKUP.unreflect(method)
+                        .asType(MethodType.genericMethodType(arity + 1));
+                if (arity >= ARITY_LIMIT) {
+                    // box first, then spread: asSpreader on a still-primitive handle
+                    // would demand a primitive array
+                    handle = handle.asSpreader(Object[].class, arity).asType(SPREADER_TYPE);
+                }
+                return handle;
             } catch (Exception ignored) {
                 // inaccessible or not adaptable: call() falls back to Method.invoke
                 return null;
diff --git a/src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy b/src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy
index 6c4deb092d..0d07d2f711 100644
--- a/src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy
+++ b/src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy
@@ -138,11 +138,59 @@ final class ClosureCallHandleTest {
     }
 
     @Test
-    void 'arity at the cache limit is not cached'() {
+    void 'arity at the specialised limit is cached as a spreader'() {
         def five = { a, b, c, d, e -> a + b + c + d + e }
         assert javaVarargsCall(five, 1, 2, 3, 4, 5) == 15
-        assert cachedTarget(five, 5) == null
-        assert handleFor(five, 5) == null
+        assert cachedTarget(five, 5) != null
+        MethodHandle handle = handleFor(five, 5)
+        assert handle != null
+        assert handle.type() == MethodType.genericMethodType(1, true)
+        shouldFail(MissingMethodException) {
+            javaVarargsCall(five, 1, 2, 3, 4)
+        }
+        shouldFail(MissingMethodException) {
+            javaVarargsCall(five, 1, 2, 3, 4, 5, 6)
+        }
+
+        def six = { a, b, c, d, e, f -> a + b + c + d + e + f }
+        assert javaVarargsCall(six, 1, 2, 3, 4, 5, 6) == 21
+        assert handleFor(six, 6).type() == MethodType.genericMethodType(1, true)
+        assert handleFor(six, 5) == null
+
+        def fiveInts = new Closure(this) {
+            int doCall(int a, int b, int c, int d, int e) { a + b + c + d + e }
+        }
+        assert javaVarargsCall(fiveInts, 1, 2, 3, 4, 5) == 15
+        assert handleFor(fiveInts, 5) != null
+
+        def boom = { a, b, c, d, e -> throw new IllegalStateException('five') }
+        def e = shouldFail(IllegalStateException) {
+            javaVarargsCall(boom, 1, 2, 3, 4, 5)
+        }
+        assert e.message == 'five'
+    }
+
+    @Test
+    void 'typed high-arity doCall still falls through when a guard fails'() {
+        def c = new Closure(this) {
+            def doCall(String a, String b, String c, String d, String e) {
+                a + b + c + d + e
+            }
+        }
+        assert javaVarargsCall(c, 'a', 'b', 'c', 'd', 'e') == 'abcde'
+        assert handleFor(c, 5) != null
+        assert javaVarargsCall(c, 'a', 'b', 'c', 'd', "${'e'}") == 'abcde'
+    }
+
+    @Test
+    void 'high-arity call override without doCall is not cached'() {
+        def c = new Closure(this) {
+            Object call(a, b, c, d, e) { 'wrapped' }
+        }
+        shouldFail(MissingMethodException) {
+            javaVarargsCall(c, 1, 2, 3, 4, 5)
+        }
+        assert handleFor(c, 5) == null
     }
 
     @Test
@@ -272,11 +320,11 @@ final class ClosureCallHandleTest {
         def c = new Closure(this) {
             def doCall(a, b, d, e, f) { [a, b, d, e, f] }
         }
-        Method method = findDoCall(c.getClass(), 5)
-        method.accessible = true
-        MethodHandle handle = MethodHandles.lookup().unreflect(method)
-                .asType(MethodType.genericMethodType(6))
+        MethodHandle handle = handleFor(c, 5)
+        assert handle != null
+        assert handle.type() == MethodType.genericMethodType(1, true)
         assert invokeHandleDirect(handle, c, [1, 2, 3, 4, 5] as Object[]) == [1, 2, 3, 4, 5]
+        assert javaVarargsCall(c, 1, 2, 3, 4, 5) == [1, 2, 3, 4, 5]
     }
 
     @Test
@@ -535,12 +583,43 @@ final class ClosureCallHandleTest {
         return (arity < slots.length) ? slots[arity] : null
     }
 
+    private static int arityLimit() {
+        def f = Class.forName('groovy.lang.Closure$CallOverride').getDeclaredField('ARITY_LIMIT')
+        f.accessible = true
+        return f.getInt(null)
+    }
+
     private static MethodHandle handleFor(Closure closure, int arity) {
-        return (MethodHandle) callOverrideField(closure, 'handles', arity)
+        if (arity < arityLimit()) {
+            return (MethodHandle) callOverrideField(closure, 'handles', arity)
+        }
+        return (MethodHandle) spreadSlotField(closure, arity, 'handle')
     }
 
     private static Method cachedTarget(Closure closure, int arity) {
-        return (Method) callOverrideField(closure, 'byArity', arity)
+        if (arity < arityLimit()) {
+            return (Method) callOverrideField(closure, 'byArity', arity)
+        }
+        return (Method) spreadSlotField(closure, arity, 'method')
+    }
+
+    private static Object spreadSlotField(Closure closure, int arity, String field) {
+        Class<?> callOverride = Class.forName('groovy.lang.Closure$CallOverride')
+        Object override = callOverrideOf(closure)
+        def spread = callOverride.getDeclaredField('spread')
+        spread.accessible = true
+        Object[] slots = (Object[]) spread.get(override)
+        Class<?> slotType = Class.forName('groovy.lang.Closure$CallOverride$SpreadSlot')
+        def arityField = slotType.getDeclaredField('arity')
+        arityField.accessible = true
+        def valueField = slotType.getDeclaredField(field)
+        valueField.accessible = true
+        for (Object slot : slots) {
+            if (arityField.getInt(slot) == arity) {
+                return valueField.get(slot)
+            }
+        }
+        return null
     }
 
     private static Method findDoCall(Class<?> type, int arity) {


(groovy) 01/03: GROOVY-12263: Invoke cached Closure doCall targets via MethodHandle

Posted by pa...@apache.org.
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

commit eda69e6b98a59ca9832dfaafef289a552e7bc69d
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Aug 15 17:33:08 2026 +0900

    GROOVY-12263: Invoke cached Closure doCall targets via MethodHandle
---
 src/main/java/groovy/lang/Closure.java             | 130 +++++++--
 .../groovy/lang/ClosureCallHandleTest.groovy       | 306 +++++++++++++++++++++
 2 files changed, 409 insertions(+), 27 deletions(-)

diff --git a/src/main/java/groovy/lang/Closure.java b/src/main/java/groovy/lang/Closure.java
index bb7f50010d..fdc5b294f6 100644
--- a/src/main/java/groovy/lang/Closure.java
+++ b/src/main/java/groovy/lang/Closure.java
@@ -41,6 +41,9 @@ import java.io.InvalidObjectException;
 import java.io.Serial;
 import java.io.Serializable;
 import java.io.Writer;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
@@ -543,13 +546,13 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
             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).
+            // eachWithIndex, inject) dispatch directly via a cached MethodHandle. 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;
             int arity = arguments.length;
             if (arity < CallOverride.ARITY_LIMIT) {
@@ -559,25 +562,14 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
                 }
             }
             if (target != null) {
+                MethodHandle handle = override.handles[arity];
                 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);
-                    }
+                    return invokeCached(handle, target, this, arguments);
                 } 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);
+                        return invokeCached(handle, target, this, arguments);
                     } finally {
                         IN_CALL_FALLBACK.set(Boolean.FALSE);
                     }
@@ -594,6 +586,59 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
         }
     }
 
+    /**
+     * Invokes a cached {@code doCall}/{@code call} target. Prefers the adapted
+     * {@link MethodHandle} so {@code Method.invoke} is not on the GDK
+     * {@code each}/{@code collect} hot path. Exceptions thrown by the body —
+     * including a body that itself throws {@link InvocationTargetException} or
+     * {@link IllegalAccessException} — are rethrown as-is on the handle path;
+     * the {@link Method#invoke} fallback unwraps only the wrapper
+     * {@link InvocationTargetException} that reflection introduces.
+     */
+    @SuppressWarnings("unchecked")
+    private static <V> V invokeCached(final MethodHandle handle, final Method target, final Closure<?> self, final Object[] arguments) {
+        if (handle != null) {
+            try {
+                return (V) invokeHandle(handle, self, arguments);
+            } catch (Throwable t) {
+                UncheckedThrow.rethrow(t);
+                return null;
+            }
+        }
+        try {
+            return (V) target.invoke(self, arguments);
+        } catch (InvocationTargetException ite) {
+            UncheckedThrow.rethrow(ite.getCause());
+            return null; // unreachable statement
+        } catch (IllegalAccessException iae) {
+            throw new GroovyRuntimeException(iae);
+        }
+    }
+
+    /**
+     * {@code invokeExact} against a handle adapted to
+     * {@link MethodType#genericMethodType(int) genericMethodType(arity+1)}
+     * (fixed-arity {@code Object} receiver and arguments, {@code Object} return).
+     * Cases {@code 0..ARITY_LIMIT-1} match that type exactly; the spreader
+     * is the type-correct fallback if the limit grows without a matching case.
+     */
+    private static Object invokeHandle(final MethodHandle handle, final Closure<?> self, final Object[] arguments) throws Throwable {
+        switch (arguments.length) {
+            case 0:
+                return handle.invokeExact((Object) self);
+            case 1:
+                return handle.invokeExact((Object) self, arguments[0]);
+            case 2:
+                return handle.invokeExact((Object) self, arguments[0], arguments[1]);
+            case 3:
+                return handle.invokeExact((Object) self, arguments[0], arguments[1], arguments[2]);
+            case 4:
+                return handle.invokeExact((Object) self, arguments[0], arguments[1], arguments[2], arguments[3]);
+            default:
+                return handle.asSpreader(Object[].class, arguments.length).invokeExact((Object) self, arguments);
+        }
+    }
+
     /**
      * 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
@@ -1434,13 +1479,16 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
     }
 
     /**
-     * NOTE: this reflective cache is a bridge, not a destination. For compiler-generated closure
+     * NOTE: this cache is a bridge, not a destination. For compiler-generated closure
      * classes the long-term plan (GEP-27) is for the compiler to emit a {@code call(Object...)}
      * override that arity-dispatches directly to the right {@code doCall} — plain virtual
-     * dispatch, no reflection, module-system-clean — at which point this cache serves only
-     * legacy jars and hand-written subclasses. Extend it with that destination in mind.
+     * dispatch, no {@code MethodHandle} adaptation, module-system-clean — at which point this
+     * cache serves only legacy jars and hand-written subclasses. Until then cached targets
+     * are invoked via {@code MethodHandle} ({@code Method.invoke} only when the target cannot
+     * be adapted). Extend it with that destination in mind.
      */
     private static final class CallOverride {
+        private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
         /**
          * 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).
@@ -1449,7 +1497,7 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
         /**
          * Marker instance indicating that no dispatch targets exist.
          */
-        static final CallOverride NONE = new CallOverride(new Method[ARITY_LIMIT], new Class[ARITY_LIMIT][], new boolean[ARITY_LIMIT]);
+        static final CallOverride NONE = new CallOverride(new Method[ARITY_LIMIT], new Class[ARITY_LIMIT][], new boolean[ARITY_LIMIT], new MethodHandle[ARITY_LIMIT]);
         /**
          * 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
@@ -1476,11 +1524,20 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
          * dispatched body keep their own fast path).
          */
         final boolean[] callForm;
+        /**
+         * {@link MethodHandles#unreflect(Method)} of {@link #byArity}, adapted to
+         * {@link MethodType#genericMethodType(int) genericMethodType(arity+1)}
+         * for {@code invokeExact}, or null when the method cannot be adapted.
+         * The call path prefers the handle so {@code Method.invoke} is not on
+         * the GDK {@code each}/{@code collect} hot path.
+         */
+        final MethodHandle[] handles;
 
-        private CallOverride(Method[] byArity, Class<?>[][] guards, boolean[] callForm) {
+        private CallOverride(Method[] byArity, Class<?>[][] guards, boolean[] callForm, MethodHandle[] handles) {
             this.byArity = byArity;
             this.guards = guards;
             this.callForm = callForm;
+            this.handles = handles;
         }
 
         static boolean guardsPass(Class<?>[] guards, Object[] args) {
@@ -1593,7 +1650,26 @@ public abstract class Closure<V> extends GroovyObjectSupport implements Cloneabl
                 }
                 any = true;
             }
-            return any ? new CallOverride(byArity, guards, callForm) : NONE;
+            if (!any) {
+                return NONE;
+            }
+            MethodHandle[] handles = new MethodHandle[ARITY_LIMIT];
+            for (int arity = 0; arity < ARITY_LIMIT; arity += 1) {
+                if (byArity[arity] != null) {
+                    handles[arity] = unreflect(byArity[arity]);
+                }
+            }
+            return new CallOverride(byArity, guards, callForm, handles);
+        }
+
+        private static MethodHandle unreflect(final Method method) {
+            try {
+                return LOOKUP.unreflect(method)
+                        .asType(MethodType.genericMethodType(method.getParameterCount() + 1));
+            } catch (Exception ignored) {
+                // inaccessible or not adaptable: call() falls back to Method.invoke
+                return null;
+            }
         }
 
         private static Method findOverride(Class<?> type, Class<?>... params) {
diff --git a/src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy b/src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy
new file mode 100644
index 0000000000..c7a598ceb5
--- /dev/null
+++ b/src/test/groovy/groovy/lang/ClosureCallHandleTest.groovy
@@ -0,0 +1,306 @@
+/*
+ *  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 groovy.lang
+
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.MethodHandle
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.MethodType
+import java.lang.reflect.InvocationTargetException
+import java.lang.reflect.Method
+
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Generated and hand-written closures must keep the same call semantics after
+ * the MethodHandle fast path is used for {@code doCall} / {@code call} targets.
+ * Handle-thrown exceptions surface as the body threw them; {@code Method.invoke}
+ * is used only when a target cannot be adapted, and only then is its wrapper
+ * {@link InvocationTargetException} unwrapped.
+ */
+final class ClosureCallHandleTest {
+
+    @Test
+    void 'gdk each collect findAll and inject still dispatch'() {
+        assert [1, 2, 3].collect { it * 2 } == [2, 4, 6]
+        assert [1, 2, 3].findAll { it > 1 } == [2, 3]
+        def sum = 0
+        [1, 2, 3].each { sum += it }
+        assert sum == 6
+        assert [1, 2, 3].inject(0) { a, b -> a + b } == 6
+    }
+
+    @Test
+    void 'typed doCall falls through to the metaclass when a guard fails'() {
+        def c = new Closure(this) {
+            def doCall(String s) { s.toUpperCase() }
+        }
+        assert c.call('ab') == 'AB'
+        // GROOVY-12164: GString is not a String, so the MH/guard path must not fire
+        assert c.call("${'ab'}") == 'AB'
+    }
+
+    @Test
+    void 'exceptions from doCall are rethrown unwrapped'() {
+        def c = { throw new IllegalStateException('boom') }
+        def e = shouldFail(IllegalStateException) {
+            c.call()
+        }
+        assert e.message == 'boom'
+    }
+
+    @Test
+    void 'InvocationTargetException from the body is not treated as a reflection wrapper'() {
+        def inner = new RuntimeException('inner')
+        def c = { throw new InvocationTargetException(inner) }
+        def e = shouldFail(InvocationTargetException) {
+            c.call()
+        }
+        assert e.cause.is(inner)
+    }
+
+    @Test
+    void 'IllegalAccessException from the body is not wrapped as GroovyRuntimeException'() {
+        // Invoke the Java varargs entry reflectively: GroovyAssert.shouldFail and Groovy
+        // call sites unwrap GroovyRuntimeException, which would hide the wrap-as-GRE bug.
+        def c = { throw new IllegalAccessException('nope') }
+        try {
+            // Method.invoke is itself varargs: wrap the Object[] argument so we
+            // pass one parameter to call(Object...), not zero.
+            Closure.getMethod('call', Object[].class).invoke(c, new Object[]{new Object[0]})
+            assert false: 'expected InvocationTargetException'
+        } catch (InvocationTargetException ite) {
+            assert ite.cause instanceof IllegalAccessException
+            assert !(ite.cause instanceof GroovyRuntimeException)
+            assert ite.cause.message == 'nope'
+        }
+    }
+
+    @Test
+    void 'checked exceptions from doCall surface as thrown'() {
+        def c = { throw new IOException('io') }
+        def e = shouldFail(IOException) {
+            c.call()
+        }
+        assert e.message == 'io'
+    }
+
+    @Test
+    void 'errors from doCall surface as thrown'() {
+        def c = { throw new Error('err') }
+        def e = shouldFail(Error) {
+            c.call()
+        }
+        assert e.message == 'err'
+    }
+
+    @Test
+    void 'zero through four argument call forms keep their results'() {
+        def zero = { 42 }
+        assert zero.call() == 42
+        def one = { x -> x }
+        assert one.call('x') == 'x'
+        def two = { a, b -> a + b }
+        assert two.call(1, 2) == 3
+        def three = { a, b, c -> a + b + c }
+        assert three.call(1, 2, 3) == 6
+        def four = { a, b, c, d -> a + b + c + d }
+        assert four.call(1, 2, 3, 4) == 10
+        assert [a: 1, b: 2].inject(0) { acc, k, v -> acc + v } == 3
+        def seen = []
+        [x: 1].eachWithIndex { k, v, i -> seen << "$k:$v:$i".toString() }
+        assert seen == ['x:1:0']
+        assert handleFor(zero, 0) != null
+        assert handleFor(one, 1) != null
+        assert handleFor(two, 2) != null
+        assert handleFor(three, 3) != null
+        assert handleFor(four, 4) != null
+    }
+
+    @Test
+    void 'arity at the cache limit is not cached'() {
+        def five = { a, b, c, d, e -> a + b + c + d + e }
+        assert five.call(1, 2, 3, 4, 5) == 15
+        assert cachedTarget(five, 5) == null
+        assert handleFor(five, 5) == null
+    }
+
+    @Test
+    void 'void and primitive doCall adapt through the handle'() {
+        def side = []
+        def empty = new Closure(this) {
+            void doCall() { side << 1 }
+        }
+        assert empty.call() == null
+        assert side == [1]
+
+        def boxed = new Closure(this) {
+            int doCall(int x) { x + 1 }
+        }
+        assert boxed.call(41) == 42
+        assert [1, 2].collect(boxed) == [2, 3]
+    }
+
+    @Test
+    void 'null Object argument reaches an all-Object doCall'() {
+        def c = { it }
+        assert c.call((Object) null) == null
+    }
+
+    @Test
+    void 'call-form override is invoked via the handle and the re-entry latch still holds'() {
+        def wrapped = new Closure(this) {
+            @Override
+            Object call(Object arg) {
+                return 'wrapped:' + arg
+            }
+        }
+        // Java/GDK entry: call(Object...) must agree with virtual call(Object)
+        assert wrapped.call(new Object[]{'x'}) == 'wrapped:x'
+        assert ['a'].collect(wrapped) == ['wrapped:a']
+        assert handleFor(wrapped, 1) != null
+
+        def reenter = new Closure(this) {
+            @Override
+            Object call() {
+                return call(new Object[0])
+            }
+            Object doCall() { 99 }
+        }
+        assert reenter.call(new Object[0]) == 99
+        assert handleFor(reenter, 0) != null
+    }
+
+    @Test
+    void 'curried and method-pointer closures stay on the metaclass path'() {
+        def add = { a, b -> a + b }
+        assert add.curry(10).call(2) == 12
+        assert 'abc'.&substring.call(1) == 'bc'
+    }
+
+    @Test
+    void 'invokeCached falls back to Method invoke when the handle is null'() {
+        def c = { x -> "r:$x" }
+        Method method = findDoCall(c.getClass(), 1)
+        method.accessible = true
+        assert invokeCachedDirect(null, method, c, ['z'] as Object[]) == 'r:z'
+    }
+
+    @Test
+    void 'invokeCached unwraps InvocationTargetException from Method invoke'() {
+        def c = { throw new IllegalStateException('via-reflect') }
+        Method method = findDoCall(c.getClass(), 0)
+        method.accessible = true
+        def e = shouldFail(IllegalStateException) {
+            invokeCachedDirect(null, method, c, new Object[0])
+        }
+        assert e.message == 'via-reflect'
+    }
+
+    @Test
+    void 'invokeCached wraps IllegalAccessException from Method invoke'() {
+        def c = new Closure(this) {
+            private Object doCall() { 'secret' }
+        }
+        Method method = c.getClass().getDeclaredMethod('doCall')
+        method.accessible = false
+        Method invokeCached = Closure.getDeclaredMethod('invokeCached', MethodHandle, Method, Closure, Object[])
+        invokeCached.accessible = true
+        try {
+            invokeCached.invoke(null, null, method, c, new Object[0])
+            assert false: 'expected InvocationTargetException wrapping GroovyRuntimeException'
+        } catch (InvocationTargetException ite) {
+            // catch the reflective wrapper so GroovyAssert does not unwrap GRE
+            assert ite.cause instanceof GroovyRuntimeException
+            assert ite.cause.cause instanceof IllegalAccessException
+        }
+    }
+
+    @Test
+    void 'invokeHandle spreader covers arity beyond the specialised switch'() {
+        def c = new Closure(this) {
+            def doCall(a, b, d, e, f) { [a, b, d, e, f] }
+        }
+        Method method = findDoCall(c.getClass(), 5)
+        method.accessible = true
+        MethodHandle handle = MethodHandles.lookup().unreflect(method)
+                .asType(MethodType.genericMethodType(6))
+        assert invokeHandleDirect(handle, c, [1, 2, 3, 4, 5] as Object[]) == [1, 2, 3, 4, 5]
+    }
+
+    @Test
+    void 'unreflect returns null when the method cannot be adapted'() {
+        def hidden = new Object() {
+            private void secret() {}
+        }
+        Method method = hidden.getClass().getDeclaredMethod('secret')
+        Class<?> callOverride = Class.forName('groovy.lang.Closure$CallOverride')
+        Method unreflect = callOverride.getDeclaredMethod('unreflect', Method)
+        unreflect.accessible = true
+        assert unreflect.invoke(null, method) == null
+    }
+
+    private static Object callOverrideField(Closure closure, String field, int arity) {
+        Class<?> callOverride = Class.forName('groovy.lang.Closure$CallOverride')
+        def table = Closure.getDeclaredField('CALL_OVERRIDES')
+        table.accessible = true
+        def override = table.get(null).get(closure.getClass())
+        def f = callOverride.getDeclaredField(field)
+        f.accessible = true
+        Object[] slots = (Object[]) f.get(override)
+        return (arity < slots.length) ? slots[arity] : null
+    }
+
+    private static MethodHandle handleFor(Closure closure, int arity) {
+        return (MethodHandle) callOverrideField(closure, 'handles', arity)
+    }
+
+    private static Method cachedTarget(Closure closure, int arity) {
+        return (Method) callOverrideField(closure, 'byArity', arity)
+    }
+
+    private static Method findDoCall(Class<?> type, int arity) {
+        Method found = type.getDeclaredMethods().find { Method m ->
+            m.name == 'doCall' && m.parameterCount == arity && !m.bridge
+        }
+        assert found != null: "no doCall/${arity} on ${type.name}"
+        return found
+    }
+
+    private static Object invokeCachedDirect(MethodHandle handle, Method target, Closure self, Object[] args) {
+        Method m = Closure.getDeclaredMethod('invokeCached', MethodHandle, Method, Closure, Object[])
+        m.accessible = true
+        try {
+            return m.invoke(null, handle, target, self, args)
+        } catch (InvocationTargetException ite) {
+            throw ite.cause
+        }
+    }
+
+    private static Object invokeHandleDirect(MethodHandle handle, Closure self, Object[] args) {
+        Method m = Closure.getDeclaredMethod('invokeHandle', MethodHandle, Closure, Object[])
+        m.accessible = true
+        try {
+            return m.invoke(null, handle, self, args)
+        } catch (InvocationTargetException ite) {
+            throw ite.cause
+        }
+    }
+}