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

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

commit fec1121305d962ff49e862c37d753c02e408a0dc
Author: Daniel Sun <[email protected]>
AuthorDate: Sun Mar 22 21:44:45 2026 +0900

    GROOVY-9381: add ScopedLocal
---
 src/main/java/groovy/concurrent/AsyncContext.java  |  30 +-
 src/main/java/groovy/concurrent/AsyncScope.java    |  34 +-
 .../apache/groovy/runtime/async/ScopedLocal.java   | 525 ++++++++++++++
 src/spec/doc/core-async-await.adoc                 |  55 +-
 .../groovy/runtime/async/ScopedLocalTest.groovy    | 806 +++++++++++++++++++++
 5 files changed, 1408 insertions(+), 42 deletions(-)

diff --git a/src/main/java/groovy/concurrent/AsyncContext.java 
b/src/main/java/groovy/concurrent/AsyncContext.java
index de9cd0d0dd..89f46b0c73 100644
--- a/src/main/java/groovy/concurrent/AsyncContext.java
+++ b/src/main/java/groovy/concurrent/AsyncContext.java
@@ -20,6 +20,8 @@ package groovy.concurrent;
 
 import groovy.lang.Closure;
 
+import org.apache.groovy.runtime.async.ScopedLocal;
+
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -50,21 +52,24 @@ import java.util.function.Supplier;
  * {@code null} as a value removes the key.
  *
  * <h2>Thread safety</h2>
- * Each thread owns its own {@code AsyncContext} instance via a
- * {@link ThreadLocal}.  Instance methods ({@link #put}, {@link #get},
+ * Each thread owns its own {@code AsyncContext} instance via
+ * {@link ScopedLocal}.  On JDK&nbsp;25+, this leverages
+ * {@code ScopedValue} for optimal virtual-thread performance;
+ * on earlier JDKs it falls back to {@code ThreadLocal}.
+ * Instance methods ({@link #put}, {@link #get},
  * {@link #remove}) are <em>not</em> synchronized — they are only called
  * on the owning thread.  Static methods ({@link #withSnapshot},
- * {@link #capture}) follow a save-and-restore pattern protected by
- * {@code try/finally}, ensuring the previous context is always reinstated
- * even if the action throws.  This design prevents stale context from
- * leaking to thread-pool threads between task executions.
+ * {@link #capture}) use a scope-based binding pattern, ensuring the
+ * previous context is always reinstated even if the action throws.
+ * This design prevents stale context from leaking to thread-pool
+ * threads between task executions.
  *
  * @since 6.0.0
  */
 public final class AsyncContext {
 
-    private static final ThreadLocal<AsyncContext> CURRENT =
-            ThreadLocal.withInitial(AsyncContext::new);
+    private static final ScopedLocal<AsyncContext> CURRENT =
+            ScopedLocal.withInitial(AsyncContext::new);
 
     private final Map<String, Object> values;
 
@@ -120,13 +125,8 @@ public final class AsyncContext {
     public static <T> T withSnapshot(Snapshot snapshot, Supplier<T> supplier) {
         Objects.requireNonNull(snapshot, "snapshot must not be null");
         Objects.requireNonNull(supplier, "supplier must not be null");
-        AsyncContext previous = CURRENT.get();
-        CURRENT.set(new AsyncContext(new LinkedHashMap<>(snapshot.values), 
true));
-        try {
-            return supplier.get();
-        } finally {
-            CURRENT.set(previous);
-        }
+        AsyncContext restored = new AsyncContext(new 
LinkedHashMap<>(snapshot.values), true);
+        return CURRENT.where(restored, supplier);
     }
 
     /**
diff --git a/src/main/java/groovy/concurrent/AsyncScope.java 
b/src/main/java/groovy/concurrent/AsyncScope.java
index 9cb90ca2f3..ddad01468d 100644
--- a/src/main/java/groovy/concurrent/AsyncScope.java
+++ b/src/main/java/groovy/concurrent/AsyncScope.java
@@ -21,6 +21,7 @@ package groovy.concurrent;
 import groovy.lang.Closure;
 import org.apache.groovy.runtime.async.AsyncSupport;
 import org.apache.groovy.runtime.async.GroovyPromise;
+import org.apache.groovy.runtime.async.ScopedLocal;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -74,12 +75,13 @@ import java.util.function.Supplier;
  * {@link #async(Closure)} and {@link #close()} cannot race: no child
  * can be registered after the scope is marked closed.</p>
  *
- * <h2>ThreadLocal management</h2>
- * <p>The {@link #withCurrent(AsyncScope, Supplier)} method uses a
- * save-and-restore pattern with a {@code try}/{@code finally} block,
- * guaranteeing that the previous scope binding is restored even if
- * the supplier throws.  Thread-pool threads therefore never retain
- * stale scope references across task boundaries.</p>
+ * <h2>Thread-scoped state management</h2>
+ * <p>The {@link #withCurrent(AsyncScope, Supplier)} method uses
+ * {@link ScopedLocal} to manage the current scope binding.  On
+ * JDK&nbsp;25+, this automatically leverages {@code ScopedValue} for
+ * optimal virtual-thread performance; on earlier JDKs it falls back to
+ * a {@code ThreadLocal} with a save-and-restore pattern inside
+ * {@code try}/{@code finally} blocks.</p>
  *
  * @see Awaitable
  * @see AsyncSupport
@@ -87,7 +89,7 @@ import java.util.function.Supplier;
  */
 public class AsyncScope implements AutoCloseable {
 
-    private static final ThreadLocal<AsyncScope> CURRENT = new ThreadLocal<>();
+    private static final ScopedLocal<AsyncScope> CURRENT = 
ScopedLocal.newInstance();
 
     /**
      * Pruning threshold: completed children are purged when the list
@@ -137,7 +139,7 @@ public class AsyncScope implements AutoCloseable {
      * @return the current scope, or {@code null}
      */
     public static AsyncScope current() {
-        return CURRENT.get();
+        return CURRENT.orElse(null);
     }
 
     /**
@@ -151,21 +153,7 @@ public class AsyncScope implements AutoCloseable {
      */
     public static <T> T withCurrent(AsyncScope scope, Supplier<T> supplier) {
         Objects.requireNonNull(supplier, "supplier must not be null");
-        AsyncScope previous = CURRENT.get();
-        if (scope == null) {
-            CURRENT.remove();
-        } else {
-            CURRENT.set(scope);
-        }
-        try {
-            return supplier.get();
-        } finally {
-            if (previous == null) {
-                CURRENT.remove();
-            } else {
-                CURRENT.set(previous);
-            }
-        }
+        return CURRENT.where(scope, supplier);
     }
 
     /**
diff --git a/src/main/java/org/apache/groovy/runtime/async/ScopedLocal.java 
b/src/main/java/org/apache/groovy/runtime/async/ScopedLocal.java
new file mode 100644
index 0000000000..4275ef93a3
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/async/ScopedLocal.java
@@ -0,0 +1,525 @@
+/*
+ *  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.async;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * A thread-scoped value holder that abstracts over {@link ThreadLocal}
+ * (JDK&nbsp;&lt;&nbsp;25) and {@code java.lang.ScopedValue} (JDK&nbsp;25+),
+ * presenting a unified API modelled after {@code ScopedValue}.
+ *
+ * <h2>Backend selection</h2>
+ * <p>On JDK&nbsp;25+, where {@code java.lang.ScopedValue} is available,
+ * this class delegates to the {@code ScopedValue} API, which is optimized
+ * for virtual threads: bindings are automatically inherited by child
+ * virtual threads, require no explicit cleanup, and impose lower
+ * per-thread memory overhead than {@code ThreadLocal}.</p>
+ * <p>On earlier JDK versions, the implementation falls back to a
+ * conventional {@code ThreadLocal} with a save-and-restore pattern
+ * inside {@code try}/{@code finally} blocks.</p>
+ *
+ * <h2>API overview</h2>
+ * <p>The API mirrors {@code ScopedValue} as closely as possible:</p>
+ * <ul>
+ *   <li>{@link #get()}, {@link #orElse(Object)}, {@link #isBound()} —
+ *       query the current binding.</li>
+ *   <li>{@link #where(ScopedLocal, Object)} — create a {@link Carrier}
+ *       that can bind one or more values for a scoped execution.</li>
+ *   <li>{@link Carrier#run(Runnable)}, {@link Carrier#call(Supplier)} —
+ *       execute code with the bindings active.</li>
+ * </ul>
+ *
+ * <h2>Usage</h2>
+ * <pre>{@code
+ * private static final ScopedLocal<String> REQUEST_ID =
+ *         ScopedLocal.newInstance();
+ *
+ * // Single binding:
+ * ScopedLocal.where(REQUEST_ID, "req-42").run(() -> {
+ *     assert "req-42".equals(REQUEST_ID.get());
+ * });
+ *
+ * // Chained bindings:
+ * ScopedLocal.where(REQUEST_ID, "req-1")
+ *            .where(TENANT_ID, "acme")
+ *            .call(() -> handleRequest());
+ *
+ * // Convenience instance methods:
+ * String result = REQUEST_ID.where("req-7", () -> process());
+ *
+ * // With an initial-value supplier:
+ * private static final ScopedLocal<MyCtx> CTX =
+ *         ScopedLocal.withInitial(MyCtx::new);
+ * }</pre>
+ *
+ * @param <T> the type of the scoped value
+ * @since 6.0.0
+ */
+public abstract class ScopedLocal<T> {
+    private static final boolean SCOPED_VALUE_AVAILABLE;
+    private static final MethodHandle SV_NEW_INSTANCE;   // () → Object  
[adapted from ScopedValue.newInstance()]
+    private static final MethodHandle SV_WHERE;          // (Object, Object) → 
Object  [adapted from ScopedValue.where(SV, Object)]
+    private static final MethodHandle SV_GET;            // (Object) → Object  
[adapted from scopedValue.get()]
+    private static final MethodHandle SV_IS_BOUND;       // (Object) → boolean 
 [adapted from scopedValue.isBound()]
+    private static final MethodHandle CARRIER_RUN;       // (Object, Runnable) 
→ void  [adapted from carrier.run(Runnable)]
+
+    static {
+        boolean available = false;
+        MethodHandle newInstance = null, where = null, get = null;
+        MethodHandle isBound = null;
+        MethodHandle carrierRun = null;
+
+        // ScopedValue is available as a preview API since JDK 21, but
+        // was only finalized (non-preview) in JDK 25.  We require the
+        // finalized version to avoid depending on preview semantics
+        // that may change between releases.
+        if (Runtime.version().feature() >= 25) {
+            try {
+                Class<?> svClass = Class.forName("java.lang.ScopedValue");
+                Class<?> carrierCls = 
Class.forName("java.lang.ScopedValue$Carrier");
+                MethodHandles.Lookup lookup = MethodHandles.publicLookup();
+
+                // Look up the raw handles, then adapt their types so that
+                // every ScopedValue/Carrier parameter and return type is
+                // erased to Object.  This allows call sites to use
+                // invokeExact — which the JVM can inline aggressively —
+                // without needing compile-time access to the ScopedValue 
class.
+                newInstance = lookup.findStatic(svClass, "newInstance",
+                        MethodType.methodType(svClass))
+                        .asType(MethodType.methodType(Object.class));
+
+                where = lookup.findStatic(svClass, "where",
+                        MethodType.methodType(carrierCls, svClass, 
Object.class))
+                        .asType(MethodType.methodType(Object.class, 
Object.class, Object.class));
+
+                get = lookup.findVirtual(svClass, "get",
+                        MethodType.methodType(Object.class))
+                        .asType(MethodType.methodType(Object.class, 
Object.class));
+
+                isBound = lookup.findVirtual(svClass, "isBound",
+                        MethodType.methodType(boolean.class))
+                        .asType(MethodType.methodType(boolean.class, 
Object.class));
+
+                carrierRun = lookup.findVirtual(carrierCls, "run",
+                        MethodType.methodType(void.class, Runnable.class))
+                        .asType(MethodType.methodType(void.class, 
Object.class, Runnable.class));
+
+                available = true;
+            } catch (Throwable t) {
+                final Logger logger = 
Logger.getLogger(ScopedLocal.class.getName());
+                if (logger.isLoggable(Level.FINE)) {
+                    logger.fine("ScopedValue not available on JDK "
+                            + Runtime.version().feature()
+                            + "; using ThreadLocal fallback: " + t);
+                }
+            }
+        }
+
+        SCOPED_VALUE_AVAILABLE = available;
+        SV_NEW_INSTANCE = newInstance;
+        SV_WHERE = where;
+        SV_GET = get;
+        SV_IS_BOUND = isBound;
+        CARRIER_RUN = carrierRun;
+    }
+
+    // ---- Factory methods ----
+
+    /**
+     * Creates a new {@code ScopedLocal} with no initial value.
+     * {@link #get()} throws {@link NoSuchElementException} when no
+     * binding is active; use {@link #orElse(Object)} for a safe
+     * default, or check {@link #isBound()} first.
+     *
+     * @param <T> the value type
+     * @return a new unbound scoped-local instance
+     */
+    public static <T> ScopedLocal<T> newInstance() {
+        if (SCOPED_VALUE_AVAILABLE) {
+            return new ScopedValueImpl<>();
+        }
+        return new ThreadLocalImpl<>();
+    }
+
+    /**
+     * Creates a new {@code ScopedLocal} whose {@link #get()} method
+     * returns a lazily-initialized default when no explicit binding
+     * exists.  The supplier is invoked at most once per thread and
+     * the result is cached, analogous to
+     * {@link ThreadLocal#withInitial(Supplier)}.
+     *
+     * @param <T>             the value type
+     * @param initialSupplier supplies the default value; must not be
+     *                        {@code null}
+     * @return a new scoped-local instance with a default supplier
+     */
+    public static <T> ScopedLocal<T> withInitial(Supplier<T> initialSupplier) {
+        Objects.requireNonNull(initialSupplier, "initialSupplier must not be 
null");
+        if (SCOPED_VALUE_AVAILABLE) {
+            return new ScopedValueImpl<>(initialSupplier);
+        }
+        return new ThreadLocalImpl<>(initialSupplier);
+    }
+
+    // ---- Static binding (mirrors ScopedValue.where) ----
+
+    /**
+     * Creates a {@link Carrier} that binds {@code key} to {@code value}.
+     * The binding takes effect when
+     * {@link Carrier#run(Runnable) Carrier.run()} or
+     * {@link Carrier#call(Supplier) Carrier.call()} is invoked.
+     * Multiple bindings can be chained via
+     * {@link Carrier#where(ScopedLocal, Object)}.
+     *
+     * @param <T>   the value type
+     * @param key   the scoped-local to bind
+     * @param value the value to bind; may be {@code null}
+     * @return a carrier holding the binding
+     */
+    public static <T> Carrier where(ScopedLocal<T> key, T value) {
+        Objects.requireNonNull(key, "key must not be null");
+        return new Carrier(key, value, null);
+    }
+
+    // ---- Accessors ----
+
+    /**
+     * Returns the value bound to this scoped-local on the current
+     * thread.  If created with {@link #withInitial(Supplier)}, the
+     * initial value is returned (and cached) when no explicit binding
+     * is active.  Otherwise, throws {@link NoSuchElementException}.
+     *
+     * @return the current value
+     * @throws NoSuchElementException if no value is bound and no
+     *                                initial supplier was provided
+     */
+    public abstract T get();
+
+    /**
+     * Returns the value bound to this scoped-local, or {@code other}
+     * if no explicit binding and no initial supplier are active.
+     *
+     * @param other the fallback value
+     * @return the current value or {@code other}
+     */
+    public abstract T orElse(T other);
+
+    /**
+     * Returns {@code true} if an explicit binding is active or an
+     * initial supplier was provided.
+     *
+     * @return whether a value is available via {@link #get()}
+     */
+    public abstract boolean isBound();
+
+    // ---- Convenience instance methods ----
+
+    /**
+     * Binds this scoped-local to {@code value} for the duration of
+     * the supplier, then restores the previous binding.
+     *
+     * @param <R>      the result type
+     * @param value    the value to bind; may be {@code null}
+     * @param supplier the action to execute with the binding active
+     * @return the supplier's result
+     */
+    public <R> R where(T value, Supplier<R> supplier) {
+        return ScopedLocal.where(this, value).call(supplier);
+    }
+
+    /**
+     * Binds this scoped-local to {@code value} for the duration of
+     * the action, then restores the previous binding.
+     *
+     * @param value  the value to bind; may be {@code null}
+     * @param action the action to execute with the binding active
+     */
+    public void where(T value, Runnable action) {
+        ScopedLocal.where(this, value).run(action);
+    }
+
+    // ---- Internal ----
+
+    /**
+     * Binds this scoped-local within a scope.  Called by
+     * {@link Carrier#run(Runnable)} and {@link Carrier#call(Supplier)}.
+     */
+    abstract void bind(Object value, Runnable action);
+
+    @SuppressWarnings("unchecked")
+    static <E extends Throwable> void sneakyThrow(Throwable t) throws E {
+        throw (E) t;
+    }
+
+    // ================================================================
+    //  Carrier — mirrors ScopedValue.Carrier
+    // ================================================================
+
+    /**
+     * An immutable set of scoped-local bindings that can be applied
+     * atomically for the duration of a {@link Runnable} or
+     * {@link Supplier}.  Obtain via
+     * {@link ScopedLocal#where(ScopedLocal, Object)}.
+     *
+     * <p>Carriers are immutable; calling {@link #where(ScopedLocal, Object)}
+     * returns a new carrier that includes the additional binding.</p>
+     *
+     * @since 6.0.0
+     */
+    public static final class Carrier {
+
+        private final ScopedLocal<?> key;
+        private final Object value;
+        private final Carrier prev;
+
+        Carrier(ScopedLocal<?> key, Object value, Carrier prev) {
+            this.key = key;
+            this.value = value;
+            this.prev = prev;
+        }
+
+        /**
+         * Adds another binding to this carrier, returning a new
+         * carrier that includes all previous bindings plus the new one.
+         *
+         * @param <T>   the value type
+         * @param key   the scoped-local to bind
+         * @param value the value; may be {@code null}
+         * @return a new carrier with the additional binding
+         */
+        public <T> Carrier where(ScopedLocal<T> key, T value) {
+            Objects.requireNonNull(key, "key must not be null");
+            return new Carrier(key, value, this);
+        }
+
+        /**
+         * Executes the action with all bindings in this carrier active.
+         * Bindings are applied in the order they were added and
+         * automatically restored when the action completes (normally or
+         * via exception).
+         *
+         * @param action the action to execute
+         */
+        public void run(Runnable action) {
+            Objects.requireNonNull(action, "action must not be null");
+            execute(action);
+        }
+
+        /**
+         * Executes the supplier with all bindings in this carrier active
+         * and returns its result.
+         *
+         * @param <R>      the result type
+         * @param supplier the supplier to execute
+         * @return the supplier's result
+         */
+        public <R> R call(Supplier<R> supplier) {
+            Objects.requireNonNull(supplier, "supplier must not be null");
+            Object[] result = new Object[1];
+            execute(() -> result[0] = supplier.get());
+            @SuppressWarnings("unchecked")
+            R r = (R) result[0];
+            return r;
+        }
+
+        private void execute(Runnable action) {
+            if (prev == null) {
+                key.bind(value, action);
+            } else {
+                prev.execute(() -> key.bind(value, action));
+            }
+        }
+    }
+
+    // ================================================================
+    //  ThreadLocal-based implementation (JDK < 25)
+    // ================================================================
+
+    private static final class ThreadLocalImpl<T> extends ScopedLocal<T> {
+
+        // Distinguishes "not set" from an explicit null binding
+        private static final Object UNSET = new Object();
+        private static final Object NULL_SENTINEL = new Object();
+
+        private final ThreadLocal<Object> delegate =
+                ThreadLocal.withInitial(() -> UNSET);
+        private final Supplier<T> initialSupplier;
+
+        ThreadLocalImpl() {
+            this.initialSupplier = null;
+        }
+
+        ThreadLocalImpl(Supplier<T> supplier) {
+            this.initialSupplier = supplier;
+        }
+
+        @Override
+        @SuppressWarnings("unchecked")
+        public T get() {
+            Object val = delegate.get();
+            if (val != UNSET) {
+                return val == NULL_SENTINEL ? null : (T) val;
+            }
+            if (initialSupplier != null) {
+                T initial = initialSupplier.get();
+                // Cache per-thread (same semantics as ThreadLocal.withInitial)
+                delegate.set(initial == null ? NULL_SENTINEL : initial);
+                return initial;
+            }
+            throw new NoSuchElementException(
+                    "ScopedLocal is not bound and has no initial supplier");
+        }
+
+        @Override
+        @SuppressWarnings("unchecked")
+        public T orElse(T other) {
+            Object val = delegate.get();
+            if (val != UNSET) {
+                return val == NULL_SENTINEL ? null : (T) val;
+            }
+            if (initialSupplier != null) {
+                T initial = initialSupplier.get();
+                delegate.set(initial == null ? NULL_SENTINEL : initial);
+                return initial;
+            }
+            return other;
+        }
+
+        @Override
+        public boolean isBound() {
+            return delegate.get() != UNSET || initialSupplier != null;
+        }
+
+        @Override
+        void bind(Object value, Runnable action) {
+            Object encoded = value == null ? NULL_SENTINEL : value;
+            Object previous = delegate.get();
+            delegate.set(encoded);
+            try {
+                action.run();
+            } finally {
+                delegate.set(previous);
+            }
+        }
+    }
+
+    // ================================================================
+    //  ScopedValue-based implementation (JDK 25+)
+    // ================================================================
+
+    private static final class ScopedValueImpl<T> extends ScopedLocal<T> {
+
+        /**
+         * Sentinel for {@code null} bindings.
+         * {@code ScopedValue} does not accept {@code null} values,
+         * so we wrap/unwrap through this sentinel transparently.
+         */
+        private static final Object NULL_SENTINEL = new Object();
+
+        /** The underlying {@code ScopedValue<Object>}. */
+        private final Object scopedValue;
+
+        /** Fallback for the withInitial pattern (per-thread cache). */
+        private final ThreadLocal<T> fallback;
+
+        ScopedValueImpl() {
+            try {
+                this.scopedValue = (Object) SV_NEW_INSTANCE.invokeExact();
+            } catch (Throwable t) {
+                throw new ExceptionInInitializerError(t);
+            }
+            this.fallback = null;
+        }
+
+        ScopedValueImpl(Supplier<T> initialSupplier) {
+            try {
+                this.scopedValue = (Object) SV_NEW_INSTANCE.invokeExact();
+            } catch (Throwable t) {
+                throw new ExceptionInInitializerError(t);
+            }
+            this.fallback = ThreadLocal.withInitial(initialSupplier);
+        }
+
+        @Override
+        @SuppressWarnings("unchecked")
+        public T get() {
+            try {
+                if ((boolean) SV_IS_BOUND.invokeExact(scopedValue)) {
+                    Object val = (Object) SV_GET.invokeExact(scopedValue);
+                    return val == NULL_SENTINEL ? null : (T) val;
+                }
+            } catch (Throwable t) {
+                sneakyThrow(t);
+            }
+            if (fallback != null) {
+                return fallback.get();
+            }
+            throw new NoSuchElementException(
+                    "ScopedLocal is not bound and has no initial supplier");
+        }
+
+        @Override
+        @SuppressWarnings("unchecked")
+        public T orElse(T other) {
+            try {
+                if ((boolean) SV_IS_BOUND.invokeExact(scopedValue)) {
+                    Object val = (Object) SV_GET.invokeExact(scopedValue);
+                    return val == NULL_SENTINEL ? null : (T) val;
+                }
+            } catch (Throwable t) {
+                sneakyThrow(t);
+            }
+            if (fallback != null) {
+                return fallback.get();
+            }
+            return other;
+        }
+
+        @Override
+        public boolean isBound() {
+            try {
+                if ((boolean) SV_IS_BOUND.invokeExact(scopedValue)) {
+                    return true;
+                }
+            } catch (Throwable t) {
+                sneakyThrow(t);
+            }
+            return fallback != null;
+        }
+
+        @Override
+        void bind(Object value, Runnable action) {
+            Object encoded = value == null ? NULL_SENTINEL : value;
+            try {
+                Object carrier = (Object) SV_WHERE.invokeExact(scopedValue, 
encoded);
+                CARRIER_RUN.invokeExact(carrier, action);
+            } catch (Throwable t) {
+                sneakyThrow(t);
+            }
+        }
+    }
+}
diff --git a/src/spec/doc/core-async-await.adoc 
b/src/spec/doc/core-async-await.adoc
index f249b6060b..1e5a98287f 100644
--- a/src/spec/doc/core-async-await.adoc
+++ b/src/spec/doc/core-async-await.adoc
@@ -1094,6 +1094,46 @@ This separation means:
 * `toCompletableFuture()` is the one explicit escape hatch for interoperating 
with Java
   APIs that require a `CompletableFuture` directly.
 
+==== Thread-Scoped State: `ScopedLocal`
+
+`AsyncScope` and `AsyncContext` both need to propagate values through thread
+boundaries.  Rather than hard-coding `ThreadLocal`, Groovy uses an internal
+abstraction called `ScopedLocal` that selects the optimal backend at class-load
+time:
+
+[cols="1h,2,2",options="header"]
+|===
+| | JDK < 25 | JDK 25+
+
+| Backend
+| `ThreadLocal` with save-and-restore
+| `java.lang.ScopedValue`
+
+| Virtual-thread inheritance
+| Captured and restored manually by the runtime
+| Automatic (ScopedValue inherits into child virtual threads)
+
+| Null values
+| Supported (via internal sentinel)
+| Supported (via internal sentinel; ScopedValue itself disallows null)
+
+| Memory lifecycle
+| Removed explicitly in `finally`
+| Automatically released when the scope exits
+|===
+
+The `ScopedLocal` API mirrors `java.lang.ScopedValue` as closely as possible:
+
+* `ScopedLocal.where(key, value)` returns a `Carrier` that activates the 
binding
+  when `run()` or `call()` is invoked — identical to `ScopedValue.where()`.
+* `get()`, `orElse()`, and `isBound()` query the current binding.
+* An optional `withInitial(Supplier)` factory provides lazy per-thread 
defaults,
+  analogous to `ThreadLocal.withInitial()`.
+
+This design ensures that the runtime can take full advantage of `ScopedValue` 
on
+modern JDKs (better performance, no memory leaks, clean virtual-thread
+integration) while remaining fully functional on JDK 17–24.
+
 [[thread-safety]]
 === Thread Safety and Robustness
 
@@ -1435,9 +1475,13 @@ internal threshold (currently 64 entries), the next 
`async()` call removes all
 already-completed futures.  This keeps memory bounded for long-lived scopes 
that
 spawn many short-lived tasks.
 
-`AsyncScope` uses a `ThreadLocal` to propagate the current scope into child 
tasks.
-The runtime's save-and-restore pattern (set before task entry, restore in 
`finally`)
-guarantees that thread-pool threads never retain stale scope references.
+`AsyncScope` uses a `ScopedLocal` to propagate the current scope into child 
tasks.
+On JDK&nbsp;25+, `ScopedLocal` delegates to `java.lang.ScopedValue`, which is
+optimized for virtual threads (zero-cost inheritance, no memory leaks).
+On earlier JDK versions, a `ThreadLocal` with save-and-restore semantics is 
used
+instead.  The runtime's scoped-binding pattern (bind before task entry, 
automatic
+restore on scope exit) guarantees that thread-pool threads never retain stale 
scope
+references.
 
 [[async-context]]
 == Async Execution Context with `AsyncContext`
@@ -1482,7 +1526,10 @@ ordinary method parameters or return values.
 
 [NOTE]
 ====
-`AsyncContext` is backed by a `ThreadLocal`.  In pooled-thread environments the
+`AsyncContext` is backed by a `ScopedLocal` with a lazy initial-value supplier.
+On JDK&nbsp;25+, this leverages `java.lang.ScopedValue` for efficient 
propagation
+across virtual threads; on JDK&nbsp;&lt;&nbsp;25, a `ThreadLocal` fallback is 
used.
+In pooled-thread environments the
 context is automatically captured and restored by the async runtime, but if you
 create your own threads you must capture a snapshot beforehand and restore it
 manually via `AsyncContext.withSnapshot(snapshot) { ... }`.
diff --git 
a/src/test/groovy/org/apache/groovy/runtime/async/ScopedLocalTest.groovy 
b/src/test/groovy/org/apache/groovy/runtime/async/ScopedLocalTest.groovy
new file mode 100644
index 0000000000..5c2b9eeb0e
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/runtime/async/ScopedLocalTest.groovy
@@ -0,0 +1,806 @@
+/*
+ *  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.async
+
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Timeout
+
+import java.lang.invoke.MethodType
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.CyclicBarrier
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
+
+import static org.junit.jupiter.api.Assertions.*
+
+/**
+ * Comprehensive unit tests for {@link ScopedLocal}.
+ * <p>
+ * These tests exercise both the {@code ThreadLocal}-based fallback
+ * (JDK&nbsp;&lt;&nbsp;25) and the {@code ScopedValue}-based
+ * implementation (JDK&nbsp;25+).  The actual backend is chosen at
+ * class-load time; the tests verify the unified contract regardless
+ * of which backend is active.
+ *
+ * @since 6.0.0
+ */
+@DisplayName("ScopedLocal")
+@Timeout(10)
+class ScopedLocalTest {
+
+    // ------------------------------------------------------------------
+    //  Backend detection and MethodHandle verification
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("ScopedValue backend is selected on JDK 25+")
+    void testBackendSelection() {
+        int jdkVersion = Runtime.version().feature()
+        boolean expected = jdkVersion >= 25
+        assertEquals(expected, ScopedLocal.SCOPED_VALUE_AVAILABLE,
+                "SCOPED_VALUE_AVAILABLE should be $expected on JDK 
$jdkVersion")
+    }
+
+    @Test
+    @DisplayName("MethodHandles are adapted for invokeExact on JDK 25+")
+    void testMethodHandleTypesAdaptedForInvokeExact() {
+        if (!ScopedLocal.SCOPED_VALUE_AVAILABLE) return
+
+        // SV_NEW_INSTANCE: () → Object
+        assertEquals(
+            MethodType.methodType(Object),
+                ScopedLocal.SV_NEW_INSTANCE.type(),
+                "SV_NEW_INSTANCE must return Object (adapted from 
ScopedValue)")
+
+        // SV_WHERE: (Object, Object) → Object
+        assertEquals(
+            MethodType.methodType(Object, Object, Object),
+                ScopedLocal.SV_WHERE.type(),
+                "SV_WHERE must accept (Object, Object) and return Object")
+
+        // SV_GET: (Object) → Object
+        assertEquals(
+            MethodType.methodType(Object, Object),
+                ScopedLocal.SV_GET.type(),
+                "SV_GET must accept (Object) and return Object")
+
+        // SV_IS_BOUND: (Object) → boolean
+        assertEquals(
+            MethodType.methodType(boolean, Object),
+                ScopedLocal.SV_IS_BOUND.type(),
+                "SV_IS_BOUND must accept (Object) and return boolean")
+
+        // CARRIER_RUN: (Object, Runnable) → void
+        assertEquals(
+            MethodType.methodType(void, Object, Runnable),
+                ScopedLocal.CARRIER_RUN.type(),
+                "CARRIER_RUN must accept (Object, Runnable) and return void")
+    }
+
+    @Test
+    @DisplayName("invokeExact calls work end-to-end through ScopedLocal API")
+    void testInvokeExactEndToEnd() {
+        // This test exercises every MethodHandle path in ScopedValueImpl:
+        //   SV_NEW_INSTANCE  → newInstance() / withInitial()
+        //   SV_IS_BOUND      → isBound() / get() / orElse()
+        //   SV_GET           → get() / orElse()
+        //   SV_WHERE         → bind() via Carrier
+        //   CARRIER_RUN      → bind() via Carrier
+        def sl = ScopedLocal.<String>newInstance()          // SV_NEW_INSTANCE
+        assertFalse(sl.isBound())                           // SV_IS_BOUND 
(false path)
+        assertEquals("fb", sl.orElse("fb"))                 // SV_IS_BOUND 
(false) + fallback
+
+        ScopedLocal.where(sl, "hello").run {                // SV_WHERE + 
CARRIER_RUN
+            assertTrue(sl.isBound())                        // SV_IS_BOUND 
(true path)
+            assertEquals("hello", sl.get())                 // SV_GET
+            assertEquals("hello", sl.orElse("other"))       // SV_GET via 
orElse
+        }
+    }
+
+    @Test
+    @DisplayName("invokeExact calls work with withInitial supplier")
+    void testInvokeExactWithInitial() {
+        def sl = ScopedLocal.withInitial { "default" }     // SV_NEW_INSTANCE
+        assertTrue(sl.isBound())                            // SV_IS_BOUND → 
fallback path
+        assertEquals("default", sl.get())                   // fallback get
+
+        ScopedLocal.where(sl, "override").run {             // SV_WHERE + 
CARRIER_RUN
+            assertEquals("override", sl.get())              // SV_IS_BOUND 
(true) + SV_GET
+        }
+
+        assertEquals("default", sl.get())                   // restored to 
fallback
+    }
+
+    // ------------------------------------------------------------------
+    //  Accessors — get(), orElse(), isBound()
+    // ------------------------------------------------------------------
+
+    @Nested
+    @DisplayName("Accessors (no initial supplier)")
+    class AccessorsNoInitial {
+
+        @Test
+        @DisplayName("get() throws NoSuchElementException when unbound")
+        void testGetUnbound() {
+            def sl = ScopedLocal.<String>newInstance()
+            assertThrows(NoSuchElementException) { sl.get() }
+        }
+
+        @Test
+        @DisplayName("orElse() returns fallback when unbound")
+        void testOrElseUnbound() {
+            def sl = ScopedLocal.<String>newInstance()
+            assertEquals("fallback", sl.orElse("fallback"))
+        }
+
+        @Test
+        @DisplayName("orElse() returns null fallback when unbound")
+        void testOrElseNullFallback() {
+            def sl = ScopedLocal.<String>newInstance()
+            assertNull(sl.orElse(null))
+        }
+
+        @Test
+        @DisplayName("isBound() returns false when unbound")
+        void testIsBoundUnbound() {
+            def sl = ScopedLocal.<String>newInstance()
+            assertFalse(sl.isBound())
+        }
+
+        @Test
+        @DisplayName("get() returns bound value inside where()")
+        void testGetBound() {
+            def sl = ScopedLocal.<String>newInstance()
+            def result = ScopedLocal.where(sl, "hello").call { sl.get() }
+            assertEquals("hello", result)
+        }
+
+        @Test
+        @DisplayName("orElse() returns bound value (not fallback) inside 
where()")
+        void testOrElseBound() {
+            def sl = ScopedLocal.<String>newInstance()
+            def result = ScopedLocal.where(sl, "hello").call { 
sl.orElse("fallback") }
+            assertEquals("hello", result)
+        }
+
+        @Test
+        @DisplayName("isBound() returns true inside where()")
+        void testIsBoundInside() {
+            def sl = ScopedLocal.<String>newInstance()
+            ScopedLocal.where(sl, "x").run { assertTrue(sl.isBound()) }
+        }
+
+        @Test
+        @DisplayName("get() throws again after where() scope exits")
+        void testGetAfterScopeExit() {
+            def sl = ScopedLocal.<String>newInstance()
+            ScopedLocal.where(sl, "temp").run { /* no-op */ }
+            assertThrows(NoSuchElementException) { sl.get() }
+        }
+    }
+
+    // ------------------------------------------------------------------
+    //  Accessors — withInitial
+    // ------------------------------------------------------------------
+
+    @Nested
+    @DisplayName("Accessors (with initial supplier)")
+    class AccessorsWithInitial {
+
+        @Test
+        @DisplayName("get() returns initial value when unbound")
+        void testGetInitial() {
+            def sl = ScopedLocal.withInitial { "default" }
+            assertEquals("default", sl.get())
+        }
+
+        @Test
+        @DisplayName("orElse() returns initial value (not fallback) when 
unbound")
+        void testOrElseInitial() {
+            def sl = ScopedLocal.withInitial { "default" }
+            assertEquals("default", sl.orElse("fallback"))
+        }
+
+        @Test
+        @DisplayName("isBound() returns true when initial supplier is present")
+        void testIsBoundInitial() {
+            def sl = ScopedLocal.withInitial { "default" }
+            assertTrue(sl.isBound())
+        }
+
+        @Test
+        @DisplayName("get() returns bound value, not initial, inside where()")
+        void testGetBoundOverridesInitial() {
+            def sl = ScopedLocal.withInitial { "default" }
+            def result = ScopedLocal.where(sl, "override").call { sl.get() }
+            assertEquals("override", result)
+        }
+
+        @Test
+        @DisplayName("get() restores to initial value after where() exits")
+        void testGetRestoredToInitial() {
+            def sl = ScopedLocal.withInitial { "default" }
+            ScopedLocal.where(sl, "override").run { /* no-op */ }
+            assertEquals("default", sl.get())
+        }
+
+        @Test
+        @DisplayName("initial supplier is lazily invoked")
+        void testLazyInitialization() {
+            def counter = new AtomicInteger(0)
+            def sl = ScopedLocal.withInitial {
+                counter.incrementAndGet()
+                "lazy"
+            }
+            assertEquals(0, counter.get(), "supplier must not be called at 
creation")
+            assertEquals("lazy", sl.get())
+            assertEquals(1, counter.get(), "supplier called on first access")
+        }
+
+        @Test
+        @DisplayName("initial value is cached after first access")
+        void testInitialValueCached() {
+            def counter = new AtomicInteger(0)
+            def sl = ScopedLocal.withInitial {
+                counter.incrementAndGet()
+                "cached"
+            }
+            sl.get()
+            sl.get()
+            sl.get()
+            assertEquals(1, counter.get(), "supplier should only be called 
once per thread")
+        }
+
+        @Test
+        @DisplayName("withInitial(null) throws NullPointerException")
+        void testWithInitialNull() {
+            assertThrows(NullPointerException) {
+                ScopedLocal.withInitial(null)
+            }
+        }
+
+        @Test
+        @DisplayName("initial supplier is per-thread")
+        void testPerThreadInitial() {
+            def threadName = new AtomicReference<String>()
+            def sl = ScopedLocal.withInitial { Thread.currentThread().name }
+
+            def latch = new CountDownLatch(1)
+            def thread = Thread.start("worker-initial") {
+                threadName.set(sl.get())
+                latch.countDown()
+            }
+            latch.await(5, TimeUnit.SECONDS)
+
+            def mainValue = sl.get()
+            assertTrue(mainValue.contains(Thread.currentThread().name) || 
mainValue != null)
+            assertEquals("worker-initial", threadName.get())
+        }
+    }
+
+    // ------------------------------------------------------------------
+    //  Null bindings
+    // ------------------------------------------------------------------
+
+    @Nested
+    @DisplayName("Null bindings")
+    class NullBindings {
+
+        @Test
+        @DisplayName("where(null) makes get() return null")
+        void testBindNull() {
+            def sl = ScopedLocal.<String>newInstance()
+            def result = ScopedLocal.where(sl, null).call { sl.get() }
+            assertNull(result)
+        }
+
+        @Test
+        @DisplayName("isBound() returns true for null binding")
+        void testIsBoundNull() {
+            def sl = ScopedLocal.<String>newInstance()
+            ScopedLocal.where(sl, null).run {
+                assertTrue(sl.isBound())
+            }
+        }
+
+        @Test
+        @DisplayName("orElse() returns null (not fallback) for null binding")
+        void testOrElseNull() {
+            def sl = ScopedLocal.<String>newInstance()
+            def result = ScopedLocal.where(sl, null).call { 
sl.orElse("fallback") }
+            assertNull(result)
+        }
+
+        @Test
+        @DisplayName("where(null) with withInitial returns null, not initial 
value")
+        void testNullOverridesInitial() {
+            def sl = ScopedLocal.withInitial { "default" }
+            def result = ScopedLocal.where(sl, null).call { sl.get() }
+            assertNull(result, "null binding must override the initial value")
+        }
+
+        @Test
+        @DisplayName("after where(null) exits, withInitial restores to initial 
value")
+        void testNullScopeRestoresInitial() {
+            def sl = ScopedLocal.withInitial { "default" }
+            ScopedLocal.where(sl, null).run { /* no-op */ }
+            assertEquals("default", sl.get())
+        }
+    }
+
+    // ------------------------------------------------------------------
+    //  Nested bindings
+    // ------------------------------------------------------------------
+
+    @Nested
+    @DisplayName("Nested bindings")
+    class NestedBindings {
+
+        @Test
+        @DisplayName("inner where() shadows outer where()")
+        void testNestedShadowing() {
+            def sl = ScopedLocal.<String>newInstance()
+            ScopedLocal.where(sl, "outer").run {
+                assertEquals("outer", sl.get())
+                ScopedLocal.where(sl, "inner").run {
+                    assertEquals("inner", sl.get())
+                }
+                assertEquals("outer", sl.get())
+            }
+        }
+
+        @Test
+        @DisplayName("three levels of nesting restore correctly")
+        void testTripleNesting() {
+            def sl = ScopedLocal.<Integer>newInstance()
+            def trace = []
+            ScopedLocal.where(sl, 1).run {
+                trace << sl.get()
+                ScopedLocal.where(sl, 2).run {
+                    trace << sl.get()
+                    ScopedLocal.where(sl, 3).run {
+                        trace << sl.get()
+                    }
+                    trace << sl.get()
+                }
+                trace << sl.get()
+            }
+            assertEquals([1, 2, 3, 2, 1], trace)
+        }
+
+        @Test
+        @DisplayName("null binding nested inside non-null binding")
+        void testNullNestedInNonNull() {
+            def sl = ScopedLocal.<String>newInstance()
+            ScopedLocal.where(sl, "outer").run {
+                assertEquals("outer", sl.get())
+                ScopedLocal.where(sl, null).run {
+                    assertNull(sl.get())
+                }
+                assertEquals("outer", sl.get())
+            }
+        }
+
+        @Test
+        @DisplayName("non-null binding nested inside null binding")
+        void testNonNullNestedInNull() {
+            def sl = ScopedLocal.<String>newInstance()
+            ScopedLocal.where(sl, null).run {
+                assertNull(sl.get())
+                ScopedLocal.where(sl, "inner").run {
+                    assertEquals("inner", sl.get())
+                }
+                assertNull(sl.get())
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------
+    //  Carrier API
+    // ------------------------------------------------------------------
+
+    @Nested
+    @DisplayName("Carrier")
+    class CarrierTests {
+
+        @Test
+        @DisplayName("Carrier.run() executes action with binding")
+        void testCarrierRun() {
+            def sl = ScopedLocal.<String>newInstance()
+            def seen = new AtomicReference<String>()
+            ScopedLocal.where(sl, "via-carrier").run {
+                seen.set(sl.get())
+            }
+            assertEquals("via-carrier", seen.get())
+        }
+
+        @Test
+        @DisplayName("Carrier.call() returns supplier result")
+        void testCarrierCall() {
+            def sl = ScopedLocal.<String>newInstance()
+            def result = ScopedLocal.where(sl, "hello").call { 
sl.get().toUpperCase() }
+            assertEquals("HELLO", result)
+        }
+
+        @Test
+        @DisplayName("Carrier.call() can return null")
+        void testCarrierCallReturnsNull() {
+            def sl = ScopedLocal.<String>newInstance()
+            def result = ScopedLocal.where(sl, "x").call { null }
+            assertNull(result)
+        }
+
+        @Test
+        @DisplayName("chained where() binds multiple ScopedLocals")
+        void testCarrierChaining() {
+            def sl1 = ScopedLocal.<String>newInstance()
+            def sl2 = ScopedLocal.<Integer>newInstance()
+            def result = ScopedLocal.where(sl1, "alpha")
+                    .where(sl2, 42)
+                    .call { sl1.get() + ":" + sl2.get() }
+            assertEquals("alpha:42", result)
+        }
+
+        @Test
+        @DisplayName("chained where() with same key uses last binding")
+        void testCarrierChainSameKey() {
+            def sl = ScopedLocal.<String>newInstance()
+            def result = ScopedLocal.where(sl, "first")
+                    .where(sl, "second")
+                    .call { sl.get() }
+            assertEquals("second", result)
+        }
+
+        @Test
+        @DisplayName("chained bindings are all restored after scope exits")
+        void testCarrierChainingRestoration() {
+            def sl1 = ScopedLocal.<String>newInstance()
+            def sl2 = ScopedLocal.<String>newInstance()
+
+            ScopedLocal.where(sl1, "A").where(sl2, "B").run { /* no-op */ }
+
+            assertFalse(sl1.isBound())
+            assertFalse(sl2.isBound())
+        }
+
+        @Test
+        @DisplayName("where(key, null) throws NullPointerException for null 
key")
+        void testCarrierNullKey() {
+            assertThrows(NullPointerException) {
+                ScopedLocal.where(null, "value")
+            }
+        }
+
+        @Test
+        @DisplayName("Carrier.run(null) throws NullPointerException")
+        void testCarrierRunNull() {
+            def sl = ScopedLocal.<String>newInstance()
+            assertThrows(NullPointerException) {
+                ScopedLocal.where(sl, "x").run(null)
+            }
+        }
+
+        @Test
+        @DisplayName("Carrier.call(null) throws NullPointerException")
+        void testCarrierCallNull() {
+            def sl = ScopedLocal.<String>newInstance()
+            assertThrows(NullPointerException) {
+                ScopedLocal.where(sl, "x").call(null)
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------
+    //  Convenience instance methods
+    // ------------------------------------------------------------------
+
+    @Nested
+    @DisplayName("Convenience instance methods")
+    class ConvenienceMethods {
+
+        @Test
+        @DisplayName("instance where(value, Supplier) works")
+        void testInstanceWhereSupplier() {
+            def sl = ScopedLocal.<String>newInstance()
+            def result = sl.where("val", { sl.get() } as 
java.util.function.Supplier)
+            assertEquals("val", result)
+        }
+
+        @Test
+        @DisplayName("instance where(value, Runnable) works")
+        void testInstanceWhereRunnable() {
+            def sl = ScopedLocal.<String>newInstance()
+            def seen = new AtomicReference<String>()
+            sl.where("val", { seen.set(sl.get()) } as Runnable)
+            assertEquals("val", seen.get())
+        }
+    }
+
+    // ------------------------------------------------------------------
+    //  Exception propagation
+    // ------------------------------------------------------------------
+
+    @Nested
+    @DisplayName("Exception propagation")
+    class ExceptionPropagation {
+
+        @Test
+        @DisplayName("RuntimeException propagates transparently from run()")
+        void testRuntimeExceptionRun() {
+            def sl = ScopedLocal.<String>newInstance()
+            def ex = assertThrows(IllegalStateException) {
+                ScopedLocal.where(sl, "x").run {
+                    throw new IllegalStateException("boom")
+                }
+            }
+            assertEquals("boom", ex.message)
+        }
+
+        @Test
+        @DisplayName("RuntimeException propagates transparently from call()")
+        void testRuntimeExceptionCall() {
+            def sl = ScopedLocal.<String>newInstance()
+            def ex = assertThrows(IllegalArgumentException) {
+                ScopedLocal.where(sl, "x").call {
+                    throw new IllegalArgumentException("bad arg")
+                }
+            }
+            assertEquals("bad arg", ex.message)
+        }
+
+        @Test
+        @DisplayName("Error propagates transparently")
+        void testErrorPropagation() {
+            def sl = ScopedLocal.<String>newInstance()
+            assertThrows(StackOverflowError) {
+                ScopedLocal.where(sl, "x").run {
+                    throw new StackOverflowError("overflow")
+                }
+            }
+        }
+
+        @Test
+        @DisplayName("checked exception propagates via sneaky throw")
+        void testCheckedExceptionSneakyThrow() {
+            def sl = ScopedLocal.<String>newInstance()
+            try {
+                ScopedLocal.where(sl, "x").run {
+                    throw new IOException("io error")
+                }
+                fail("should have thrown")
+            } catch (IOException e) {
+                assertEquals("io error", e.message)
+            }
+        }
+
+        @Test
+        @DisplayName("binding is restored after exception in run()")
+        void testBindingRestoredAfterException() {
+            def sl = ScopedLocal.<String>newInstance()
+            ScopedLocal.where(sl, "outer").run {
+                try {
+                    ScopedLocal.where(sl, "inner").run {
+                        throw new RuntimeException("fail")
+                    }
+                } catch (RuntimeException ignored) {}
+                assertEquals("outer", sl.get(), "outer binding must be 
restored")
+            }
+        }
+
+        @Test
+        @DisplayName("binding is restored after exception in call()")
+        void testBindingRestoredAfterExceptionCall() {
+            def sl = ScopedLocal.<String>newInstance()
+            ScopedLocal.where(sl, "outer").run {
+                try {
+                    ScopedLocal.where(sl, "inner").call {
+                        throw new RuntimeException("fail")
+                    }
+                } catch (RuntimeException ignored) {}
+                assertEquals("outer", sl.get())
+            }
+        }
+
+        @Test
+        @DisplayName("exception from chained carrier propagates and restores 
all bindings")
+        void testChainedCarrierException() {
+            def sl1 = ScopedLocal.<String>newInstance()
+            def sl2 = ScopedLocal.<Integer>newInstance()
+
+            assertThrows(RuntimeException) {
+                ScopedLocal.where(sl1, "a").where(sl2, 1).run {
+                    throw new RuntimeException("chained fail")
+                }
+            }
+
+            assertFalse(sl1.isBound())
+            assertFalse(sl2.isBound())
+        }
+    }
+
+    // ------------------------------------------------------------------
+    //  Thread isolation
+    // ------------------------------------------------------------------
+
+    @Nested
+    @DisplayName("Thread isolation")
+    class ThreadIsolation {
+
+        @Test
+        @DisplayName("bindings are thread-local, not shared")
+        void testThreadIsolation() {
+            def sl = ScopedLocal.<String>newInstance()
+            def barrier = new CyclicBarrier(2)
+            def otherSeen = new AtomicReference<Boolean>()
+
+            ScopedLocal.where(sl, "main-value").run {
+                def thread = Thread.start {
+                    otherSeen.set(sl.isBound())
+                    barrier.await(5, TimeUnit.SECONDS)
+                }
+                barrier.await(5, TimeUnit.SECONDS)
+                thread.join(5000)
+            }
+
+            assertFalse(otherSeen.get(), "binding must not leak to another 
thread")
+        }
+
+        @Test
+        @DisplayName("concurrent where() on same ScopedLocal from different 
threads")
+        void testConcurrentBindings() {
+            def sl = ScopedLocal.<Integer>newInstance()
+            int threadCount = 8
+            def barrier = new CyclicBarrier(threadCount)
+            def results = new 
AtomicReference<List>(Collections.synchronizedList([]))
+            def latch = new CountDownLatch(threadCount)
+
+            (0..<threadCount).each { int i ->
+                Thread.start {
+                    ScopedLocal.where(sl, i).run {
+                        barrier.await(5, TimeUnit.SECONDS)
+                        // All threads read concurrently
+                        results.get() << sl.get()
+                        latch.countDown()
+                    }
+                }
+            }
+
+            assertTrue(latch.await(10, TimeUnit.SECONDS))
+            def sorted = results.get().sort()
+            assertEquals((0..<threadCount).toList(), sorted,
+                    "each thread must see its own bound value")
+        }
+
+        @Test
+        @DisplayName("withInitial creates independent instances per thread")
+        void testWithInitialPerThread() {
+            def counter = new AtomicInteger(0)
+            def sl = ScopedLocal.withInitial { counter.incrementAndGet() }
+
+            int threadCount = 4
+            def latch = new CountDownLatch(threadCount)
+            def values = Collections.synchronizedList([])
+
+            (0..<threadCount).each {
+                Thread.start {
+                    values << sl.get()
+                    latch.countDown()
+                }
+            }
+
+            assertTrue(latch.await(5, TimeUnit.SECONDS))
+            // Each thread should get a unique value from the counter
+            assertEquals(threadCount, values.toSet().size(),
+                    "each thread must get an independent initial value")
+        }
+    }
+
+    // ------------------------------------------------------------------
+    //  Edge cases
+    // ------------------------------------------------------------------
+
+    @Nested
+    @DisplayName("Edge cases")
+    class EdgeCases {
+
+        @Test
+        @DisplayName("where() with same value as current is a no-op 
semantically")
+        void testRebindSameValue() {
+            def sl = ScopedLocal.<String>newInstance()
+            ScopedLocal.where(sl, "same").run {
+                def result = ScopedLocal.where(sl, "same").call { sl.get() }
+                assertEquals("same", result)
+            }
+        }
+
+        @Test
+        @DisplayName("empty Runnable in run() does not throw")
+        void testEmptyRunnable() {
+            def sl = ScopedLocal.<String>newInstance()
+            // Just run it — if it throws, the test fails
+            ScopedLocal.where(sl, "x").run { /* empty */ }
+        }
+
+        @Test
+        @DisplayName("Carrier is immutable — original not affected by 
chaining")
+        void testCarrierImmutability() {
+            def sl1 = ScopedLocal.<String>newInstance()
+            def sl2 = ScopedLocal.<Integer>newInstance()
+
+            def carrier1 = ScopedLocal.where(sl1, "a")
+            def carrier2 = carrier1.where(sl2, 42)
+
+            // carrier1 should only bind sl1
+            carrier1.run {
+                assertEquals("a", sl1.get())
+                assertFalse(sl2.isBound())
+            }
+
+            // carrier2 should bind both
+            carrier2.run {
+                assertEquals("a", sl1.get())
+                assertEquals(42, sl2.get())
+            }
+        }
+
+        @Test
+        @DisplayName("deeply nested binding and restoration")
+        void testDeeplyNested() {
+            def sl = ScopedLocal.<Integer>newInstance()
+            int depth = 100
+            Runnable innermost = {
+                assertEquals(depth, sl.get())
+            }
+
+            Runnable current = innermost
+            for (int i = depth; i >= 1; i--) {
+                int val = i
+                Runnable next = current
+                current = {
+                    ScopedLocal.where(sl, val).run {
+                        assertEquals(val, sl.get())
+                        next.run()
+                        assertEquals(val, sl.get())
+                    }
+                }
+            }
+            current.run()
+            assertFalse(sl.isBound())
+        }
+
+        @Test
+        @DisplayName("withInitial supplier returning null is handled 
correctly")
+        void testWithInitialReturningNull() {
+            def sl = ScopedLocal.withInitial { null }
+            assertNull(sl.get(), "initial supplier returning null should yield 
null")
+            assertTrue(sl.isBound(), "should be considered bound with initial 
supplier")
+        }
+
+        @Test
+        @DisplayName("call() result is returned even when value is bound to 
null")
+        void testCallWithNullBinding() {
+            def sl = ScopedLocal.<String>newInstance()
+            def result = ScopedLocal.where(sl, null).call { "computed" }
+            assertEquals("computed", result)
+        }
+    }
+}

Reply via email to