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/27 23:59:00 UTC
(groovy) 01/02: GROOVY-12307: Indy: relink racing a MetaClass change can cache a stale target under a fresh SwitchPoint
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 410bc020ae232c6c2209de3a96bc3bafbf80a518
Author: Paul King <pa...@asert.com.au>
AuthorDate: Thu Aug 27 15:01:42 2026 +1000
GROOVY-12307: Indy: relink racing a MetaClass change can cache a stale target under a fresh SwitchPoint
Call-site linking selected the target from the MetaClass first and fetched
the class-domain SwitchPoint afterwards, so a MetaClass change landing in
between published the stale selection under the freshly rotated guard,
where it dispatched until an unrelated future invalidation (permanently,
when the race hit the last change). Capture the token before selection
first reads MOP state — a racing change then invalidates it and the guard
re-links. The capture retries (bounded) because lazy MetaClass creation
invalidates its own token, and degrades to the uncacheable path for
resolutions that re-create the MetaClass on every lookup. Same fix for the
cold reflective tier and the GEP-15 compound-assign resolver.
---
.../v8/ColdReflectiveMethodHandleWrapper.java | 8 ++--
.../groovy/vmplugin/v8/IndyCompoundAssign.java | 21 ++++++++-
.../codehaus/groovy/vmplugin/v8/IndyInterface.java | 29 +++++++------
.../org/codehaus/groovy/vmplugin/v8/Selector.java | 50 ++++++++++++++++++++--
.../vmplugin/v8/IndyScopedSwitchPointTest.groovy | 20 ++++++++-
5 files changed, 104 insertions(+), 24 deletions(-)
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
index c07140cc3b..6a3bc527b9 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
@@ -21,9 +21,7 @@ package org.codehaus.groovy.vmplugin.v8;
import groovy.lang.GroovyObject;
import groovy.lang.MetaClass;
import groovy.lang.MetaMethod;
-import org.apache.groovy.runtime.indy.IndyInvalidation;
import org.codehaus.groovy.reflection.CachedMethod;
-import org.codehaus.groovy.reflection.ClassInfo;
import org.codehaus.groovy.runtime.GroovyCategorySupport;
import org.codehaus.groovy.runtime.wrappers.Wrapper;
import org.codehaus.groovy.vmplugin.VMPluginFactory;
@@ -155,8 +153,10 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
if (a instanceof Wrapper) return null; // cast markers need the full coercion path
argClasses[i] = (a == null) ? null : a.getClass();
}
- Object receiver = arguments[0];
- SwitchPoint classSp = ClassInfo.getClassInfo(IndyInvalidation.switchPointClassFor(receiver)).getIndySwitchPoint();
+ // the token the selector captured before selection read MOP state; a
+ // MetaClass change racing the selection has invalidated it, so
+ // isValidFor rejects the wrapper instead of serving the stale selection
+ SwitchPoint classSp = selector.mopSwitchPoint;
return new ColdReflectiveMethodHandleWrapper(cm, callSite, sender, methodName, callID,
safeNavigation, thisCall, spreadCall, mc, classSp, argClasses);
}
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java
index 208c00b10a..d02c289c55 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java
@@ -19,6 +19,7 @@
package org.codehaus.groovy.vmplugin.v8;
import groovy.lang.MetaClass;
+import org.apache.groovy.runtime.indy.IndyInvalidation;
import org.codehaus.groovy.GroovyBugError;
import org.codehaus.groovy.reflection.ClassInfo;
import org.codehaus.groovy.runtime.InvokerHelper;
@@ -27,6 +28,7 @@ import org.codehaus.groovy.runtime.ScriptBytecodeAdapter;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
+import java.lang.invoke.SwitchPoint;
/**
* GEP-15: resolver for dynamic compound-assignment operators
@@ -123,7 +125,22 @@ public final class IndyCompoundAssign {
if (receiver == null) return genericWrapper(assignName, baseName); // legacy helper handles null receiver
- MetaClass mc = InvokerHelper.getMetaClass(receiver);
+ // Class-domain SwitchPoint token, captured before respondsTo/selection
+ // read MOP state (see IndyInterface.applyMopSwitchPoints). Re-capture
+ // when the token dies during metaclass resolution: lazy MetaClass
+ // creation bumps the class generation, so the first pass's token is
+ // born invalid and the next pass finds the cached instance. Bounded
+ // like Selector.captureTokenAndGetMetaClass — resolutions that
+ // re-create the MetaClass on every lookup can never yield a live
+ // token and fall through to the uncacheable legacy helper.
+ SwitchPoint mopSwitchPoint = null;
+ MetaClass mc = null;
+ for (int attempt = 0; attempt < 3 && mopSwitchPoint == null; attempt++) {
+ mopSwitchPoint = IndyInvalidation.classSwitchPointFor(receiver);
+ mc = InvokerHelper.getMetaClass(receiver);
+ if (mopSwitchPoint.hasBeenInvalidated()) mopSwitchPoint = null;
+ }
+ if (mopSwitchPoint == null) return genericWrapper(assignName, baseName);
boolean useAssign;
String name;
if (!mc.respondsTo(receiver, assignName, new Object[]{arg}).isEmpty()) {
@@ -147,7 +164,7 @@ public final class IndyCompoundAssign {
MethodHandle test = MethodHandles.insertArguments(GUARD, 0, rc, ac);
MethodHandle guarded = MethodHandles.guardWithTest(test, invoke, elseTarget);
// Per-class SwitchPoint, same domain as a normal indy call (GROOVY-12191).
- guarded = IndyInterface.applyMopSwitchPoints(guarded, elseTarget, receiver);
+ guarded = IndyInterface.applyMopSwitchPoints(guarded, elseTarget, mopSwitchPoint);
// Per-instance metaclasses make a class-keyed cache unsound, so mark such
// shapes uncacheable (the wrapper is still used for the current call) —
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 bdd708cd7f..9e32684c33 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
@@ -34,6 +34,7 @@ import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.invoke.MutableCallSite;
+import java.lang.invoke.SwitchPoint;
import java.lang.reflect.Modifier;
import java.util.Map;
import java.util.function.Function;
@@ -308,18 +309,22 @@ public class IndyInterface {
* Production call-site wiring ({@link Selector}, {@link IndyCompoundAssign})
* enters here so the bytecode bootstrap surface owns how handles are guarded.
* <p>
- * Domain resolution (receiver → class → class-level MetaClass instance
- * SwitchPoint, or always-relink when none is installed) and invalidation
+ * The caller supplies the class-domain SwitchPoint, captured — via
+ * {@link IndyInvalidation#classSwitchPointFor} — <em>before</em> its
+ * selection first read MOP state: a MetaClass change racing the selection
+ * then invalidates the captured token, so the installed guard routes to
+ * the fallback and re-links instead of publishing the stale selection
+ * under the post-change SwitchPoint. Domain resolution and invalidation
* policy stay in {@link IndyInvalidation}; this method only binds the
- * resolved SwitchPoint with {@code guardWithTest}.
+ * token with {@code guardWithTest}.
*
- * @param handle fast-path handle
- * @param fallback re-link handle
- * @param receiver call receiver (may be {@code null})
+ * @param handle fast-path handle
+ * @param fallback re-link handle
+ * @param switchPoint class-domain SwitchPoint captured before selection
* @return guarded handle
*/
- static MethodHandle applyMopSwitchPoints(final MethodHandle handle, final MethodHandle fallback, final Object receiver) {
- return IndyInvalidation.classSwitchPointFor(receiver).guardWithTest(handle, fallback);
+ static MethodHandle applyMopSwitchPoints(final MethodHandle handle, final MethodHandle fallback, final SwitchPoint switchPoint) {
+ return switchPoint.guardWithTest(handle, fallback);
}
/**
@@ -560,10 +565,10 @@ public class IndyInterface {
* predate the change while its construction-time stamp postdates it, so caching it
* could dispatch stale until an unrelated future invalidation; the sentinel is stored
* instead, forcing re-selection on the next hit. (SwitchPoint guards are immune to
- * this race because the token is acquired during selection and mutated by the
- * invalidation itself; the stamp is compared against a sample, so the sample must be
- * taken on the far side of the selection.) Uncacheable selections store the sentinel
- * as always. On a non-AOT site this is exactly the historical put.
+ * this race because the token is acquired before the selection reads any MOP state
+ * and mutated by the invalidation itself; the stamp is compared against a sample, so
+ * the sample must be taken on the far side of the selection.) Uncacheable selections
+ * store the sentinel as always. On a non-AOT site this is exactly the historical put.
*/
static void putSelected(CacheableCallSite callSite, String className, MethodHandleWrapper mhw, long preSelectionStamp) {
boolean selectionSpansInvalidation = callSite.isAotLinked() && AotDispatch.stamp() != preSelectionStamp;
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 dfe8c9e490..47f8e426c4 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
@@ -33,6 +33,7 @@ import groovy.lang.MetaProperty;
import groovy.lang.MissingMethodException;
import groovy.lang.ProxyMetaClass;
import groovy.transform.Internal;
+import org.apache.groovy.runtime.indy.IndyInvalidation;
import org.codehaus.groovy.GroovyBugError;
import org.codehaus.groovy.reflection.CachedField;
import org.codehaus.groovy.reflection.CachedMethod;
@@ -59,6 +60,7 @@ import org.codehaus.groovy.vmplugin.VMPluginFactory;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
+import java.lang.invoke.SwitchPoint;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
@@ -172,6 +174,13 @@ public abstract class Selector {
*/
public CallType callType;
+ /**
+ * Receiver's class-domain MetaClass SwitchPoint, captured before
+ * selection reads MOP state (see {@code captureTokenAndGetMetaClass});
+ * {@code null} until then.
+ */
+ SwitchPoint mopSwitchPoint;
+
/**
* Cache values for read-only access
*/
@@ -898,6 +907,37 @@ public abstract class Selector {
return mc;
}
+ /**
+ * Captures the receiver's class-domain SwitchPoint into
+ * {@link #mopSwitchPoint} and resolves the metaclass under it,
+ * re-capturing when the token is invalidated by the resolution
+ * itself (lazy MetaClass creation bumps the class generation — the
+ * next pass finds the cached instance and converges) or by a racing
+ * MetaClass change. Selection thus starts with a token that was live
+ * after the metaclass reference was read: a change landing anywhere
+ * after that read invalidates the token, and the guard installed with
+ * it routes to the fallback and re-links, instead of publishing the
+ * stale selection under the post-change SwitchPoint where it would
+ * dispatch until an unrelated future invalidation.
+ * <p>
+ * The retry is bounded: some resolutions re-create the MetaClass on
+ * <em>every</em> lookup (e.g. ClosureMetaClass under
+ * {@code ExpandoMetaClass.enableGlobally()}, which
+ * {@code isValidWeakMetaClass} always rejects), each time bumping the
+ * generation, so no pass can ever end with a live token. Such
+ * selections degrade to uncacheable: no guards are installed and
+ * every call re-selects — always fresh, and free of the link-time
+ * livelock and the fallback recursion a dead-token guard would cause.
+ */
+ final void captureTokenAndGetMetaClass() {
+ for (int attempt = 0; attempt < 3; attempt++) {
+ mopSwitchPoint = IndyInvalidation.classSwitchPointFor(args[0]);
+ getMetaClass();
+ if (!mopSwitchPoint.hasBeenInvalidated()) return;
+ }
+ cache = false;
+ }
+
/**
* Uses the metaclass to get a meta method for a method call.
* There will be no meta method selected, if the metaclass is no MetaClassImpl
@@ -1290,8 +1330,10 @@ public abstract class Selector {
// Per-class MetaClass SwitchPoint (GROOVY-12191). Category enter/leave
// bulk-invalidates class SwitchPoints so sites re-link without a second
- // hot-path guard.
- handle = applyMopSwitchPoints(handle, fallback, receiver);
+ // hot-path guard. The token is the one captured before selection first
+ // read MOP state; cast paths that consulted none capture lazily here.
+ handle = applyMopSwitchPoints(handle, fallback,
+ mopSwitchPoint != null ? mopSwitchPoint : IndyInvalidation.classSwitchPointFor(receiver));
if (LOG_ENABLED) LOG.info("added class switch point guard");
Predicate<Class<?>> nonFinalOrNullUnsafe = (t) -> {
@@ -1392,7 +1434,7 @@ public abstract class Selector {
*/
void buildInvokeHandle() {
if (!setNullForSafeNavigation() && !setInterceptor()) {
- getMetaClass();
+ captureTokenAndGetMetaClass();
setSelectionBase();
MetaClassImpl mci = getMetaClassImpl(mc, callType != CallType.GET);
chooseMeta(mci);
@@ -1430,7 +1472,7 @@ public abstract class Selector {
|| args[0] instanceof Class || args[0] instanceof GroovyInterceptable) {
return null;
}
- getMetaClass();
+ captureTokenAndGetMetaClass();
if (!cache) return null; // e.g. per-instance metaclasses in play
setSelectionBase();
MetaClassImpl mci = getMetaClassImpl(mc, true);
diff --git a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy
index 37e2799517..42317d67cf 100644
--- a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy
+++ b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy
@@ -74,12 +74,28 @@ final class IndyScopedSwitchPointTest {
def target = MethodHandles.constant(int, 1)
def fallback = MethodHandles.constant(int, 2)
def receiver = new ApplyHost()
- def guarded = IndyInterface.applyMopSwitchPoints(target, fallback, receiver)
+ def guarded = IndyInterface.applyMopSwitchPoints(target, fallback, IndyInvalidation.classSwitchPointFor(receiver))
assertEquals(1, guarded.invokeWithArguments())
IndyInvalidation.invalidateClass(ApplyHost)
assertEquals(2, guarded.invokeWithArguments())
}
+ @Test
+ void applyMopSwitchPoints_preSelectionTokenSeesMidSelectionMutation() {
+ // Simulates a re-link racing a MetaClass change: the token is captured
+ // before selection reads MOP state, the class is invalidated while the
+ // (stale) target is being built, and the guard installed afterwards must
+ // route to the fallback — never publish the stale selection under the
+ // fresh SwitchPoint, where it would dispatch until the next mutation.
+ def stale = MethodHandles.constant(int, 1)
+ def fallback = MethodHandles.constant(int, 2)
+ def receiver = new ApplyHost()
+ def token = IndyInvalidation.classSwitchPointFor(receiver)
+ IndyInvalidation.invalidateClass(ApplyHost) // mutation lands mid-selection
+ def guarded = IndyInterface.applyMopSwitchPoints(stale, fallback, token)
+ assertEquals(2, guarded.invokeWithArguments())
+ }
+
@Test
void applyMopSwitchPoints_matchesPublicGuardApi() {
// Production path (applyMopSwitchPoints) and public IndyInvalidation API
@@ -87,7 +103,7 @@ final class IndyScopedSwitchPointTest {
def target = MethodHandles.constant(int, 7)
def fallback = MethodHandles.constant(int, 8)
def receiver = new ApplyHost()
- def viaInterface = IndyInterface.applyMopSwitchPoints(target, fallback, receiver)
+ def viaInterface = IndyInterface.applyMopSwitchPoints(target, fallback, IndyInvalidation.classSwitchPointFor(receiver))
def viaInvalidation = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, receiver)
assertEquals(7, viaInterface.invokeWithArguments())
assertEquals(7, viaInvalidation.invokeWithArguments())