You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by su...@apache.org on 2026/07/25 17:50:56 UTC

(groovy) branch GROOVY-12191 updated (9f118c0c70 -> ae8e49741e)

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

asf-gitbox-commits pushed a change to branch GROOVY-12191
in repository https://gitbox.apache.org/repos/asf/groovy.git


 discard 9f118c0c70 GROOVY-12191: Scope indy SwitchPoint invalidation
     new ae8e49741e GROOVY-12191: Scope indy SwitchPoint invalidation

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (9f118c0c70)
            \
             N -- N -- N   refs/heads/GROOVY-12191 (ae8e49741e)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 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:
 .../runtime/indy/IndyInvalidationTest.groovy       | 138 +++++++++++++++++
 .../runtime/indy/SwitchPointInvalidatorTest.groovy |  47 ++++++
 .../vmplugin/v8/IndyScopedSwitchPointTest.groovy   | 164 +++++++++++++++++++++
 .../runtime/indy/IndyInvalidationStatsProbe.java}  |  38 +++--
 .../groovy/vmplugin/v8/IndyLoggingProbe.java       |  62 ++++++++
 5 files changed, 433 insertions(+), 16 deletions(-)
 create mode 100644 src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy
 copy src/test/{groovy/org/codehaus/groovy/runtime/methoddispatching/vm8/FooTwo.java => java/org/apache/groovy/runtime/indy/IndyInvalidationStatsProbe.java} (50%)
 create mode 100644 src/test/java/org/codehaus/groovy/vmplugin/v8/IndyLoggingProbe.java


(groovy) 01/01: GROOVY-12191: Scope indy SwitchPoint invalidation

Posted by su...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch GROOVY-12191
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit ae8e49741e74067371823148c8993e8c0b73e317
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Jul 25 20:06:58 2026 +0900

    GROOVY-12191: Scope indy SwitchPoint invalidation
---
 .../groovy/runtime/indy/IndyInvalidation.java      | 285 ++++++++++++++
 .../runtime/indy/SwitchPointInvalidator.java       | 134 +++++++
 .../apache/groovy/runtime/indy/package-info.java   |  30 ++
 .../org/codehaus/groovy/reflection/ClassInfo.java  | 112 +++++-
 .../v8/ColdReflectiveMethodHandleWrapper.java      |  23 +-
 .../groovy/vmplugin/v8/IndyCompoundAssign.java     |   4 +-
 .../codehaus/groovy/vmplugin/v8/IndyInterface.java |  76 +++-
 .../org/codehaus/groovy/vmplugin/v8/Selector.java  |  13 +-
 src/test/groovy/bugs/Groovy12191.groovy            | 174 +++++++++
 .../runtime/indy/IndyInvalidationTest.groovy       | 409 +++++++++++++++++++++
 .../runtime/indy/SwitchPointInvalidatorTest.groovy | 209 +++++++++++
 .../vmplugin/v8/IndyScopedSwitchPointTest.groovy   | 164 +++++++++
 .../runtime/indy/IndyInvalidationStatsProbe.java   |  51 +++
 .../groovy/vmplugin/v8/IndyLoggingProbe.java       |  62 ++++
 subprojects/performance/README.adoc                |   9 +
 .../groovy/bench/ScopedInvalidationBench.groovy    | 273 ++++++++++++++
 16 files changed, 1994 insertions(+), 34 deletions(-)

diff --git a/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java
new file mode 100644
index 0000000000..3e11e70dbc
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java
@@ -0,0 +1,285 @@
+/*
+ *  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.runtime.indy;
+
+import org.apache.groovy.util.SystemUtil;
+import org.codehaus.groovy.reflection.ClassInfo;
+import org.codehaus.groovy.runtime.NullObject;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.SwitchPoint;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Scoped invokedynamic {@link SwitchPoint} invalidation for the Groovy MOP
+ * (GROOVY-12191).
+ * <p>
+ * <b>Model (intentionally minimal):</b> one SwitchPoint domain per class,
+ * attached to {@link ClassInfo}. MetaClass changes for type {@code T} retire
+ * {@code T}'s SwitchPoint and those of loaded subtypes/implementors (hierarchy
+ * fan-out). Category enter/leave and unscoped MetaClass events bulk-retire
+ * every loaded class SwitchPoint — there is no second, process-wide category
+ * SwitchPoint on the hot path. Linked handles therefore always carry a single
+ * guard, matching the pre-6.0 monomorphic shape while avoiding global deopt on
+ * unrelated MetaClass churn.
+ * <p>
+ * Install guards with
+ * {@link #guardWithMopSwitchPoints(MethodHandle, MethodHandle, Object)}.
+ * Optional stats logging: {@code -Dgroovy.indy.invalidation.stats=true}.
+ *
+ * @since 6.0.0
+ */
+public final class IndyInvalidation {
+
+    private static final Logger LOG = Logger.getLogger(IndyInvalidation.class.getName());
+    private static final boolean STATS_LOG = SystemUtil.getBooleanSafe("groovy.indy.invalidation.stats");
+
+    private static final AtomicLong CLASS_INVALIDATIONS = new AtomicLong();
+    private static final AtomicLong CATEGORY_INVALIDATIONS = new AtomicLong();
+
+    private static final SwitchPoint[] EMPTY_SWITCH_POINTS = new SwitchPoint[0];
+
+    private IndyInvalidation() {
+    }
+
+    /**
+     * Creates a per-class SwitchPoint invalidator for {@link ClassInfo}.
+     *
+     * @return a new invalidator
+     */
+    public static SwitchPointInvalidator newClassInvalidator() {
+        return new SwitchPointInvalidator();
+    }
+
+    /**
+     * Invalidates SwitchPoints for category enter/leave and
+     * {@code VMPlugin.invalidateCallSites()}.
+     * <p>
+     * Implemented as a bulk class-domain invalidation so every linked site
+     * re-links under the new category state. There is no separate category
+     * SwitchPoint on the hot path.
+     */
+    public static void invalidateCategory() {
+        invalidateAllLoadedClassSwitchPoints();
+        CATEGORY_INVALIDATIONS.incrementAndGet();
+        if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
+            LOG.fine("category invalidation via bulk class SwitchPoints; total="
+                    + CATEGORY_INVALIDATIONS.get());
+        }
+    }
+
+    /**
+     * Invalidates the per-class SwitchPoint for {@code type} and every loaded
+     * subtype/implementor. No-op when {@code type} is {@code null}.
+     * Does not bump {@link ClassInfo#getVersion()} on subtypes.
+     *
+     * @param type the class whose MetaClass changed
+     */
+    public static void invalidateClass(final Class<?> type) {
+        if (type == null) {
+            return;
+        }
+        invalidateClassHierarchy(type);
+        CLASS_INVALIDATIONS.incrementAndGet();
+        if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
+            LOG.fine("class SwitchPoint hierarchy invalidated for " + type.getName()
+                    + "; total=" + CLASS_INVALIDATIONS.get());
+        }
+    }
+
+    /**
+     * Invalidates SwitchPoints for {@code type} and all loaded classes assignable
+     * from {@code type}. Final classes short-circuit to a single invalidation.
+     * Non-final types batch via {@link SwitchPoint#invalidateAll(SwitchPoint[])}.
+     *
+     * @param type the root of the invalidation fan-out
+     */
+    public static void invalidateClassHierarchy(final Class<?> type) {
+        ClassInfo root = ClassInfo.getClassInfo(type);
+        if (cannotHaveLoadedSubtypes(type)) {
+            root.invalidateIndySwitchPoint();
+            return;
+        }
+
+        List<SwitchPoint> batch = new ArrayList<>();
+        try {
+            SwitchPoint rootSp = root.detachLiveIndySwitchPoint();
+            if (rootSp != null) {
+                batch.add(rootSp);
+            }
+            Collection<ClassInfo> all = ClassInfo.getAllClassInfo();
+            for (ClassInfo info : all) {
+                Class<?> loaded = info.getTheClass();
+                if (loaded != null && loaded != type && type.isAssignableFrom(loaded)) {
+                    SwitchPoint sp = info.detachLiveIndySwitchPoint();
+                    if (sp != null) {
+                        batch.add(sp);
+                    }
+                }
+            }
+        } finally {
+            // Always invalidate detached SPs even if the scan fails mid-way.
+            invalidateBatch(batch);
+        }
+    }
+
+    /**
+     * Detaches and invalidates every live per-class SwitchPoint.
+     * Used for category enter/leave and unattributed MetaClass changes.
+     */
+    public static void invalidateAllLoadedClassSwitchPoints() {
+        List<SwitchPoint> batch = new ArrayList<>();
+        try {
+            for (ClassInfo info : ClassInfo.getAllClassInfo()) {
+                SwitchPoint sp = info.detachLiveIndySwitchPoint();
+                if (sp != null) {
+                    batch.add(sp);
+                }
+            }
+        } finally {
+            invalidateBatch(batch);
+        }
+    }
+
+    /**
+     * Invalidates every live class-domain SwitchPoint. Used when a MetaClass
+     * registry event carries no {@code Class} attribution.
+     */
+    public static void invalidateUnscoped() {
+        invalidateAllLoadedClassSwitchPoints();
+        CLASS_INVALIDATIONS.incrementAndGet();
+        if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
+            LOG.fine("unscoped SwitchPoint invalidation; total=" + CLASS_INVALIDATIONS.get());
+        }
+    }
+
+    private static void invalidateBatch(final List<SwitchPoint> batch) {
+        if (batch.isEmpty()) {
+            return;
+        }
+        SwitchPoint.invalidateAll(batch.toArray(EMPTY_SWITCH_POINTS));
+    }
+
+    /**
+     * {@code final} classes and primitives cannot have subclasses. Interfaces
+     * and non-final classes may have loaded implementors/subtypes.
+     */
+    private static boolean cannotHaveLoadedSubtypes(final Class<?> type) {
+        return type.isPrimitive() || (Modifier.isFinal(type.getModifiers()) && !type.isInterface());
+    }
+
+    /**
+     * Resolves the class used for the per-class SwitchPoint domain of a receiver.
+     * {@code null} maps to {@link NullObject}; a {@link Class} receiver uses itself.
+     *
+     * @param receiver the call receiver (may be {@code null})
+     * @return the class whose {@link ClassInfo} SwitchPoint guards this site
+     */
+    public static Class<?> switchPointClassFor(final Object receiver) {
+        if (receiver == null) {
+            return NullObject.class;
+        }
+        if (receiver instanceof Class<?> c) {
+            return c;
+        }
+        return receiver.getClass();
+    }
+
+    /**
+     * Returns the per-class switch point for the given receiver.
+     *
+     * @param receiver the call receiver (may be {@code null})
+     * @return the class-domain switch point
+     */
+    public static SwitchPoint classSwitchPointFor(final Object receiver) {
+        return ClassInfo.getClassInfo(switchPointClassFor(receiver)).getIndySwitchPoint();
+    }
+
+    /**
+     * Returns the per-class switch point for the given class.
+     *
+     * @param type the class (must not be {@code null})
+     * @return the class-domain switch point
+     */
+    public static SwitchPoint classSwitchPointFor(final Class<?> type) {
+        return ClassInfo.getClassInfo(type).getIndySwitchPoint();
+    }
+
+    /**
+     * Installs a per-class SwitchPoint guard on {@code handle}.
+     * MetaClass or category changes that retire that class's SwitchPoint cause
+     * the site to take {@code fallback}.
+     *
+     * @param handle   the fast-path handle
+     * @param fallback the re-link / fallback handle
+     * @param receiver the receiver used to select the class-domain switch point
+     * @return the guarded handle
+     */
+    public static MethodHandle guardWithMopSwitchPoints(
+            final MethodHandle handle, final MethodHandle fallback, final Object receiver) {
+        return guardWithMopSwitchPoints(handle, fallback, switchPointClassFor(receiver));
+    }
+
+    /**
+     * Installs a per-class SwitchPoint guard on {@code handle}.
+     *
+     * @param handle        the fast-path handle
+     * @param fallback      the re-link / fallback handle
+     * @param receiverClass the class whose MetaClass domain guards this site
+     * @return the guarded handle
+     */
+    public static MethodHandle guardWithMopSwitchPoints(
+            final MethodHandle handle, final MethodHandle fallback, final Class<?> receiverClass) {
+        SwitchPoint classSp = ClassInfo.getClassInfo(receiverClass).getIndySwitchPoint();
+        return classSp.guardWithTest(handle, fallback);
+    }
+
+    /**
+     * Returns the total number of scoped class invalidations
+     * ({@link #invalidateClass(Class)} / {@link #invalidateUnscoped()} events).
+     *
+     * @return class invalidation count
+     */
+    public static long classInvalidationCount() {
+        return CLASS_INVALIDATIONS.get();
+    }
+
+    /**
+     * Returns the total number of category-style bulk invalidations.
+     *
+     * @return category invalidation count
+     */
+    public static long categoryInvalidationCount() {
+        return CATEGORY_INVALIDATIONS.get();
+    }
+
+    /**
+     * Resets process-wide counters (tests only). Does not reset live switch points.
+     */
+    public static void resetCountersForTesting() {
+        CLASS_INVALIDATIONS.set(0);
+        CATEGORY_INVALIDATIONS.set(0);
+    }
+}
diff --git a/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java
new file mode 100644
index 0000000000..bc279301ed
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java
@@ -0,0 +1,134 @@
+/*
+ *  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.runtime.indy;
+
+import java.lang.invoke.SwitchPoint;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Lazily allocates a {@link SwitchPoint} and retires it on demand.
+ * <p>
+ * Call sites install the switch point returned by {@link #getSwitchPoint()} in
+ * {@code guardWithTest} chains. {@link #invalidate()} retires the live switch
+ * point so linked sites fall back and re-link; the next {@link #getSwitchPoint()}
+ * allocates a fresh one.
+ * <p>
+ * Hierarchy fan-out and other bulk paths should prefer {@link #detachLive()} and a
+ * single {@link SwitchPoint#invalidateAll(SwitchPoint[])} over many individual
+ * {@link #invalidate()} calls (GROOVY-12191).
+ * <p>
+ * There is no permanent failover: after scoping invalidation to a single class
+ * (plus subtypes), deopt storms from a process-wide SwitchPoint no longer apply,
+ * so a simpler replace-on-invalidate model is sufficient.
+ *
+ * @since 6.0.0
+ */
+public final class SwitchPointInvalidator {
+
+    /**
+     * Reusable one-element array for single-SP {@link SwitchPoint#invalidateAll},
+     * guarded by {@link #SINGLE_INVALIDATE_LOCK}. Invalidation is rare relative to
+     * steady-state dispatch, so the lock is not on a hot path.
+     */
+    private static final Object SINGLE_INVALIDATE_LOCK = new Object();
+    private static final SwitchPoint[] SINGLE_INVALIDATE_BUF = new SwitchPoint[1];
+
+    /** {@code null} means no live switch point (lazy allocation on next get). */
+    private final AtomicReference<SwitchPoint> current = new AtomicReference<>();
+    private final AtomicInteger retirementCount = new AtomicInteger();
+
+    /**
+     * Creates an invalidator.
+     */
+    public SwitchPointInvalidator() {
+    }
+
+    /**
+     * Returns a live switch point to install in a call-site guard chain.
+     * Lazily allocates on first use after construction or after retirement.
+     *
+     * @return a live switch point (never {@code null})
+     */
+    public SwitchPoint getSwitchPoint() {
+        for (;;) {
+            SwitchPoint sp = current.get();
+            if (sp != null) {
+                return sp;
+            }
+            SwitchPoint created = new SwitchPoint();
+            if (current.compareAndSet(null, created)) {
+                return created;
+            }
+        }
+    }
+
+    /**
+     * Invalidates the current live switch point (if any) so linked sites re-link.
+     * A subsequent {@link #getSwitchPoint()} allocates a fresh switch point.
+     */
+    public void invalidate() {
+        SwitchPoint retired = detachLive();
+        invalidateIfLive(retired);
+    }
+
+    /**
+     * Atomically detaches the live switch point for bulk
+     * {@link SwitchPoint#invalidateAll} without invalidating it yet.
+     * The caller <em>must</em> invalidate any non-null return value.
+     *
+     * @return the detached live switch point, or {@code null} if none was live
+     */
+    public SwitchPoint detachLive() {
+        SwitchPoint sp = current.getAndSet(null);
+        if (sp != null) {
+            retirementCount.incrementAndGet();
+        }
+        return sp;
+    }
+
+    /**
+     * Returns how many live switch points have been retired.
+     *
+     * @return the retirement count
+     */
+    public int getRetirementCount() {
+        return retirementCount.get();
+    }
+
+    /**
+     * Invalidates a single live switch point, reusing a process-wide one-element
+     * buffer under a short lock to avoid allocating {@code SwitchPoint[]} on the
+     * rare invalidation path.
+     *
+     * @param sp candidate switch point (may be {@code null})
+     */
+    static void invalidateIfLive(final SwitchPoint sp) {
+        if (sp != null && !sp.hasBeenInvalidated()) {
+            synchronized (SINGLE_INVALIDATE_LOCK) {
+                SINGLE_INVALIDATE_BUF[0] = sp;
+                try {
+                    SwitchPoint.invalidateAll(SINGLE_INVALIDATE_BUF);
+                } finally {
+                    SINGLE_INVALIDATE_BUF[0] = null;
+                }
+            }
+        }
+    }
+}
diff --git a/src/main/java/org/apache/groovy/runtime/indy/package-info.java b/src/main/java/org/apache/groovy/runtime/indy/package-info.java
new file mode 100644
index 0000000000..6e55043f4f
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/indy/package-info.java
@@ -0,0 +1,30 @@
+/*
+ *  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.
+ */
+
+/**
+ * Scoped invokedynamic SwitchPoint utilities for the Groovy MOP (GROOVY-12191).
+ * <p>
+ * One SwitchPoint per class (on {@link org.codehaus.groovy.reflection.ClassInfo}):
+ * MetaClass changes retire that class and loaded subtypes; category enter/leave
+ * bulk-retires all loaded class SwitchPoints. Linked call sites carry a single
+ * guard — no process-wide MOP SwitchPoint on the hot path.
+ *
+ * @since 6.0.0
+ */
+package org.apache.groovy.runtime.indy;
diff --git a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
index aa08713430..005a93e10c 100644
--- a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
+++ b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
@@ -25,6 +25,9 @@ import groovy.lang.GroovySystem;
 import groovy.lang.MetaClass;
 import groovy.lang.MetaClassRegistry;
 import groovy.lang.MetaMethod;
+import groovy.transform.Internal;
+import org.apache.groovy.runtime.indy.IndyInvalidation;
+import org.apache.groovy.runtime.indy.SwitchPointInvalidator;
 import org.apache.groovy.util.concurrent.ManagedIdentityConcurrentMap;
 import org.codehaus.groovy.reflection.GroovyClassValue.ComputeValue;
 import org.codehaus.groovy.reflection.stdclasses.ArrayCachedClass;
@@ -49,9 +52,9 @@ import org.codehaus.groovy.util.LockableObject;
 import org.codehaus.groovy.util.ManagedConcurrentLinkedQueue;
 import org.codehaus.groovy.util.ManagedReference;
 import org.codehaus.groovy.util.ReferenceBundle;
-import org.codehaus.groovy.vmplugin.VMPluginFactory;
 
 import java.io.Serial;
+import java.lang.invoke.SwitchPoint;
 import java.lang.ref.WeakReference;
 import java.math.BigDecimal;
 import java.math.BigInteger;
@@ -77,6 +80,11 @@ public class ClassInfo implements Finalizable {
     public final int hash = -1;
     private final WeakReference<Class<?>> classRef;
     private final AtomicInteger version = new AtomicInteger();
+    /**
+     * Per-class SwitchPoint domain for indy call sites (GROOVY-12191).
+     * Invalidated on MetaClass / version changes for this class only.
+     */
+    private final SwitchPointInvalidator indySwitchPoint = IndyInvalidation.newClassInvalidator();
     private MetaClass strongMetaClass;
     private ManagedReference<MetaClass> weakMetaClass;
     MetaMethod[] dgmMetaMethods = MetaMethod.EMPTY_ARRAY;
@@ -124,12 +132,88 @@ public class ClassInfo implements Finalizable {
     }
 
     /**
-     * Increments the version number and invalidates call sites.
+     * Increments the version number and invalidates this class's indy SwitchPoint
+     * (and subtype SwitchPoints via hierarchy fan-out).
      * Called when metaclass modifications occur (e.g., adding methods to an {@code ExpandoMetaClass}).
+     * <p>
+     * Call sites linked against this class or its subtypes re-link (GROOVY-12191);
+     * unrelated classes keep their optimized targets. Category enter/leave bulk-invalidates
+     * class SwitchPoints via {@link org.codehaus.groovy.vmplugin.VMPlugin#invalidateCallSites()}.
+     * <p>
+     * SwitchPoint policy is owned by {@link IndyInvalidation}; this method only bumps
+     * generation then delegates hierarchy invalidation.
      */
     public void incVersion() {
         version.incrementAndGet();
-        VMPluginFactory.getPlugin().invalidateCallSites();
+        Class<?> type = getTheClass();
+        if (type != null) {
+            IndyInvalidation.invalidateClass(type);
+        } else {
+            invalidateIndySwitchPoint();
+        }
+    }
+
+    /**
+     * Bumps {@link #getVersion()} and, when {@code retireSwitchPoint} is true,
+     * retires this class's SwitchPoint only (no subtype fan-out). Used by
+     * strong/weak MetaClass install paths; hierarchy fan-out is applied by the
+     * MetaClass registry listener via {@link IndyInvalidation#invalidateClass(Class)}.
+     * <p>
+     * First MetaClass install ({@code null →} default MC) only bumps version.
+     * Replacement and clear retire the SwitchPoint so any already-linked sites re-link.
+     */
+    private void bumpGenerationLocal(final boolean retireSwitchPoint) {
+        version.incrementAndGet();
+        if (retireSwitchPoint) {
+            // Local only: registry listener will fan out to subtypes when present.
+            invalidateIndySwitchPoint();
+        }
+    }
+
+    /**
+     * Whether this class currently has a class-level MetaClass (strong or weak).
+     */
+    private boolean hasClassLevelMetaClass() {
+        if (strongMetaClass != null) {
+            return true;
+        }
+        ManagedReference<MetaClass> weakRef = weakMetaClass;
+        return weakRef != null && weakRef.get() != null;
+    }
+
+    /**
+     * Returns the SwitchPoint that guards indy targets for this class's MetaClass state.
+     *
+     * @return the class-domain SwitchPoint for indy
+     * @since 6.0.0
+     */
+    @Internal
+    public SwitchPoint getIndySwitchPoint() {
+        return indySwitchPoint.getSwitchPoint();
+    }
+
+    /**
+     * Invalidates this class's indy SwitchPoint without bumping {@link #getVersion()}.
+     * Prefer {@link #incVersion()} when the MetaClass actually changed.
+     *
+     * @since 6.0.0
+     */
+    @Internal
+    public void invalidateIndySwitchPoint() {
+        indySwitchPoint.invalidate();
+    }
+
+    /**
+     * Detaches the live indy SwitchPoint for bulk {@link SwitchPoint#invalidateAll}
+     * without invalidating it yet. Package policy lives in {@link IndyInvalidation};
+     * the caller must invalidate any non-null return value.
+     *
+     * @return the detached live SwitchPoint, or {@code null} if none was live
+     * @since 6.0.0
+     */
+    @Internal
+    public SwitchPoint detachLiveIndySwitchPoint() {
+        return indySwitchPoint.detachLive();
     }
 
     /**
@@ -254,11 +338,20 @@ public class ClassInfo implements Finalizable {
     /**
      * Sets the strong (immutable) metaclass for this class.
      * Increments the version number and manages ExpandoMetaClass registry.
+     * <p>
+     * On replacement or clear, also invalidates this class's indy SwitchPoint
+     * (GROOVY-12191). First install ({@code null →} MC) only bumps version;
+     * hierarchy fan-out for subtypes is applied by MetaClass registry listeners
+     * or {@link #incVersion()}.
      *
      * @param answer the metaclass to set, or {@code null} to clear
      */
     public void setStrongMetaClass(MetaClass answer) {
-        version.incrementAndGet();
+        // Version always; SwitchPoint when replacing/clearing an established MC.
+        // Hierarchy fan-out for subtypes is applied by registry listeners / incVersion
+        // (GROOVY-12191). First install (null → MC) only bumps version.
+        boolean replaceOrClear = hasClassLevelMetaClass() || answer == null;
+        bumpGenerationLocal(replaceOrClear);
 
         // safe value here to avoid multiple reads with possibly
         // differing values due to concurrency
@@ -300,11 +393,16 @@ public class ClassInfo implements Finalizable {
     /**
      * Sets the weak (mutable) metaclass for this class.
      * Clears the strong metaclass and increments the version number.
+     * <p>
+     * On replacement or clear, also invalidates this class's indy SwitchPoint
+     * (GROOVY-12191). First install only bumps version.
      *
      * @param answer the metaclass to set, or {@code null} to clear
      */
     public void setWeakMetaClass(MetaClass answer) {
-        version.incrementAndGet();
+        // Version always; SwitchPoint when replacing/clearing (GROOVY-12191).
+        boolean replaceOrClear = hasClassLevelMetaClass() || answer == null;
+        bumpGenerationLocal(replaceOrClear);
 
         strongMetaClass = null;
         ManagedReference<MetaClass> newRef = null;
@@ -527,7 +625,9 @@ public class ClassInfo implements Finalizable {
      * @param metaClass the metaclass to set, or {@code null} to remove the association
      */
     public void setPerInstanceMetaClass(Object obj, MetaClass metaClass) {
-        version.incrementAndGet();
+        // Per-instance MetaClass changes always retire the class SwitchPoint so
+        // sites that may observe instance-level dispatch re-link (GROOVY-12191).
+        bumpGenerationLocal(true);
 
         if (metaClass != null) {
             if (perInstanceMetaClassMap == null)
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 6cbd8f3687..c07140cc3b 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
@@ -21,7 +21,9 @@ 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;
@@ -48,10 +50,9 @@ import java.lang.reflect.Modifier;
  * 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.
+ * full chain would install: per-class SwitchPoint freshness (GROOVY-12191;
+ * category enter/leave bulk-invalidates class SwitchPoints), exact argument
+ * classes, and metaclass identity for {@code GroovyObject} receivers.
  */
 class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
 
@@ -63,7 +64,7 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
     final Boolean thisCall;
     final Boolean spreadCall;
     private final MetaClass selectionMetaClass;
-    private final SwitchPoint validity;
+    private final SwitchPoint classValidity;
     private final Class<?>[] argClasses;
     private final MethodHandle reflectiveHandle;
     /**
@@ -76,7 +77,7 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
 
     private ColdReflectiveMethodHandleWrapper(MetaMethod method, CacheableCallSite callSite, Class<?> sender,
             String methodName, int callID, Boolean safeNavigation, Boolean thisCall, Boolean spreadCall,
-            MetaClass selectionMetaClass, SwitchPoint validity, Class<?>[] argClasses) {
+            MetaClass selectionMetaClass, SwitchPoint classValidity, Class<?>[] argClasses) {
         super(null, null, method, true);
         this.callSite = callSite;
         this.sender = sender;
@@ -86,7 +87,7 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
         this.thisCall = thisCall;
         this.spreadCall = spreadCall;
         this.selectionMetaClass = selectionMetaClass;
-        this.validity = validity;
+        this.classValidity = classValidity;
         this.argClasses = argClasses;
         this.reflectiveHandle = IndyInterface.COLD_REFLECTIVE_INVOKER.bindTo(this);
     }
@@ -112,7 +113,9 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
      * @return {@code true} if the cached selection is still valid for them
      */
     boolean isValidFor(Object[] arguments) {
-        if (validity.hasBeenInvalidated()) return false;
+        if (classValidity != null && classValidity.hasBeenInvalidated()) {
+            return false;
+        }
         Class<?>[] classes = argClasses;
         if (arguments.length != classes.length) return false;
         for (int i = 0; i < classes.length; i++) {
@@ -152,8 +155,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();
         return new ColdReflectiveMethodHandleWrapper(cm, callSite, sender, methodName, callID,
-                safeNavigation, thisCall, spreadCall, mc, IndyInterface.switchPoint, argClasses);
+                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 81325caa32..208c00b10a 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java
@@ -146,8 +146,8 @@ public final class IndyCompoundAssign {
         Class<?> ac = (arg == null) ? null : arg.getClass();
         MethodHandle test = MethodHandles.insertArguments(GUARD, 0, rc, ac);
         MethodHandle guarded = MethodHandles.guardWithTest(test, invoke, elseTarget);
-        // a metaclass change invalidates the site, exactly as for a normal indy call.
-        guarded = IndyInterface.switchPoint.guardWithTest(guarded, elseTarget);
+        // Per-class SwitchPoint, same domain as a normal indy call (GROOVY-12191).
+        guarded = IndyInterface.applyMopSwitchPoints(guarded, elseTarget, receiver);
 
         // 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 9379244204..969f8d20d4 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
@@ -20,6 +20,7 @@ package org.codehaus.groovy.vmplugin.v8;
 
 import groovy.lang.GroovyRuntimeException;
 import groovy.lang.GroovySystem;
+import org.apache.groovy.runtime.indy.IndyInvalidation;
 import org.apache.groovy.util.SystemUtil;
 import org.codehaus.groovy.GroovyBugError;
 import org.codehaus.groovy.runtime.GeneratedClosure;
@@ -215,29 +216,75 @@ public class IndyInterface {
     }
 
     /**
-     * Shared switch point invalidated when metaclass state changes.
+     * Legacy process-wide SwitchPoint retained for binary compatibility.
+     * <p>
+     * <strong>Behavioral change in 6.0 (GROOVY-12191):</strong> MetaClass changes
+     * are scoped per {@link org.codehaus.groovy.reflection.ClassInfo}. This field
+     * is no longer used as the MOP guard on linked call sites. It is rotated when
+     * {@link #invalidateSwitchPoints()} runs (category enter/leave /
+     * {@code VMPlugin.invalidateCallSites()}) so external readers that still
+     * observe the field see an invalidation. Call sites must use
+     * {@link IndyInvalidation#guardWithMopSwitchPoints} instead.
+     *
+     * @see IndyInvalidation
+     * @deprecated Use {@link IndyInvalidation#guardWithMopSwitchPoints}; this field
+     *             is not the call-site MOP guard.
      */
-    protected static SwitchPoint switchPoint = new SwitchPoint();
+    @Deprecated
+    protected static volatile SwitchPoint switchPoint = new SwitchPoint();
 
     static {
-        GroovySystem.getMetaClassRegistry().addMetaClassRegistryChangeEventListener(cmcu -> invalidateSwitchPoints());
+        // MetaClass registry changes invalidate the affected class + subtypes (GROOVY-12191).
+        // Hierarchy fan-out is owned here (not duplicated in setStrongMetaClass beyond a
+        // local retire of the exact class for paths that never fire the registry).
+        GroovySystem.getMetaClassRegistry().addMetaClassRegistryChangeEventListener(cmcu -> {
+            Class<?> type = cmcu.getClassToUpdate();
+            if (type != null) {
+                IndyInvalidation.invalidateClass(type);
+                if (LOG_ENABLED) {
+                    LOG.info("invalidating class SwitchPoint hierarchy for " + type.getName());
+                }
+            } else {
+                IndyInvalidation.invalidateUnscoped();
+                if (LOG_ENABLED) {
+                    LOG.info("unscoped SwitchPoint invalidation (unattributed MetaClass change)");
+                }
+            }
+        });
     }
 
     /**
-     * Callback for constant metaclass update change
+     * Category enter/leave and {@code VMPlugin.invalidateCallSites()}.
+     * Bulk-invalidates every loaded class SwitchPoint so sites re-link under the
+     * new category state, and rotates the legacy {@link #switchPoint} field.
+     * Per-class MetaClass changes use {@link IndyInvalidation#invalidateClass(Class)}
+     * (or {@link org.codehaus.groovy.reflection.ClassInfo#incVersion()}) instead.
      */
     protected static void invalidateSwitchPoints() {
         if (LOG_ENABLED) {
-            LOG.info("invalidating switch point");
+            LOG.info("invalidating class SwitchPoints for category / invalidateCallSites");
         }
-
-        synchronized (IndyInterface.class) {
-            SwitchPoint old = switchPoint;
-            switchPoint = new SwitchPoint();
+        IndyInvalidation.invalidateCategory();
+        // Binary-compat: rotate the legacy field so external observers still see a change.
+        SwitchPoint old = switchPoint;
+        switchPoint = new SwitchPoint();
+        if (old != null && !old.hasBeenInvalidated()) {
             SwitchPoint.invalidateAll(new SwitchPoint[]{old});
         }
     }
 
+    /**
+     * Installs the per-class MOP SwitchPoint guard on a linked handle.
+     *
+     * @param handle   fast-path handle
+     * @param fallback re-link handle
+     * @param receiver call receiver (may be {@code null})
+     * @return guarded handle
+     */
+    static MethodHandle applyMopSwitchPoints(final MethodHandle handle, final MethodHandle fallback, final Object receiver) {
+        return IndyInvalidation.guardWithMopSwitchPoints(handle, fallback, receiver);
+    }
+
     /**
      * Bootstrap method for method calls from Groovy-compiled code with indy.
      *
@@ -472,9 +519,14 @@ public class IndyInterface {
                 throw ScriptBytecodeAdapter.unwrap(gre);
             }
         }
-        MethodHandle mh = selectMethodHandle(cold.callSite, cold.sender, cold.methodName, cold.callID,
-                cold.safeNavigation, cold.thisCall, cold.spreadCall, 1, arguments);
-        return mh.invokeExact(arguments);
+        // Re-select without the cold tier so an always-invalid SwitchPoint (or any
+        // permanent cold miss after class-domain failover) cannot recurse through
+        // tryBuild → invokeColdReflective (GROOVY-12191).
+        MethodHandleWrapper full = fallback(cold.callSite, cold.sender, cold.methodName, cold.callID,
+                cold.safeNavigation, cold.thisCall, cold.spreadCall, 1, arguments, false);
+        cold.callSite.put(receiverCacheKey(arguments[0]),
+                full.isCanSetTarget() ? full : NULL_METHOD_HANDLE_WRAPPER);
+        return full.getCachedMethodHandle().invokeExact(arguments);
     }
 
     /**
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 989e1eb0f5..9a717fd814 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
@@ -100,7 +100,7 @@ import static org.codehaus.groovy.vmplugin.v8.IndyGuardsFiltersAndSignatures.unw
 import static org.codehaus.groovy.vmplugin.v8.IndyInterface.CallType;
 import static org.codehaus.groovy.vmplugin.v8.IndyInterface.LOG;
 import static org.codehaus.groovy.vmplugin.v8.IndyInterface.LOG_ENABLED;
-import static org.codehaus.groovy.vmplugin.v8.IndyInterface.switchPoint;
+import static org.codehaus.groovy.vmplugin.v8.IndyInterface.applyMopSwitchPoints;
 
 /**
  * Base state holder for invokedynamic method, property, constructor, and cast selection.
@@ -1283,9 +1283,11 @@ public abstract class Selector {
                 }
             }
 
-            // handle constant metaclass and category changes
-            handle = switchPoint.guardWithTest(handle, fallback);
-            if (LOG_ENABLED) LOG.info("added switch point guard");
+            // 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);
+            if (LOG_ENABLED) LOG.info("added class switch point guard");
 
             java.util.function.Predicate<Class<?>> nonFinalOrNullUnsafe = (t) -> {
                 return !Modifier.isFinal(t.getModifiers())
@@ -1444,7 +1446,8 @@ public abstract class Selector {
      * ({@code arguments[0]} is the receiver). The result has type
      * {@code callSite.type()}. Guard and switch-point wrapping are intentionally
      * omitted — the caller (compound-assignment) applies its own per-shape guard
-     * and shares the global MOP {@link IndyInterface#switchPoint}. The caller must
+     * and the scoped per-class MOP SwitchPoint (see
+     * {@link org.apache.groovy.runtime.indy.IndyInvalidation}). The caller must
      * have already established that {@code methodName} resolves for this receiver
      * (e.g. via {@code respondsTo}); this routes the actual invocation through the
      * same selection, coercion and wrapping path as a normal call.
diff --git a/src/test/groovy/bugs/Groovy12191.groovy b/src/test/groovy/bugs/Groovy12191.groovy
new file mode 100644
index 0000000000..a792f09d1d
--- /dev/null
+++ b/src/test/groovy/bugs/Groovy12191.groovy
@@ -0,0 +1,174 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package bugs
+
+import org.apache.groovy.runtime.indy.IndyInvalidation
+import org.codehaus.groovy.reflection.ClassInfo
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.SwitchPoint
+
+import static groovy.test.GroovyAssert.assertScript
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * GROOVY-12191: scoped indy SwitchPoint invalidation.
+ * Metaclass changes for class A must not invalidate class B's SwitchPoint;
+ * category enter/leave bulk-invalidates class SwitchPoints so category methods
+ * become visible to previously linked sites.
+ */
+final class Groovy12191 {
+
+    @Test
+    void metaclassChange_invalidatesOnlyAffectedClassSwitchPoint() {
+        SwitchPoint spA = ClassInfo.getClassInfo(TypeA).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(TypeB).indySwitchPoint
+        assertFalse(spA.hasBeenInvalidated())
+        assertFalse(spB.hasBeenInvalidated())
+
+        TypeA.metaClass.extra = { -> 'from-emc' }
+        assertTrue(spA.hasBeenInvalidated())
+        assertFalse(spB.hasBeenInvalidated())
+
+        assertScript '''
+            class TypeA { }
+            class TypeB {
+                String id() { 'B' }
+            }
+            TypeA.metaClass.extra = { -> 'from-emc' }
+            assert new TypeA().extra() == 'from-emc'
+            assert new TypeB().id() == 'B'
+        '''
+    }
+
+    @Test
+    void registrySetMetaClass_invalidatesClassDomain() {
+        SwitchPoint sp = ClassInfo.getClassInfo(TypeC).indySwitchPoint
+        def mc = new ExpandoMetaClass(TypeC, true, true)
+        mc.initialize()
+        GroovySystem.metaClassRegistry.setMetaClass(TypeC, mc)
+        assertTrue(sp.hasBeenInvalidated())
+    }
+
+    @Test
+    void categoryEnterLeave_bulkInvalidatesClassSwitchPoints() {
+        SwitchPoint classSp = ClassInfo.getClassInfo(TypeD).indySwitchPoint
+
+        use(TypeDCategory) {
+            assert 'decorated' == new TypeD().label()
+        }
+
+        // Category enter/leave bulk-invalidates class domains (single-guard model).
+        assertTrue(classSp.hasBeenInvalidated())
+    }
+
+    @Test
+    void categoryMethods_areVisibleToDynamicCalls() {
+        assertScript '''
+            class Host {
+                String name() { 'host' }
+            }
+            class HostCategory {
+                static String name(Host self) { 'category' }
+            }
+            def h = new Host()
+            assert h.name() == 'host'
+            use (HostCategory) {
+                assert h.name() == 'category'
+            }
+            assert h.name() == 'host'
+        '''
+    }
+
+    @Test
+    void indyCalls_pickUpNewMetaClassMethodsAfterChange() {
+        assertScript '''
+            class Svc {
+                int add(int a, int b) { a + b }
+            }
+            def s = new Svc()
+            assert s.add(1, 2) == 3
+            Svc.metaClass.mul = { int a, int b -> a * b }
+            s.metaClass = null
+            assert s.mul(3, 4) == 12
+            assert s.add(3, 4) == 7
+            assert new Svc().mul(2, 5) == 10
+        '''
+    }
+
+    @Test
+    void parentMetaClassChange_invalidatesSubclassSwitchPoint() {
+        SwitchPoint childSp = ClassInfo.getClassInfo(HierChild).indySwitchPoint
+        assertFalse(childSp.hasBeenInvalidated())
+
+        HierParent.metaClass.hello = { -> 'from-parent' }
+
+        assertTrue(childSp.hasBeenInvalidated())
+        assertScript '''
+            class HierParent {}
+            class HierChild extends HierParent {}
+            HierParent.metaClass.hello = { -> 'from-parent' }
+            assert new HierChild().hello() == 'from-parent'
+        '''
+    }
+
+    @Test
+    void indyCalls_overrideViaFreshMetaClassInstance() {
+        assertScript '''
+            class Svc {
+                String greet() { 'hello' }
+            }
+            def s = new Svc()
+            assert s.greet() == 'hello'
+            def emc = new ExpandoMetaClass(Svc, true, true)
+            emc.greet = { -> 'world' }
+            emc.initialize()
+            GroovySystem.metaClassRegistry.setMetaClass(Svc, emc)
+            s.metaClass = null
+            assert s.greet() == 'world'
+        '''
+    }
+
+    @Test
+    void unrelatedMetaClassChurn_doesNotInvalidateHotClass() {
+        SwitchPoint hotSp = ClassInfo.getClassInfo(TypeD).indySwitchPoint
+        assertFalse(hotSp.hasBeenInvalidated())
+
+        // Churn an unrelated type — scoped invalidation must leave TypeD alone.
+        10.times { i ->
+            TypeC.metaClass."dyn${i}" = { -> i }
+        }
+        assertFalse(hotSp.hasBeenInvalidated())
+    }
+
+    static class TypeA {}
+    static class TypeB {
+        String id() { 'B' }
+    }
+    static class TypeC {}
+    static class TypeD {
+        String label() { 'plain' }
+    }
+    static class TypeDCategory {
+        static String label(TypeD self) { 'decorated' }
+    }
+    static class HierParent {}
+    static class HierChild extends HierParent {}
+}
diff --git a/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy b/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy
new file mode 100644
index 0000000000..9159d825f7
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy
@@ -0,0 +1,409 @@
+/*
+ *  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.runtime.indy
+
+import org.codehaus.groovy.reflection.ClassInfo
+import org.codehaus.groovy.runtime.NullObject
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.SwitchPoint
+import java.lang.ref.WeakReference
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertNotSame
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Unit tests for {@link IndyInvalidation} — single class-domain SwitchPoint model.
+ */
+final class IndyInvalidationTest {
+
+    @BeforeEach
+    void resetCounters() {
+        IndyInvalidation.resetCountersForTesting()
+    }
+
+    @Test
+    void switchPointClassFor_null_classReceiver_andInstance() {
+        assertSame(NullObject, IndyInvalidation.switchPointClassFor(null))
+        assertSame(String, IndyInvalidation.switchPointClassFor(String))
+        assertSame(String, IndyInvalidation.switchPointClassFor('hi'))
+        assertSame(Integer, IndyInvalidation.switchPointClassFor(42))
+    }
+
+    @Test
+    void invalidateClass_onlyAffectsThatClass_notSiblings() {
+        SwitchPoint spA = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+        assertFalse(spA.hasBeenInvalidated())
+        assertFalse(spB.hasBeenInvalidated())
+
+        long before = IndyInvalidation.classInvalidationCount()
+        IndyInvalidation.invalidateClass(ClassA)
+        assertTrue(spA.hasBeenInvalidated())
+        assertFalse(spB.hasBeenInvalidated())
+        assertEquals(before + 1, IndyInvalidation.classInvalidationCount())
+
+        SwitchPoint spA2 = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        assertNotSame(spA, spA2)
+        assertFalse(spA2.hasBeenInvalidated())
+        assertSame(spB, ClassInfo.getClassInfo(ClassB).indySwitchPoint)
+    }
+
+    @Test
+    void invalidateClass_fansOutToSubtypes() {
+        SwitchPoint parentSp = ClassInfo.getClassInfo(Parent).indySwitchPoint
+        SwitchPoint childSp = ClassInfo.getClassInfo(Child).indySwitchPoint
+        SwitchPoint siblingSp = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+
+        IndyInvalidation.invalidateClass(Parent)
+
+        assertTrue(parentSp.hasBeenInvalidated())
+        assertTrue(childSp.hasBeenInvalidated())
+        assertFalse(siblingSp.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateClass_null_isNoOp() {
+        long before = IndyInvalidation.classInvalidationCount()
+        IndyInvalidation.invalidateClass(null)
+        assertEquals(before, IndyInvalidation.classInvalidationCount())
+    }
+
+    @Test
+    void invalidateClassHierarchy_finalClass_skipsSubtypeScan() {
+        SwitchPoint stringSp = ClassInfo.getClassInfo(String).indySwitchPoint
+        SwitchPoint siblingSp = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+        IndyInvalidation.invalidateClassHierarchy(String)
+        assertTrue(stringSp.hasBeenInvalidated())
+        assertFalse(siblingSp.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateCategory_bulkInvalidatesClassDomains() {
+        SwitchPoint classSp = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        long catCount = IndyInvalidation.categoryInvalidationCount()
+
+        IndyInvalidation.invalidateCategory()
+
+        assertTrue(classSp.hasBeenInvalidated())
+        assertEquals(catCount + 1, IndyInvalidation.categoryInvalidationCount())
+        // Fresh SP available after bulk retire
+        SwitchPoint fresh = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        assertFalse(fresh.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateUnscoped_retiresLoadedClassSwitchPoints() {
+        SwitchPoint spA = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+        long before = IndyInvalidation.classInvalidationCount()
+        IndyInvalidation.invalidateUnscoped()
+        assertTrue(spA.hasBeenInvalidated())
+        assertTrue(spB.hasBeenInvalidated())
+        assertEquals(before + 1, IndyInvalidation.classInvalidationCount())
+    }
+
+    @Test
+    void classInfoIncVersion_invalidatesClassDomain_notSiblings() {
+        SwitchPoint spA = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+        int versionBefore = ClassInfo.getClassInfo(ClassA).version
+
+        ClassInfo.getClassInfo(ClassA).incVersion()
+
+        assertEquals(versionBefore + 1, ClassInfo.getClassInfo(ClassA).version)
+        assertTrue(spA.hasBeenInvalidated())
+        assertFalse(spB.hasBeenInvalidated())
+    }
+
+    @Test
+    void guardWithMopSwitchPoints_fallsBackOnClassInvalidate() {
+        def target = MethodHandles.constant(int, 1)
+        def fallback = MethodHandles.constant(int, 2)
+        def receiver = new ClassA()
+
+        def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, receiver)
+        assertEquals(1, guarded.invokeWithArguments())
+
+        IndyInvalidation.invalidateClass(ClassA)
+        assertEquals(2, guarded.invokeWithArguments())
+    }
+
+    @Test
+    void guardWithMopSwitchPoints_fallsBackOnCategoryInvalidate() {
+        def target = MethodHandles.constant(int, 1)
+        def fallback = MethodHandles.constant(int, 2)
+        def receiver = new ClassA()
+
+        def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, receiver)
+        assertEquals(1, guarded.invokeWithArguments())
+
+        // Category maps to bulk class-domain invalidation (single-guard model).
+        IndyInvalidation.invalidateCategory()
+        assertEquals(2, guarded.invokeWithArguments())
+    }
+
+    @Test
+    void guardWithMopSwitchPoints_classOverload() {
+        def target = MethodHandles.constant(int, 3)
+        def fallback = MethodHandles.constant(int, 4)
+        def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, ClassA)
+        assertEquals(3, guarded.invokeWithArguments())
+        IndyInvalidation.invalidateClass(ClassA)
+        assertEquals(4, guarded.invokeWithArguments())
+    }
+
+    @Test
+    void classSwitchPointFor_classAndReceiverOverloads() {
+        SwitchPoint byClass = IndyInvalidation.classSwitchPointFor(String)
+        SwitchPoint byReceiver = IndyInvalidation.classSwitchPointFor('x')
+        assertSame(byClass, byReceiver)
+        assertFalse(byClass.hasBeenInvalidated())
+        SwitchPoint byNull = IndyInvalidation.classSwitchPointFor((Object) null)
+        assertSame(ClassInfo.getClassInfo(NullObject).indySwitchPoint, byNull)
+    }
+
+    @Test
+    void resetCountersForTesting_zerosCounters() {
+        IndyInvalidation.invalidateClass(ClassA)
+        IndyInvalidation.invalidateCategory()
+        assertTrue(IndyInvalidation.classInvalidationCount() > 0)
+        assertTrue(IndyInvalidation.categoryInvalidationCount() > 0)
+        IndyInvalidation.resetCountersForTesting()
+        assertEquals(0, IndyInvalidation.classInvalidationCount())
+        assertEquals(0, IndyInvalidation.categoryInvalidationCount())
+    }
+
+    @Test
+    void setStrongMetaClass_firstInstall_bumpsVersionWithoutSwitchPoint() {
+        def info = ClassInfo.getClassInfo(LocalGen)
+        info.setStrongMetaClass(null)
+        info.setWeakMetaClass(null)
+        SwitchPoint sp = info.indySwitchPoint
+        assertFalse(sp.hasBeenInvalidated())
+        int versionBefore = info.version
+
+        def mc = new groovy.lang.MetaClassImpl(LocalGen)
+        mc.initialize()
+        info.setStrongMetaClass(mc)
+
+        assertEquals(versionBefore + 1, info.version)
+        assertFalse(sp.hasBeenInvalidated(), 'first install must not invalidate SwitchPoint')
+        info.setStrongMetaClass(null)
+    }
+
+    @Test
+    void setStrongMetaClass_replace_invalidatesLocalSwitchPoint() {
+        def info = ClassInfo.getClassInfo(LocalGenReplace)
+        def mc1 = new groovy.lang.MetaClassImpl(LocalGenReplace)
+        mc1.initialize()
+        info.setStrongMetaClass(mc1)
+        SwitchPoint sp = info.indySwitchPoint
+        assertFalse(sp.hasBeenInvalidated())
+
+        def mc2 = new groovy.lang.MetaClassImpl(LocalGenReplace)
+        mc2.initialize()
+        info.setStrongMetaClass(mc2)
+
+        assertTrue(sp.hasBeenInvalidated(), 'replacement must invalidate SwitchPoint')
+        info.setStrongMetaClass(null)
+    }
+
+    @Test
+    void setWeakMetaClass_replace_invalidatesLocalSwitchPoint() {
+        def info = ClassInfo.getClassInfo(LocalGenWeak)
+        def mc1 = new groovy.lang.MetaClassImpl(LocalGenWeak)
+        mc1.initialize()
+        info.setWeakMetaClass(mc1)
+        SwitchPoint sp = info.indySwitchPoint
+        assertFalse(sp.hasBeenInvalidated())
+
+        def mc2 = new groovy.lang.MetaClassImpl(LocalGenWeak)
+        mc2.initialize()
+        info.setWeakMetaClass(mc2)
+
+        assertTrue(sp.hasBeenInvalidated())
+        info.setWeakMetaClass(null)
+    }
+
+    @Test
+    void invalidateClassHierarchy_interface_fansOutToImplementors() {
+        SwitchPoint ifaceSp = ClassInfo.getClassInfo(Marker).indySwitchPoint
+        SwitchPoint implSp = ClassInfo.getClassInfo(MarkerImpl).indySwitchPoint
+        SwitchPoint otherSp = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+
+        IndyInvalidation.invalidateClassHierarchy(Marker)
+
+        assertTrue(ifaceSp.hasBeenInvalidated())
+        assertTrue(implSp.hasBeenInvalidated())
+        assertFalse(otherSp.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateClassHierarchy_primitive_shortCircuits() {
+        // Primitives cannot have subtypes — hits cannotHaveLoadedSubtypes early path.
+        SwitchPoint intSp = ClassInfo.getClassInfo(int).indySwitchPoint
+        SwitchPoint sibling = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+        IndyInvalidation.invalidateClassHierarchy(int)
+        assertTrue(intSp.hasBeenInvalidated())
+        assertFalse(sibling.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateClassHierarchy_whenRootAlreadyDetached_stillFansOut() {
+        // Root detach returns null; subtypes still retire (covers rootSp == null branch).
+        def parentInfo = ClassInfo.getClassInfo(ParentAlreadyDetached)
+        def childInfo = ClassInfo.getClassInfo(ChildAlreadyDetached)
+        SwitchPoint parentSp = parentInfo.indySwitchPoint
+        SwitchPoint childSp = childInfo.indySwitchPoint
+        parentInfo.detachLiveIndySwitchPoint() // retire without invalidateAll
+        assertTrue(parentSp != null)
+        IndyInvalidation.invalidateClassHierarchy(ParentAlreadyDetached)
+        // parent was already detached (not necessarily invalidated); child must be retired
+        assertTrue(childSp.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateAllLoaded_whenNoLiveSwitchPoints_isNoOpBatch() {
+        // Detach every tracked type used by this suite so the bulk batch is empty.
+        ClassInfo.getClassInfo(ClassA).detachLiveIndySwitchPoint()
+        ClassInfo.getClassInfo(ClassB).detachLiveIndySwitchPoint()
+        IndyInvalidation.invalidateAllLoadedClassSwitchPoints() // empty-batch early return
+        // still callable and safe
+        IndyInvalidation.invalidateAllLoadedClassSwitchPoints()
+    }
+
+    @Test
+    void newClassInvalidator_createsIndependentDomain() {
+        def a = IndyInvalidation.newClassInvalidator()
+        def b = IndyInvalidation.newClassInvalidator()
+        SwitchPoint spa = a.switchPoint
+        SwitchPoint spb = b.switchPoint
+        a.invalidate()
+        assertTrue(spa.hasBeenInvalidated())
+        assertFalse(spb.hasBeenInvalidated())
+    }
+
+    @Test
+    void statsLogging_enabled_inChildProcess() {
+        // STATS_LOG is fixed at class init; cover the fine-log branches in a fresh JVM.
+        def javaBin = System.getProperty('java.home') + '/bin/java'
+        def cp = System.getProperty('java.class.path')
+        def pb = new ProcessBuilder(
+                javaBin,
+                '-Dgroovy.indy.invalidation.stats=true',
+                '-Djava.util.logging.config.file=', // allow programmatic FINE below via default
+                '-cp', cp,
+                'org.apache.groovy.runtime.indy.IndyInvalidationStatsProbe')
+        pb.redirectErrorStream(true)
+        def proc = pb.start()
+        def out = proc.inputStream.text
+        assertEquals(0, proc.waitFor(), "stats probe failed: $out")
+        assertTrue(out.contains('OK'), out)
+    }
+
+    @Test
+    void invalidateClassHierarchy_skipsClearedClassInfoWeakRefs() {
+        // Force a ClassInfo whose weak Class ref is cleared so the scan hits loaded == null.
+        def info = ClassInfo.getClassInfo(ClearedRefHost)
+        info.indySwitchPoint // ensure live
+        def field = ClassInfo.getDeclaredField('classRef')
+        field.accessible = true
+        def wr = (WeakReference) field.get(info)
+        wr.clear()
+        assertEquals(null, info.theClass)
+        // Must not throw; other ClassInfos still scanned.
+        IndyInvalidation.invalidateClassHierarchy(Parent)
+    }
+
+    @Test
+    void classInfoIncVersion_whenTheClassCleared_invalidatesLocally() {
+        def info = ClassInfo.getClassInfo(ClearedIncVersionHost)
+        SwitchPoint sp = info.indySwitchPoint
+        def field = ClassInfo.getDeclaredField('classRef')
+        field.accessible = true
+        ((WeakReference) field.get(info)).clear()
+        assertEquals(null, info.theClass)
+        info.incVersion() // else branch: invalidateIndySwitchPoint only
+        assertTrue(sp.hasBeenInvalidated())
+    }
+
+    @Test
+    void setPerInstanceMetaClass_retiresClassSwitchPoint() {
+        def type = Groovy12191PerInstHost
+        def info = ClassInfo.getClassInfo(type)
+        // Construct before installing a custom class-level MetaClass.
+        def instance = new Groovy12191PerInstHost()
+        SwitchPoint sp = info.indySwitchPoint
+        assertFalse(sp.hasBeenInvalidated())
+        def emc = new ExpandoMetaClass(type, false, true)
+        emc.initialize()
+        info.setPerInstanceMetaClass(instance, emc)
+        assertTrue(sp.hasBeenInvalidated())
+        info.setPerInstanceMetaClass(instance, null)
+    }
+
+
+    @Test
+    void hasClassLevelMetaClass_weakOnly_firstInstallDoesNotRetire() {
+        def info = ClassInfo.getClassInfo(WeakOnlyHost)
+        info.setStrongMetaClass(null)
+        info.setWeakMetaClass(null)
+        def mc = new groovy.lang.MetaClassImpl(WeakOnlyHost)
+        mc.initialize()
+        SwitchPoint sp = info.indySwitchPoint
+        info.setWeakMetaClass(mc) // first weak install
+        assertFalse(sp.hasBeenInvalidated())
+        info.setWeakMetaClass(null)
+    }
+
+    @Test
+    void guardWithMopSwitchPoints_nullReceiver_usesNullObjectDomain() {
+        def target = MethodHandles.constant(int, 5)
+        def fallback = MethodHandles.constant(int, 6)
+        def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, (Object) null)
+        assertEquals(5, guarded.invokeWithArguments())
+        IndyInvalidation.invalidateClass(NullObject)
+        assertEquals(6, guarded.invokeWithArguments())
+    }
+
+    private static final class ClassA {}
+    private static final class ClassB {}
+    private static class Parent {}
+    private static final class Child extends Parent {}
+    private static class ParentAlreadyDetached {}
+    private static final class ChildAlreadyDetached extends ParentAlreadyDetached {}
+    private static final class LocalGen {}
+    private static final class LocalGenReplace {}
+    private static final class LocalGenWeak {}
+    private static final class WeakOnlyHost {}
+    private static final class ClearedRefHost {}
+    private static final class ClearedIncVersionHost {}
+    private static interface Marker {}
+    private static final class MarkerImpl implements Marker {}
+}
+
+/** Top-level host for per-instance MetaClass SwitchPoint coverage. */
+class Groovy12191PerInstHost {}
+
diff --git a/src/test/groovy/org/apache/groovy/runtime/indy/SwitchPointInvalidatorTest.groovy b/src/test/groovy/org/apache/groovy/runtime/indy/SwitchPointInvalidatorTest.groovy
new file mode 100644
index 0000000000..4d2caeb4f6
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/runtime/indy/SwitchPointInvalidatorTest.groovy
@@ -0,0 +1,209 @@
+/*
+ *  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.runtime.indy
+
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.SwitchPoint
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.CyclicBarrier
+import java.util.concurrent.atomic.AtomicInteger
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertNotSame
+import static org.junit.jupiter.api.Assertions.assertNull
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Unit tests for {@link SwitchPointInvalidator}.
+ */
+final class SwitchPointInvalidatorTest {
+
+    @Test
+    void lazyAllocation_returnsValidSwitchPointOnFirstUse() {
+        def inv = new SwitchPointInvalidator()
+        SwitchPoint sp = inv.switchPoint
+        assertFalse(sp.hasBeenInvalidated())
+        assertSame(sp, inv.switchPoint)
+        assertEquals(0, inv.retirementCount)
+    }
+
+    @Test
+    void invalidate_withoutPriorGet_isSafe_andDoesNotCount() {
+        def inv = new SwitchPointInvalidator()
+        inv.invalidate()
+        assertEquals(0, inv.retirementCount)
+        SwitchPoint sp = inv.switchPoint
+        assertFalse(sp.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidate_invalidatesCapturedSwitchPoint_andAllocatesFresh() {
+        def inv = new SwitchPointInvalidator()
+        SwitchPoint first = inv.switchPoint
+        inv.invalidate()
+        assertTrue(first.hasBeenInvalidated())
+        assertEquals(1, inv.retirementCount)
+        SwitchPoint second = inv.switchPoint
+        assertNotSame(first, second)
+        assertFalse(second.hasBeenInvalidated())
+    }
+
+    @Test
+    void detachLive_returnsLiveSpWithoutInvalidating_andCountsRetirement() {
+        def inv = new SwitchPointInvalidator()
+        SwitchPoint live = inv.switchPoint
+        assertFalse(live.hasBeenInvalidated())
+        SwitchPoint detached = inv.detachLive()
+        assertSame(live, detached)
+        assertFalse(detached.hasBeenInvalidated(), 'detach must not invalidate; caller batches')
+        assertEquals(1, inv.retirementCount)
+        assertNull(inv.detachLive())
+        SwitchPoint.invalidateAll(detached)
+        assertTrue(detached.hasBeenInvalidated())
+        SwitchPoint fresh = inv.switchPoint
+        assertNotSame(live, fresh)
+        assertFalse(fresh.hasBeenInvalidated())
+    }
+
+    @Test
+    void detachLive_whenNothingLive_returnsNull() {
+        def inv = new SwitchPointInvalidator()
+        assertNull(inv.detachLive())
+        assertEquals(0, inv.retirementCount)
+    }
+
+    @Test
+    void guardWithTest_fallsBackAfterInvalidate() {
+        def inv = new SwitchPointInvalidator()
+        def target = MethodHandles.constant(int, 1)
+        def fallback = MethodHandles.constant(int, 2)
+        def guarded = inv.switchPoint.guardWithTest(target, fallback)
+        assertEquals(1, guarded.invokeWithArguments())
+        inv.invalidate()
+        assertEquals(2, guarded.invokeWithArguments())
+    }
+
+    @Test
+    void concurrentGetAndInvalidate_neverLeavesOrphanValidSp() {
+        def inv = new SwitchPointInvalidator()
+        int threads = 8
+        int rounds = 200
+        def start = new CyclicBarrier(threads)
+        def errors = new AtomicInteger()
+        def workers = (0..<threads).collect { i ->
+            Thread.start {
+                start.await()
+                rounds.times { r ->
+                    try {
+                        if ((i + r) % 3 == 0) {
+                            inv.invalidate()
+                        } else {
+                            SwitchPoint sp = inv.switchPoint
+                            // After a concurrent invalidate, a previously captured SP may
+                            // be invalid; a freshly returned SP from get must be valid.
+                            if (!sp.hasBeenInvalidated()) {
+                                // ok
+                            }
+                        }
+                    } catch (Throwable t) {
+                        errors.incrementAndGet()
+                    }
+                }
+            }
+        }
+        workers*.join()
+        assertEquals(0, errors.get())
+        SwitchPoint after = inv.switchPoint
+        assertFalse(after.hasBeenInvalidated())
+    }
+
+    @Test
+    void concurrentDetach_onlyOneThreadReceivesSp() {
+        def inv = new SwitchPointInvalidator()
+        SwitchPoint live = inv.switchPoint
+        int threads = 4
+        def start = new CountDownLatch(1)
+        def got = Collections.synchronizedList([])
+        def workers = (0..<threads).collect {
+            Thread.start {
+                start.await()
+                SwitchPoint d = inv.detachLive()
+                if (d != null) {
+                    got << d
+                }
+            }
+        }
+        start.countDown()
+        workers*.join()
+        assertEquals(1, got.size())
+        assertSame(live, got[0])
+        assertEquals(1, inv.retirementCount)
+    }
+
+    @Test
+    void invalidateIfLive_null_isNoOp() {
+        SwitchPointInvalidator.invalidateIfLive(null)
+    }
+
+    @Test
+    void invalidateIfLive_alreadyInvalidated_isNoOp() {
+        def inv = new SwitchPointInvalidator()
+        SwitchPoint sp = inv.switchPoint
+        inv.invalidate()
+        assertTrue(sp.hasBeenInvalidated())
+        // second call must not throw; covers !hasBeenInvalidated() false branch
+        SwitchPointInvalidator.invalidateIfLive(sp)
+    }
+
+    @Test
+    void invalidate_afterDetach_passesNullToInvalidateIfLive() {
+        def inv = new SwitchPointInvalidator()
+        inv.switchPoint
+        inv.detachLive()
+        inv.invalidate() // detachLive returns null → invalidateIfLive(null)
+        assertEquals(1, inv.retirementCount)
+    }
+
+    @Test
+    void concurrentGet_retryOnCasLoss() {
+        // Hammer getSwitchPoint from many threads so some lose the CAS and retry.
+        def inv = new SwitchPointInvalidator()
+        inv.invalidate() // ensure current is null
+        int threads = 16
+        def start = new CyclicBarrier(threads)
+        def seen = Collections.synchronizedSet([] as Set)
+        def workers = (0..<threads).collect {
+            Thread.start {
+                start.await()
+                50.times {
+                    seen << inv.switchPoint
+                }
+            }
+        }
+        workers*.join()
+        // All threads must observe a non-null valid SP; typically a single identity
+        // after the first successful CAS, but concurrent invalidate is not used here.
+        assertFalse(seen.isEmpty())
+        seen.each { SwitchPoint sp -> assertFalse(sp.hasBeenInvalidated()) }
+    }
+}
diff --git a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy
new file mode 100644
index 0000000000..f24f9596d1
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy
@@ -0,0 +1,164 @@
+/*
+ *  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 org.apache.groovy.runtime.indy.IndyInvalidation
+import org.codehaus.groovy.reflection.CachedMethod
+import org.codehaus.groovy.reflection.ClassInfo
+import org.codehaus.groovy.vmplugin.VMPluginFactory
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.MethodType
+import java.lang.invoke.SwitchPoint
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertNotSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Coverage for GROOVY-12191 wiring in {@link IndyInterface} and package-local
+ * cold-tier validity (same package as production classes under test).
+ */
+final class IndyScopedSwitchPointTest {
+
+    @Test
+    void registryListener_withClass_invalidatesHierarchy() {
+        // Ensure the MetaClass registry listener path (type != null) runs.
+        SwitchPoint sp = ClassInfo.getClassInfo(RegistryHost).indySwitchPoint
+        def mc = new ExpandoMetaClass(RegistryHost, true, true)
+        mc.initialize()
+        GroovySystem.metaClassRegistry.setMetaClass(RegistryHost, mc)
+        assertTrue(sp.hasBeenInvalidated())
+        GroovySystem.metaClassRegistry.removeMetaClass(RegistryHost)
+    }
+
+    @Test
+    void indyLogging_enabled_inChildProcess_coversLogBranches() {
+        // LOG_ENABLED is frozen at IndyInterface class init — cover the logging
+        // branches in a fresh JVM with -Dgroovy.indy.logging=true.
+        def javaBin = System.getProperty('java.home') + '/bin/java'
+        def cp = System.getProperty('java.class.path')
+        def pb = new ProcessBuilder(
+                javaBin,
+                '-Dgroovy.indy.logging=true',
+                '-cp', cp,
+                'org.codehaus.groovy.vmplugin.v8.IndyLoggingProbe')
+        pb.redirectErrorStream(true)
+        def proc = pb.start()
+        def out = proc.inputStream.text
+        def code = proc.waitFor()
+        assertEquals(0, code, "probe failed: $out")
+        assertTrue(out.contains('OK'), out)
+    }
+
+    @Test
+    void applyMopSwitchPoints_installsClassGuard() {
+        def target = MethodHandles.constant(int, 1)
+        def fallback = MethodHandles.constant(int, 2)
+        def receiver = new ApplyHost()
+        def guarded = IndyInterface.applyMopSwitchPoints(target, fallback, receiver)
+        assertEquals(1, guarded.invokeWithArguments())
+        IndyInvalidation.invalidateClass(ApplyHost)
+        assertEquals(2, guarded.invokeWithArguments())
+    }
+
+    @Test
+    void invalidateSwitchPoints_bulkInvalidatesAndRotatesLegacyField() {
+        SwitchPoint classSp = ClassInfo.getClassInfo(LegacyHost).indySwitchPoint
+        SwitchPoint legacyBefore = IndyInterface.switchPoint
+        assertFalse(classSp.hasBeenInvalidated())
+
+        IndyInterface.invalidateSwitchPoints()
+
+        assertTrue(classSp.hasBeenInvalidated())
+        assertNotSame(legacyBefore, IndyInterface.switchPoint)
+        assertTrue(legacyBefore.hasBeenInvalidated())
+        assertFalse(IndyInterface.switchPoint.hasBeenInvalidated())
+    }
+
+    @Test
+    void vmPlugin_invalidateCallSites_routesToScopedCategoryPath() {
+        SwitchPoint classSp = ClassInfo.getClassInfo(PluginHost).indySwitchPoint
+        VMPluginFactory.plugin.invalidateCallSites()
+        assertTrue(classSp.hasBeenInvalidated())
+    }
+
+    @Test
+    void registryListener_unscoped_path_viaInvalidateUnscoped() {
+        SwitchPoint sp = ClassInfo.getClassInfo(UnscopedHost).indySwitchPoint
+        // Null-class registry events call IndyInvalidation.invalidateUnscoped().
+        IndyInvalidation.invalidateUnscoped()
+        assertTrue(sp.hasBeenInvalidated())
+    }
+
+    @Test
+    void coldReflective_isValidFor_falseWhenSwitchPointInvalidated() {
+        def sp = ClassInfo.getClassInfo(ColdHost).indySwitchPoint
+        def cold = newColdWrapper(sp)
+        def args = [new ColdHost()] as Object[]
+        assertTrue(cold.isValidFor(args))
+        SwitchPoint.invalidateAll(sp)
+        assertFalse(cold.isValidFor(args))
+    }
+
+    @Test
+    void coldReflective_isValidFor_falseOnArityMismatch() {
+        def sp = ClassInfo.getClassInfo(ColdHost).indySwitchPoint
+        def cold = newColdWrapper(sp)
+        assertFalse(cold.isValidFor([new ColdHost(), 'extra'] as Object[]))
+    }
+
+    @Test
+    void coldReflective_isValidFor_falseOnClassMismatch() {
+        def sp = ClassInfo.getClassInfo(ColdHost).indySwitchPoint
+        def cold = newColdWrapper(sp)
+        assertFalse(cold.isValidFor(['not-a-ColdHost'] as Object[]))
+    }
+
+    /**
+     * Minimal cold wrapper for validity tests (same package as production class).
+     */
+    private static ColdReflectiveMethodHandleWrapper newColdWrapper(SwitchPoint sp) {
+        def javaMethod = ColdHost.getDeclaredMethod('ping')
+        def metaMethod = CachedMethod.find(javaMethod)
+        assert metaMethod != null
+        def ctor = ColdReflectiveMethodHandleWrapper.getDeclaredConstructor(
+                groovy.lang.MetaMethod, CacheableCallSite, Class, String, int,
+                Boolean, Boolean, Boolean, groovy.lang.MetaClass, SwitchPoint, Class[])
+        ctor.accessible = true
+        def type = MethodType.methodType(Object, Object)
+        def site = new CacheableCallSite(type, MethodHandles.lookup())
+        def mc = GroovySystem.metaClassRegistry.getMetaClass(ColdHost)
+        (ColdReflectiveMethodHandleWrapper) ctor.newInstance(
+                metaMethod, site, ColdHost, 'ping', IndyInterface.CallType.METHOD.orderNumber,
+                Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, mc, sp, [ColdHost] as Class[])
+    }
+
+    static class ApplyHost {}
+    static class LegacyHost {}
+    static class PluginHost {}
+    static class UnscopedHost {}
+    static class RegistryHost {}
+    /** Java-style host so {@link CachedMethod#find} is straightforward. */
+    static class ColdHost {
+        String ping() { 'ok' }
+    }
+}
diff --git a/src/test/java/org/apache/groovy/runtime/indy/IndyInvalidationStatsProbe.java b/src/test/java/org/apache/groovy/runtime/indy/IndyInvalidationStatsProbe.java
new file mode 100644
index 0000000000..3e4822d117
--- /dev/null
+++ b/src/test/java/org/apache/groovy/runtime/indy/IndyInvalidationStatsProbe.java
@@ -0,0 +1,51 @@
+/*
+ *  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.runtime.indy;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Child-process probe for {@code -Dgroovy.indy.invalidation.stats=true} log branches.
+ * Invoked from {@link IndyInvalidationTest}.
+ */
+public final class IndyInvalidationStatsProbe {
+
+    private IndyInvalidationStatsProbe() {
+    }
+
+    public static void main(final String[] args) {
+        Logger log = Logger.getLogger(IndyInvalidation.class.getName());
+        log.setLevel(Level.FINE);
+        // Ensure a parent handler does not filter FINE.
+        Logger root = Logger.getLogger("");
+        root.setLevel(Level.FINE);
+        for (var h : root.getHandlers()) {
+            h.setLevel(Level.FINE);
+        }
+
+        IndyInvalidation.invalidateClass(ProbeHost.class);
+        IndyInvalidation.invalidateCategory();
+        IndyInvalidation.invalidateUnscoped();
+        System.out.println("OK");
+    }
+
+    public static final class ProbeHost {
+    }
+}
diff --git a/src/test/java/org/codehaus/groovy/vmplugin/v8/IndyLoggingProbe.java b/src/test/java/org/codehaus/groovy/vmplugin/v8/IndyLoggingProbe.java
new file mode 100644
index 0000000000..6829d6c520
--- /dev/null
+++ b/src/test/java/org/codehaus/groovy/vmplugin/v8/IndyLoggingProbe.java
@@ -0,0 +1,62 @@
+/*
+ *  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.GroovySystem;
+import groovy.lang.MetaClassRegistryChangeEvent;
+import org.codehaus.groovy.reflection.ClassInfo;
+
+/**
+ * Child-process probe for {@code -Dgroovy.indy.logging=true} branches
+ * (GROOVY-12191). Invoked only from {@link IndyScopedSwitchPointTest}.
+ */
+public final class IndyLoggingProbe {
+
+    private IndyLoggingProbe() {
+    }
+
+    public static void main(final String[] args) {
+        if (!IndyInterface.LOG_ENABLED) {
+            System.err.println("LOG_ENABLED expected true");
+            System.exit(2);
+        }
+        // type != null listener log path
+        ClassInfo.getClassInfo(ProbeHost.class).getIndySwitchPoint();
+        GroovySystem.getMetaClassRegistry().setMetaClass(
+                ProbeHost.class,
+                GroovySystem.getMetaClassRegistry().getMetaClass(ProbeHost.class));
+        // category / invalidateCallSites log path
+        IndyInterface.invalidateSwitchPoints();
+        // unscoped log path via synthetic null-class event. Other registry listeners
+        // may NPE on a null Class; the IndyInterface listener must still run.
+        MetaClassRegistryChangeEvent event = new MetaClassRegistryChangeEvent(
+                GroovySystem.getMetaClassRegistry(), null, null, null, null);
+        for (var listener : GroovySystem.getMetaClassRegistry().getMetaClassRegistryChangeEventListeners()) {
+            try {
+                listener.updateConstantMetaClass(event);
+            } catch (Throwable ignore) {
+                // non-indy listeners may reject null class
+            }
+        }
+        System.out.println("OK");
+    }
+
+    public static final class ProbeHost {
+    }
+}
diff --git a/subprojects/performance/README.adoc b/subprojects/performance/README.adoc
index ad8ebc47ec..50f5d476aa 100644
--- a/subprojects/performance/README.adoc
+++ b/subprojects/performance/README.adoc
@@ -134,6 +134,15 @@ threshold (`groovy.indy.optimize.threshold`, default 1000). Static and
 instance dispatch take different cold paths (static sites promote on first
 hit, GROOVY-11935), hence the two benches.
 
+== Scoped invalidation (GROOVY-12191)
+
+`ScopedInvalidationBench` measures per-class SwitchPoint scoping: unrelated
+MetaClass churn must not deoptimize a hot monomorphic site; same-type re-link,
+category enter/leave (bulk class-domain invalidation), and hierarchy fan-out
+must still force re-link. Steady-state monomorphic sites carry a single
+SwitchPoint guard. Complements the Grails-oriented `CallSiteInvalidationBench`
+under `org.apache.groovy.perf.grails`.
+
 == Dashboard pages
 
 The `dashboard/` directory is the source of truth for the hand-crafted pages
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy
new file mode 100644
index 0000000000..340109db61
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy
@@ -0,0 +1,273 @@
+/*
+ *  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.bench
+
+import groovy.lang.GroovySystem
+
+import org.openjdk.jmh.annotations.Benchmark
+import org.openjdk.jmh.annotations.BenchmarkMode
+import org.openjdk.jmh.annotations.Fork
+import org.openjdk.jmh.annotations.Level
+import org.openjdk.jmh.annotations.Measurement
+import org.openjdk.jmh.annotations.Mode
+import org.openjdk.jmh.annotations.OutputTimeUnit
+import org.openjdk.jmh.annotations.Scope
+import org.openjdk.jmh.annotations.Setup
+import org.openjdk.jmh.annotations.State
+import org.openjdk.jmh.annotations.Warmup
+import org.openjdk.jmh.infra.Blackhole
+
+import java.util.concurrent.TimeUnit
+
+/**
+ * Steady-state cost of scoped invokedynamic SwitchPoint invalidation (GROOVY-12191).
+ * <p>
+ * Complements the Grails-oriented {@code CallSiteInvalidationBench} (AverageTime,
+ * grails suite) with a bench-suite Throughput regression set for the single
+ * per-class SwitchPoint domain. Baseline / cross-type / same-type rows
+ * intentionally overlap that bench so ratios are available under the same
+ * {@code .bench.} include pattern; unique coverage here is category enter/leave
+ * (bulk class-domain invalidation), hierarchy fan-out, and setup-isolated
+ * post-burst steady state:
+ * <ul>
+ *   <li><b>Per-class</b> — MetaClass changes on an unrelated type must not
+ *       deoptimize a hot monomorphic site;</li>
+ *   <li><b>Same-type</b> — MetaClass changes on the hot type must re-link;</li>
+ *   <li><b>Category</b> — {@code use(Category)} enter/leave bulk-invalidates
+ *       class SwitchPoints so category methods become visible;</li>
+ *   <li><b>Hierarchy fan-out</b> — parent MetaClass changes invalidate
+ *       subclass-linked sites.</li>
+ * </ul>
+ * Compare {@link #hotLoop_afterUnrelatedBurst} / {@link #hotLoop_unrelatedMetaClassChurn}
+ * against {@link #hotLoop_baseline} (should stay close after scoping) and against
+ * {@link #hotLoop_sameTypeMetaClassChurn} (must pay re-link cost).
+ *
+ * @see <a href="https://issues.apache.org/jira/browse/GROOVY-12191">GROOVY-12191</a>
+ */
+@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@State(Scope.Thread)
+class ScopedInvalidationBench {
+
+    /** Iterations of the hot call loop per benchmark invocation. */
+    static final int ITERATIONS = 100_000
+
+    /** How often to perform a MetaClass mutation inside the churn loops. */
+    static final int CHURN_EVERY = 1_000
+
+    /**
+     * Hot monomorphic call-site receiver.
+     */
+    static class Hot {
+        int value = 42
+        int compute() { value * 2 }
+    }
+
+    /**
+     * Unrelated type used only for cross-type MetaClass churn.
+     */
+    static class Unrelated {
+        String label = 'cold'
+    }
+
+    /**
+     * Parent of {@link HierChild} for hierarchy fan-out churn.
+     */
+    static class HierParent {
+        int base() { 1 }
+    }
+
+    /**
+     * Subclass receiver whose SwitchPoint must clear when the parent MetaClass changes.
+     */
+    static class HierChild extends HierParent {
+        int leaf() { 2 }
+    }
+
+    /**
+     * Category that decorates {@link Hot#compute()}.
+     */
+    static class HotCategory {
+        static int compute(Hot self) { self.value * 3 }
+    }
+
+    Hot hot
+    HierChild child
+
+    /**
+     * Resets MetaClasses and allocates fresh receivers each iteration so
+     * SwitchPoint state from a previous sample does not bleed into the next.
+     */
+    @Setup(Level.Iteration)
+    void setup() {
+        GroovySystem.metaClassRegistry.removeMetaClass(Hot)
+        GroovySystem.metaClassRegistry.removeMetaClass(Unrelated)
+        GroovySystem.metaClassRegistry.removeMetaClass(HierParent)
+        GroovySystem.metaClassRegistry.removeMetaClass(HierChild)
+        hot = new Hot()
+        child = new HierChild()
+    }
+
+    /**
+     * Steady-state monomorphic calls with no MetaClass mutation.
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void hotLoop_baseline(Blackhole bh) {
+        int sum = 0
+        for (int i = 0; i < ITERATIONS; i++) {
+            sum += hot.compute()
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Hot monomorphic loop with MetaClass changes on an <em>unrelated</em> type.
+     * With scoped invalidation the hot site should keep its linked target;
+     * residual cost vs baseline is mostly MetaClass mutation work, not deopt.
+     * Prefer {@link #hotLoop_afterUnrelatedBurst} for a pure no-deopt check.
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void hotLoop_unrelatedMetaClassChurn(Blackhole bh) {
+        int sum = 0
+        for (int i = 0; i < ITERATIONS; i++) {
+            sum += hot.compute()
+            if (i % CHURN_EVERY == 0) {
+                Unrelated.metaClass."dyn${i % 5}" = { -> i }
+            }
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Pure hot loop after a burst of unrelated MetaClass changes performed in
+     * {@link AfterUnrelatedBurstState#setup()}. After scoping (GROOVY-12191),
+     * this should approach {@link #hotLoop_baseline}.
+     * @param state state that applied the unrelated MetaClass burst
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void hotLoop_afterUnrelatedBurst(AfterUnrelatedBurstState state, Blackhole bh) {
+        int sum = 0
+        Hot h = state.hot
+        for (int i = 0; i < ITERATIONS; i++) {
+            sum += h.compute()
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Hot monomorphic loop with MetaClass changes on the <em>same</em> type —
+     * each change must retire the class-domain SwitchPoint and re-link.
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void hotLoop_sameTypeMetaClassChurn(Blackhole bh) {
+        int sum = 0
+        for (int i = 0; i < ITERATIONS; i++) {
+            sum += hot.compute()
+            if (i % CHURN_EVERY == 0) {
+                Hot.metaClass."dyn${i % 5}" = { -> i }
+            }
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Category enter/leave interleaved with hot calls — exercises the
+     * process-wide category SwitchPoint domain (never fails over).
+     * Uses a modest number of {@code use} blocks so category re-link cost
+     * is visible without dominating wall-clock.
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void hotLoop_categoryEnterLeave(Blackhole bh) {
+        int sum = 0
+        int callsPerUse = ITERATIONS / 10
+        for (int b = 0; b < 10; b++) {
+            use(HotCategory) {
+                for (int i = 0; i < callsPerUse; i++) {
+                    sum += hot.compute()
+                }
+            }
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Subclass receiver calls with periodic parent MetaClass changes (hierarchy
+     * fan-out must invalidate the child SwitchPoint).
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void hierarchy_parentMetaClassChurn(Blackhole bh) {
+        int sum = 0
+        for (int i = 0; i < ITERATIONS; i++) {
+            sum += child.leaf()
+            sum += child.base()
+            if (i % CHURN_EVERY == 0) {
+                HierParent.metaClass."dyn${i % 5}" = { -> i }
+            }
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Subclass receiver calls with no MetaClass mutation (hierarchy baseline).
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void hierarchy_baseline(Blackhole bh) {
+        int sum = 0
+        for (int i = 0; i < ITERATIONS; i++) {
+            sum += child.leaf()
+            sum += child.base()
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Iteration state for {@link ScopedInvalidationBench#hotLoop_afterUnrelatedBurst}:
+     * resets MetaClasses, then applies a burst of unrelated MetaClass mutations
+     * <em>outside</em> the measured loop so residual deopt (if any) is isolated.
+     */
+    @State(Scope.Thread)
+    static class AfterUnrelatedBurstState {
+        /** Hot receiver used by the measured loop. */
+        Hot hot
+
+        /**
+         * Resets MetaClasses, allocates a fresh hot receiver, and applies
+         * unrelated MetaClass churn before the benchmark body runs.
+         */
+        @Setup(Level.Iteration)
+        void setup() {
+            GroovySystem.metaClassRegistry.removeMetaClass(Hot)
+            GroovySystem.metaClassRegistry.removeMetaClass(Unrelated)
+            hot = new Hot()
+            for (int i = 0; i < 50; i++) {
+                Unrelated.metaClass."startup${i % 10}" = { -> i }
+            }
+        }
+    }
+}