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 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) + } + } +}
