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 3c58690c39 GROOVY-12137: experimental reflective cold tier for indy 
dispatch (spike)
3c58690c39 is described below

commit 3c58690c3913d828b56e922a65f806b587dd153e
Author: Paul King <[email protected]>
AuthorDate: Tue Jul 7 20:32:04 2026 +1000

    GROOVY-12137: experimental reflective cold tier for indy dispatch (spike)
    
    Behind the groovy.indy.cold.reflection flag (default: off), plain method
    calls dispatch reflectively while a call site is cold, deferring all
    MethodHandle chain construction — and its one-time LambdaForm creation
    cost — to hit-count promotion. The shared cold dispatcher has a single
    (Object[])Object shape for every call site, arity, and primitive
    pattern; promotion installs the unchanged full guarded chain, so the
    hot path is identical to master.
    
    Plain calls are public, non-static, non-category CachedMethods on
    public classes with a real receiver and no spread/safe-null/interceptor
    semantics; caller-sensitive targets (including unannotated
    caller-context-sensitive cases like object serialization, and interface
    selections resolving to sensitive implementations) stay on the full
    path. Selection reuses Selector's existing logic via a new
    selection-only hook; validity is re-checked per call with plain-Java
    equivalents of the chain's guards. A per-wrapper cumulative counter
    promotes polymorphic receiver shapes that the consecutive-hit counter
    never catches.
    
    Measured (deterministic class-load counters + fresh-JVM timing):
    ~2/3 of per-shape LambdaForm cost removed on cold dispatch workloads,
    1.34-1.40x faster cold dispatch with many cold sites, steady state
    unchanged after promotion, MOP-heavy test subset (3437 tests) green
    with the flag on.
    
    Changes from review feedback: consolidate caller-sensitivity, harden 
invariants
    
        - selectForColdReflection now throws GroovyBugError for non-METHOD call
          types instead of silently returning null, so a broken fallback gate
          cannot hide behind the full handle path
        - the NullObject exclusion is marked TODO: its methods are ordinary
          methods on a singleton receiver and could be served reflectively
        - all method-determined caller-sensitivity — the annotation probe plus
          declaring-class cases like object serialization's stack-walking loader
          lookup — now lives behind CachedMethod#isCallerSensitive, computed
          once per method; the wrapper keeps only the receiver-dependent case
        - implementation resolution now applies to abstract selections only
          (e.g. the metaclass picking ObjectInput.readObject() for a call on an
          ObjectInputStream): interface default methods carry their own
          annotations, are probed directly, and take the reflective tier
---
 .../java/org/apache/groovy/util/SystemUtil.java    |  18 ++
 .../codehaus/groovy/reflection/CachedMethod.java   |  48 +++++
 .../v8/ColdReflectiveMethodHandleWrapper.java      | 193 +++++++++++++++++
 .../codehaus/groovy/vmplugin/v8/IndyInterface.java | Bin 20799 -> 26208 bytes
 .../org/codehaus/groovy/vmplugin/v8/Selector.java  |  58 +++++
 .../groovy/bench/DynamicDispatchColdBench.java     |  23 ++
 .../groovy/bench/dispatch/CallsiteBench.java       |  30 +++
 .../groovy/perf/ColdReflectionParityTest.groovy    | 104 +++++++++
 .../perf/cold-reflection-parity-corpus.groovy      | 234 +++++++++++++++++++++
 9 files changed, 708 insertions(+)

diff --git a/src/main/java/org/apache/groovy/util/SystemUtil.java 
b/src/main/java/org/apache/groovy/util/SystemUtil.java
index b7b55d3d0b..28a8798de8 100644
--- a/src/main/java/org/apache/groovy/util/SystemUtil.java
+++ b/src/main/java/org/apache/groovy/util/SystemUtil.java
@@ -119,6 +119,24 @@ public class SystemUtil {
         return false;
     }
 
+    /**
+     * Retrieves a Boolean System property, supporting an opt-out 
(default-true)
+     * flag: absent property yields the default, an explicit value is parsed.
+     *
+     * @param name the name of the system property.
+     * @param def the default value when the property is not set
+     * @return the parsed Boolean system property or the default value
+     */
+    public static boolean getBooleanSafe(String name, boolean def) {
+        try {
+            String value = System.getProperty(name);
+            return (value == null) ? def : Boolean.parseBoolean(value);
+        } catch (SecurityException ignore) {
+            // suppress exception
+        }
+        return def;
+    }
+
     /**
      * Retrieves an Integer System property
      *
diff --git a/src/main/java/org/codehaus/groovy/reflection/CachedMethod.java 
b/src/main/java/org/codehaus/groovy/reflection/CachedMethod.java
index 3f38b0ab1c..7f5663592b 100644
--- a/src/main/java/org/codehaus/groovy/reflection/CachedMethod.java
+++ b/src/main/java/org/codehaus/groovy/reflection/CachedMethod.java
@@ -82,6 +82,7 @@ public class CachedMethod extends MetaMethod implements 
Comparable {
 
     private int hashCode;
     private boolean skipCompiled;
+    private Boolean callerSensitive;
 
     private boolean makeAccessibleDone;
     private CachedMethod transformedMethod;
@@ -526,6 +527,53 @@ public class CachedMethod extends MetaMethod implements 
Comparable {
         }
     }
 
+    /**
+     * Indicates whether the underlying method is caller-sensitive, meaning its
+     * behaviour depends on the immediate caller — such methods must not be
+     * invoked through intermediaries that change the observed caller. This
+     * covers the {@code jdk.internal.reflect.CallerSensitive} annotation and
+     * declaring-class-based cases that are caller-context sensitive without
+     * the annotation (object serialization's stack-walking loader lookup).
+     * Computed once and cached; when the probe cannot decide, the method is
+     * conservatively treated as caller-sensitive. Note that an abstract
+     * method never carries the annotation itself: callers dispatching
+     * virtually should also probe the runtime receiver's implementation.
+     *
+     * @return {@code true} if the method is (or must be assumed) 
caller-sensitive
+     * @since 6.0.0
+     */
+    public boolean isCallerSensitive() {
+        Boolean sensitive = callerSensitive;
+        if (sensitive == null) {
+            sensitive = computeCallerSensitive(cachedMethod);
+            callerSensitive = sensitive;
+        }
+        return sensitive;
+    }
+
+    private static boolean computeCallerSensitive(final Method method) {
+        // object serialization is caller-context sensitive without carrying
+        // the annotation: its latestUserDefinedLoader walks the stack for the
+        // class loader, so intermediary frames change its behavior
+        Class<?> declaringClass = method.getDeclaringClass();
+        if (java.io.ObjectInput.class.isAssignableFrom(declaringClass)
+                || java.io.ObjectOutput.class.isAssignableFrom(declaringClass)
+                || 
java.io.ObjectInputStream.class.isAssignableFrom(declaringClass)
+                || 
java.io.ObjectOutputStream.class.isAssignableFrom(declaringClass)) {
+            return true;
+        }
+        try {
+            for (Annotation annotation : method.getDeclaredAnnotations()) {
+                if 
("jdk.internal.reflect.CallerSensitive".equals(annotation.annotationType().getName()))
 {
+                    return true;
+                }
+            }
+            return false;
+        } catch (Throwable ignore) {
+            return true;
+        }
+    }
+
     /**
      * Makes this method accessible and returns the underlying Java method.
      * Synonym for {@link #getCachedMethod()}.
diff --git 
a/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
 
b/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
new file mode 100644
index 0000000000..6cbd8f3687
--- /dev/null
+++ 
b/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
@@ -0,0 +1,193 @@
+/*
+ *  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.vmplugin.v8;
+
+import groovy.lang.GroovyObject;
+import groovy.lang.MetaClass;
+import groovy.lang.MetaMethod;
+import org.codehaus.groovy.reflection.CachedMethod;
+import org.codehaus.groovy.runtime.GroovyCategorySupport;
+import org.codehaus.groovy.runtime.wrappers.Wrapper;
+import org.codehaus.groovy.vmplugin.VMPluginFactory;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.SwitchPoint;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+
+/**
+ * Experimental reflective cold-tier wrapper, guarded by the
+ * {@code groovy.indy.cold.reflection} system property.
+ * <p>
+ * The regular cold tier builds a fully guarded MethodHandle chain on the
+ * first invocation of every receiver shape — paying the one-time LambdaForm
+ * creation cost for a MethodType shape determined by the call site's arity
+ * and primitive pattern. This wrapper instead carries only the selected
+ * {@link MetaMethod} plus the data needed to re-validate the selection in
+ * plain Java; invocation happens reflectively via
+ * {@link IndyInterface#invokeColdReflective}. Its bound invocation handle has
+ * the same {@code (Object[])Object} shape for every call site, so the cold
+ * tier spins no per-shape LambdaForms. The full guarded chain is built only
+ * at promotion time (see {@code IndyInterface.fromCacheHandle}), leaving the
+ * hot path unchanged.
+ * <p>
+ * The plain-Java validity checks in {@link #isValidFor} mirror the guards the
+ * full chain would install: switch-point freshness (which also covers
+ * category enter/leave, as those invalidate the switch point), exact argument
+ * classes (at least as strict as the chain's same-class guards), and
+ * metaclass identity for {@code GroovyObject} receivers.
+ */
+class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
+
+    final CacheableCallSite callSite;
+    final Class<?> sender;
+    final String methodName;
+    final int callID;
+    final Boolean safeNavigation;
+    final Boolean thisCall;
+    final Boolean spreadCall;
+    private final MetaClass selectionMetaClass;
+    private final SwitchPoint validity;
+    private final Class<?>[] argClasses;
+    private final MethodHandle reflectiveHandle;
+    /**
+     * Cumulative reflective invocations, independent of the PIC's
+     * consecutive-hit counter (which resets whenever receivers alternate, so
+     * polymorphic sites would otherwise stay on the reflective tier forever).
+     * Racy increments only delay promotion slightly, which is harmless.
+     */
+    private int reflectiveHits;
+
+    private ColdReflectiveMethodHandleWrapper(MetaMethod method, 
CacheableCallSite callSite, Class<?> sender,
+            String methodName, int callID, Boolean safeNavigation, Boolean 
thisCall, Boolean spreadCall,
+            MetaClass selectionMetaClass, SwitchPoint validity, Class<?>[] 
argClasses) {
+        super(null, null, method, true);
+        this.callSite = callSite;
+        this.sender = sender;
+        this.methodName = methodName;
+        this.callID = callID;
+        this.safeNavigation = safeNavigation;
+        this.thisCall = thisCall;
+        this.spreadCall = spreadCall;
+        this.selectionMetaClass = selectionMetaClass;
+        this.validity = validity;
+        this.argClasses = argClasses;
+        this.reflectiveHandle = 
IndyInterface.COLD_REFLECTIVE_INVOKER.bindTo(this);
+    }
+
+    @Override
+    public MethodHandle getCachedMethodHandle() {
+        return reflectiveHandle;
+    }
+
+    /**
+     * Counts a reflective invocation.
+     *
+     * @return the cumulative reflective invocation count
+     */
+    int incrementReflectiveHits() {
+        return ++reflectiveHits;
+    }
+
+    /**
+     * Plain-Java equivalent of the guards the full handle chain would install.
+     *
+     * @param arguments receiver-first invocation arguments
+     * @return {@code true} if the cached selection is still valid for them
+     */
+    boolean isValidFor(Object[] arguments) {
+        if (validity.hasBeenInvalidated()) return false;
+        Class<?>[] classes = argClasses;
+        if (arguments.length != classes.length) return false;
+        for (int i = 0; i < classes.length; i++) {
+            Object a = arguments[i];
+            if (a == null ? classes[i] != null : a.getClass() != classes[i]) 
return false;
+        }
+        Object receiver = arguments[0];
+        return !(receiver instanceof GroovyObject go) || go.getMetaClass() == 
selectionMetaClass;
+    }
+
+    /**
+     * Attempts a reflective cold-tier selection for a plain method call.
+     * "Plain" means: real receiver (non-null, not a {@code Class}, not
+     * {@code GroovyInterceptable}), no spread/safe-null semantics, cacheable
+     * selection resolving to a public, non-static, non-category
+     * {@link CachedMethod} on a public class that is not caller-sensitive.
+     * Anything else returns {@code null} and takes the full handle path.
+     *
+     * @return the reflective wrapper, or {@code null} if unsupported
+     */
+    static MethodHandleWrapper tryBuild(Selector selector, CacheableCallSite 
callSite, Class<?> sender,
+            String methodName, int callID, Boolean safeNavigation, Boolean 
thisCall, Boolean spreadCall,
+            Object[] arguments) {
+        MetaMethod selected = selector.selectForColdReflection();
+        if (selected == null || selected instanceof 
GroovyCategorySupport.CategoryMethod) return null;
+        MetaClass mc = selector.getSelectionMetaClass();
+        if (mc == null || !selector.cache) return null;
+        MetaMethod transformed = 
VMPluginFactory.getPlugin().transformMetaMethod(mc, selected, sender);
+        if (!(transformed instanceof CachedMethod cm)) return null;
+        int modifiers = cm.getModifiers();
+        if (Modifier.isStatic(modifiers) || !Modifier.isPublic(modifiers)) 
return null;
+        if (!Modifier.isPublic(cm.getDeclaringClass().getModifiers())) return 
null;
+        if (isCallerSensitive(cm, arguments[0].getClass())) return null;
+        Class<?>[] argClasses = new Class[arguments.length];
+        for (int i = 0; i < arguments.length; i++) {
+            Object a = arguments[i];
+            if (a instanceof Wrapper) return null; // cast markers need the 
full coercion path
+            argClasses[i] = (a == null) ? null : a.getClass();
+        }
+        return new ColdReflectiveMethodHandleWrapper(cm, callSite, sender, 
methodName, callID,
+                safeNavigation, thisCall, spreadCall, mc, 
IndyInterface.switchPoint, argClasses);
+    }
+
+    /**
+     * Reflective invocation would report the wrong caller for caller-sensitive
+     * methods (and promotion would then change the observed caller mid-run),
+     * so such targets stay on the full handle path. The per-method logic —
+     * annotation probe plus the declaring-class cases like serialization —
+     * lives on {@link CachedMethod#isCallerSensitive()}, computed once per
+     * method rather than per call site. The only receiver-dependent concern
+     * handled here is virtual dispatch through an <em>abstract</em> selection:
+     * the metaclass can select e.g. {@code ObjectInput.readObject()} for a
+     * call on an {@code ObjectInputStream}, and the sensitivity then belongs
+     * to the receiver's implementation. Non-abstract interface methods
+     * (defaults) carry their own annotations, so they are probed directly.
+     * When a probe cannot decide, the method is treated as sensitive.
+     */
+    private static boolean isCallerSensitive(CachedMethod cm, Class<?> 
receiverClass) {
+        if (cm.isCallerSensitive()) return true;
+        Method method = cm.getCachedMethod();
+        if (Modifier.isAbstract(method.getModifiers())) {
+            // the metaclass can select an abstract method (e.g. it picks
+            // ObjectInput.readObject() for a call on an ObjectInputStream);
+            // virtual dispatch then lands on the receiver's implementation,
+            // which may be caller-sensitive even though the abstract method
+            // cannot carry the annotation. Non-abstract interface methods
+            // (defaults) carry their own annotations and need no resolution.
+            try {
+                Method implementation = 
receiverClass.getMethod(method.getName(), method.getParameterTypes());
+                CachedMethod implCached = CachedMethod.find(implementation);
+                if (implCached != null) return implCached.isCallerSensitive();
+            } catch (Throwable ignore) {
+            }
+            return true; // could not resolve the implementation: stay safe
+        }
+        return false;
+    }
+}
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java 
b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
index 877f3188e0..d3141658f4 100644
Binary files a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java 
and b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java differ
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java 
b/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
index 9f31e9f43c..989e1eb0f5 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
@@ -207,6 +207,31 @@ public abstract class Selector {
      */
     abstract void setCallSiteTarget();
 
+    /**
+     * Experimental, guarded by {@code groovy.indy.cold.reflection}: runs only
+     * the method-selection portion of linking, populating {@link #method}
+     * without constructing any invocation handle, so the cold tier can
+     * dispatch reflectively and defer all MethodHandle building to promotion.
+     * Selector types that do not support this return {@code null}; callers
+     * then continue with the full {@link #setCallSiteTarget()} path (selection
+     * is deterministic, so re-running it there is safe).
+     *
+     * @return the selected meta method, or {@code null} if this call needs the
+     * full handle-building path
+     */
+    MetaMethod selectForColdReflection() {
+        return null;
+    }
+
+    /**
+     * Returns the metaclass captured by {@link #selectForColdReflection()}.
+     *
+     * @return the selection metaclass, or {@code null} if selection did not 
run
+     */
+    MetaClass getSelectionMetaClass() {
+        return null;
+    }
+
     
//--------------------------------------------------------------------------
 
     private static class CastSelector extends MethodSelector {
@@ -1378,6 +1403,39 @@ public abstract class Selector {
                 addExceptionHandler();
             }
         }
+
+        @Override
+        MetaMethod selectForColdReflection() {
+            // the cold tier is only for plain method calls; the fallback gates
+            // on CallType.METHOD, so any other call type reaching this method
+            // indicates a broken invariant rather than an unsupported case
+            if (callType != CallType.METHOD) {
+                throw new GroovyBugError("selectForColdReflection called for 
call type " + callType);
+            }
+            // these all need the full handle path: spread and safe-null have
+            // their own adaptation/constant handles; a Class receiver is a
+            // static-method style call, which promotes on its first hit anyway
+            // (GROOVY-11935); GroovyInterceptable routes via invokeMethod.
+            // TODO: a null receiver dispatches to NullObject, whose methods 
are
+            // ordinary methods on a singleton receiver — investigate serving
+            // them from the reflective tier instead of the full handle path
+            if (safeNavigation || spread || args[0] == null
+                    || args[0] instanceof Class || args[0] instanceof 
GroovyInterceptable) {
+                return null;
+            }
+            getMetaClass();
+            if (!cache) return null; // e.g. per-instance metaclasses in play
+            setSelectionBase();
+            MetaClassImpl mci = getMetaClassImpl(mc, true);
+            if (mci == null) return null;
+            chooseMeta(mci);
+            return method;
+        }
+
+        @Override
+        MetaClass getSelectionMetaClass() {
+            return mc;
+        }
     }
 
     /**
diff --git 
a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/DynamicDispatchColdBench.java
 
b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/DynamicDispatchColdBench.java
index ba8c03c94b..3fd1e66bbc 100644
--- 
a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/DynamicDispatchColdBench.java
+++ 
b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/DynamicDispatchColdBench.java
@@ -92,6 +92,29 @@ public class DynamicDispatchColdBench {
         return new DynamicDispatchCold().polySum(n);
     }
 
+    /**
+     * As {@link #dynamicMono_groovy} but with the experimental reflective cold
+     * tier enabled (GROOVY-12137): cold dispatch runs reflectively until
+     * hit-count promotion, avoiding per-shape MethodHandle chain construction.
+     * @return the computed sum
+     */
+    @Benchmark
+    @Fork(value = 25, jvmArgsAppend = "-Dgroovy.indy.cold.reflection=true")
+    public int dynamicMono_groovyColdReflect() {
+        return new DynamicDispatchCold().monoSum(n);
+    }
+
+    /**
+     * As {@link #dynamicPoly_groovy} but with the experimental reflective cold
+     * tier enabled (GROOVY-12137).
+     * @return the computed sum
+     */
+    @Benchmark
+    @Fork(value = 25, jvmArgsAppend = "-Dgroovy.indy.cold.reflection=true")
+    public int dynamicPoly_groovyColdReflect() {
+        return new DynamicDispatchCold().polySum(n);
+    }
+
     /**
      * Dynamic property reads from a cold call site.
      * @return the computed sum
diff --git 
a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/dispatch/CallsiteBench.java
 
b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/dispatch/CallsiteBench.java
index 268d91be39..70e5ca21e3 100644
--- 
a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/dispatch/CallsiteBench.java
+++ 
b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/dispatch/CallsiteBench.java
@@ -46,6 +46,21 @@ public class CallsiteBench {
         Callsite.dispatch(state.receivers, bh);
     }
 
+    /**
+     * Monomorphic dispatch via Groovy dynamic with the experimental reflective
+     * cold tier enabled (GROOVY-12137). Steady state must match
+     * {@link #dispatch_1_monomorphic_groovy}: after hit-count promotion the
+     * hot path is identical, so a sustained gap on the dashboards is a
+     * regression signal for the cold tier.
+     * @param state the monomorphic receiver state
+     * @param bh the blackhole for consuming results
+     */
+    @Benchmark
+    @Fork(value = 2, jvmArgsAppend = "-Dgroovy.indy.cold.reflection=true")
+    public void dispatch_1_monomorphic_groovyColdReflect(MonomorphicState 
state, Blackhole bh) {
+        Callsite.dispatch(state.receivers, bh);
+    }
+
     /**
      * Monomorphic dispatch via Groovy {@code @CompileStatic}.
      * @param state the monomorphic receiver state
@@ -76,6 +91,21 @@ public class CallsiteBench {
         Callsite.dispatch(state.receivers, bh);
     }
 
+    /**
+     * Polymorphic dispatch (3 types) via Groovy dynamic with the experimental
+     * reflective cold tier enabled (GROOVY-12137). Polymorphic sites never
+     * reach the consecutive-hit promotion, so this exercises the per-wrapper
+     * cumulative promotion; steady state must match
+     * {@link #dispatch_3_polymorphic_groovy}.
+     * @param state the polymorphic receiver state
+     * @param bh the blackhole for consuming results
+     */
+    @Benchmark
+    @Fork(value = 2, jvmArgsAppend = "-Dgroovy.indy.cold.reflection=true")
+    public void dispatch_3_polymorphic_groovyColdReflect(PolymorphicState 
state, Blackhole bh) {
+        Callsite.dispatch(state.receivers, bh);
+    }
+
     /**
      * Polymorphic dispatch (3 types) via Groovy {@code @CompileStatic}.
      * @param state the polymorphic receiver state
diff --git 
a/subprojects/performance/src/test/groovy/org/apache/groovy/perf/ColdReflectionParityTest.groovy
 
b/subprojects/performance/src/test/groovy/org/apache/groovy/perf/ColdReflectionParityTest.groovy
new file mode 100644
index 0000000000..54561e9c6f
--- /dev/null
+++ 
b/subprojects/performance/src/test/groovy/org/apache/groovy/perf/ColdReflectionParityTest.groovy
@@ -0,0 +1,104 @@
+/*
+ *  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.apache.groovy.perf
+
+import org.junit.jupiter.api.Test
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Standing parity guard for the reflective cold tier (GROOVY-12137,
+ * {@code groovy.indy.cold.reflection}, on by default). The tier's fast path
+ * dispatches via {@code java.lang.reflect.Method.invoke} while the normal
+ * path uses MethodHandles, so its unique risk is behavioural divergence
+ * between those two invocation mechanisms (e.g. primitive-return box identity,
+ * caller-sensitivity, exception unwrapping, argument coercion).
+ * <p>
+ * The companion corpus {@code cold-reflection-parity-corpus.groovy} exercises
+ * a broad battery of dynamic-dispatch shapes and prints a deterministic
+ * transcript. This test runs it in two fresh JVMs — the tier explicitly
+ * disabled and explicitly enabled (the flag is read once at
+ * {@code IndyInterface} class-init, so it cannot be toggled in-process, and
+ * both states are pinned so the test is independent of the default) — and
+ * asserts the transcripts are byte-identical. Any difference is a genuine
+ * reflection-vs-MethodHandle divergence.
+ */
+class ColdReflectionParityTest {
+
+    private static final String CORPUS_RESOURCE = 
'cold-reflection-parity-corpus.groovy'
+
+    @Test
+    void coldReflectionMatchesBaselineDispatch() {
+        String off = runCorpus(false)
+        String on = runCorpus(true)
+        if (off != on) {
+            // surface the first divergence for a readable failure
+            def a = off.readLines(), b = on.readLines()
+            def diff = (0..<Math.max(a.size(), b.size())).findResults { i ->
+                def x = i < a.size() ? a[i] : '<missing>'
+                def y = i < b.size() ? b[i] : '<missing>'
+                x != y ? "  line ${i + 1}:\n    off: $x\n    on : $y" : null
+            }.take(10).join('\n')
+            assert false, "cold.reflection transcript diverged from 
baseline:\n$diff"
+        }
+        assertTrue(off.readLines().size() >= 60, "corpus produced too few 
checks (${off.readLines().size()})")
+    }
+
+    @Test
+    void coldReflectionTierGatedByFlag() {
+        // The tier logs each reflective dispatch. Assert both directions:
+        // enabled must fire broadly (so the parity test cannot pass 
vacuously),
+        // and disabled must not fire at all (so the "off" case genuinely
+        // exercises the non-reflective path, not merely a coincident result).
+        int firedOn = countColdDispatches(runCorpus(true, true))
+        int firedOff = countColdDispatches(runCorpus(false, true))
+        assertTrue(firedOn >= 50, "reflective cold tier fired only $firedOn 
times when enabled; expected broad exercise")
+        assertEquals(0, firedOff, "reflective cold tier fired $firedOff times 
when disabled; expected none")
+    }
+
+    private static int countColdDispatches(String log) {
+        log.readLines().count { it.contains('using reflective cold tier') }
+    }
+
+    private static String runCorpus(boolean coldReflection, boolean logging = 
false) {
+        def corpus = File.createTempFile('cold-reflection-parity', '.groovy')
+        corpus.deleteOnExit()
+        corpus.bytes = 
ColdReflectionParityTest.getResourceAsStream(CORPUS_RESOURCE).bytes
+
+        def java = new File(System.getProperty('java.home'), 
'bin/java').absolutePath
+        def cp = System.getProperty('java.class.path')
+        def cmd = [java, '-cp', cp]
+        // pin the state explicitly (the flag is opt-out / on by default), so
+        // the off case actively disables and the test does not rely on the 
default
+        cmd << "-Dgroovy.indy.cold.reflection=${coldReflection}".toString()
+        if (logging) cmd << '-Dgroovy.indy.logging=true'
+        cmd += ['groovy.ui.GroovyMain', corpus.absolutePath]
+
+        def pb = new ProcessBuilder(cmd)
+        // when logging, the tier writes to the JUL logger (stderr); fold it in
+        if (logging) pb.redirectErrorStream(true)
+        def process = pb.start()
+        def stdout = process.inputStream.text
+        def stderr = logging ? '' : process.errorStream.text
+        int rc = process.waitFor()
+        assertEquals(0, rc, "corpus JVM (coldReflection=$coldReflection) 
failed rc=$rc:\n$stderr")
+        return stdout
+    }
+}
diff --git 
a/subprojects/performance/src/test/resources/org/apache/groovy/perf/cold-reflection-parity-corpus.groovy
 
b/subprojects/performance/src/test/resources/org/apache/groovy/perf/cold-reflection-parity-corpus.groovy
new file mode 100644
index 0000000000..ffe5860332
--- /dev/null
+++ 
b/subprojects/performance/src/test/resources/org/apache/groovy/perf/cold-reflection-parity-corpus.groovy
@@ -0,0 +1,234 @@
+/*
+ *  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.
+ */
+
+// Differential parity corpus for groovy.indy.cold.reflection (GROOVY-12137),
+// driven by ColdReflectionParityTest. Dynamic dispatch throughout (no
+// @CompileStatic) so plain calls hit the reflective cold tier when the flag
+// is on. Prints a fully deterministic transcript; the test runs it with the
+// flag off and on in separate JVMs and asserts the transcripts are
+// byte-identical, so any difference is a genuine reflection-vs-MethodHandle
+// divergence. Every check returns a stable String (or throws, captured
+// stably) — keep all inputs/outputs deterministic (no identity hashes,
+// timing, or randomness) when extending it.
+
+// ---- target classes ----
+class Prim {
+    int i(int x) { x }
+    long lng(long x) { x }
+    char ch() { (char) 65 }
+    boolean bool() { true }
+    byte by() { (byte) 7 }
+    short sh() { (short) 9 }
+    float fl() { 1.5f }
+    double db() { 2.5d }
+}
+class Coerce {
+    String s(String x) { x }
+    long widen(long x) { x }
+    String chr(char c) { String.valueOf(c) }
+    String num(Number n) { n.getClass().simpleName + ':' + n }
+}
+class Vararg {
+    String v(String... xs) { xs.length + ':' + xs.join(',') }
+    int arr(byte[]... a) { a.length }
+    String mix(Object... o) { o.length + ':' + o.collect { 
it?.getClass()?.simpleName }.join(',') }
+}
+class Overload {
+    String m(String s) { 'S' }
+    String m(Integer i) { 'I' }
+    String m(Object o) { 'O' }
+    String m(int i) { 'prim' }
+}
+class Ex {
+    def boom() { throw new IllegalStateException('boom') }
+    def missing() { this.nope() }
+    def checked() { throw new java.io.IOException('io') }
+}
+class Ret {
+    void v() { }
+    def nul() { null }
+    int[] arr() { [1, 2, 3] as int[] }
+    Void voidBox() { null }
+}
+class Vis {
+    String pub() { 'pub' }
+    protected String prot() { 'prot' }
+    private String priv() { 'priv' }
+}
+class Pogo {
+    String greet() { 'OWNER' }
+    int echoInt(int x) { x }
+}
+class Pogo2 {
+    int echoInt(int x) { x * 10 }
+    String label() { 'P2' }
+}
+class Intercept implements GroovyInterceptable {
+    def invokeMethod(String name, args) { "intercept:$name" }
+    String real() { 'real' }
+}
+class MethMissing {
+    def methodMissing(String name, args) { "mm:$name/${args.length}" }
+    String real() { 'real' }
+}
+
+// ---- harness ----
+def out = new StringBuilder()
+Closure rec = { String tag, Closure body ->
+    String line
+    try {
+        def r = body()
+        line = "$tag | OK | ${r}"
+    } catch (Throwable t) {
+        line = "$tag | EX | ${t.getClass().name}: ${t.message}"
+    }
+    out.append(line).append('\n')
+}
+// helper: value + box-cache identity, deterministic
+def boxId = { v, canonical -> "${v}/cached=${v.is(canonical)}" }
+
+// ---- A. primitive return value + box identity (the Method.invoke box axis) 
----
+rec('A.int.cached')   { def p = new Prim(); boxId(p.i(42),   
Integer.valueOf(42)) }
+rec('A.int.uncached') { def p = new Prim(); boxId(p.i(9999), 
Integer.valueOf(9999)) }
+rec('A.int.neg')      { def p = new Prim(); boxId(p.i(-128), 
Integer.valueOf(-128)) }
+rec('A.long')         { def p = new Prim(); boxId(p.lng(7L), Long.valueOf(7L)) 
}
+rec('A.long.big')     { def p = new Prim(); boxId(p.lng(99999L), 
Long.valueOf(99999L)) }
+rec('A.char')         { def p = new Prim(); def v = p.ch(); "${(int) 
v}/cached=${v.is(Character.valueOf((char) 65))}" }
+rec('A.bool')         { def p = new Prim(); def v = p.bool(); 
"${v}/cached=${v.is(Boolean.TRUE)}" }
+rec('A.byte')         { def p = new Prim(); boxId(p.by(), Byte.valueOf((byte) 
7)) }
+rec('A.short')        { def p = new Prim(); boxId(p.sh(), 
Short.valueOf((short) 9)) }
+rec('A.float')        { def p = new Prim(); "${p.fl()}" }
+rec('A.double')       { def p = new Prim(); "${p.db()}" }
+
+// ---- B. exceptions ----
+rec('B.rte')     { new Ex().boom() }
+rec('B.mme')     { new Ex().missing() }
+rec('B.checked') { new Ex().checked() }
+rec('B.npe')     { def x = null; x.foo() }
+rec('B.nosuch')  { new Prim().totallyUnknownMethod() }
+
+// ---- C. argument coercion ----
+rec('C.gstring') { def who = 'x'; new Coerce().s("a${who}${1 + 1}b") }
+rec('C.widen')   { new Coerce().widen(5) }        // Integer 5 -> long
+rec('C.char')    { new Coerce().chr('Z' as char) }
+rec('C.num.int') { new Coerce().num(5) }
+rec('C.num.bd')  { new Coerce().num(3.14G) }
+rec('C.num.big') { new Coerce().num(123456789012345G) }
+
+// ---- D. varargs / arrays ----
+rec('D.varg.0')   { new Vararg().v() }
+rec('D.varg.1')   { new Vararg().v('a') }
+rec('D.varg.3')   { new Vararg().v('a', 'b', 'c') }
+rec('D.varg.arr') { new Vararg().arr('ab'.bytes) }        // byte[] into 
byte[]... (GROOVY-12139)
+rec('D.varg.arr2') { new Vararg().arr('ab'.bytes, 'cd'.bytes) }
+rec('D.varg.mix') { new Vararg().mix(1, 'x', [2], null) }
+
+// ---- E. overload selection ----
+rec('E.ov.string') { new Overload().m('x') }
+rec('E.ov.int')    { new Overload().m(5) }
+rec('E.ov.integer'){ new Overload().m(Integer.valueOf(5)) }
+rec('E.ov.obj')    { new Overload().m([1, 2]) }
+rec('E.ov.null')   { new Overload().m(null) }
+
+// ---- F. caller-sensitive: serialization (must stay off the cold tier) ----
+rec('F.ser.list') {
+    def baos = new ByteArrayOutputStream()
+    new ObjectOutputStream(baos).withCloseable { it.writeObject([1, 2, 3]) }
+    def r = null
+    new ObjectInputStream(new 
ByteArrayInputStream(baos.toByteArray())).withCloseable { r = it.readObject() }
+    r.toString()
+}
+
+// ---- G. visibility ----
+rec('G.pub')  { new Vis().pub() }
+rec('G.prot') { new Vis().prot() }
+rec('G.priv') { new Vis().priv() }
+
+// ---- H. return-type corners ----
+rec('H.void')  { new Ret().v(); 'done' }
+rec('H.null')  { new Ret().nul() }
+rec('H.array') { def a = new Ret().arr(); "${a.length}:${a[0]},${a[2]}" }
+rec('H.voidBox') { new Ret().voidBox() }
+
+// ---- I. receiver kinds (POJO / DGM) ----
+rec('I.pojo.substr')  { 'Hello'.substring(1, 3) }
+rec('I.pojo.listget') { [10, 20, 30].get(1) }
+rec('I.dgm.reverse')  { 'abc'.reverse() }
+rec('I.dgm.sum')      { [1, 2, 3, 4].sum() }
+rec('I.dgm.collect')  { [1, 2, 3].collect { it * 2 }.toString() }
+rec('I.dgm.max')      { [3, 1, 2].max() }
+
+// ---- J. metaclass dynamics (validity invalidation across cold entry) ----
+rec('J.emc') {
+    def r = new Pogo()
+    def a = r.greet()
+    Pogo.metaClass.greet = { -> 'MC' }
+    def b = new Pogo().greet()
+    GroovySystem.metaClassRegistry.removeMetaClass(Pogo)
+    def c = new Pogo().greet()
+    "$a|$b|$c"
+}
+rec('J.category') {
+    def r = new Pogo()
+    def before = r.greet()
+    def during = use(PogoCat) { r.greet() }
+    def after = r.greet()
+    "$before|$during|$after"
+}
+
+// ---- K. promotion boundary: many calls crossing the hit-count threshold ----
+rec('K.promote') {
+    def r = new Pogo()
+    long acc = 0
+    for (int i = 0; i < 2500; i++) { acc += r.echoInt(i % 100) }
+    acc
+}
+rec('K.promote.poly') {
+    def rs = [new Pogo(), new Pogo2()]   // both have echoInt; alternate to 
stress per-wrapper promotion
+    long acc = 0
+    for (int i = 0; i < 3000; i++) { acc += rs[i % 2].echoInt(i % 50) }
+    acc
+}
+
+// ---- N. more breadth: statics, null-into-primitive, chaining, GString 
coercion ----
+rec('N.static.parseInt') { Integer.parseInt('42') }          // Class receiver 
— cold tier excluded
+rec('N.static.valueOf')  { Long.valueOf('123') }
+rec('N.null.prim')       { new Prim().i(null) }              // null into 
primitive param
+rec('N.chain')           { 'Hello World'.toLowerCase().split(' ')[1] }
+rec('N.chain.dgm')       { [3, 1, 2, 1].unique().sort().join('-') }
+rec('N.gstring.coerce')  { new Coerce().widen("${40 + 2}".toInteger()) }
+rec('N.ov.char')         { new Overload().m('x' as char) }   // char -> which 
overload?
+rec('N.map.get')         { [a: 1, b: 2].get('b') }
+rec('N.string.eq')       { 'abc'.equals('abc') }
+rec('N.number.plus')     { 5.plus(3) }                        // operator as 
method via DGM/number
+rec('N.list.add')        { def l = [1, 2]; l.add(3); l.toString() }
+rec('N.bool.method')     { Boolean.parseBoolean('TRUE') }
+
+// ---- M. MOP interception (must decline the cold tier) ----
+rec('M.interceptable.dyn')  { new Intercept().anything(1, 2) }
+rec('M.interceptable.real') { new Intercept().real() }
+rec('M.mm.real')    { new MethMissing().real() }
+rec('M.mm.missing') { new MethMissing().ghost(1, 2) }
+
+print out.toString()
+
+class PogoCat {
+    static String greet(Pogo self) { 'CAT' }
+    static int echoInt(Pogo self, int x) { x }
+}


Reply via email to