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/03/22 14:08:04 UTC
(groovy) branch GROOVY-9381_3 updated (e4b6bb8481 -> fec1121305)
This is an automated email from the ASF dual-hosted git repository.
sunlan pushed a change to branch GROOVY-9381_3
in repository https://gitbox.apache.org/repos/asf/groovy.git
discard e4b6bb8481 GROOVY-9381: add ScopedLocal
new fec1121305 GROOVY-9381: add ScopedLocal
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 (e4b6bb8481)
\
N -- N -- N refs/heads/GROOVY-9381_3 (fec1121305)
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:
.../apache/groovy/runtime/async/ScopedLocal.java | 22 +++++++++-------------
1 file changed, 9 insertions(+), 13 deletions(-)
(groovy) 01/01: GROOVY-9381: add ScopedLocal
Posted by su...@apache.org.
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 <su...@apache.org>
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 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 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 < 25) and {@code java.lang.ScopedValue} (JDK 25+),
+ * presenting a unified API modelled after {@code ScopedValue}.
+ *
+ * <h2>Backend selection</h2>
+ * <p>On JDK 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 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 25+, this leverages `java.lang.ScopedValue` for efficient propagation
+across virtual threads; on JDK < 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 < 25) and the {@code ScopedValue}-based
+ * implementation (JDK 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)
+ }
+ }
+}