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/14 08:42:43 UTC
(groovy) branch GROOVY-9381_3 updated (f043c2cb5b -> d4f97d5e3c)
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 f043c2cb5b Minor tweaks
discard aa4e567485 GROOVY-9381: Add native async/await support
add 166784dddf GROOVY-11872: SC: propagate `STATIC_COMPILE_NODE` metadata in `Verifier`
add 50f267d5b2 Bump org.apache.maven:maven-core from 3.9.13 to 3.9.14 (#2396)
add 7b18440c4f GROOVY-8699: SC: emit direct bytecode for simple list (#2395)
new d4f97d5e3c GROOVY-9381: Add native async/await support
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 (f043c2cb5b)
\
N -- N -- N refs/heads/GROOVY-9381_3 (d4f97d5e3c)
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:
build-logic/build.gradle | 2 +-
build.gradle | 2 +-
.../groovy/classgen/AsmClassGenerator.java | 4 +-
.../org/codehaus/groovy/classgen/Verifier.java | 8 +-
.../sc/transformers/ListExpressionTransformer.java | 68 ++++++++-
src/test/groovy/bugs/Groovy10034.groovy | 9 +-
.../ArraysAndCollectionsStaticCompileTest.groovy | 54 ++++---
.../sc/CombinedIndyAndStaticCompilationTest.groovy | 160 ++++++++++++++-------
8 files changed, 223 insertions(+), 84 deletions(-)
(groovy) 01/01: GROOVY-9381: Add native async/await support
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 d4f97d5e3c41453c46798dcb819f5c0733e5976a
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Mar 7 20:00:28 2026 +0900
GROOVY-9381: Add native async/await support
---
build.gradle | 2 +
gradle/verification-metadata.xml | 16 +
src/antlr/GroovyLexer.g4 | 3 +
src/antlr/GroovyParser.g4 | 15 +-
src/main/java/groovy/concurrent/AsyncStream.java | 127 +
src/main/java/groovy/concurrent/AwaitResult.java | 122 +
src/main/java/groovy/concurrent/Awaitable.java | 537 +++++
.../java/groovy/concurrent/AwaitableAdapter.java | 86 +
.../concurrent/AwaitableAdapterRegistry.java | 262 ++
src/main/java/groovy/transform/Async.java | 134 ++
.../apache/groovy/parser/antlr4/AstBuilder.java | 176 +-
.../groovy/parser/antlr4/ModifierManager.java | 22 +-
.../groovy/runtime/async/AsyncStreamGenerator.java | 348 +++
.../apache/groovy/runtime/async/AsyncSupport.java | 1055 ++++++++
.../groovy/runtime/async/FlowPublisherAdapter.java | 446 ++++
.../apache/groovy/runtime/async/GroovyPromise.java | 207 ++
.../java/org/codehaus/groovy/ast/ModifierNode.java | 2 +
.../groovy/transform/AsyncASTTransformation.java | 250 ++
.../groovy/transform/AsyncTransformHelper.java | 412 ++++
.../services/groovy.concurrent.AwaitableAdapter | 16 +
src/spec/doc/core-async-await.adoc | 1286 ++++++++++
src/spec/test/AsyncAwaitSpecTest.groovy | 1405 +++++++++++
.../runtime/async/AsyncConcurrencyTest.groovy | 539 +++++
.../codehaus/groovy/transform/AsyncApiTest.groovy | 2461 +++++++++++++++++++
.../groovy/transform/AsyncAwaitSyntaxTest.groovy | 2537 ++++++++++++++++++++
.../groovy/transform/AsyncBestPracticesTest.groovy | 1470 ++++++++++++
.../groovy/transform/AsyncClosureLambdaTest.groovy | 730 ++++++
.../groovy/transform/AsyncDeferFlowTest.groovy | 516 ++++
.../transform/AsyncExceptionHandlingTest.groovy | 1411 +++++++++++
.../transform/AsyncFrameworkIntegrationTest.groovy | 756 ++++++
.../groovy/transform/AsyncPatternsTest.groovy | 1636 +++++++++++++
.../groovy/transform/AsyncTransformTest.groovy | 1758 ++++++++++++++
.../groovy/transform/AsyncVirtualThreadTest.groovy | 1141 +++++++++
versions.properties | 2 +
34 files changed, 21882 insertions(+), 4 deletions(-)
diff --git a/build.gradle b/build.gradle
index b0541b518b..9828769a66 100644
--- a/build.gradle
+++ b/build.gradle
@@ -122,6 +122,8 @@ dependencies {
testImplementation "com.thoughtworks.qdox:qdox:${versions.qdox}"
testImplementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}"
testImplementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${versions.jackson}"
+ testImplementation "io.reactivex.rxjava3:rxjava:${versions.rxjava3}"
+ testImplementation "io.projectreactor:reactor-core:${versions.reactor}"
testFixturesImplementation projects.groovyXml
testFixturesImplementation projects.groovyTest
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 0b6c75291a..045ed8f1bb 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -114,6 +114,7 @@
<ignored-key id="C71FB765CD9DE313" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="C7CA19B7B620D787" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="CA80D1F0EB6CA4BA" reason="Key couldn't be downloaded from any key server"/>
+ <ignored-key id="D1031D14464180E0" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="D2151178A123C97F" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="D364ABAA39A47320" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="D7742D58455ECC7C" reason="Key couldn't be downloaded from any key server"/>
@@ -861,6 +862,16 @@
<sha512 value="f220e44fe6b61f8dbb61226f832dfb16a09584384540fd48a4dff5c4de9fee060623f85cbead720dfe776aa25105949e70758a9bb1d9db43f63068d8d22164c9" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="io.projectreactor" name="reactor-core" version="3.7.3">
+ <artifact name="reactor-core-3.7.3.jar">
+ <pgp value="48B086A7D843CFA258E83286928FBF39003C0425"/>
+ </artifact>
+ </component>
+ <component group="io.reactivex.rxjava3" name="rxjava" version="3.1.10">
+ <artifact name="rxjava-3.1.10.jar">
+ <pgp value="E9CC3CD1AE59E851E4DB3FA350FFD7487D34B5B9"/>
+ </artifact>
+ </component>
<component group="jakarta.activation" name="jakarta.activation-api" version="1.2.1">
<artifact name="jakarta.activation-api-1.2.1.jar">
<pgp value="6DD3B8C64EF75253BEB2C53AD908A43FB7EC07AC"/>
@@ -2216,6 +2227,11 @@
<sha512 value="adcc480f68828ffd68d03846be852988b595c2e1bb69224d273578dd6c2ad2773edfe96625a7c00bc40ae0f2d1cac8412eaa54b88cc8e681b0b4c0ee3b082333" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="org.reactivestreams" name="reactive-streams" version="1.0.4">
+ <artifact name="reactive-streams-1.0.4.jar">
+ <sha512 value="cdab6bd156f39106cd6bbfd47df1f4b0a89dc4aa28c68c31ef12a463193c688897e415f01b8d7f0d487b0e6b5bd2f19044bf8605704b024f26d6aa1f4f9a2471" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
+ </artifact>
+ </component>
<component group="org.reflections" name="reflections" version="0.10.2">
<artifact name="reflections-0.10.2.jar">
<pgp value="3F2A008A91D11A7FAC4A0786F13D3E721D56BD54"/>
diff --git a/src/antlr/GroovyLexer.g4 b/src/antlr/GroovyLexer.g4
index 79ddf78dbd..f1d69c5c95 100644
--- a/src/antlr/GroovyLexer.g4
+++ b/src/antlr/GroovyLexer.g4
@@ -392,6 +392,9 @@ DEF : 'def';
IN : 'in';
TRAIT : 'trait';
THREADSAFE : 'threadsafe'; // reserved keyword
+ASYNC : 'async';
+AWAIT : 'await';
+DEFER : 'defer';
// §3.9 Keywords
BuiltInPrimitiveType
diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4
index 88e3b8b713..daccdcdcc3 100644
--- a/src/antlr/GroovyParser.g4
+++ b/src/antlr/GroovyParser.g4
@@ -134,6 +134,7 @@ modifier
| VOLATILE
| DEF
| VAR
+ | ASYNC
)
;
@@ -600,7 +601,7 @@ switchStatement
;
loopStatement
- : FOR LPAREN forControl RPAREN nls statement #forStmtAlt
+ : FOR AWAIT? LPAREN forControl RPAREN nls statement #forStmtAlt
| WHILE expressionInPar nls statement #whileStmtAlt
| DO nls statement nls WHILE expressionInPar #doWhileStmtAlt
;
@@ -642,6 +643,8 @@ statement
| continueStatement #continueStmtAlt
| { inSwitchExpressionLevel > 0 }?
yieldStatement #yieldStmtAlt
+ | YIELD RETURN nls expression #yieldReturnStmtAlt
+ | DEFER nls statementExpression #deferStmtAlt
| identifier COLON nls statement #labeledStmtAlt
| assertStatement #assertStmtAlt
| localVariableDeclaration #localVariableDeclarationStmtAlt
@@ -778,6 +781,10 @@ expression
// must come before postfixExpression to resolve the ambiguities between casting and call on parentheses expression, e.g. (int)(1 / 2)
: castParExpression castOperandExpression #castExprAlt
+ // async closure/lambda must come before postfixExpression to resolve the ambiguities between async and method call, e.g. async { ... }
+ | ASYNC nls closureOrLambdaExpression #asyncClosureExprAlt
+ | AWAIT nls (LPAREN expression RPAREN | expression) #awaitExprAlt
+
// qualified names, array expressions, method invocation, post inc/dec
| postfixExpression #postfixExprAlt
@@ -1228,6 +1235,9 @@ identifier
: Identifier
| CapitalizedIdentifier
| AS
+ | ASYNC
+ | AWAIT
+ | DEFER
| IN
| PERMITS
| RECORD
@@ -1246,12 +1256,15 @@ keywords
: ABSTRACT
| AS
| ASSERT
+ | ASYNC
+ | AWAIT
| BREAK
| CASE
| CATCH
| CLASS
| CONST
| CONTINUE
+ | DEFER
| DEF
| DEFAULT
| DO
diff --git a/src/main/java/groovy/concurrent/AsyncStream.java b/src/main/java/groovy/concurrent/AsyncStream.java
new file mode 100644
index 0000000000..4baa81115a
--- /dev/null
+++ b/src/main/java/groovy/concurrent/AsyncStream.java
@@ -0,0 +1,127 @@
+/*
+ * 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 groovy.concurrent;
+
+/**
+ * Asynchronous iteration abstraction, analogous to C#'s
+ * {@code IAsyncEnumerable<T>} or JavaScript's async iterables.
+ * <p>
+ * Used with the {@code for await} syntax:
+ * <pre>
+ * for await (item in asyncStream) {
+ * process(item)
+ * }
+ * </pre>
+ * <p>
+ * An {@code AsyncStream} can be produced in several ways:
+ * <ul>
+ * <li>Using {@code yield return} inside an {@code async} method or closure
+ * to create a generator-style stream</li>
+ * <li>Adapting JDK {@link java.util.concurrent.Flow.Publisher} instances
+ * (supported out of the box by the built-in adapter)</li>
+ * <li>Adapting third-party reactive types (Reactor {@code Flux}, RxJava
+ * {@code Observable}) via {@link AwaitableAdapter}</li>
+ * </ul>
+ *
+ * @param <T> the element type
+ * @see AwaitableAdapter
+ * @see AwaitableAdapterRegistry
+ * @since 6.0.0
+ */
+public interface AsyncStream<T> extends AutoCloseable {
+
+ /**
+ * Asynchronously advances to the next element. Returns an {@link Awaitable}
+ * that completes with {@code true} if an element is available, or
+ * {@code false} if the stream is exhausted.
+ */
+ Awaitable<Boolean> moveNext();
+
+ /**
+ * Returns the current element. Must only be called after {@link #moveNext()}
+ * has completed with {@code true}.
+ */
+ T getCurrent();
+
+ /**
+ * Closes the stream and releases any associated resources.
+ * <p>
+ * The default implementation is a no-op. Implementations that bridge to
+ * generators, publishers, or other resource-owning sources may override
+ * this to propagate cancellation upstream. Compiler-generated
+ * {@code for await} loops invoke {@code close()} automatically from a
+ * {@code finally} block, including on early {@code break}, {@code return},
+ * and exceptional exit.
+ *
+ * @since 6.0.0
+ */
+ @Override
+ default void close() {
+ }
+
+ /**
+ * Converts the given source to an {@code AsyncStream}.
+ * <p>
+ * If the source is already an {@code AsyncStream}, it is returned as-is.
+ * Otherwise, the {@link AwaitableAdapterRegistry} is consulted to find a
+ * suitable adapter. Built-in adapters handle {@link Iterable} and
+ * {@link java.util.Iterator}; the auto-discovered {@code FlowPublisherAdapter}
+ * handles {@link java.util.concurrent.Flow.Publisher}; third-party frameworks
+ * can register additional adapters via the registry.
+ * <p>
+ * This is the recommended entry point for converting external collection or
+ * reactive types to {@code AsyncStream}:
+ * <pre>
+ * AsyncStream<String> stream = AsyncStream.from(myList)
+ * AsyncStream<Integer> stream2 = AsyncStream.from(myFlowPublisher)
+ * </pre>
+ *
+ * @param source the source object; must not be {@code null}
+ * @param <T> the element type
+ * @return an async stream backed by the source
+ * @throws IllegalArgumentException if {@code source} is {@code null}
+ * or no adapter supports the source type
+ * @see AwaitableAdapterRegistry#toAsyncStream(Object)
+ * @since 6.0.0
+ */
+ @SuppressWarnings("unchecked")
+ static <T> AsyncStream<T> from(Object source) {
+ return AwaitableAdapterRegistry.toAsyncStream(source);
+ }
+
+ /**
+ * Returns an empty {@code AsyncStream} that completes immediately.
+ */
+ @SuppressWarnings("unchecked")
+ static <T> AsyncStream<T> empty() {
+ return (AsyncStream<T>) EMPTY;
+ }
+
+ /**
+ * Singleton empty stream instance.
+ * <p>
+ * This is an implementation detail backing {@link #empty()}.
+ * User code should call {@code AsyncStream.empty()} rather than
+ * referencing this field directly.
+ */
+ AsyncStream<Object> EMPTY = new AsyncStream<>() {
+ @Override public Awaitable<Boolean> moveNext() { return Awaitable.of(false); }
+ @Override public Object getCurrent() { return null; }
+ };
+}
diff --git a/src/main/java/groovy/concurrent/AwaitResult.java b/src/main/java/groovy/concurrent/AwaitResult.java
new file mode 100644
index 0000000000..ea89d97dff
--- /dev/null
+++ b/src/main/java/groovy/concurrent/AwaitResult.java
@@ -0,0 +1,122 @@
+/*
+ * 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 groovy.concurrent;
+
+import java.util.Objects;
+import java.util.function.Function;
+
+/**
+ * Represents the outcome of an asynchronous computation that may have
+ * succeeded or failed. This is used by {@code awaitAllSettled()} —
+ * the Groovy equivalent of JavaScript's {@code Promise.allSettled()}.
+ * <p>
+ * An {@code AwaitResult} is either a {@linkplain #isSuccess() success}
+ * carrying a value, or a {@linkplain #isFailure() failure} carrying a
+ * {@link Throwable}.
+ *
+ * @param <T> the value type
+ * @since 6.0.0
+ */
+public final class AwaitResult<T> {
+
+ private final T value;
+ private final Throwable error;
+ private final boolean success;
+
+ private AwaitResult(T value, Throwable error, boolean success) {
+ this.value = value;
+ this.error = error;
+ this.success = success;
+ }
+
+ /**
+ * Creates a successful result with the given value.
+ *
+ * @param value the computation result (may be {@code null})
+ * @param <T> the value type
+ * @return a success result wrapping the value
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> AwaitResult<T> success(Object value) {
+ return new AwaitResult<>((T) value, null, true);
+ }
+
+ /**
+ * Creates a failure result with the given exception.
+ *
+ * @param error the exception that caused the failure; must not be {@code null}
+ * @param <T> the value type (never actually used, since the result is a failure)
+ * @return a failure result wrapping the exception
+ * @throws NullPointerException if {@code error} is {@code null}
+ */
+ public static <T> AwaitResult<T> failure(Throwable error) {
+ return new AwaitResult<>(null, Objects.requireNonNull(error), false);
+ }
+
+ /** Returns {@code true} if this result represents a successful completion. */
+ public boolean isSuccess() {
+ return success;
+ }
+
+ /** Returns {@code true} if this result represents a failed completion. */
+ public boolean isFailure() {
+ return !success;
+ }
+
+ /**
+ * Returns the value if successful.
+ *
+ * @return the computation result
+ * @throws IllegalStateException if this result represents a failure
+ */
+ public T getValue() {
+ if (!success) throw new IllegalStateException("Cannot get value from a failed result");
+ return value;
+ }
+
+ /**
+ * Returns the exception if failed.
+ *
+ * @return the exception that caused the failure
+ * @throws IllegalStateException if this result represents a success
+ */
+ public Throwable getError() {
+ if (success) throw new IllegalStateException("Cannot get error from a successful result");
+ return error;
+ }
+
+ /**
+ * Returns the value if successful, or applies the given function to
+ * the error to produce a fallback value.
+ */
+ public T getOrElse(Function<Throwable, ? extends T> fallback) {
+ return success ? value : fallback.apply(error);
+ }
+
+ /**
+ * Returns a human-readable representation of this result:
+ * {@code AwaitResult.Success[value]} or {@code AwaitResult.Failure[error]}.
+ */
+ @Override
+ public String toString() {
+ return success
+ ? "AwaitResult.Success[" + value + "]"
+ : "AwaitResult.Failure[" + error + "]";
+ }
+}
diff --git a/src/main/java/groovy/concurrent/Awaitable.java b/src/main/java/groovy/concurrent/Awaitable.java
new file mode 100644
index 0000000000..d7bf8fb689
--- /dev/null
+++ b/src/main/java/groovy/concurrent/Awaitable.java
@@ -0,0 +1,537 @@
+/*
+ * 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 groovy.concurrent;
+
+import org.apache.groovy.runtime.async.AsyncSupport;
+import org.apache.groovy.runtime.async.GroovyPromise;
+
+import java.util.List;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+/**
+ * Core abstraction for asynchronous computations in Groovy, analogous to
+ * C#'s {@code Task<T>} and JavaScript's {@code Promise<T>}.
+ * <p>
+ * {@code Awaitable} serves as both an instance type (the result of {@code async}
+ * methods and the input to {@code await}) and a static API surface for
+ * combinators, factories, and configuration — just as C#'s {@code Task} exposes
+ * {@code Task.WhenAll}, {@code Task.WhenAny}, {@code Task.Delay}, and
+ * {@code Task.FromResult} as static members.
+ * <p>
+ * <b>Static combinators</b> (all return {@code Awaitable}, suitable for use
+ * with {@code await}):
+ * <ul>
+ * <li>{@link #all(Object...) Awaitable.all(a, b, c)} — like
+ * {@code Task.WhenAll()} / {@code Promise.all()}</li>
+ * <li>{@link #any(Object...) Awaitable.any(a, b)} — like
+ * {@code Task.WhenAny()} / {@code Promise.race()}</li>
+ * <li>{@link #allSettled(Object...) Awaitable.allSettled(a, b)} — like
+ * {@code Promise.allSettled()}</li>
+ * <li>{@link #delay(long) Awaitable.delay(ms)} — like
+ * {@code Task.Delay()} / {@code setTimeout}</li>
+ * <li>{@link #orTimeoutMillis(Object, long) Awaitable.orTimeoutMillis(task, ms)} — like
+ * Kotlin's {@code withTimeout} or a JavaScript promise raced against a timer</li>
+ * <li>{@link #completeOnTimeoutMillis(Object, Object, long)
+ * Awaitable.completeOnTimeoutMillis(task, fallback, ms)} —
+ * like a timeout with fallback/default value</li>
+ * </ul>
+ * <p>
+ * <b>Static factories and conversion:</b>
+ * <ul>
+ * <li>{@link #from(Object) Awaitable.from(source)} — converts any supported
+ * async type (CompletableFuture, CompletionStage, Future, Flow.Publisher, etc.)
+ * to an {@code Awaitable}</li>
+ * <li>{@link #of(Object) Awaitable.of(value)} — like
+ * {@code Task.FromResult()} / {@code Promise.resolve()}</li>
+ * <li>{@link #failed(Throwable) Awaitable.failed(error)} — like
+ * {@code Task.FromException()} / {@code Promise.reject()}</li>
+ * </ul>
+ * <p>
+ * <b>Instance continuations</b> provide ergonomic composition without exposing
+ * raw {@link CompletableFuture} APIs:
+ * <ul>
+ * <li>{@link #then(Function)} and {@link #thenCompose(Function)} for success chaining</li>
+ * <li>{@link #thenAccept(Consumer)} for side-effecting continuations</li>
+ * <li>{@link #exceptionally(Function)}, {@link #whenComplete(BiConsumer)},
+ * and {@link #handle(BiFunction)} for failure/completion handling</li>
+ * <li>{@link #orTimeout(long, TimeUnit)} and
+ * {@link #completeOnTimeout(Object, long, TimeUnit)} for deadline composition</li>
+ * </ul>
+ * <p>
+ * Third-party frameworks (RxJava, Reactor, etc.) can integrate by registering
+ * an {@link AwaitableAdapter} via {@link AwaitableAdapterRegistry}.
+ * <p>
+ * The default implementation, {@link GroovyPromise}, delegates to
+ * {@link CompletableFuture} but users never need to depend on that detail.
+ *
+ * @param <T> the result type
+ * @see GroovyPromise
+ * @see AwaitableAdapter
+ * @since 6.0.0
+ */
+public interface Awaitable<T> {
+
+ /**
+ * Blocks until the computation completes and returns the result.
+ *
+ * @return the computed result
+ * @throws InterruptedException if the calling thread is interrupted while waiting
+ * @throws ExecutionException if the computation completed exceptionally
+ */
+ T get() throws InterruptedException, ExecutionException;
+
+ /**
+ * Blocks until the computation completes or the timeout expires.
+ *
+ * @param timeout the maximum time to wait
+ * @param unit the time unit of the timeout argument
+ * @return the computed result
+ * @throws InterruptedException if the calling thread is interrupted while waiting
+ * @throws ExecutionException if the computation completed exceptionally
+ * @throws TimeoutException if the wait timed out
+ */
+ T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
+
+ /**
+ * Returns {@code true} if the computation has completed (normally,
+ * exceptionally, or via cancellation).
+ *
+ * @return {@code true} if complete
+ */
+ boolean isDone();
+
+ /**
+ * Attempts to cancel the computation. If the computation has not yet started
+ * or is still running, it will be cancelled with a {@link CancellationException}.
+ * Analogous to C#'s {@code CancellationToken}.
+ *
+ * @return {@code true} if the computation was successfully cancelled
+ */
+ boolean cancel();
+
+ /**
+ * Returns {@code true} if the computation was cancelled before completing normally.
+ *
+ * @return {@code true} if cancelled
+ */
+ boolean isCancelled();
+
+ /**
+ * Returns {@code true} if this computation completed exceptionally
+ * (including cancellation).
+ *
+ * @return {@code true} if completed with an error or cancellation
+ */
+ boolean isCompletedExceptionally();
+
+ /**
+ * Returns a new {@code Awaitable} whose result is obtained by applying the
+ * given function to this awaitable's result when it completes.
+ *
+ * @param fn the mapping function
+ * @param <U> the type of the mapped result
+ * @return a new awaitable holding the mapped result
+ */
+ <U> Awaitable<U> then(Function<? super T, ? extends U> fn);
+
+ /**
+ * Returns a new {@code Awaitable} produced by applying the given async
+ * function to this awaitable's result, flattening the nested {@code Awaitable}.
+ * This is the monadic {@code flatMap} operation for awaitables.
+ *
+ * @param fn the async mapping function that returns an {@code Awaitable}
+ * @param <U> the type of the inner awaitable's result
+ * @return a new awaitable holding the inner result
+ */
+ <U> Awaitable<U> thenCompose(Function<? super T, ? extends Awaitable<U>> fn);
+
+ /**
+ * Returns a new {@code Awaitable} that, when this one completes normally,
+ * invokes the given consumer and completes with {@code null}.
+ * <p>
+ * This is analogous to JavaScript's {@code promise.then(v => sideEffect(v))}
+ * when the result value is no longer needed, and to the consumer-oriented
+ * continuations commonly used with C#, Kotlin, and Swift async APIs.
+ *
+ * @param action the side-effecting consumer to invoke on success
+ * @return a new awaitable that completes after the action runs
+ * @since 6.0.0
+ */
+ default Awaitable<Void> thenAccept(Consumer<? super T> action) {
+ return GroovyPromise.of(toCompletableFuture().thenAccept(action));
+ }
+
+ /**
+ * Returns a new {@code Awaitable} that, if this one completes exceptionally,
+ * applies the given function to the exception to produce a recovery value.
+ * The throwable passed to the function is deeply unwrapped to strip JDK
+ * wrapper layers.
+ *
+ * @param fn the recovery function
+ * @return a new awaitable that recovers from failures
+ */
+ Awaitable<T> exceptionally(Function<Throwable, ? extends T> fn);
+
+ /**
+ * Returns a new {@code Awaitable} that invokes the given action when this
+ * computation completes, regardless of success or failure.
+ * <p>
+ * The supplied throwable is transparently unwrapped so handlers see the
+ * original failure rather than {@link ExecutionException} /
+ * {@link java.util.concurrent.CompletionException} wrappers.
+ * This is analogous to JavaScript's {@code Promise.prototype.finally()} and
+ * to completion callbacks in C#, Kotlin, and Swift.
+ *
+ * @param action the completion callback receiving the result or failure
+ * @return a new awaitable that completes with the original result
+ * @since 6.0.0
+ */
+ default Awaitable<T> whenComplete(BiConsumer<? super T, ? super Throwable> action) {
+ return GroovyPromise.of(toCompletableFuture().whenComplete((value, error) ->
+ action.accept(value, error == null ? null : AsyncSupport.deepUnwrap(error))));
+ }
+
+ /**
+ * Returns a new {@code Awaitable} that handles both the successful and the
+ * exceptional completion paths in a single continuation.
+ * <p>
+ * The supplied throwable is transparently unwrapped so the handler sees the
+ * original failure. This mirrors Java's {@code CompletableFuture.handle()},
+ * while providing the same “single place for success/failure projection”
+ * convenience commonly used in C#, Kotlin, and Swift async code.
+ *
+ * @param fn the handler receiving either the value or the failure
+ * @param <U> the projected result type
+ * @return a new awaitable holding the handler's result
+ * @since 6.0.0
+ */
+ default <U> Awaitable<U> handle(BiFunction<? super T, Throwable, ? extends U> fn) {
+ return GroovyPromise.of(toCompletableFuture().handle((value, error) ->
+ fn.apply(value, error == null ? null : AsyncSupport.deepUnwrap(error))));
+ }
+
+ /**
+ * Returns a new {@code Awaitable} that fails with {@link TimeoutException}
+ * if this computation does not complete within the specified milliseconds.
+ * <p>
+ * Unlike {@link #get(long, TimeUnit)}, this is a non-blocking, composable
+ * timeout combinator: it returns another {@code Awaitable} that can itself
+ * be awaited, chained, or passed to {@link #all(Object...)} / {@link #any(Object...)}.
+ *
+ * @param timeoutMillis the timeout duration in milliseconds
+ * @return a new awaitable with timeout semantics
+ * @since 6.0.0
+ */
+ default Awaitable<T> orTimeoutMillis(long timeoutMillis) {
+ return Awaitable.orTimeout(this, timeoutMillis, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Returns a new {@code Awaitable} that fails with {@link TimeoutException}
+ * if this computation does not complete within the specified duration.
+ *
+ * @param duration the timeout duration
+ * @param unit the time unit
+ * @return a new awaitable with timeout semantics
+ * @since 6.0.0
+ */
+ default Awaitable<T> orTimeout(long duration, TimeUnit unit) {
+ return Awaitable.orTimeout(this, duration, unit);
+ }
+
+ /**
+ * Returns a new {@code Awaitable} that completes with the supplied fallback
+ * value if this computation does not finish before the timeout expires.
+ *
+ * @param fallback the value to use when the timeout expires
+ * @param timeoutMillis the timeout duration in milliseconds
+ * @return a new awaitable that yields either the original result or the fallback
+ * @since 6.0.0
+ */
+ default Awaitable<T> completeOnTimeoutMillis(T fallback, long timeoutMillis) {
+ return Awaitable.completeOnTimeout(this, fallback, timeoutMillis, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Returns a new {@code Awaitable} that completes with the supplied fallback
+ * value if this computation does not finish before the timeout expires.
+ *
+ * @param fallback the value to use when the timeout expires
+ * @param duration the timeout duration
+ * @param unit the time unit
+ * @return a new awaitable that yields either the original result or the fallback
+ * @since 6.0.0
+ */
+ default Awaitable<T> completeOnTimeout(T fallback, long duration, TimeUnit unit) {
+ return Awaitable.completeOnTimeout(this, fallback, duration, unit);
+ }
+
+ /**
+ * Converts this {@code Awaitable} to a JDK {@link CompletableFuture}
+ * for interoperability with APIs that require it.
+ *
+ * @return a {@code CompletableFuture} representing this computation
+ */
+ CompletableFuture<T> toCompletableFuture();
+
+ // ---- Static factories ----
+
+ /**
+ * Converts the given source to an {@code Awaitable}.
+ * <p>
+ * If the source is already an {@code Awaitable}, it is returned as-is.
+ * Otherwise, the {@link AwaitableAdapterRegistry} is consulted to find a
+ * suitable adapter. Built-in adapters handle {@link CompletableFuture},
+ * {@link java.util.concurrent.CompletionStage}, {@link java.util.concurrent.Future},
+ * and {@link java.util.concurrent.Flow.Publisher}; third-party frameworks
+ * can register additional adapters via the registry.
+ * <p>
+ * This is the recommended entry point for converting external async types
+ * to {@code Awaitable}:
+ * <pre>
+ * Awaitable<String> aw = Awaitable.from(someCompletableFuture)
+ * Awaitable<Integer> aw2 = Awaitable.from(someReactorMono)
+ * </pre>
+ *
+ * @param source the source object; must not be {@code null}
+ * @param <T> the result type
+ * @return an awaitable backed by the source
+ * @throws IllegalArgumentException if {@code source} is {@code null}
+ * or no adapter supports the source type
+ * @see AwaitableAdapterRegistry#toAwaitable(Object)
+ * @since 6.0.0
+ */
+ static <T> Awaitable<T> from(Object source) {
+ return AwaitableAdapterRegistry.toAwaitable(source);
+ }
+
+ /**
+ * Returns an already-completed {@code Awaitable} with the given value.
+ * Analogous to C#'s {@code Task.FromResult()} or JavaScript's
+ * {@code Promise.resolve()}.
+ *
+ * @param value the result value (may be {@code null})
+ * @param <T> the result type
+ * @return a completed awaitable
+ */
+ static <T> Awaitable<T> of(T value) {
+ return new GroovyPromise<>(CompletableFuture.completedFuture(value));
+ }
+
+ /**
+ * Returns an already-failed {@code Awaitable} with the given exception.
+ * Analogous to C#'s {@code Task.FromException()} or JavaScript's
+ * {@code Promise.reject()}.
+ */
+ static <T> Awaitable<T> failed(Throwable error) {
+ return new GroovyPromise<>(CompletableFuture.failedFuture(error));
+ }
+
+ // ---- Combinators (like C#'s Task.WhenAll/WhenAny, JS's Promise.all/any) ----
+
+ /**
+ * Returns an {@code Awaitable} that completes when all given sources
+ * complete, with a list of their results in order.
+ * <p>
+ * Analogous to C#'s {@code Task.WhenAll()} or JavaScript's
+ * {@code Promise.all()}. Unlike blocking APIs, this returns immediately
+ * and the caller should {@code await} the result:
+ * <pre>
+ * def results = await Awaitable.all(task1, task2, task3)
+ * </pre>
+ *
+ * @param sources the awaitables, futures, or adapted objects to wait for
+ * @return an awaitable that resolves to a list of results
+ */
+ static Awaitable<List<Object>> all(Object... sources) {
+ return AsyncSupport.allAsync(sources);
+ }
+
+ /**
+ * Returns an {@code Awaitable} that completes when the first of the given
+ * sources completes, with the winner's result.
+ * <p>
+ * Analogous to C#'s {@code Task.WhenAny()} or JavaScript's
+ * {@code Promise.any()} / {@code Promise.race()}.
+ * <pre>
+ * def winner = await Awaitable.any(task1, task2)
+ * </pre>
+ *
+ * @param sources the awaitables to race
+ * @return an awaitable that resolves to the first completed result
+ */
+ @SuppressWarnings("unchecked")
+ static <T> Awaitable<T> any(Object... sources) {
+ return AsyncSupport.anyAsync(sources);
+ }
+
+ /**
+ * Returns an {@code Awaitable} that completes when all given sources
+ * have settled (succeeded or failed), with a list of {@link AwaitResult}
+ * objects describing each outcome.
+ * <p>
+ * Analogous to JavaScript's {@code Promise.allSettled()}. Never throws
+ * for individual failures; they are captured in the result list.
+ * <pre>
+ * def results = await Awaitable.allSettled(task1, task2)
+ * results.each { println it.success ? it.value : it.error }
+ * </pre>
+ *
+ * @param sources the awaitables to settle
+ * @return an awaitable that resolves to a list of settled results
+ */
+ static Awaitable<List<AwaitResult<Object>>> allSettled(Object... sources) {
+ return AsyncSupport.allSettledAsync(sources);
+ }
+
+ // ---- Delay (like C#'s Task.Delay, JS's setTimeout) ----
+
+ /**
+ * Returns an {@code Awaitable} that completes after the specified delay.
+ * Analogous to C#'s {@code Task.Delay()} or JavaScript's
+ * {@code new Promise(resolve => setTimeout(resolve, ms))}.
+ * <pre>
+ * await Awaitable.delay(1000) // pause for 1 second
+ * </pre>
+ *
+ * @param milliseconds the delay in milliseconds (must be ≥ 0)
+ * @return an awaitable that completes after the delay
+ */
+ static Awaitable<Void> delay(long milliseconds) {
+ return AsyncSupport.delay(milliseconds);
+ }
+
+ /**
+ * Returns an {@code Awaitable} that completes after the specified delay.
+ *
+ * @param duration the delay duration (must be ≥ 0)
+ * @param unit the time unit
+ * @return an awaitable that completes after the delay
+ */
+ static Awaitable<Void> delay(long duration, TimeUnit unit) {
+ return AsyncSupport.delay(duration, unit);
+ }
+
+ // ---- Timeout combinators ----
+
+ /**
+ * Adapts the given source to an {@code Awaitable} and applies a non-blocking
+ * fail-fast timeout. Returns a new awaitable that fails with
+ * {@link TimeoutException} if the source does not complete before the
+ * deadline elapses.
+ * <p>
+ * The source may be a Groovy {@link Awaitable}, a JDK
+ * {@link CompletableFuture}/{@link java.util.concurrent.CompletionStage},
+ * or any type supported by {@link AwaitableAdapterRegistry}. This provides
+ * a concise timeout combinator analogous to Kotlin's {@code withTimeout},
+ * but as a value-level operation that returns another awaitable.
+ *
+ * @param source the async source to time out
+ * @param timeoutMillis the timeout duration in milliseconds
+ * @param <T> the result type
+ * @return a new awaitable with timeout semantics
+ * @since 6.0.0
+ */
+ static <T> Awaitable<T> orTimeoutMillis(Object source, long timeoutMillis) {
+ return AsyncSupport.orTimeout(source, timeoutMillis, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Adapts the given source and applies a non-blocking fail-fast timeout
+ * with explicit {@link TimeUnit}.
+ *
+ * @param source the async source to time out
+ * @param duration the timeout duration
+ * @param unit the time unit
+ * @param <T> the result type
+ * @return a new awaitable with timeout semantics
+ * @since 6.0.0
+ */
+ static <T> Awaitable<T> orTimeout(Object source, long duration, TimeUnit unit) {
+ return AsyncSupport.orTimeout(source, duration, unit);
+ }
+
+ /**
+ * Adapts the given source and returns a new awaitable that yields the
+ * supplied fallback value if the timeout expires first.
+ *
+ * @param source the async source to wait for
+ * @param fallback the fallback value to use on timeout
+ * @param timeoutMillis the timeout duration in milliseconds
+ * @param <T> the result type
+ * @return a new awaitable yielding either the original result or the fallback
+ * @since 6.0.0
+ */
+ static <T> Awaitable<T> completeOnTimeoutMillis(Object source, T fallback, long timeoutMillis) {
+ return AsyncSupport.completeOnTimeout(source, fallback, timeoutMillis, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Adapts the given source and returns a new awaitable that yields the
+ * supplied fallback value if the timeout expires first.
+ *
+ * @param source the async source to wait for
+ * @param fallback the fallback value to use on timeout
+ * @param duration the timeout duration
+ * @param unit the time unit
+ * @param <T> the result type
+ * @return a new awaitable yielding either the original result or the fallback
+ * @since 6.0.0
+ */
+ static <T> Awaitable<T> completeOnTimeout(Object source, T fallback, long duration, TimeUnit unit) {
+ return AsyncSupport.completeOnTimeout(source, fallback, duration, unit);
+ }
+
+ // ---- Executor configuration ----
+
+ /**
+ * Returns the current executor used for {@code async} operations.
+ * On JDK 21+, this defaults to a virtual-thread-per-task executor.
+ * On earlier JDKs, a cached daemon thread pool is used.
+ */
+ static Executor getExecutor() {
+ return AsyncSupport.getExecutor();
+ }
+
+ /**
+ * Sets the executor to use for {@code async} operations.
+ * Pass {@code null} to reset to the default executor.
+ *
+ * @param executor the executor to use, or {@code null} for default
+ */
+ static void setExecutor(Executor executor) {
+ AsyncSupport.setExecutor(executor);
+ }
+
+ /**
+ * Returns {@code true} if the running JVM supports virtual threads (JDK 21+).
+ */
+ static boolean isVirtualThreadsAvailable() {
+ return AsyncSupport.isVirtualThreadsAvailable();
+ }
+}
diff --git a/src/main/java/groovy/concurrent/AwaitableAdapter.java b/src/main/java/groovy/concurrent/AwaitableAdapter.java
new file mode 100644
index 0000000000..148440a595
--- /dev/null
+++ b/src/main/java/groovy/concurrent/AwaitableAdapter.java
@@ -0,0 +1,86 @@
+/*
+ * 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 groovy.concurrent;
+
+/**
+ * Service Provider Interface (SPI) for adapting third-party asynchronous types
+ * to Groovy's {@link Awaitable} and {@link AsyncStream} abstractions.
+ * <p>
+ * Implementations are discovered automatically via {@link java.util.ServiceLoader}.
+ * To register an adapter, create a file
+ * {@code META-INF/services/groovy.concurrent.AwaitableAdapter} containing the
+ * fully-qualified class name of your implementation.
+ * <p>
+ * <b>Example – RxJava adapter:</b>
+ * <pre>
+ * public class RxJavaAwaitableAdapter implements AwaitableAdapter {
+ * public boolean supportsAwaitable(Class<?> type) {
+ * return io.reactivex.rxjava3.core.Single.class.isAssignableFrom(type);
+ * }
+ * public <T> Awaitable<T> toAwaitable(Object source) {
+ * Single<T> single = (Single<T>) source;
+ * return new org.apache.groovy.runtime.async.GroovyPromise<>(single.toCompletionStage().toCompletableFuture());
+ * }
+ * // ... supportsAsyncStream / toAsyncStream for Observable, Flowable, etc.
+ * }
+ * </pre>
+ *
+ * @see AwaitableAdapterRegistry
+ * @since 6.0.0
+ */
+public interface AwaitableAdapter {
+
+ /**
+ * Returns {@code true} if this adapter can convert instances of the given
+ * type to {@link Awaitable}.
+ */
+ boolean supportsAwaitable(Class<?> type);
+
+ /**
+ * Converts the given source object to an {@link Awaitable}.
+ * Called only when {@link #supportsAwaitable} returned {@code true}.
+ */
+ <T> Awaitable<T> toAwaitable(Object source);
+
+ /**
+ * Returns {@code true} if this adapter can convert instances of the given
+ * type to {@link AsyncStream}. Defaults to {@code false}; override if
+ * this adapter supports multi-value stream conversion (e.g., for Reactor
+ * {@code Flux} or RxJava {@code Observable}).
+ *
+ * @param type the source class to check
+ * @return {@code true} if this adapter can produce an {@code AsyncStream}
+ */
+ default boolean supportsAsyncStream(Class<?> type) {
+ return false;
+ }
+
+ /**
+ * Converts the given source object to an {@link AsyncStream}.
+ * Called only when {@link #supportsAsyncStream} returned {@code true}.
+ *
+ * @param source the source object (e.g., a Reactor {@code Flux})
+ * @param <T> the element type
+ * @return an async stream backed by the source
+ * @throws UnsupportedOperationException if not overridden (default)
+ */
+ default <T> AsyncStream<T> toAsyncStream(Object source) {
+ throw new UnsupportedOperationException("AsyncStream conversion not supported by " + getClass().getName());
+ }
+}
diff --git a/src/main/java/groovy/concurrent/AwaitableAdapterRegistry.java b/src/main/java/groovy/concurrent/AwaitableAdapterRegistry.java
new file mode 100644
index 0000000000..61398d154d
--- /dev/null
+++ b/src/main/java/groovy/concurrent/AwaitableAdapterRegistry.java
@@ -0,0 +1,262 @@
+/*
+ * 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 groovy.concurrent;
+
+import org.apache.groovy.runtime.async.GroovyPromise;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.ServiceLoader;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Future;
+
+/**
+ * Central registry for {@link AwaitableAdapter} instances.
+ * <p>
+ * On class-load, adapters are discovered via {@link ServiceLoader} from
+ * {@code META-INF/services/groovy.concurrent.AwaitableAdapter}. A built-in
+ * adapter is always present as the lowest-priority fallback, handling:
+ * <ul>
+ * <li>{@link CompletableFuture} and {@link CompletionStage}</li>
+ * <li>{@link Future} (adapted via a blocking wrapper)</li>
+ * </ul>
+ * <p>
+ * JDK {@link java.util.concurrent.Flow.Publisher} support is provided by
+ * the separately registered
+ * {@link org.apache.groovy.runtime.async.FlowPublisherAdapter}, which is
+ * auto-discovered via ServiceLoader. Third-party frameworks (Reactor,
+ * RxJava, etc.) may register their own higher-priority adapters.
+ * <p>
+ * Additional adapters can be registered at runtime via {@link #register}.
+ *
+ * @see AwaitableAdapter
+ * @since 6.0.0
+ */
+public class AwaitableAdapterRegistry {
+
+ private static final List<AwaitableAdapter> ADAPTERS = new CopyOnWriteArrayList<>();
+
+ /**
+ * Optional executor supplier for blocking Future adaptation, to avoid
+ * starving the common pool. Defaults to null; when set, the provided
+ * executor is used instead of {@code CompletableFuture.runAsync}'s default.
+ */
+ private static volatile Executor blockingExecutor;
+
+ static {
+ // SPI-discovered adapters
+ for (AwaitableAdapter adapter : ServiceLoader.load(AwaitableAdapter.class)) {
+ ADAPTERS.add(adapter);
+ }
+ // Built-in fallback (lowest priority)
+ ADAPTERS.add(new BuiltInAdapter());
+ }
+
+ private AwaitableAdapterRegistry() { }
+
+ /**
+ * Registers an adapter with higher priority than existing ones.
+ *
+ * @return an {@link AutoCloseable} that, when closed, removes this adapter
+ * from the registry. Useful for test isolation.
+ */
+ public static AutoCloseable register(AwaitableAdapter adapter) {
+ ADAPTERS.add(0, adapter);
+ return () -> ADAPTERS.remove(adapter);
+ }
+
+ /**
+ * Removes the given adapter from the registry.
+ *
+ * @return {@code true} if the adapter was found and removed
+ */
+ public static boolean unregister(AwaitableAdapter adapter) {
+ return ADAPTERS.remove(adapter);
+ }
+
+ /**
+ * Sets the executor used for blocking {@link Future#get()} adaptation.
+ * When non-null, this executor is used instead of
+ * {@link CompletableFuture#runAsync(Runnable)}'s default executor to avoid
+ * pool starvation when many blocking futures are being adapted
+ * simultaneously.
+ *
+ * @param executor the executor to use, or {@code null} to use the default
+ */
+ public static void setBlockingExecutor(Executor executor) {
+ blockingExecutor = executor;
+ }
+
+ /**
+ * Converts the given source to an {@link Awaitable}.
+ * If the source is already an {@code Awaitable}, it is returned as-is.
+ * <p>
+ * <b>Tip:</b> user code should generally prefer {@link Awaitable#from(Object)},
+ * which delegates to this method but is more discoverable from the
+ * {@code Awaitable} type itself.
+ *
+ * @param source the source object; must not be {@code null}
+ * @throws IllegalArgumentException if {@code source} is {@code null}
+ * or no adapter supports the source type
+ * @see Awaitable#from(Object)
+ */
+ @SuppressWarnings("unchecked")
+ static <T> Awaitable<T> toAwaitable(Object source) {
+ if (source == null) {
+ throw new IllegalArgumentException("Cannot convert null to Awaitable");
+ }
+ if (source instanceof Awaitable) return (Awaitable<T>) source;
+ Class<?> type = source.getClass();
+ for (AwaitableAdapter adapter : ADAPTERS) {
+ if (adapter.supportsAwaitable(type)) {
+ return adapter.toAwaitable(source);
+ }
+ }
+ throw new IllegalArgumentException(
+ "No AwaitableAdapter found for type: " + type.getName()
+ + ". Register one via AwaitableAdapterRegistry.register() or ServiceLoader.");
+ }
+
+ /**
+ * Converts the given source to an {@link AsyncStream}.
+ * If the source is already an {@code AsyncStream}, it is returned as-is.
+ * <p>
+ * <b>Tip:</b> user code should generally prefer {@link AsyncStream#from(Object)},
+ * which delegates to this method but is more discoverable from the
+ * {@code AsyncStream} type itself.
+ *
+ * @param source the source object; must not be {@code null}
+ * @throws IllegalArgumentException if {@code source} is {@code null}
+ * or no adapter supports the source type
+ * @see AsyncStream#from(Object)
+ */
+ @SuppressWarnings("unchecked")
+ static <T> AsyncStream<T> toAsyncStream(Object source) {
+ if (source == null) {
+ throw new IllegalArgumentException("Cannot convert null to AsyncStream");
+ }
+ if (source instanceof AsyncStream) return (AsyncStream<T>) source;
+ Class<?> type = source.getClass();
+ for (AwaitableAdapter adapter : ADAPTERS) {
+ if (adapter.supportsAsyncStream(type)) {
+ return adapter.toAsyncStream(source);
+ }
+ }
+ throw new IllegalArgumentException(
+ "No AsyncStream adapter found for type: " + type.getName()
+ + ". Register one via AwaitableAdapterRegistry.register() or ServiceLoader.");
+ }
+
+ /**
+ * Built-in adapter handling JDK {@link CompletableFuture}, {@link CompletionStage},
+ * {@link Future}, and {@link Iterable}/{@link Iterator} (for async stream bridging).
+ * <p>
+ * {@link CompletionStage} support enables seamless integration with frameworks
+ * that return {@code CompletionStage} (e.g., Spring's async APIs, Reactor's
+ * {@code Mono.toFuture()}, etc.) without any additional adapter registration.
+ */
+ private static class BuiltInAdapter implements AwaitableAdapter {
+
+ @Override
+ public boolean supportsAwaitable(Class<?> type) {
+ return CompletionStage.class.isAssignableFrom(type)
+ || Future.class.isAssignableFrom(type);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public <T> Awaitable<T> toAwaitable(Object source) {
+ if (source instanceof CompletionStage) {
+ return new GroovyPromise<>(((CompletionStage<T>) source).toCompletableFuture());
+ }
+ if (source instanceof Future) {
+ Future<T> future = (Future<T>) source;
+ CompletableFuture<T> cf = new CompletableFuture<>();
+ if (future.isDone()) {
+ completeFrom(cf, future);
+ } else {
+ Executor exec = blockingExecutor;
+ if (exec != null) {
+ CompletableFuture.runAsync(() -> completeFrom(cf, future), exec);
+ } else {
+ CompletableFuture.runAsync(() -> completeFrom(cf, future));
+ }
+ }
+ return new GroovyPromise<>(cf);
+ }
+ throw new IllegalArgumentException("Cannot convert to Awaitable: " + source.getClass());
+ }
+
+ @Override
+ public boolean supportsAsyncStream(Class<?> type) {
+ return Iterable.class.isAssignableFrom(type)
+ || Iterator.class.isAssignableFrom(type);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public <T> AsyncStream<T> toAsyncStream(Object source) {
+ final Iterator<T> iterator;
+ if (source instanceof Iterable) {
+ iterator = ((Iterable<T>) source).iterator();
+ } else if (source instanceof Iterator) {
+ iterator = (Iterator<T>) source;
+ } else {
+ throw new IllegalArgumentException("Cannot convert to AsyncStream: " + source.getClass());
+ }
+ return new AsyncStream<T>() {
+ private T current;
+
+ @Override
+ public Awaitable<Boolean> moveNext() {
+ boolean hasNext = iterator.hasNext();
+ if (hasNext) current = iterator.next();
+ return Awaitable.of(hasNext);
+ }
+
+ @Override
+ public T getCurrent() {
+ return current;
+ }
+ };
+ }
+
+ private static <T> void completeFrom(CompletableFuture<T> cf, Future<T> future) {
+ try {
+ cf.complete(future.get());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ CancellationException ce = new CancellationException("Interrupted while adapting Future");
+ ce.initCause(e);
+ cf.completeExceptionally(ce);
+ } catch (ExecutionException e) {
+ cf.completeExceptionally(e.getCause() != null ? e.getCause() : e);
+ } catch (CancellationException e) {
+ cf.completeExceptionally(e);
+ } catch (Exception e) {
+ cf.completeExceptionally(e);
+ }
+ }
+ }
+}
diff --git a/src/main/java/groovy/transform/Async.java b/src/main/java/groovy/transform/Async.java
new file mode 100644
index 0000000000..2e09ba2a46
--- /dev/null
+++ b/src/main/java/groovy/transform/Async.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package groovy.transform;
+
+import org.codehaus.groovy.transform.GroovyASTTransformationClass;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Method annotation to make a method execute asynchronously and return an
+ * {@link groovy.concurrent.Awaitable Awaitable}.
+ * <p>
+ * When applied to a method, the {@code @Async} transformation will:
+ * <ul>
+ * <li>Change the method's return type to {@code Awaitable<T>} (where {@code T} is the original return type)</li>
+ * <li>Execute the method body asynchronously via {@link org.apache.groovy.runtime.async.AsyncSupport#executeAsync AsyncSupport.executeAsync}</li>
+ * <li>Transform any {@code await(expr)} calls within the method to use {@link org.apache.groovy.runtime.async.AsyncSupport#await AsyncSupport.await}</li>
+ * <li>For generator methods containing {@code yield return}, return
+ * {@link groovy.concurrent.AsyncStream AsyncStream<T>} instead</li>
+ * <li>If the method contains {@code defer} statements, wrap the body
+ * in try-finally to execute deferred actions in LIFO order on exit</li>
+ * </ul>
+ * <p>
+ * The {@code await(future)} pattern inside an {@code @Async} method blocks the async thread until the
+ * given future completes and returns the unwrapped result. This provides a sequential programming model
+ * over asynchronous operations, similar to JavaScript's {@code async/await} or C#'s {@code async/await}.
+ * <p>
+ * <em>Example usage:</em>
+ * <pre>
+ * import groovy.transform.Async
+ * import groovy.concurrent.Awaitable
+ *
+ * class DataService {
+ *
+ * {@code @}Async
+ * def fetchUser(long id) {
+ * def profile = await(fetchProfile(id))
+ * def orders = await(fetchOrders(id))
+ * return [profile: profile, orders: orders]
+ * }
+ *
+ * {@code @}Async
+ * Map fetchProfile(long id) {
+ * return [name: "User$id"]
+ * }
+ *
+ * {@code @}Async
+ * List fetchOrders(long id) {
+ * return ["order1", "order2"]
+ * }
+ * }
+ *
+ * def service = new DataService()
+ * def awaitable = service.fetchUser(1) // returns Awaitable
+ * def result = awaitable.get() // blocks until complete
+ * assert result.profile.name == 'User1'
+ * assert result.orders.size() == 2
+ * </pre>
+ * <p>
+ * For ad-hoc async execution without annotating a method, use the {@code async}
+ * closure syntax. Note that {@code async { ... }} returns a {@code Closure}
+ * that must be explicitly called:
+ * <pre>
+ * def task = async {
+ * def data = await fetchFromRemote()
+ * return process(data)
+ * }
+ * def result = task().get() // call the closure, then get the result
+ * </pre>
+ * <p>
+ * <b>Custom Executor:</b> By default, tasks run on virtual threads (JDK 21+) or
+ * a cached daemon thread pool whose maximum size is controlled by the system
+ * property {@code groovy.async.parallelism} (JDK < 21). You can specify a custom
+ * executor by providing the name of a field in the class:
+ * <pre>
+ * class MyService {
+ * private final Executor myExecutor = Executors.newFixedThreadPool(10)
+ *
+ * {@code @}Async(executor = "myExecutor")
+ * def fetchData() {
+ * return await(remoteCall())
+ * }
+ * }
+ * </pre>
+ * <p>
+ * <b>Restrictions:</b>
+ * <ul>
+ * <li>Cannot be applied to abstract methods</li>
+ * <li>Cannot be applied to constructors</li>
+ * <li>Cannot be applied to methods that already return an async type
+ * ({@code Awaitable}, {@code AsyncStream}, {@code CompletableFuture},
+ * {@code CompletionStage}, or {@code Future})</li>
+ * </ul>
+ *
+ * @see groovy.concurrent.Awaitable
+ * @see groovy.concurrent.AsyncStream
+ * @since 6.0.0
+ */
+@Documented
+@Retention(RetentionPolicy.SOURCE)
+@Target(ElementType.METHOD)
+@GroovyASTTransformationClass("org.codehaus.groovy.transform.AsyncASTTransformation")
+public @interface Async {
+ /**
+ * Optional name of a field in the declaring class that holds an
+ * {@link java.util.concurrent.Executor Executor} to use for async execution.
+ * If empty (the default), uses the default executor (virtual threads on
+ * JDK 21+, or a cached daemon thread pool controlled by the system
+ * property {@code groovy.async.parallelism}).
+ *
+ * @return the executor field name, or empty string for the default executor
+ */
+ String executor() default "";
+}
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
index ff07bc7fb1..198067cf4d 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
@@ -131,6 +131,7 @@ import org.codehaus.groovy.runtime.StringGroovyMethods;
import org.codehaus.groovy.syntax.Numbers;
import org.codehaus.groovy.syntax.SyntaxException;
import org.codehaus.groovy.syntax.Types;
+import org.codehaus.groovy.transform.AsyncTransformHelper;
import org.objectweb.asm.Opcodes;
import java.io.BufferedReader;
@@ -465,7 +466,12 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
}
@Override
- public ForStatement visitForStmtAlt(final ForStmtAltContext ctx) {
+ public Statement visitForStmtAlt(final ForStmtAltContext ctx) {
+ // 'for await' async iteration
+ if (ctx.AWAIT() != null) {
+ return visitForAwait(ctx);
+ }
+
Function<Statement, ForStatement> maker = this.visitForControl(ctx.forControl());
Statement loopBody = this.unpackStatement((Statement) this.visit(ctx.statement()));
@@ -473,6 +479,58 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
return configureAST(maker.apply(loopBody), ctx);
}
+ /**
+ * Transforms {@code for await (item in source) { ... }} into a while-loop
+ * over an {@link groovy.concurrent.AsyncStream}: the source expression is
+ * adapted via {@code AsyncSupport.toAsyncStream()}, then repeatedly polled
+ * with {@code moveNext()} / {@code getCurrent()}.
+ * <p>
+ * Variable modifiers (e.g. {@code final}) from the enhanced-for declaration
+ * are applied to the synthesised loop variable, consistent with the
+ * standard {@code for (... in ...)} handling in
+ * {@link #visitEnhancedForControl}.
+ */
+ private Statement visitForAwait(final ForStmtAltContext ctx) {
+ ForControlContext forCtrl = ctx.forControl();
+ EnhancedForControlContext enhCtrl = forCtrl.enhancedForControl();
+ if (enhCtrl == null) {
+ throw createParsingFailedException("for await requires enhanced for syntax: for await (item in source)", ctx);
+ }
+
+ ClassNode varType = enhCtrl.type() != null ? this.visitType(enhCtrl.type()) : ClassHelper.dynamicType();
+ String varName = this.visitIdentifier(enhCtrl.identifier());
+ Expression source = (Expression) this.visit(enhCtrl.expression());
+ Statement loopBody = this.unpackStatement((Statement) this.visit(ctx.statement()));
+
+ // Apply variable modifiers (e.g. final) to the loop variable
+ VariableExpression loopVar = varX(varName, varType);
+ ModifierManager modifierManager = new ModifierManager(this,
+ this.visitVariableModifiersOpt(enhCtrl.variableModifiersOpt()));
+ modifierManager.processVariableExpression(loopVar);
+
+ String streamVar = "$__asyncStream__" + (asyncStreamCounter++);
+
+ // def $__asyncStream__N = AsyncSupport.toAsyncStream(source)
+ Expression toStreamCall = AsyncTransformHelper.buildToAsyncStreamCall(source);
+ ExpressionStatement streamDecl = new ExpressionStatement(declX(varX(streamVar), toStreamCall));
+
+ // while (AsyncSupport.await($__asyncStream__N.moveNext()))
+ Expression moveNextCall = callX(varX(streamVar), "moveNext");
+ Expression awaitCall = AsyncTransformHelper.buildAwaitCall(moveNextCall);
+ BooleanExpression condition = new BooleanExpression(awaitCall);
+
+ // def <varName> = $__asyncStream__N.getCurrent()
+ Expression getCurrentCall = callX(varX(streamVar), "getCurrent");
+ ExpressionStatement getItemStmt = new ExpressionStatement(declX(loopVar, getCurrentCall));
+
+ BlockStatement whileBody = block(getItemStmt, loopBody);
+ WhileStatement whileStmt = new WhileStatement(condition, whileBody);
+ Expression closeCall = AsyncTransformHelper.buildCloseStreamCall(varX(streamVar));
+ TryCatchStatement tryFinally = new TryCatchStatement(whileStmt, block(new ExpressionStatement(closeCall)));
+
+ return configureAST(block(streamDecl, tryFinally), ctx);
+ }
+
@Override
public Function<Statement, ForStatement> visitForControl(final ForControlContext ctx) {
// e.g. `for (var e : x) { }` and `for (e in x) { }`
@@ -865,6 +923,40 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
return configureAST(this.visitYieldStatement(ctx.yieldStatement()), ctx);
}
+ @Override
+ public ExpressionStatement visitYieldReturnStmtAlt(final YieldReturnStmtAltContext ctx) {
+ if (asyncContextDepth == 0) {
+ throw createParsingFailedException(
+ "`yield return` can only be used inside an async method or async closure", ctx);
+ }
+ Expression expr = (Expression) this.visit(ctx.expression());
+ Expression yieldCall = AsyncTransformHelper.buildYieldReturnCall(expr);
+ return configureAST(new ExpressionStatement(yieldCall), ctx);
+ }
+
+ @Override
+ public ExpressionStatement visitDeferStmtAlt(final DeferStmtAltContext ctx) {
+ if (asyncContextDepth == 0) {
+ throw createParsingFailedException(
+ "`defer` can only be used inside an async method or async closure", ctx);
+ }
+ Expression action;
+ ExpressionStatement stmtExprStmt = (ExpressionStatement) this.visit(ctx.statementExpression());
+ Expression expr = stmtExprStmt.getExpression();
+ if (expr instanceof ClosureExpression) {
+ // Already a closure/lambda — use directly as the defer action
+ action = expr;
+ } else {
+ // Wrap the statement expression in a closure: { -> expr }
+ ClosureExpression wrapper = closureX(Parameter.EMPTY_ARRAY, block(stmtExprStmt));
+ wrapper.setSourcePosition(stmtExprStmt);
+ action = wrapper;
+ }
+ // Emit: AsyncSupport.defer($__deferScope__, action)
+ Expression deferCall = AsyncTransformHelper.buildDeferCall(action);
+ return configureAST(new ExpressionStatement(deferCall), ctx);
+ }
+
@Override
public ContinueStatement visitContinueStatement(final ContinueStatementContext ctx) {
if (switchExpressionRuleContextStack.peek() instanceof SwitchExpressionContext) {
@@ -1201,6 +1293,10 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
throw createParsingFailedException("only sealed type declarations should have `permits` clause", ctx);
}
+ if (modifierManager.containsAny(ASYNC)) {
+ throw createParsingFailedException("modifier `async` is not allowed for type declarations", modifierManager.get(ASYNC).get());
+ }
+
int modifiers = modifierManager.getClassModifiersOpValue();
boolean syntheticPublic = ((modifiers & Opcodes.ACC_SYNTHETIC) != 0);
@@ -1685,8 +1781,15 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
Parameter[] parameters = this.visitFormalParameters(ctx.formalParameters());
ClassNode[] exceptions = this.visitQualifiedClassNameList(ctx.qualifiedClassNameList());
+ boolean isAsync = modifierManager.containsAny(ASYNC);
+ if (isAsync) asyncContextDepth++;
anonymousInnerClassesDefinedInMethodStack.push(new LinkedList<>());
- Statement code = this.visitMethodBody(ctx.methodBody());
+ Statement code;
+ try {
+ code = this.visitMethodBody(ctx.methodBody());
+ } finally {
+ if (isAsync) asyncContextDepth--;
+ }
List<InnerClassNode> anonymousInnerClassList = anonymousInnerClassesDefinedInMethodStack.pop();
MethodNode methodNode;
@@ -1699,6 +1802,12 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
} else { // script method declaration
methodNode = createScriptMethodNode(modifierManager, methodName, returnType, parameters, exceptions, code);
}
+
+ // Inject @Async annotation for methods declared with the 'async' keyword modifier
+ if (isAsync) {
+ methodNode.addAnnotation(new AnnotationNode(ClassHelper.make("groovy.transform.Async")));
+ }
+
anonymousInnerClassList.forEach(e -> e.setEnclosingMethod(methodNode));
methodNode.setGenericsTypes(this.visitTypeParameters(ctx.typeParameters()));
@@ -1923,6 +2032,10 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
asBoolean(ctx.modifiers()) ? this.visitModifiers(ctx.modifiers()) : Collections.emptyList()
);
+ if (modifierManager.containsAny(ASYNC)) {
+ throw createParsingFailedException("modifier `async` is not allowed for variable declarations", modifierManager.get(ASYNC).get());
+ }
+
if (asBoolean(ctx.typeNamePairs())) { // e.g. def (int a, int b) = [1, 2]
return this.createMultiAssignmentDeclarationListStatement(ctx, modifierManager);
}
@@ -2877,6 +2990,61 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
throw createParsingFailedException("Unsupported unary expression: " + ctx.getText(), ctx);
}
+ @Override
+ public Expression visitAwaitExprAlt(final AwaitExprAltContext ctx) {
+ Expression expr = (Expression) this.visit(ctx.expression());
+ return configureAST(
+ AsyncTransformHelper.buildAwaitCall(expr),
+ ctx);
+ }
+
+ @Override
+ public Expression visitAsyncClosureExprAlt(final AsyncClosureExprAltContext ctx) {
+ asyncContextDepth++;
+ ClosureExpression closure;
+ try {
+ closure = this.visitClosureOrLambdaExpression(ctx.closureOrLambdaExpression());
+ } finally {
+ asyncContextDepth--;
+ }
+ boolean hasUserParams = closure.getParameters() != null && closure.getParameters().length > 0;
+ boolean hasYieldReturn = AsyncTransformHelper.containsYieldReturn(closure.getCode());
+ boolean hasDefer = AsyncTransformHelper.containsDefer(closure.getCode());
+
+ // If defer is present, wrap the closure body in try-finally with defer scope
+ if (hasDefer) {
+ Statement wrappedBody = AsyncTransformHelper.wrapWithDeferScope(closure.getCode());
+ ClosureExpression newClosure = new ClosureExpression(closure.getParameters(), wrappedBody);
+ newClosure.setVariableScope(closure.getVariableScope());
+ newClosure.setSourcePosition(closure);
+ closure = newClosure;
+ }
+
+ if (hasYieldReturn) {
+ // Inject synthetic $__asyncGen__ as first parameter — rebuild closure
+ Parameter genParam = AsyncTransformHelper.createGenParam();
+ Parameter[] existingParams = closure.getParameters();
+ Parameter[] newParams;
+ if (hasUserParams) {
+ newParams = new Parameter[existingParams.length + 1];
+ newParams[0] = genParam;
+ System.arraycopy(existingParams, 0, newParams, 1, existingParams.length);
+ } else {
+ newParams = new Parameter[]{genParam};
+ }
+ ClosureExpression genClosure = new ClosureExpression(newParams, closure.getCode());
+ genClosure.setVariableScope(closure.getVariableScope());
+ genClosure.setSourcePosition(closure);
+ // Transform yieldReturn(expr) → yieldReturn($__asyncGen__, expr)
+ AsyncTransformHelper.injectGenParamIntoYieldReturnCalls(genClosure.getCode(), genParam);
+ return configureAST(AsyncTransformHelper.buildWrapAsyncGeneratorCall(
+ new ArgumentListExpression(genClosure)), ctx);
+ } else {
+ return configureAST(AsyncTransformHelper.buildWrapAsyncCall(
+ new ArgumentListExpression(closure)), ctx);
+ }
+ }
+
@Override
public CastExpression visitCastExprAlt(final CastExprAltContext ctx) {
Expression expr = (Expression) this.visit(ctx.expression());
@@ -4727,6 +4895,7 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
private final GroovyLangParser parser;
private final GroovydocManager groovydocManager;
private final TryWithResourcesASTTransformation tryWithResourcesASTTransformation;
+ private int asyncStreamCounter;
private final List<ClassNode> classNodeList = new ArrayList<>();
private final Deque<ClassNode> classNodeStack = new ArrayDeque<>();
@@ -4738,6 +4907,9 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
private int visitingClosureCount;
private int visitingAssertStatementCount;
private int visitingArrayInitializerCount;
+ /** Tracks nesting depth of async contexts (async methods and async closures)
+ * to validate that {@code defer} is only used within an async body. */
+ private int asyncContextDepth;
private static final int SLL_THRESHOLD = SystemUtil.getIntegerSafe("groovy.antlr4.sll.threshold", -1);
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/ModifierManager.java b/src/main/java/org/apache/groovy/parser/antlr4/ModifierManager.java
index 4d86449b8d..b84dd808a8 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/ModifierManager.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/ModifierManager.java
@@ -37,6 +37,7 @@ import java.util.Optional;
import java.util.stream.Collectors;
import static org.apache.groovy.parser.antlr4.GroovyLangParser.ABSTRACT;
+import static org.apache.groovy.parser.antlr4.GroovyLangParser.ASYNC;
import static org.apache.groovy.parser.antlr4.GroovyLangParser.FINAL;
import static org.apache.groovy.parser.antlr4.GroovyLangParser.NATIVE;
import static org.apache.groovy.parser.antlr4.GroovyLangParser.STATIC;
@@ -47,7 +48,7 @@ import static org.apache.groovy.parser.antlr4.GroovyLangParser.VOLATILE;
*/
class ModifierManager {
private static final Map<Class, List<Integer>> INVALID_MODIFIERS_MAP = Maps.of(
- ConstructorNode.class, Arrays.asList(STATIC, FINAL, ABSTRACT, NATIVE),
+ ConstructorNode.class, Arrays.asList(STATIC, FINAL, ABSTRACT, NATIVE, ASYNC),
MethodNode.class, Arrays.asList(VOLATILE/*, TRANSIENT*/) // Transient is left open for properties for legacy reasons but should be removed before ClassCompletionVerifier runs (CLASSGEN)
);
private AstBuilder astBuilder;
@@ -163,6 +164,7 @@ class ModifierManager {
}
public Parameter processParameter(Parameter parameter) {
+ rejectAsyncModifier("parameter declarations");
modifierNodeList.forEach(e -> {
parameter.setModifiers(parameter.getModifiers() | e.getOpcode());
@@ -191,6 +193,7 @@ class ModifierManager {
}
public VariableExpression processVariableExpression(VariableExpression ve) {
+ rejectAsyncModifier("variable declarations");
modifierNodeList.forEach(e -> {
ve.setModifiers(ve.getModifiers() | e.getOpcode());
@@ -200,6 +203,23 @@ class ModifierManager {
return ve;
}
+ /**
+ * Rejects the {@code async} modifier when used in an unsupported context
+ * (e.g. parameter or local variable declarations). The grammar allows
+ * {@code async} as a general modifier, but semantically it is only valid
+ * on method declarations. This check prevents silent acceptance.
+ *
+ * @param target human-readable description of the disallowed context
+ */
+ private void rejectAsyncModifier(String target) {
+ Optional<ModifierNode> asyncModifier = get(ASYNC);
+ if (asyncModifier.isPresent()) {
+ throw astBuilder.createParsingFailedException(
+ "modifier `async` is only allowed for method declarations, not for " + target,
+ asyncModifier.get());
+ }
+ }
+
public <T extends AnnotatedNode> T attachAnnotations(T node) {
this.getAnnotations().forEach(node::addAnnotation);
diff --git a/src/main/java/org/apache/groovy/runtime/async/AsyncStreamGenerator.java b/src/main/java/org/apache/groovy/runtime/async/AsyncStreamGenerator.java
new file mode 100644
index 0000000000..d5b5e36e0c
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/async/AsyncStreamGenerator.java
@@ -0,0 +1,348 @@
+/*
+ * 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 groovy.concurrent.AsyncStream;
+import groovy.concurrent.Awaitable;
+
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * A producer/consumer implementation of {@link AsyncStream} used by
+ * {@code async} methods that contain {@code yield return} statements.
+ * <p>
+ * The producer (method body) runs on a separate thread and calls
+ * {@link #yield(Object)} for each emitted element. The consumer
+ * calls {@link #moveNext()}/{@link #getCurrent()} — typically via
+ * a {@code for await} loop.
+ *
+ * <h2>Back-pressure</h2>
+ * Uses a {@link SynchronousQueue} to provide natural back-pressure:
+ * the producer thread blocks at each {@code yield return} until the
+ * consumer has consumed the previous element (mirroring C#'s async
+ * iterator suspension semantics).
+ *
+ * <h2>Cooperative cancellation via thread tracking</h2>
+ * The {@link #producerThread} and {@link #consumerThread} fields track the
+ * threads currently blocked inside {@link SynchronousQueue#put} (producer)
+ * and {@link SynchronousQueue#take} (consumer). This tracking is essential
+ * because {@code SynchronousQueue} operations block <em>indefinitely</em>
+ * when the counterpart is absent:
+ * <ul>
+ * <li>If the consumer exits the {@code for await} loop early (via
+ * {@code break}, {@code return}, or an exception), the producer may
+ * still be blocked in {@code queue.put()}. Without a reference to the
+ * producer thread, there is no way to unblock it, resulting in a
+ * <b>permanent thread leak</b>.</li>
+ * <li>Conversely, if the producer finishes but the consumer is still
+ * blocked in {@code queue.take()} (e.g. an external cancellation
+ * closed the stream), the consumer thread would also leak.</li>
+ * </ul>
+ * When {@link #close()} is called (typically from the compiler-generated
+ * {@code finally} block of a {@code for await} loop), it atomically sets the
+ * {@link #closed} flag and then interrupts both the producer and consumer
+ * threads (if any), causing their blocking {@code SynchronousQueue} operations
+ * to throw {@link InterruptedException}. The interrupted methods detect the
+ * closed state and exit gracefully — the producer's {@link #yield} throws
+ * {@link java.util.concurrent.CancellationException}, while the consumer's
+ * {@link #moveNext()} returns {@code Awaitable.of(false)}.
+ * <p>
+ * A <em>double-check</em> pattern in {@link #moveNext()} closes a TOCTOU race
+ * window: the {@link #closed} flag is re-checked <em>after</em> registering the
+ * consumer thread, ensuring that an externally-triggered {@code close()} cannot
+ * slip between the initial check and the registration, which would otherwise
+ * leave the consumer blocked forever in {@code queue.take()}.
+ * <p>
+ * The {@link #attachProducer}/{@link #detachProducer} lifecycle methods are
+ * called by the async runtime (in {@link AsyncSupport}) to register and
+ * unregister the producer thread. The consumer thread is tracked
+ * automatically inside {@link #moveNext()}.
+ * <p>
+ * This class is an internal implementation detail and should not be referenced
+ * directly by user code.
+ *
+ * @param <T> the element type
+ * @since 6.0.0
+ */
+public class AsyncStreamGenerator<T> implements AsyncStream<T> {
+
+ private static final Object DONE = new Object();
+
+ /**
+ * Cached awaitables for the two common {@code moveNext()} outcomes.
+ * Eliminates per-call object allocation on the hot path.
+ */
+ private static final Awaitable<Boolean> MOVE_NEXT_TRUE = Awaitable.of(Boolean.TRUE);
+ private static final Awaitable<Boolean> MOVE_NEXT_FALSE = Awaitable.of(Boolean.FALSE);
+
+ private final SynchronousQueue<Object> queue = new SynchronousQueue<>();
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+ private final AtomicReference<Thread> producerThread = new AtomicReference<>();
+ private final AtomicReference<Thread> consumerThread = new AtomicReference<>();
+ private volatile T current;
+
+ /**
+ * Registers the given thread as the producer for this stream.
+ * Called by the async runtime immediately after the producer
+ * thread starts, <em>before</em> the generator body executes.
+ * <p>
+ * If the stream has already been {@linkplain #close() closed} by the
+ * time this method runs, the thread is immediately interrupted so
+ * that the generator body can exit promptly.
+ *
+ * @param thread the producer thread to register
+ */
+ void attachProducer(Thread thread) {
+ producerThread.set(thread);
+ if (closed.get()) {
+ thread.interrupt();
+ }
+ }
+
+ /**
+ * Unregisters the given thread as the producer for this stream.
+ * Called from a {@code finally} block after the generator body
+ * completes (normally or exceptionally).
+ *
+ * @param thread the producer thread to unregister
+ */
+ void detachProducer(Thread thread) {
+ producerThread.compareAndSet(thread, null);
+ }
+
+ /**
+ * Produces the next element. Called from the generator body when
+ * a {@code yield return expr} statement is executed. Blocks until
+ * the consumer is ready.
+ */
+ public void yield(Object value) {
+ if (closed.get()) {
+ throw streamClosed(null);
+ }
+ try {
+ queue.put(new Item(value));
+ } catch (InterruptedException e) {
+ if (closed.get()) {
+ throw streamClosed(e);
+ }
+ Thread.currentThread().interrupt();
+ throw interrupted("Interrupted during yield", e);
+ }
+ }
+
+ /**
+ * Signals that the generator has completed (no more elements).
+ * <p>
+ * If the blocking {@code queue.put()} is interrupted, a best-effort
+ * non-blocking {@code queue.offer()} is attempted. If that also fails
+ * (no consumer is currently blocked in {@code take()}), the stream is
+ * force-closed to prevent the consumer from blocking indefinitely on a
+ * subsequent {@link #moveNext()} call. This defensive close ensures no
+ * thread leak occurs even under unexpected interrupt timing.
+ */
+ public void complete() {
+ if (closed.get()) {
+ return;
+ }
+ try {
+ queue.put(DONE);
+ } catch (InterruptedException e) {
+ if (!closed.get()) {
+ Thread.currentThread().interrupt();
+ // Best-effort: non-blocking handoff to a waiting consumer.
+ // If no consumer is waiting, offer() returns false and the DONE
+ // signal is lost — force-close to unblock future moveNext() calls.
+ if (!queue.offer(DONE)) {
+ close();
+ }
+ }
+ }
+ }
+
+ /**
+ * Signals that the generator failed with an exception.
+ * <p>
+ * If the blocking {@code queue.put()} is interrupted, a best-effort
+ * non-blocking {@code queue.offer()} is attempted. If that also fails,
+ * the stream is force-closed (same rationale as {@link #complete()}).
+ * The original error is not propagated to the consumer in this edge case;
+ * instead the consumer sees a clean stream closure — this is acceptable
+ * because the interrupt itself indicates an external cancellation.
+ */
+ public void error(Throwable t) {
+ if (closed.get()) {
+ return;
+ }
+ ErrorItem item = new ErrorItem(t != null ? t : new NullPointerException("null error in generator"));
+ try {
+ queue.put(item);
+ } catch (InterruptedException e) {
+ if (!closed.get()) {
+ Thread.currentThread().interrupt();
+ if (!queue.offer(item)) {
+ close();
+ }
+ }
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * Blocks the calling (consumer) thread on the {@link SynchronousQueue} until
+ * the producer offers the next element, a completion sentinel, or an error.
+ * If the stream has been {@linkplain #close() closed}, returns
+ * {@code Awaitable.of(false)} immediately without blocking.
+ * <p>
+ * The consumer thread is registered via {@code consumerThread} during the
+ * blocking call so that {@link #close()} can interrupt it if needed.
+ * <p>
+ * <b>Single-consumer invariant</b>: only one thread may call
+ * {@code moveNext()} at a time. A {@code compareAndSet(null, current)}
+ * guard enforces this — a concurrent second caller receives an
+ * {@link IllegalStateException} immediately instead of silently corrupting
+ * the producer/consumer handshake.
+ * <p>
+ * A <em>double-check</em> of the {@link #closed} flag is performed after
+ * registration to close the TOCTOU race window: if {@code close()} executes
+ * between the initial {@code closed.get()} check and the
+ * {@code consumerThread} CAS, the consumer reference would not yet be
+ * visible to {@code close()}, so no interrupt would be delivered, and
+ * {@code queue.take()} would block indefinitely. The re-check after
+ * registration detects this case and returns immediately.
+ *
+ * @return an {@code Awaitable<Boolean>} that resolves to {@code true} if a
+ * new element is available via {@link #getCurrent()}, or {@code false}
+ * if the stream is exhausted or closed
+ */
+ @Override
+ @SuppressWarnings("unchecked")
+ public Awaitable<Boolean> moveNext() {
+ if (closed.get()) {
+ return MOVE_NEXT_FALSE;
+ }
+ // Enforce single-consumer semantics: only one thread may call moveNext()
+ // at a time. A concurrent second caller would overwrite consumerThread,
+ // breaking close()'s interrupt targeting and causing data races on
+ // queue.take(). CAS guards this invariant at the cost of one atomic op.
+ Thread currentThread = Thread.currentThread();
+ if (!consumerThread.compareAndSet(null, currentThread)) {
+ Thread existing = consumerThread.get();
+ if (existing != currentThread) {
+ throw new IllegalStateException(
+ "AsyncStream does not support concurrent consumers. "
+ + "Current consumer: " + existing);
+ }
+ }
+ // Double-check after registration: if close() raced between the first
+ // closed check and consumerThread CAS, the consumer reference was not
+ // yet visible to close(), so no interrupt was delivered. Without this
+ // re-check the consumer would block in queue.take() indefinitely.
+ if (closed.get()) {
+ consumerThread.compareAndSet(currentThread, null);
+ return MOVE_NEXT_FALSE;
+ }
+ try {
+ Object next = queue.take();
+ if (next == DONE) {
+ closed.set(true);
+ return MOVE_NEXT_FALSE;
+ }
+ if (next instanceof ErrorItem ei) {
+ closed.set(true);
+ Throwable cause = ei.error;
+ if (cause instanceof Error err) throw err;
+ throw AsyncSupport.sneakyThrow(cause);
+ }
+ current = (T) ((Item) next).value;
+ return MOVE_NEXT_TRUE;
+ } catch (InterruptedException e) {
+ if (closed.get()) {
+ return MOVE_NEXT_FALSE;
+ }
+ Thread.currentThread().interrupt();
+ throw interrupted("Interrupted during moveNext", e);
+ } finally {
+ consumerThread.compareAndSet(currentThread, null);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * Returns the most recently consumed element. The value is updated each time
+ * {@link #moveNext()} returns {@code true}.
+ *
+ * @return the current element, or {@code null} before the first successful
+ * {@code moveNext()} call
+ */
+ @Override
+ public T getCurrent() {
+ return current;
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * Atomically marks this stream as closed and interrupts any producer or
+ * consumer thread that is currently blocked on the {@link SynchronousQueue}.
+ * The interrupted threads detect the {@link #closed} flag and exit
+ * gracefully (see the class-level javadoc for details).
+ * <p>
+ * This method is idempotent: only the first invocation performs the
+ * interrupt; subsequent calls are no-ops. A thread calling {@code close()}
+ * on itself (e.g. the consumer calling close inside a {@code for await}
+ * body) is never self-interrupted.
+ */
+ @Override
+ public void close() {
+ if (!closed.compareAndSet(false, true)) {
+ return;
+ }
+ Thread producer = producerThread.getAndSet(null);
+ if (producer != null && producer != Thread.currentThread()) {
+ producer.interrupt();
+ }
+ Thread consumer = consumerThread.getAndSet(null);
+ if (consumer != null && consumer != Thread.currentThread()) {
+ consumer.interrupt();
+ }
+ }
+
+ private static CancellationException interrupted(String message, InterruptedException cause) {
+ CancellationException ce = new CancellationException(message);
+ ce.initCause(cause);
+ return ce;
+ }
+
+ private static CancellationException streamClosed(InterruptedException cause) {
+ CancellationException ce = new CancellationException("Async stream was closed");
+ if (cause != null) {
+ ce.initCause(cause);
+ }
+ return ce;
+ }
+
+ // Wrapper to handle null values in the queue
+ private record Item(Object value) { }
+ private record ErrorItem(Throwable error) { }
+}
diff --git a/src/main/java/org/apache/groovy/runtime/async/AsyncSupport.java b/src/main/java/org/apache/groovy/runtime/async/AsyncSupport.java
new file mode 100644
index 0000000000..96658f81e8
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/async/AsyncSupport.java
@@ -0,0 +1,1055 @@
+/*
+ * 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 groovy.concurrent.AsyncStream;
+import groovy.concurrent.AwaitResult;
+import groovy.concurrent.Awaitable;
+import groovy.lang.Closure;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.UndeclaredThrowableException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Deque;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Internal runtime support for the {@code async}/{@code await} language feature
+ * and the {@link groovy.transform.Async @Async} annotation.
+ * <p>
+ * This class contains the actual implementation invoked by compiler-generated
+ * code. User code should prefer the static methods on
+ * {@link groovy.concurrent.Awaitable} (e.g., {@code Awaitable.all()},
+ * {@code Awaitable.delay()}) for combinators and configuration.
+ * <p>
+ * Key responsibilities:
+ * <ul>
+ * <li><b>Async execution</b> — {@code executeAsync()}, {@code executeAsyncVoid()},
+ * and {@code wrapAsync()} run closures on the configured executor</li>
+ * <li><b>Await</b> — all {@code await()} overloads use
+ * {@link groovy.concurrent.Awaitable#from(Object) Awaitable.from()} so
+ * that third-party async types
+ * (RxJava {@code Single}, Reactor {@code Mono}, etc.) are supported
+ * transparently once an adapter is registered</li>
+ * <li><b>Async generators</b> — {@code generateAsyncStream()} manages the
+ * producer/consumer bridge for methods using {@code yield return}</li>
+ * <li><b>Async stream conversion</b> — {@code toAsyncStream()} adapts
+ * collections, arrays, and adapter-supported types for {@code for await}</li>
+ * <li><b>Async stream cleanup</b> — {@code closeStream()} lets compiler-generated
+ * {@code for await} loops release resources and propagate early-exit
+ * cancellation upstream</li>
+ * <li><b>Defer</b> — {@code createDeferScope()}, {@code defer()}, and
+ * {@code executeDeferScope()} implement Go-style deferred cleanup with
+ * LIFO execution and exception suppression</li>
+ * <li><b>Delay</b> — {@code delay()} provides non-blocking delays using a
+ * shared {@link java.util.concurrent.ScheduledExecutorService}</li>
+ * <li><b>Timeouts</b> — {@code orTimeout()} and {@code completeOnTimeout()} apply
+ * non-blocking deadlines while preserving the {@link Awaitable} abstraction</li>
+ * </ul>
+ * <p>
+ * <b>Thread pool configuration</b>
+ * <ul>
+ * <li>On JDK 21+ the default executor is a virtual-thread-per-task executor
+ * obtained via {@code Executors.newVirtualThreadPerTaskExecutor()}.
+ * Virtual threads make blocking within {@code await()} essentially free:
+ * the JVM parks the virtual thread and releases the underlying carrier
+ * thread, achieving efficiency comparable to C#'s state-machine-based
+ * approach without requiring compiler-level control-flow rewriting.</li>
+ * <li>On earlier JDKs the fallback is a cached (elastic) daemon thread pool
+ * whose maximum size is controlled by the system property
+ * {@code groovy.async.parallelism} (default: {@code 256}). Idle threads
+ * are reaped after 60 seconds. A cached pool is used instead of a
+ * fixed pool to prevent thread starvation when async tasks block
+ * (e.g., in generators or nested {@code await} calls). A
+ * {@link java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy
+ * CallerRunsPolicy} is used so that task submission never fails
+ * with {@link java.util.concurrent.RejectedExecutionException} when
+ * the pool is saturated.</li>
+ * <li>The executor can be overridden at any time via {@link #setExecutor}.</li>
+ * </ul>
+ * <p>
+ * <b>Exception handling</b> follows the same transparency principle as C# and
+ * JavaScript: the <em>original</em> exception is rethrown without being wrapped
+ * in an {@link java.util.concurrent.ExecutionException ExecutionException} or
+ * {@link java.util.concurrent.CompletionException CompletionException}.
+ *
+ * @see groovy.concurrent.Awaitable
+ * @see groovy.transform.Async
+ * @since 6.0.0
+ */
+public class AsyncSupport {
+
+ /**
+ * {@code true} if the running JVM supports virtual threads (JDK 21+).
+ * Detected once at class-load time via {@link MethodHandle} reflection.
+ */
+ private static final boolean VIRTUAL_THREADS_AVAILABLE;
+
+ private static final Executor VIRTUAL_THREAD_EXECUTOR;
+
+ /**
+ * Maximum thread pool size when virtual threads are unavailable.
+ * Configurable via the system property {@code groovy.async.parallelism}.
+ * Defaults to {@code 256} to prevent runaway thread creation while still
+ * allowing ample concurrency for blocking async operations.
+ */
+ private static final int FALLBACK_MAX_THREADS;
+
+ private static final Executor FALLBACK_EXECUTOR;
+
+ static {
+ Executor vtExecutor = null;
+ boolean vtAvailable = false;
+ try {
+ MethodHandle mh = MethodHandles.lookup().findStatic(
+ Executors.class, "newVirtualThreadPerTaskExecutor",
+ MethodType.methodType(ExecutorService.class));
+ vtExecutor = (Executor) mh.invoke();
+ vtAvailable = true;
+ } catch (Throwable ignored) {
+ // JDK < 21 — virtual threads not available
+ }
+ VIRTUAL_THREAD_EXECUTOR = vtExecutor;
+ VIRTUAL_THREADS_AVAILABLE = vtAvailable;
+
+ FALLBACK_MAX_THREADS = org.apache.groovy.util.SystemUtil.getIntegerSafe(
+ "groovy.async.parallelism", 256);
+ if (!VIRTUAL_THREADS_AVAILABLE) {
+ // Use a cached thread pool (threads created on demand, idle threads
+ // reaped after 60 s) to avoid thread starvation. Async/await relies
+ // on blocking operations (SynchronousQueue in generators, Future.get
+ // in await) — a fixed-size pool can deadlock when all threads block.
+ // CallerRunsPolicy provides graceful degradation when all threads
+ // are busy: the submitting thread runs the task itself, preventing
+ // RejectedExecutionException at the cost of temporary back-pressure.
+ FALLBACK_EXECUTOR = new ThreadPoolExecutor(
+ 0, FALLBACK_MAX_THREADS,
+ 60L, TimeUnit.SECONDS,
+ new SynchronousQueue<>(),
+ r -> {
+ Thread t = new Thread(r);
+ t.setDaemon(true);
+ t.setName("groovy-async-" + t.getId());
+ return t;
+ },
+ new ThreadPoolExecutor.CallerRunsPolicy());
+ } else {
+ FALLBACK_EXECUTOR = null;
+ }
+ }
+
+ private static volatile Executor defaultExecutor =
+ VIRTUAL_THREADS_AVAILABLE ? VIRTUAL_THREAD_EXECUTOR : FALLBACK_EXECUTOR;
+
+ private AsyncSupport() { }
+
+ // ---- await overloads ------------------------------------------------
+
+ /**
+ * Awaits the result of an {@link Awaitable}.
+ * <p>
+ * Blocks the calling thread until the computation completes and returns
+ * its result. If the computation failed, the original exception is
+ * rethrown transparently (even if it is a checked exception). If the
+ * waiting thread is interrupted, an {@link java.util.concurrent.CancellationException}
+ * is thrown and the interrupt flag is restored.
+ *
+ * @param awaitable the computation to await
+ * @param <T> the result type
+ * @return the computed result
+ */
+ public static <T> T await(Awaitable<T> awaitable) {
+ try {
+ return awaitable.get();
+ } catch (ExecutionException e) {
+ throw rethrowUnwrapped(e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ CancellationException ce = new CancellationException("Interrupted while awaiting");
+ ce.initCause(e);
+ throw ce;
+ }
+ }
+
+ /**
+ * Awaits the result of a {@link CompletableFuture}.
+ * Uses {@link CompletableFuture#join()} for non-interruptible waiting.
+ *
+ * @param future the future to await
+ * @param <T> the result type
+ * @return the computed result
+ */
+ public static <T> T await(CompletableFuture<T> future) {
+ try {
+ return future.join();
+ } catch (CompletionException e) {
+ throw rethrowUnwrapped(e);
+ } catch (CancellationException e) {
+ throw e;
+ }
+ }
+
+ /**
+ * Awaits the result of a {@link CompletionStage} by converting it to a
+ * {@link CompletableFuture} first.
+ *
+ * @param stage the completion stage to await
+ * @param <T> the result type
+ * @return the computed result
+ */
+ public static <T> T await(CompletionStage<T> stage) {
+ return await(stage.toCompletableFuture());
+ }
+
+ /**
+ * Awaits the result of a {@link Future}. If the future is a
+ * {@link CompletableFuture}, delegates to the more efficient
+ * {@link #await(CompletableFuture)} overload.
+ *
+ * @param future the future to await
+ * @param <T> the result type
+ * @return the computed result
+ */
+ public static <T> T await(Future<T> future) {
+ if (future instanceof CompletableFuture<T> cf) {
+ return await(cf);
+ }
+ try {
+ return future.get();
+ } catch (ExecutionException e) {
+ throw rethrowUnwrapped(e);
+ } catch (CancellationException e) {
+ throw e;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ CancellationException ce = new CancellationException("Interrupted while awaiting future");
+ ce.initCause(e);
+ throw ce;
+ }
+ }
+
+ /**
+ * Awaits an arbitrary object by adapting it to {@link Awaitable} via
+ * {@link Awaitable#from(Object)}. This is the fallback overload called
+ * by the {@code await} expression when the compile-time type is not one
+ * of the other supported types. Returns {@code null} for a {@code null}
+ * argument.
+ *
+ * @param source the object to await
+ * @param <T> the result type
+ * @return the computed result, or {@code null} if {@code source} is {@code null}
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> T await(Object source) {
+ if (source == null) return null;
+ if (source instanceof Awaitable) return await((Awaitable<T>) source);
+ if (source instanceof CompletableFuture) return await((CompletableFuture<T>) source);
+ if (source instanceof CompletionStage) return await((CompletionStage<T>) source);
+ if (source instanceof Future) return await((Future<T>) source);
+ if (source instanceof Closure) return awaitClosure((Closure<?>) source);
+ return await(Awaitable.<T>from(source));
+ }
+
+ /**
+ * Handles the case where a {@link Closure} is passed to {@code await}.
+ * <p>
+ * An {@code async} closure or lambda must be explicitly called before
+ * awaiting. For example, {@code await(myClosure())} is correct, while
+ * {@code await(myClosure)} is not. This method always throws an
+ * {@link IllegalArgumentException} with guidance on the correct usage.
+ *
+ * @param closure the closure that was incorrectly passed to await
+ * @param <T> the result type (never actually returned)
+ * @return never returns
+ * @throws IllegalArgumentException always
+ */
+ @SuppressWarnings("unchecked")
+ private static <T> T awaitClosure(Closure<?> closure) {
+ throw new IllegalArgumentException(
+ "Cannot await a Closure directly. "
+ + "Call the closure first, then await the result: "
+ + "await(myClosure()) or await(myClosure(args))");
+ }
+
+ // ---- async ----------------------------------------------------------
+
+ /**
+ * Executes the given closure asynchronously on the specified executor,
+ * returning an {@link Awaitable} that completes with the closure's return
+ * value. Used by {@link groovy.transform.Async @Async} methods.
+ *
+ * @param closure the closure to execute
+ * @param executor the executor on which to run the closure
+ * @param <T> the result type
+ * @return an awaitable representing the pending computation
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> Awaitable<T> executeAsync(Closure<T> closure, Executor executor) {
+ return GroovyPromise.of(CompletableFuture.supplyAsync(() -> {
+ try {
+ return closure.call();
+ } catch (Throwable t) {
+ throw wrapForFuture(t);
+ }
+ }, executor));
+ }
+
+ /**
+ * Void variant of {@link #executeAsync(Closure, Executor)} for
+ * {@link groovy.transform.Async @Async} methods whose return type is
+ * {@code void}.
+ *
+ * @param closure the closure to execute
+ * @param executor the executor on which to run the closure
+ * @return an awaitable that completes when the closure finishes
+ */
+ public static Awaitable<Void> executeAsyncVoid(Closure<?> closure, Executor executor) {
+ return GroovyPromise.of(CompletableFuture.runAsync(() -> {
+ try {
+ closure.call();
+ } catch (Throwable t) {
+ throw wrapForFuture(t);
+ }
+ }, executor));
+ }
+
+ /**
+ * Executes the given closure asynchronously using the default executor,
+ * returning an {@link Awaitable}. This method is used internally by the
+ * {@link groovy.transform.Async @Async} annotation transformation and
+ * the {@code async} method modifier. For the {@code async { ... }}
+ * closure expression syntax, see {@link #wrapAsync(Closure)}.
+ *
+ * @param closure the closure to execute
+ * @param <T> the result type
+ * @return an awaitable representing the pending computation
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> Awaitable<T> async(Closure<T> closure) {
+ return executeAsync(closure, defaultExecutor);
+ }
+
+ /**
+ * Wraps a closure so that each invocation executes the body asynchronously
+ * and returns an {@link Awaitable}. This is the runtime entry point for
+ * the {@code async { ... }} expression syntax. The returned closure must
+ * be explicitly called to start the async computation:
+ * <pre>
+ * def task = async { expensiveWork() }
+ * def result = await(task()) // explicit call required
+ * </pre>
+ *
+ * @param closure the closure to wrap
+ * @param <T> the result type
+ * @return a new closure whose calls produce awaitables
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> Closure<Awaitable<T>> wrapAsync(Closure<T> closure) {
+ return new Closure<Awaitable<T>>(closure.getOwner(), closure.getThisObject()) {
+ @SuppressWarnings("unused")
+ public Awaitable<T> doCall(Object... args) {
+ return GroovyPromise.of(CompletableFuture.supplyAsync(() -> {
+ try {
+ return closure.call(args);
+ } catch (Throwable t) {
+ throw wrapForFuture(t);
+ }
+ }, defaultExecutor));
+ }
+ };
+ }
+
+ /**
+ * Wraps a generator closure (one containing {@code yield return} statements)
+ * so that each invocation returns an {@link AsyncStream} producing the
+ * yielded elements. This is the runtime entry point for all async
+ * generator closures ({@code async { yield return ... }}). The returned
+ * closure must be explicitly called to start the generation:
+ * <pre>
+ * def gen = async { yield return 1; yield return 2 }
+ * for await (item in gen()) { println item }
+ * </pre>
+ *
+ * @param closure the generator closure to wrap
+ * @param <T> the element type
+ * @return a new closure whose calls produce async streams
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> Closure<AsyncStream<T>> wrapAsyncGenerator(Closure<?> closure) {
+ return new Closure<AsyncStream<T>>(closure.getOwner(), closure.getThisObject()) {
+ @SuppressWarnings("unused")
+ public AsyncStream<T> doCall(Object... args) {
+ AsyncStreamGenerator<T> gen = new AsyncStreamGenerator<>();
+ CompletableFuture.runAsync(() -> {
+ gen.attachProducer(Thread.currentThread());
+ try {
+ Object[] allArgs = new Object[args.length + 1];
+ allArgs[0] = gen;
+ System.arraycopy(args, 0, allArgs, 1, args.length);
+ closure.call(allArgs);
+ gen.complete();
+ } catch (Throwable t) {
+ gen.error(t);
+ } finally {
+ gen.detachProducer(Thread.currentThread());
+ }
+ }, defaultExecutor);
+ return gen;
+ }
+ };
+ }
+
+ // ---- awaitAll / awaitAny / awaitAllSettled ---------------------------
+
+ /**
+ * Waits for all given awaitables to complete and returns their results
+ * as a list, preserving order. Analogous to JavaScript's
+ * {@code Promise.all()}. If any awaitable fails, the exception is
+ * rethrown immediately.
+ * <p>
+ * Returns an empty list if {@code awaitables} is {@code null} or empty.
+ *
+ * @param awaitables the awaitables (or futures, completion stages, etc.);
+ * elements must not be {@code null}
+ * @return a list of results in the same order as the arguments
+ * @throws IllegalArgumentException if any element is {@code null}
+ */
+ @SuppressWarnings("unchecked")
+ public static List<Object> awaitAll(Object... awaitables) {
+ if (awaitables == null || awaitables.length == 0) {
+ return new ArrayList<>();
+ }
+ for (int i = 0; i < awaitables.length; i++) {
+ if (awaitables[i] == null) {
+ throw new IllegalArgumentException("awaitAll: element at index " + i + " is null");
+ }
+ }
+ CompletableFuture<?>[] futures =
+ Arrays.stream(awaitables).map(AsyncSupport::toCompletableFuture).toArray(CompletableFuture[]::new);
+ try {
+ CompletableFuture.allOf(futures).join();
+ } catch (CompletionException e) {
+ throw rethrowUnwrapped(e);
+ }
+ List<Object> results = new ArrayList<>(futures.length);
+ for (CompletableFuture<?> f : futures) {
+ results.add(f.join());
+ }
+ return results;
+ }
+
+ /**
+ * Waits for any one of the given awaitables to complete and returns
+ * its result. Analogous to JavaScript's {@code Promise.any()} / {@code Promise.race()}.
+ *
+ * @param awaitables the awaitables to race; must not be {@code null},
+ * empty, or contain {@code null} elements
+ * @return the result of the first awaitable that completes
+ * @throws IllegalArgumentException if {@code awaitables} is {@code null},
+ * empty, or contains {@code null} elements
+ */
+ @SuppressWarnings("unchecked")
+ public static Object awaitAny(Object... awaitables) {
+ if (awaitables == null || awaitables.length == 0) {
+ throw new IllegalArgumentException("awaitAny requires at least one awaitable");
+ }
+ for (int i = 0; i < awaitables.length; i++) {
+ if (awaitables[i] == null) {
+ throw new IllegalArgumentException("awaitAny: element at index " + i + " is null");
+ }
+ }
+ CompletableFuture<?>[] futures =
+ Arrays.stream(awaitables).map(AsyncSupport::toCompletableFuture).toArray(CompletableFuture[]::new);
+ try {
+ return CompletableFuture.anyOf(futures).join();
+ } catch (CompletionException e) {
+ throw rethrowUnwrapped(e);
+ }
+ }
+
+ /**
+ * Waits for all given awaitables to settle (complete either successfully
+ * or with an exception) and returns a list of {@link AwaitResult} objects.
+ * Analogous to JavaScript's {@code Promise.allSettled()}. Never throws
+ * for individual failures; failures are captured in the result list.
+ * <p>
+ * Returns an empty list if {@code awaitables} is {@code null} or empty.
+ *
+ * @param awaitables the awaitables to settle; elements must not be {@code null}
+ * @return a list of results in the same order as the arguments
+ * @throws IllegalArgumentException if any element is {@code null}
+ */
+ @SuppressWarnings("unchecked")
+ public static List<AwaitResult<Object>> awaitAllSettled(Object... awaitables) {
+ if (awaitables == null || awaitables.length == 0) {
+ return new ArrayList<>();
+ }
+ for (int i = 0; i < awaitables.length; i++) {
+ if (awaitables[i] == null) {
+ throw new IllegalArgumentException("awaitAllSettled: element at index " + i + " is null");
+ }
+ }
+ CompletableFuture<?>[] futures =
+ Arrays.stream(awaitables).map(AsyncSupport::toCompletableFuture).toArray(CompletableFuture[]::new);
+ CompletableFuture.allOf(
+ Arrays.stream(futures)
+ .map(f -> f.handle((v, t) -> null))
+ .toArray(CompletableFuture[]::new)
+ ).join();
+
+ return getAwaitResults(futures);
+ }
+
+ /** Converts an arbitrary source to a {@link CompletableFuture} for internal use. */
+ private static CompletableFuture<?> toCompletableFuture(Object source) {
+ if (source instanceof CompletableFuture<?> cf) return cf;
+ if (source instanceof Awaitable<?> a) return a.toCompletableFuture();
+ if (source instanceof CompletionStage<?> cs) return cs.toCompletableFuture();
+ return Awaitable.from(source).toCompletableFuture();
+ }
+
+ // ---- non-blocking combinators (return Awaitable) --------------------
+
+ /**
+ * Non-blocking variant of {@link #awaitAll(Object...)} that returns an
+ * {@link Awaitable} instead of blocking. Used by
+ * {@link Awaitable#all(Object...)}.
+ */
+ public static Awaitable<List<Object>> allAsync(Object... sources) {
+ if (sources == null || sources.length == 0) {
+ return Awaitable.of(new ArrayList<>());
+ }
+ CompletableFuture<?>[] futures = new CompletableFuture[sources.length];
+ for (int i = 0; i < sources.length; i++) {
+ if (sources[i] == null) {
+ throw new IllegalArgumentException("Awaitable.all: element at index " + i + " is null");
+ }
+ futures[i] = toCompletableFuture(sources[i]);
+ }
+ CompletableFuture<List<Object>> combined = CompletableFuture.allOf(futures)
+ .thenApply(v -> {
+ List<Object> results = new ArrayList<>(futures.length);
+ for (CompletableFuture<?> f : futures) {
+ results.add(f.join());
+ }
+ return results;
+ });
+ return GroovyPromise.of(combined);
+ }
+
+ /**
+ * Non-blocking variant of {@link #awaitAny(Object...)} that returns an
+ * {@link Awaitable} instead of blocking. Used by
+ * {@link Awaitable#any(Object...)}.
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> Awaitable<T> anyAsync(Object... sources) {
+ if (sources == null || sources.length == 0) {
+ throw new IllegalArgumentException("Awaitable.any requires at least one source");
+ }
+ for (int i = 0; i < sources.length; i++) {
+ if (sources[i] == null) {
+ throw new IllegalArgumentException("Awaitable.any: element at index " + i + " is null");
+ }
+ }
+ CompletableFuture<?>[] futures = Arrays.stream(sources).map(AsyncSupport::toCompletableFuture).toArray(CompletableFuture[]::new);
+ return GroovyPromise.of((CompletableFuture<T>) CompletableFuture.anyOf(futures));
+ }
+
+ /**
+ * Non-blocking variant of {@link #awaitAllSettled(Object...)} that returns an
+ * {@link Awaitable} instead of blocking. Used by
+ * {@link Awaitable#allSettled(Object...)}.
+ */
+ public static Awaitable<List<AwaitResult<Object>>> allSettledAsync(Object... sources) {
+ if (sources == null || sources.length == 0) {
+ return Awaitable.of(new ArrayList<>());
+ }
+ for (int i = 0; i < sources.length; i++) {
+ if (sources[i] == null) {
+ throw new IllegalArgumentException("Awaitable.allSettled: element at index " + i + " is null");
+ }
+ }
+ CompletableFuture<?>[] futures = Arrays.stream(sources).map(AsyncSupport::toCompletableFuture).toArray(CompletableFuture[]::new);
+ // Wait for all to settle (handle converts failures to non-exceptional completions)
+ CompletableFuture<List<AwaitResult<Object>>> combined = CompletableFuture.allOf(
+ Arrays.stream(futures)
+ .map(f -> f.handle((v, t) -> null))
+ .toArray(CompletableFuture[]::new)
+ ).thenApply(v -> getAwaitResults(futures));
+ return GroovyPromise.of(combined);
+ }
+
+ private static List<AwaitResult<Object>> getAwaitResults(CompletableFuture<?>[] futures) {
+ List<AwaitResult<Object>> results = new ArrayList<>(futures.length);
+ for (CompletableFuture<?> f : futures) {
+ try {
+ results.add(AwaitResult.success(f.join()));
+ } catch (CompletionException e) {
+ results.add(AwaitResult.failure(deepUnwrap(e)));
+ } catch (CancellationException e) {
+ results.add(AwaitResult.failure(e));
+ }
+ }
+ return results;
+ }
+
+ /**
+ * Applies fail-fast timeout semantics to the given source.
+ * Returns an awaitable that fails with {@link TimeoutException} if the
+ * source does not complete before the timeout elapses.
+ * <p>
+ * The timeout does <em>not</em> cancel the original source automatically.
+ * This mirrors value-level race composition and keeps cancellation explicit.
+ *
+ * @param source the async source to time out
+ * @param timeout the timeout duration
+ * @param unit the timeout unit
+ * @param <T> the result type
+ * @return an awaitable that fails on timeout
+ * @throws IllegalArgumentException if any argument is invalid
+ * @since 6.0.0
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> Awaitable<T> orTimeout(Object source, long timeout, TimeUnit unit) {
+ validateTimeoutArguments(source, timeout, unit, "Awaitable.orTimeout");
+ CompletableFuture<T> sourceFuture = (CompletableFuture<T>) toCompletableFuture(source);
+ CompletableFuture<T> result = new CompletableFuture<>();
+ TimeoutException te = new TimeoutException(
+ "Timed out after " + timeout + " " + unit.name().toLowerCase(Locale.ROOT));
+ scheduleTimeoutRace(sourceFuture, result, () -> result.completeExceptionally(te), timeout, unit);
+ return GroovyPromise.of(result);
+ }
+
+ /**
+ * Millisecond shortcut for {@link #orTimeout(Object, long, TimeUnit)}.
+ *
+ * @param source the async source to time out
+ * @param timeoutMillis timeout in milliseconds
+ * @param <T> result type
+ * @return an awaitable that fails on timeout
+ * @since 6.0.0
+ */
+ public static <T> Awaitable<T> orTimeoutMillis(Object source, long timeoutMillis) {
+ return orTimeout(source, timeoutMillis, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Applies fallback-on-timeout semantics to the given source.
+ * Returns an awaitable that completes with the supplied fallback value if
+ * the source has not completed before the timeout elapses.
+ *
+ * @param source the async source to wait for
+ * @param fallback fallback value when timeout elapses first
+ * @param timeout the timeout duration
+ * @param unit the timeout unit
+ * @param <T> the result type
+ * @return an awaitable that yields either source result or fallback value
+ * @throws IllegalArgumentException if any argument is invalid
+ * @since 6.0.0
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> Awaitable<T> completeOnTimeout(Object source, T fallback, long timeout, TimeUnit unit) {
+ validateTimeoutArguments(source, timeout, unit, "Awaitable.completeOnTimeout");
+ CompletableFuture<T> sourceFuture = (CompletableFuture<T>) toCompletableFuture(source);
+ CompletableFuture<T> result = new CompletableFuture<>();
+ scheduleTimeoutRace(sourceFuture, result, () -> result.complete(fallback), timeout, unit);
+ return GroovyPromise.of(result);
+ }
+
+ /**
+ * Millisecond shortcut for {@link #completeOnTimeout(Object, Object, long, TimeUnit)}.
+ *
+ * @param source the async source to wait for
+ * @param fallback fallback value when timeout elapses first
+ * @param timeoutMillis timeout in milliseconds
+ * @param <T> result type
+ * @return an awaitable that yields either source result or fallback
+ * @since 6.0.0
+ */
+ public static <T> Awaitable<T> completeOnTimeoutMillis(Object source, T fallback, long timeoutMillis) {
+ return completeOnTimeout(source, fallback, timeoutMillis, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Shared logic for {@link #orTimeout} and {@link #completeOnTimeout}:
+ * schedules the timeout action and wires up source completion to cancel
+ * the timer and propagate the result or error.
+ */
+ private static <T> void scheduleTimeoutRace(
+ CompletableFuture<T> sourceFuture,
+ CompletableFuture<T> result,
+ Runnable onTimeout,
+ long duration, TimeUnit unit) {
+ ScheduledFuture<?> timeoutTask = DELAY_SCHEDULER.schedule(onTimeout, duration, unit);
+ sourceFuture.whenComplete((value, error) -> {
+ timeoutTask.cancel(false);
+ if (error != null) {
+ result.completeExceptionally(deepUnwrap(error));
+ } else {
+ result.complete(value);
+ }
+ });
+ }
+
+ private static void validateTimeoutArguments(Object source, long duration, TimeUnit unit, String methodName) {
+ if (source == null) {
+ throw new IllegalArgumentException(methodName + ": source must not be null");
+ }
+ if (unit == null) {
+ throw new IllegalArgumentException(methodName + ": TimeUnit must not be null");
+ }
+ if (duration < 0) {
+ throw new IllegalArgumentException(methodName + ": duration must not be negative: " + duration);
+ }
+ }
+
+ // ---- delay ----------------------------------------------------------
+
+ /**
+ * Shared single-thread scheduler for {@link #delay} operations.
+ * Uses a daemon thread so it does not prevent JVM shutdown.
+ */
+ private static final ScheduledExecutorService DELAY_SCHEDULER =
+ Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "groovy-async-delay");
+ t.setDaemon(true);
+ return t;
+ });
+
+ /**
+ * Returns an {@link Awaitable} that completes (with {@code null}) after
+ * the specified delay. Analogous to C#'s {@code Task.Delay()} or
+ * JavaScript's {@code new Promise(resolve => setTimeout(resolve, ms))}.
+ * <p>
+ * This is a non-blocking delay: the calling thread is not blocked.
+ * Use {@code await delay(ms)} inside an {@code async} context to pause
+ * the async workflow for the given duration without blocking a thread.
+ *
+ * @param milliseconds the delay in milliseconds (must be ≥ 0)
+ * @return an awaitable that completes after the delay
+ * @throws IllegalArgumentException if {@code milliseconds} is negative
+ */
+ public static Awaitable<Void> delay(long milliseconds) {
+ return delay(milliseconds, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Returns an {@link Awaitable} that completes (with {@code null}) after
+ * the specified delay in the given time unit.
+ *
+ * @param duration the delay duration (must be ≥ 0)
+ * @param unit the time unit (must not be {@code null})
+ * @return an awaitable that completes after the delay
+ * @throws IllegalArgumentException if {@code duration} is negative or
+ * {@code unit} is {@code null}
+ */
+ public static Awaitable<Void> delay(long duration, TimeUnit unit) {
+ if (unit == null) {
+ throw new IllegalArgumentException("TimeUnit must not be null");
+ }
+ if (duration < 0) {
+ throw new IllegalArgumentException("delay duration must not be negative: " + duration);
+ }
+ if (duration == 0) {
+ return Awaitable.of(null);
+ }
+ CompletableFuture<Void> cf = new CompletableFuture<>();
+ DELAY_SCHEDULER.schedule(() -> cf.complete(null), duration, unit);
+ return GroovyPromise.of(cf);
+ }
+
+ // ---- for-await support ----------------------------------------------
+
+ /**
+ * Converts an arbitrary source to an {@link AsyncStream} via the adapter
+ * registry. Used by compiler-generated code for {@code for await} loops.
+ * Returns {@link AsyncStream#empty()} for {@code null} input to ensure
+ * null-safe iteration.
+ *
+ * @param source the source object, or {@code null}
+ * @param <T> the element type
+ * @return an async stream, never {@code null}
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> AsyncStream<T> toAsyncStream(Object source) {
+ if (source == null) return AsyncStream.empty();
+ if (source instanceof AsyncStream) return (AsyncStream<T>) source;
+ return AsyncStream.from(source);
+ }
+
+ /**
+ * Closes the given async stream if it is non-null.
+ * <p>
+ * Compiler-generated {@code for await} loops call this from a synthetic
+ * {@code finally} block so that early {@code break}, {@code return}, or
+ * exceptional exit reliably releases the underlying stream resources and
+ * propagates cancellation to upstream producers where supported.
+ *
+ * @param stream the stream to close, or {@code null}
+ * @since 6.0.0
+ */
+ public static void closeStream(Object stream) {
+ if (stream instanceof AsyncStream<?> asyncStream) {
+ asyncStream.close();
+ }
+ }
+
+ // ---- yield return / async generator ----------------------------------
+
+ /**
+ * Safety overload: if {@code yield return} is used outside an async
+ * generator context, this single-argument overload produces a clear
+ * error message at runtime.
+ *
+ * @param value ignored — the method always throws
+ * @throws IllegalStateException always
+ */
+ public static void yieldReturn(Object value) {
+ throw new IllegalStateException(
+ "yield return can only be used inside an async generator method or closure");
+ }
+
+ /**
+ * Called by compiler-generated code for {@code yield return expr} statements
+ * inside async generator methods and closures.
+ *
+ * @param generator the {@link AsyncStreamGenerator} instance (injected as a
+ * synthetic parameter by the compiler)
+ * @param value the value to yield to the consumer
+ * @throws IllegalStateException if {@code generator} is {@code null}
+ */
+ public static void yieldReturn(Object generator, Object value) {
+ if (generator == null) {
+ throw new IllegalStateException("yield return used outside of an async generator context");
+ }
+ ((AsyncStreamGenerator<?>) generator).yield(value);
+ }
+
+ /**
+ * Creates an {@link AsyncStream} from a generator closure. The closure
+ * body is executed asynchronously on the default executor; each
+ * {@code yield return} statement produces an element in the stream.
+ * This method is used internally by {@link groovy.transform.Async @Async}
+ * generator methods. For the {@code async { yield return ... }} closure
+ * expression syntax, see {@link #wrapAsyncGenerator(Closure)}.
+ *
+ * @param body the generator closure (receives an {@link AsyncStreamGenerator}
+ * as its single argument)
+ * @param <T> the element type
+ * @return a live async stream backed by the running generator
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> AsyncStream<T> generateAsyncStream(Closure<?> body) {
+ AsyncStreamGenerator<T> gen = new AsyncStreamGenerator<>();
+ CompletableFuture.runAsync(() -> {
+ gen.attachProducer(Thread.currentThread());
+ try {
+ body.call(gen);
+ gen.complete();
+ } catch (Throwable t) {
+ gen.error(t);
+ } finally {
+ gen.detachProducer(Thread.currentThread());
+ }
+ }, defaultExecutor);
+ return gen;
+ }
+
+ // ---- executor configuration -----------------------------------------
+
+ /**
+ * Returns {@code true} if the running JVM supports virtual threads (JDK 21+).
+ */
+ public static boolean isVirtualThreadsAvailable() {
+ return VIRTUAL_THREADS_AVAILABLE;
+ }
+
+ /**
+ * Returns the current executor used for {@code async} operations.
+ *
+ * @return the active executor, never {@code null}
+ */
+ public static Executor getExecutor() {
+ return defaultExecutor;
+ }
+
+ /**
+ * Sets the executor to use for {@code async} operations. Pass {@code null}
+ * to reset to the default executor (virtual thread executor on JDK 21+,
+ * or a cached daemon thread pool whose maximum size is controlled by the
+ * system property {@code groovy.async.parallelism}).
+ *
+ * @param executor the custom executor, or {@code null} to reset
+ */
+ public static void setExecutor(Executor executor) {
+ defaultExecutor = executor != null
+ ? executor
+ : (VIRTUAL_THREADS_AVAILABLE ? VIRTUAL_THREAD_EXECUTOR : FALLBACK_EXECUTOR);
+ }
+
+ // ---- defer support -----------------------------------------------------
+
+ /**
+ * Creates a new defer scope — a LIFO list of {@link Closure} actions
+ * to be executed when the enclosing method returns. Called by
+ * compiler-generated code at the start of methods that contain
+ * {@code defer} statements.
+ *
+ * @return a new, empty defer scope (a mutable list of closures)
+ */
+ public static Deque<Closure<?>> createDeferScope() {
+ return new ArrayDeque<>();
+ }
+
+ /**
+ * Registers a deferred action in the given scope. The action will be
+ * executed in LIFO order when {@link #executeDeferScope} is called
+ * (typically in a {@code finally} block generated by the compiler).
+ * <p>
+ * This method is the runtime entry point for the {@code defer { ... }}
+ * statement, inspired by Go's {@code defer} keyword.
+ *
+ * @param scope the defer scope (created by {@link #createDeferScope()});
+ * must not be {@code null}
+ * @param action the closure to execute on method exit;
+ * must not be {@code null}
+ * @throws IllegalStateException if {@code scope} is {@code null}
+ * (indicates {@code defer} used outside an async context)
+ * @throws IllegalArgumentException if {@code action} is {@code null}
+ */
+ public static void defer(Deque<Closure<?>> scope, Closure<?> action) {
+ if (scope == null) {
+ throw new IllegalStateException("defer must be used inside an async method or closure");
+ }
+ if (action == null) {
+ throw new IllegalArgumentException("defer action must not be null");
+ }
+ scope.push(action);
+ }
+
+ /**
+ * Executes all deferred actions in the given scope in LIFO order.
+ * Each action is executed independently: if one throws an exception,
+ * subsequent actions still execute. If multiple actions throw, the
+ * first exception is rethrown and subsequent ones are added as
+ * {@linkplain Throwable#addSuppressed(Throwable) suppressed exceptions}.
+ * <p>
+ * Called by compiler-generated code in the {@code finally} block of
+ * methods that contain {@code defer} statements.
+ * <p>
+ * A {@code null} or empty scope is treated as a no-op for robustness.
+ *
+ * @param scope the defer scope to execute; may be {@code null}
+ */
+ public static void executeDeferScope(Deque<Closure<?>> scope) {
+ if (scope == null || scope.isEmpty()) return;
+ Throwable firstError = null;
+ while (!scope.isEmpty()) {
+ try {
+ scope.pop().call();
+ } catch (Throwable t) {
+ if (firstError == null) {
+ firstError = t;
+ } else {
+ firstError.addSuppressed(t);
+ }
+ }
+ }
+ if (firstError != null) {
+ sneakyThrow(firstError);
+ }
+ }
+
+ // ---- internal -------------------------------------------------------
+
+ /**
+ * Deeply unwraps nested exception wrapper layers ({@link CompletionException},
+ * {@link ExecutionException}, {@link UndeclaredThrowableException},
+ * {@link InvocationTargetException}) to find the original root cause.
+ * <p>
+ * A depth limit of 64 is imposed to guard against circular cause chains,
+ * which could otherwise cause an infinite loop.
+ *
+ * @param t the wrapper exception
+ * @return the innermost non-wrapper exception, or the deepest reachable
+ * cause if the depth limit is reached
+ */
+ public static Throwable deepUnwrap(Throwable t) {
+ Throwable cause = t;
+ int depth = 0;
+ while (cause.getCause() != null && depth++ < 64
+ && (cause instanceof CompletionException
+ || cause instanceof ExecutionException
+ || cause instanceof UndeclaredThrowableException
+ || cause instanceof InvocationTargetException)) {
+ cause = cause.getCause();
+ }
+ return cause;
+ }
+
+ /**
+ * Unwraps and rethrows the original exception transparently, bypassing
+ * Java's checked-exception constraints via {@link #sneakyThrow}.
+ */
+ static RuntimeException rethrowUnwrapped(Throwable wrapper) {
+ Throwable cause = deepUnwrap(wrapper);
+ if (cause instanceof Error err) throw err;
+ throw sneakyThrow(cause);
+ }
+
+ /**
+ * Throws the given exception as an unchecked type using type-erasure.
+ * Enables transparent propagation of checked exceptions from async code.
+ */
+ @SuppressWarnings("unchecked")
+ static <T extends Throwable> RuntimeException sneakyThrow(Throwable t) throws T {
+ throw (T) t;
+ }
+
+ /**
+ * Wraps a throwable in a {@link CompletionException} if it is not already one.
+ * Used when rethrowing from within {@link CompletableFuture} lambdas.
+ */
+ static CompletionException wrapForFuture(Throwable t) {
+ if (t instanceof CompletionException ce) return ce;
+ return new CompletionException(t);
+ }
+}
diff --git a/src/main/java/org/apache/groovy/runtime/async/FlowPublisherAdapter.java b/src/main/java/org/apache/groovy/runtime/async/FlowPublisherAdapter.java
new file mode 100644
index 0000000000..cf15cb91a6
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/async/FlowPublisherAdapter.java
@@ -0,0 +1,446 @@
+/*
+ * 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 groovy.concurrent.AsyncStream;
+import groovy.concurrent.Awaitable;
+import groovy.concurrent.AwaitableAdapter;
+
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Flow;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Bridges JDK {@link Flow.Publisher} instances into Groovy's
+ * {@link Awaitable}/{@link AsyncStream} world.
+ *
+ * <h2>Registration</h2>
+ * This adapter is auto-discovered by the
+ * {@link groovy.concurrent.AwaitableAdapterRegistry} via the
+ * {@code META-INF/services/groovy.concurrent.AwaitableAdapter} file.
+ * It handles any object that implements {@link Flow.Publisher}.
+ *
+ * <h2>Adaptation modes</h2>
+ * <ul>
+ * <li><b>Single-value</b> ({@code await publisher}):
+ * subscribes, takes the first {@code onNext} item, cancels the
+ * upstream subscription, and completes the returned {@link Awaitable}.</li>
+ * <li><b>Multi-value</b> ({@code for await (item in publisher)}):
+ * wraps the publisher into an {@link AsyncStream} backed by a
+ * bounded {@link LinkedBlockingQueue}, providing natural
+ * back-pressure by requesting one item at a time.</li>
+ * </ul>
+ *
+ * <h2>Thread safety</h2>
+ * <p>All subscriber callbacks ({@code onSubscribe}, {@code onNext},
+ * {@code onError}, {@code onComplete}) are safe for invocation from
+ * any thread. Subscription references use {@link AtomicReference}
+ * for safe publication and race-free cancellation. The close path
+ * uses a CAS on an {@link AtomicBoolean} to guarantee exactly-once
+ * cleanup semantics.</p>
+ *
+ * <h2>Reactive Streams compliance</h2>
+ * <p>This adapter follows the Reactive Streams specification
+ * (JDK {@link Flow} variant) rules:</p>
+ * <ul>
+ * <li>§2.5 — duplicate {@code onSubscribe} cancels the second subscription</li>
+ * <li>§2.13 — {@code null} items in {@code onNext} are rejected immediately</li>
+ * <li>All signals ({@code onNext}, {@code onError}, {@code onComplete}) use
+ * blocking {@code put()} with a non-blocking {@code offer()} fallback
+ * when the publisher thread is interrupted — this prevents both silent
+ * item loss and consumer deadlock even under unexpected interrupts</li>
+ * <li>Terminal callbacks atomically close the upstream side
+ * ({@code closedRef}) and clear/cancel the stored subscription.
+ * This makes post-terminal {@code onNext} calls from non-compliant
+ * publishers harmless and releases resources promptly.</li>
+ * <li>Back-pressure is enforced by requesting exactly one item after
+ * each consumed element; demand is signalled <em>before</em>
+ * {@code moveNext()} returns, preventing livelock when producer and
+ * consumer share the same thread pool</li>
+ * </ul>
+ *
+ * @see groovy.concurrent.AwaitableAdapterRegistry
+ * @see AsyncStream
+ * @since 6.0.0
+ */
+public class FlowPublisherAdapter implements AwaitableAdapter {
+
+ /**
+ * Queue capacity for the push→pull bridge in
+ * {@link #publisherToAsyncStream}. 256 provides a generous buffer
+ * for bursty publishers while bounding memory.
+ */
+ private static final int QUEUE_CAPACITY = 256;
+
+ /**
+ * Cached awaitables for the two common {@code moveNext()} outcomes.
+ * Eliminates per-call {@link CompletableFuture} + {@link GroovyPromise}
+ * allocation on the hot path (every element and stream-end).
+ */
+ private static final Awaitable<Boolean> MOVE_NEXT_TRUE = Awaitable.of(Boolean.TRUE);
+ private static final Awaitable<Boolean> MOVE_NEXT_FALSE = Awaitable.of(Boolean.FALSE);
+
+ /**
+ * Returns {@code true} if the given type is assignable to
+ * {@link Flow.Publisher}, enabling single-value {@code await}.
+ *
+ * @param type the source type to check
+ * @return {@code true} if this adapter can handle the type
+ */
+ @Override
+ public boolean supportsAwaitable(Class<?> type) {
+ return Flow.Publisher.class.isAssignableFrom(type);
+ }
+
+ /**
+ * Returns {@code true} if the given type is assignable to
+ * {@link Flow.Publisher}, enabling multi-value {@code for await}.
+ *
+ * @param type the source type to check
+ * @return {@code true} if this adapter can produce an async stream
+ */
+ @Override
+ public boolean supportsAsyncStream(Class<?> type) {
+ return Flow.Publisher.class.isAssignableFrom(type);
+ }
+
+ /**
+ * Converts a {@link Flow.Publisher} to a single-value {@link Awaitable}
+ * by subscribing and taking the first emitted item.
+ *
+ * @param source the publisher instance
+ * @param <T> the element type
+ * @return an awaitable that resolves to the first emitted value
+ */
+ @Override
+ @SuppressWarnings("unchecked")
+ public <T> Awaitable<T> toAwaitable(Object source) {
+ return publisherToAwaitable((Flow.Publisher<T>) source);
+ }
+
+ /**
+ * Converts a {@link Flow.Publisher} to a multi-value {@link AsyncStream}
+ * for use with {@code for await} loops.
+ *
+ * @param source the publisher instance
+ * @param <T> the element type
+ * @return an async stream that yields publisher items
+ */
+ @Override
+ @SuppressWarnings("unchecked")
+ public <T> AsyncStream<T> toAsyncStream(Object source) {
+ return publisherToAsyncStream((Flow.Publisher<T>) source);
+ }
+
+ // ---- Single-value adaptation (await publisher) ----
+
+ /**
+ * Subscribes to the publisher, takes the <em>first</em> emitted item,
+ * cancels the upstream subscription, and returns a completed
+ * {@link Awaitable}.
+ *
+ * <p>If the publisher completes without emitting any item,
+ * the returned awaitable resolves to {@code null}.</p>
+ *
+ * @param publisher the upstream publisher
+ * @param <T> the element type
+ * @return an awaitable that completes with the first emitted value
+ */
+ private <T> Awaitable<T> publisherToAwaitable(Flow.Publisher<T> publisher) {
+ CompletableFuture<T> cf = new CompletableFuture<>();
+ // AtomicReference ensures safe publication of the subscription
+ // across the onSubscribe thread and callback threads (§1.3).
+ AtomicReference<Flow.Subscription> subRef = new AtomicReference<>();
+ // Guard against non-compliant publishers that send multiple signals
+ AtomicBoolean done = new AtomicBoolean(false);
+
+ publisher.subscribe(new Flow.Subscriber<T>() {
+ @Override
+ public void onSubscribe(Flow.Subscription s) {
+ // §2.5: reject duplicate subscriptions
+ if (!subRef.compareAndSet(null, s)) {
+ s.cancel();
+ return;
+ }
+ s.request(1);
+ }
+
+ @Override
+ public void onNext(T item) {
+ // §2.13: null items are spec violations
+ if (item == null) {
+ onError(new NullPointerException(
+ "Flow.Publisher onNext received null (Reactive Streams §2.13)"));
+ return;
+ }
+ if (done.compareAndSet(false, true)) {
+ cf.complete(item);
+ Flow.Subscription sub = subRef.getAndSet(null);
+ if (sub != null) sub.cancel();
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ if (done.compareAndSet(false, true)) {
+ cf.completeExceptionally(t);
+ // Cancel subscription to release resources (idempotent per §3.7)
+ Flow.Subscription sub = subRef.getAndSet(null);
+ if (sub != null) sub.cancel();
+ }
+ }
+
+ @Override
+ public void onComplete() {
+ // Publisher completed before emitting — resolve to null
+ if (done.compareAndSet(false, true)) {
+ cf.complete(null);
+ // Mirror onNext/onError cleanup for prompt resource release.
+ Flow.Subscription sub = subRef.getAndSet(null);
+ if (sub != null) sub.cancel();
+ }
+ }
+ });
+
+ return new GroovyPromise<>(cf);
+ }
+
+ // ---- Multi-value adaptation (for await publisher) ----
+
+ // Signal wrapper types allow us to distinguish values, errors, and
+ // completion in a single queue without type confusion.
+
+ private record ValueSignal<T>(T value) {
+ }
+
+ private record ErrorSignal(Throwable error) {
+ }
+
+ /** Singleton sentinel for stream completion. */
+ private static final Object COMPLETE_SENTINEL = new Object();
+
+ /**
+ * Wraps a {@link Flow.Publisher} into an {@link AsyncStream},
+ * providing a pull-based iteration interface over a push-based source.
+ *
+ * <p>Back-pressure is enforced by requesting exactly one item after
+ * each consumed element. Demand is signalled <em>before</em> the
+ * consumer's {@code moveNext()} awaitable completes, so the publisher
+ * can begin producing the next value while the consumer processes the
+ * current one — this prevents livelock when producer and consumer
+ * share the same thread pool.</p>
+ *
+ * <p>The internal bounded queue (capacity {@value QUEUE_CAPACITY})
+ * absorbs minor timing jitter between producer and consumer. Signals
+ * use blocking {@code put()} for normal delivery with a non-blocking
+ * {@code offer()} fallback when the publisher thread is interrupted —
+ * ensuring no items or terminal events are silently dropped.</p>
+ *
+ * <p><b>Resource management:</b> When the consumer calls
+ * {@link AsyncStream#close()} (e.g. via {@code break} in a
+ * {@code for await} loop), the upstream subscription is cancelled
+ * and a completion sentinel is injected to unblock any pending
+ * {@code moveNext()} call.</p>
+ *
+ * @param publisher the upstream publisher
+ * @param <T> the element type
+ * @return an async stream that yields publisher items
+ */
+ @SuppressWarnings("unchecked")
+ private <T> AsyncStream<T> publisherToAsyncStream(Flow.Publisher<T> publisher) {
+ LinkedBlockingQueue<Object> queue = new LinkedBlockingQueue<>(QUEUE_CAPACITY);
+ AtomicReference<Flow.Subscription> subRef = new AtomicReference<>();
+ // Tracks whether the stream has been closed (by consumer or terminal signal).
+ // CAS ensures exactly-once semantics for the close/cleanup path.
+ AtomicBoolean closedRef = new AtomicBoolean(false);
+ AtomicBoolean streamClosed = new AtomicBoolean(false);
+
+ publisher.subscribe(new Flow.Subscriber<T>() {
+ @Override
+ public void onSubscribe(Flow.Subscription s) {
+ // §2.5: reject duplicate subscriptions
+ if (!subRef.compareAndSet(null, s)) {
+ s.cancel();
+ return;
+ }
+ // Double-check pattern: if close() raced between the CAS and this point,
+ // the subscription must be cancelled immediately to avoid a dangling stream.
+ if (closedRef.get()) {
+ Flow.Subscription sub = subRef.getAndSet(null);
+ if (sub != null) sub.cancel();
+ return;
+ }
+ s.request(1);
+ }
+
+ @Override
+ public void onNext(T item) {
+ // §2.13: null items are spec violations
+ if (item == null) {
+ onError(new NullPointerException(
+ "Flow.Publisher onNext received null (Reactive Streams §2.13)"));
+ return;
+ }
+ if (!closedRef.get()) {
+ try {
+ // Blocking put() guarantees the item reaches the consumer.
+ // Since demand is capped at 1 (one request(1) per moveNext),
+ // a well-behaved publisher will never overflow the queue; put()
+ // still protects against misbehaving publishers by blocking
+ // rather than silently dropping the value.
+ queue.put(new ValueSignal<>(item));
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ // Blocking put() was interrupted. Fall back to non-blocking
+ // offer() so the item still reaches the consumer. With
+ // one-at-a-time demand the queue is almost never full, so
+ // offer() effectively always succeeds. If it doesn't
+ // (misbehaving publisher overfilling the queue), cancel
+ // upstream and inject an error signal to terminate the
+ // consumer cleanly instead of silently dropping the item.
+ if (!queue.offer(new ValueSignal<>(item))) {
+ Flow.Subscription sub = subRef.getAndSet(null);
+ if (sub != null) sub.cancel();
+ closedRef.set(true);
+ queue.offer(new ErrorSignal(
+ new CancellationException(
+ "Item delivery interrupted and queue full")));
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ // First terminal signal wins. Ignore duplicate terminal callbacks.
+ if (!closedRef.compareAndSet(false, true)) {
+ return;
+ }
+ // Cancel subscription eagerly to release upstream resources
+ Flow.Subscription sub = subRef.getAndSet(null);
+ if (sub != null) sub.cancel();
+ try {
+ // Terminal signals use blocking put() to guarantee delivery —
+ // the consumer MUST see the error to propagate it correctly.
+ queue.put(new ErrorSignal(t));
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ // Blocking put() was interrupted. Fall back to non-blocking
+ // offer(). If that also fails (queue full), set streamClosed
+ // so the consumer's next moveNext() returns false instead of
+ // blocking indefinitely.
+ if (!queue.offer(new ErrorSignal(t))) {
+ streamClosed.set(true);
+ }
+ }
+ }
+
+ @Override
+ public void onComplete() {
+ // First terminal signal wins. Ignore duplicate terminal callbacks.
+ if (!closedRef.compareAndSet(false, true)) {
+ return;
+ }
+ // Clear subscription consistently with other terminal paths.
+ Flow.Subscription sub = subRef.getAndSet(null);
+ if (sub != null) sub.cancel();
+ try {
+ // Blocking put() guarantees the consumer will see the sentinel,
+ // even if the queue was temporarily full from buffered values.
+ queue.put(COMPLETE_SENTINEL);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ // Same fallback as onError: try non-blocking offer,
+ // set streamClosed if that also fails.
+ if (!queue.offer(COMPLETE_SENTINEL)) {
+ streamClosed.set(true);
+ }
+ }
+ }
+ });
+
+ return new AsyncStream<T>() {
+ private T current;
+
+ @Override
+ public Awaitable<Boolean> moveNext() {
+ if (streamClosed.get()) {
+ return MOVE_NEXT_FALSE;
+ }
+
+ try {
+ Object signal = queue.take();
+
+ if (signal instanceof ValueSignal) {
+ current = ((ValueSignal<T>) signal).value;
+ // Signal demand BEFORE returning so the publisher can
+ // begin producing the next value while the consumer
+ // processes this one — prevents livelock when both
+ // share a thread pool.
+ Flow.Subscription sub = subRef.get();
+ if (sub != null) sub.request(1);
+ return MOVE_NEXT_TRUE;
+ } else if (signal instanceof ErrorSignal es) {
+ streamClosed.set(true);
+ // Throw directly (matching AsyncStreamGenerator) to
+ // avoid unnecessary CF allocation on the error path
+ // and JDK 23+ CompletableFuture.get() wrapping issues.
+ Throwable cause = es.error;
+ if (cause instanceof Error err) throw err;
+ throw AsyncSupport.sneakyThrow(cause);
+ } else {
+ // COMPLETE_SENTINEL — end-of-stream
+ streamClosed.set(true);
+ return MOVE_NEXT_FALSE;
+ }
+ } catch (InterruptedException ie) {
+ streamClosed.set(true);
+ Thread.currentThread().interrupt();
+ CancellationException ce = new CancellationException("Interrupted during moveNext");
+ ce.initCause(ie);
+ throw ce;
+ }
+ }
+
+ @Override
+ public T getCurrent() {
+ return current;
+ }
+
+ @Override
+ public void close() {
+ if (streamClosed.compareAndSet(false, true)) {
+ closedRef.set(true);
+ Flow.Subscription sub = subRef.getAndSet(null);
+ if (sub != null) sub.cancel();
+ // Drain the queue and inject a sentinel to unblock a
+ // concurrent moveNext() that may be blocked in take().
+ // offer() is non-blocking and cannot throw InterruptedException;
+ // after clear(), the queue is empty (capacity 256) so offer()
+ // effectively always succeeds.
+ queue.clear();
+ queue.offer(COMPLETE_SENTINEL);
+ }
+ }
+ };
+ }
+}
diff --git a/src/main/java/org/apache/groovy/runtime/async/GroovyPromise.java b/src/main/java/org/apache/groovy/runtime/async/GroovyPromise.java
new file mode 100644
index 0000000000..5d03000748
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/async/GroovyPromise.java
@@ -0,0 +1,207 @@
+/*
+ * 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 groovy.concurrent.Awaitable;
+
+import java.util.Objects;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Function;
+
+/**
+ * Default {@link Awaitable} implementation backed by a {@link CompletableFuture}.
+ * <p>
+ * This is the concrete type returned by {@code async} methods. It delegates
+ * all operations to an underlying {@code CompletableFuture} while keeping the
+ * public API limited to the {@code Awaitable} contract, thereby decoupling
+ * user code from JDK-specific async APIs.
+ * <p>
+ * This class is an internal implementation detail and should not be referenced
+ * directly by user code. Use the {@link Awaitable} interface instead.
+ *
+ * @param <T> the result type
+ * @see Awaitable
+ * @since 6.0.0
+ */
+public class GroovyPromise<T> implements Awaitable<T> {
+
+ private final CompletableFuture<T> future;
+
+ /**
+ * Creates a new {@code GroovyPromise} wrapping the given {@link CompletableFuture}.
+ *
+ * @param future the backing future; must not be {@code null}
+ * @throws NullPointerException if {@code future} is {@code null}
+ */
+ public GroovyPromise(CompletableFuture<T> future) {
+ this.future = Objects.requireNonNull(future, "future must not be null");
+ }
+
+ /**
+ * Creates a {@code GroovyPromise} wrapping the given {@link CompletableFuture}.
+ */
+ public static <T> GroovyPromise<T> of(CompletableFuture<T> future) {
+ return new GroovyPromise<>(future);
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * Waits if necessary for the computation to complete, then retrieves its result.
+ * If the future was cancelled, the original {@link CancellationException} is
+ * unwrapped from the JDK 23+ wrapper for cross-version consistency.
+ */
+ @Override
+ public T get() throws InterruptedException, ExecutionException {
+ try {
+ return future.get();
+ } catch (CancellationException e) {
+ throw unwrapCancellation(e);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * Waits at most the given time for the computation to complete.
+ * Unwraps JDK 23+ {@link CancellationException} wrappers for consistency.
+ */
+ @Override
+ public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {
+ try {
+ return future.get(timeout, unit);
+ } catch (CancellationException e) {
+ throw unwrapCancellation(e);
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean isDone() {
+ return future.isDone();
+ }
+
+ /**
+ * Attempts to cancel this computation. Delegates to
+ * {@link CompletableFuture#cancel(boolean) CompletableFuture.cancel(true)}.
+ * <p>
+ * <b>Note:</b> {@code CompletableFuture} cancellation sets the future's state
+ * to cancelled but does <em>not</em> reliably interrupt the underlying thread.
+ * Async work already in progress may continue running in the background.
+ * For cooperative cancellation, check {@link Thread#isInterrupted()} in
+ * long-running async bodies.
+ *
+ * @return {@code true} if the future was successfully cancelled
+ */
+ @Override
+ public boolean cancel() {
+ return future.cancel(true);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean isCancelled() {
+ return future.isCancelled();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean isCompletedExceptionally() {
+ return future.isCompletedExceptionally();
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * Returns a new {@code GroovyPromise} whose result is obtained by applying
+ * the given function to this promise's result.
+ */
+ @Override
+ public <U> Awaitable<U> then(Function<? super T, ? extends U> fn) {
+ return new GroovyPromise<>(future.thenApply(fn));
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * Returns a new {@code GroovyPromise} that is the result of composing this
+ * promise with the async function, enabling flat-mapping of awaitables.
+ */
+ @Override
+ public <U> Awaitable<U> thenCompose(Function<? super T, ? extends Awaitable<U>> fn) {
+ return new GroovyPromise<>(future.thenCompose(t -> fn.apply(t).toCompletableFuture()));
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * Returns a new {@code GroovyPromise} that handles exceptions thrown by this promise.
+ * The throwable passed to the handler is deeply unwrapped to strip JDK
+ * wrapper layers ({@code CompletionException}, {@code ExecutionException}).
+ */
+ @Override
+ public Awaitable<T> exceptionally(Function<Throwable, ? extends T> fn) {
+ return new GroovyPromise<>(future.exceptionally(t -> {
+ // Unwrap all wrapper layers so handler sees the original exception
+ Throwable cause = AsyncSupport.deepUnwrap(t);
+ return fn.apply(cause);
+ }));
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * Returns the underlying {@link CompletableFuture} for interop with JDK APIs.
+ */
+ @Override
+ public CompletableFuture<T> toCompletableFuture() {
+ return future;
+ }
+
+ /**
+ * JDK 23+ wraps a stored {@link CancellationException} in a new instance
+ * with the generic message {@code "get"} when {@link CompletableFuture#get()}
+ * is called. Unwrap it here so Groovy users consistently observe the
+ * original cancellation message and cause chain across all supported JDKs.
+ */
+ private static CancellationException unwrapCancellation(CancellationException exception) {
+ Throwable cause = exception.getCause();
+ return cause instanceof CancellationException ce ? ce : exception;
+ }
+
+ /**
+ * Returns a human-readable representation showing the promise state:
+ * {@code GroovyPromise{pending}}, {@code GroovyPromise{completed}}, or
+ * {@code GroovyPromise{failed}}.
+ */
+ @Override
+ public String toString() {
+ if (future.isDone()) {
+ if (future.isCompletedExceptionally()) {
+ return "GroovyPromise{failed}";
+ }
+ return "GroovyPromise{completed}";
+ }
+ return "GroovyPromise{pending}";
+ }
+}
diff --git a/src/main/java/org/codehaus/groovy/ast/ModifierNode.java b/src/main/java/org/codehaus/groovy/ast/ModifierNode.java
index e76e1b4849..a141ffae12 100644
--- a/src/main/java/org/codehaus/groovy/ast/ModifierNode.java
+++ b/src/main/java/org/codehaus/groovy/ast/ModifierNode.java
@@ -25,6 +25,7 @@ import java.util.Map;
import java.util.Objects;
import static org.apache.groovy.parser.antlr4.GroovyParser.ABSTRACT;
+import static org.apache.groovy.parser.antlr4.GroovyParser.ASYNC;
import static org.apache.groovy.parser.antlr4.GroovyParser.DEF;
import static org.apache.groovy.parser.antlr4.GroovyParser.DEFAULT;
import static org.apache.groovy.parser.antlr4.GroovyParser.FINAL;
@@ -55,6 +56,7 @@ public class ModifierNode extends ASTNode {
public static final int ANNOTATION_TYPE = -999;
public static final Map<Integer, Integer> MODIFIER_OPCODE_MAP = Maps.of(
ANNOTATION_TYPE, 0,
+ ASYNC, 0,
DEF, 0,
VAR, 0,
diff --git a/src/main/java/org/codehaus/groovy/transform/AsyncASTTransformation.java b/src/main/java/org/codehaus/groovy/transform/AsyncASTTransformation.java
new file mode 100644
index 0000000000..5edd5f9e15
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/transform/AsyncASTTransformation.java
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.codehaus.groovy.transform;
+
+import groovy.concurrent.AsyncStream;
+import groovy.concurrent.Awaitable;
+import groovy.transform.Async;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotatedNode;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.ClassCodeExpressionTransformer;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.GenericsType;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.VariableScope;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.control.CompilePhase;
+import org.codehaus.groovy.control.SourceUnit;
+
+import static org.codehaus.groovy.ast.tools.GeneralUtils.block;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.varX;
+import static org.codehaus.groovy.ast.tools.GenericsUtils.makeClassSafeWithGenerics;
+
+/**
+ * Handles code generation for the {@link Async @Async} annotation.
+ * <p>
+ * Transforms the annotated method so that:
+ * <ol>
+ * <li>{@code await(expr)} calls within the method body are redirected to
+ * the runtime's {@code await()} via {@link AsyncTransformHelper}</li>
+ * <li>The method body is executed asynchronously — or as an async generator
+ * if it contains {@code yield return} — via factory methods on
+ * {@link AsyncTransformHelper}</li>
+ * <li>Methods containing {@code defer} statements are wrapped in a
+ * try-finally block that executes deferred actions in LIFO order</li>
+ * <li>The return type becomes {@code Awaitable<T>}
+ * (or {@code AsyncStream<T>} for generators)</li>
+ * </ol>
+ * <p>
+ * All AST node construction for async runtime calls is delegated to
+ * {@link AsyncTransformHelper}, which encapsulates the internal method
+ * names and class references. This class focuses on method-level
+ * validation and structural transformation.
+ * <p>
+ * This transformation runs during the {@link CompilePhase#CANONICALIZATION}
+ * phase — before type resolution, which allows the modified return types to
+ * participate in normal type checking.
+ *
+ * @see Async
+ * @see AsyncTransformHelper
+ * @since 6.0.0
+ */
+@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
+public class AsyncASTTransformation extends AbstractASTTransformation {
+
+ private static final Class<?> MY_CLASS = Async.class;
+ private static final ClassNode MY_TYPE = ClassHelper.make(MY_CLASS);
+ private static final String MY_TYPE_NAME = "@" + MY_TYPE.getNameWithoutPackage();
+ private static final ClassNode AWAITABLE_TYPE = ClassHelper.make(Awaitable.class);
+ private static final ClassNode ASYNC_STREAM_TYPE = ClassHelper.make(AsyncStream.class);
+
+ @Override
+ public void visit(ASTNode[] nodes, SourceUnit source) {
+ init(nodes, source);
+ AnnotatedNode parent = (AnnotatedNode) nodes[1];
+ AnnotationNode anno = (AnnotationNode) nodes[0];
+ if (!MY_TYPE.equals(anno.getClassNode())) return;
+
+ if (!(parent instanceof MethodNode mNode)) return;
+
+ // Validate
+ if (mNode.isAbstract()) {
+ addError(MY_TYPE_NAME + " cannot be applied to abstract method '" + mNode.getName() + "'", mNode);
+ return;
+ }
+ if ("<init>".equals(mNode.getName()) || "<clinit>".equals(mNode.getName())) {
+ addError(MY_TYPE_NAME + " cannot be applied to constructors", mNode);
+ return;
+ }
+ ClassNode originalReturnType = mNode.getReturnType();
+ if (AWAITABLE_TYPE.getName().equals(originalReturnType.getName())
+ || ASYNC_STREAM_TYPE.getName().equals(originalReturnType.getName())
+ || "java.util.concurrent.CompletableFuture".equals(originalReturnType.getName())
+ || "java.util.concurrent.CompletionStage".equals(originalReturnType.getName())
+ || "java.util.concurrent.Future".equals(originalReturnType.getName())) {
+ addError(MY_TYPE_NAME + " cannot be applied to a method that already returns an async type", mNode);
+ return;
+ }
+ Statement originalBody = mNode.getCode();
+ if (originalBody == null) return;
+
+ ClassNode cNode = mNode.getDeclaringClass();
+
+ // Resolve executor expression
+ String executorFieldName = getMemberStringValue(anno, "executor");
+ Expression executorExpr;
+ if (executorFieldName != null && !executorFieldName.isEmpty()) {
+ FieldNode field = cNode.getDeclaredField(executorFieldName);
+ if (field == null) {
+ addError(MY_TYPE_NAME + ": executor field '" + executorFieldName
+ + "' not found in class " + cNode.getName(), mNode);
+ return;
+ }
+ if (mNode.isStatic() && !field.isStatic()) {
+ addError(MY_TYPE_NAME + ": executor field '" + executorFieldName
+ + "' must be static for static method '" + mNode.getName() + "'", mNode);
+ return;
+ }
+ executorExpr = varX(executorFieldName);
+ } else {
+ executorExpr = AsyncTransformHelper.buildGetExecutorCall();
+ }
+
+ // Step 1: Transform await() method calls to AsyncSupport.await()
+ new AwaitCallTransformer(source).visitMethod(mNode);
+
+ // Step 2: Check if body contains yield return (AsyncSupport.yieldReturn) calls
+ Statement transformedBody = mNode.getCode();
+ boolean hasYieldReturn = AsyncTransformHelper.containsYieldReturn(transformedBody);
+ boolean hasDefer = AsyncTransformHelper.containsDefer(transformedBody);
+
+ // Step 2a: If defer is present, wrap body in try-finally with defer scope
+ if (hasDefer) {
+ transformedBody = AsyncTransformHelper.wrapWithDeferScope(transformedBody);
+ }
+
+ Parameter[] closureParams;
+ if (hasYieldReturn) {
+ closureParams = new Parameter[]{ AsyncTransformHelper.createGenParam() };
+ } else {
+ closureParams = Parameter.EMPTY_ARRAY;
+ }
+ ClosureExpression closure = new ClosureExpression(closureParams, transformedBody);
+ VariableScope closureScope = new VariableScope(mNode.getVariableScope());
+ for (Parameter p : mNode.getParameters()) {
+ p.setClosureSharedVariable(true);
+ closureScope.putReferencedLocalVariable(p);
+ }
+ closure.setVariableScope(closureScope);
+
+ if (hasYieldReturn) {
+ // Transform yieldReturn(expr) → yieldReturn($__asyncGen__, expr) in closure body
+ AsyncTransformHelper.injectGenParamIntoYieldReturnCalls(transformedBody, closureParams[0]);
+
+ // Async generator: wrap body in AsyncSupport.generateAsyncStream { ... }
+ Expression genCall = AsyncTransformHelper.buildGenerateAsyncStreamCall(closure);
+ mNode.setCode(block(returnS(genCall)));
+
+ // Return type: AsyncStream<T>
+ ClassNode innerType;
+ if (ClassHelper.isPrimitiveVoid(originalReturnType) || ClassHelper.OBJECT_TYPE.equals(originalReturnType)) {
+ innerType = ClassHelper.OBJECT_TYPE;
+ } else if (ClassHelper.isPrimitiveType(originalReturnType)) {
+ innerType = ClassHelper.getWrapper(originalReturnType);
+ } else {
+ innerType = originalReturnType;
+ }
+ ClassNode streamReturnType = makeClassSafeWithGenerics(ASYNC_STREAM_TYPE, new GenericsType(innerType));
+ mNode.setReturnType(streamReturnType);
+ } else {
+ // Regular async: delegate to AsyncSupport.executeAsync/executeAsyncVoid
+ boolean isVoid = ClassHelper.isPrimitiveVoid(originalReturnType);
+ Expression asyncCall;
+ if (isVoid) {
+ asyncCall = AsyncTransformHelper.buildExecuteAsyncVoidCall(closure, executorExpr);
+ } else {
+ asyncCall = AsyncTransformHelper.buildExecuteAsyncCall(closure, executorExpr);
+ }
+ mNode.setCode(block(returnS(asyncCall)));
+
+ // Return type: Awaitable<T>
+ ClassNode innerType;
+ if (isVoid) {
+ innerType = ClassHelper.void_WRAPPER_TYPE;
+ } else if (ClassHelper.isPrimitiveType(originalReturnType)) {
+ innerType = ClassHelper.getWrapper(originalReturnType);
+ } else {
+ innerType = originalReturnType;
+ }
+ ClassNode awaitableReturnType = makeClassSafeWithGenerics(AWAITABLE_TYPE, new GenericsType(innerType));
+ mNode.setReturnType(awaitableReturnType);
+ }
+ }
+
+ /**
+ * Walks the method body and transforms {@code await(expr)} method calls
+ * into {@code AsyncSupport.await(expr)} static calls. With the native
+ * grammar ({@code await expr}), most calls are already compiled to
+ * {@code AsyncSupport.await()} by the parser; this transformer handles
+ * the {@code await(expr)} call-style used in {@link Async @Async}-annotated
+ * methods where {@code await} is parsed as a regular method call.
+ */
+ private static class AwaitCallTransformer extends ClassCodeExpressionTransformer {
+
+ private final SourceUnit sourceUnit;
+
+ AwaitCallTransformer(SourceUnit sourceUnit) {
+ this.sourceUnit = sourceUnit;
+ }
+
+ @Override
+ protected SourceUnit getSourceUnit() {
+ return sourceUnit;
+ }
+
+ @Override
+ public Expression transform(Expression expr) {
+ if (expr == null) return null;
+
+ if (expr instanceof ClosureExpression ce) {
+ ce.getCode().visit(this);
+ return ce;
+ }
+
+ if (expr instanceof MethodCallExpression mce
+ && AsyncTransformHelper.isAwaitMethodName(mce.getMethodAsString())
+ && mce.isImplicitThis()) {
+ Expression transformedArgs = mce.getArguments().transformExpression(this);
+ Expression replacement = AsyncTransformHelper.buildAwaitCall(transformedArgs);
+ replacement.setSourcePosition(expr);
+ return replacement;
+ }
+
+ return expr.transformExpression(this);
+ }
+ }
+}
diff --git a/src/main/java/org/codehaus/groovy/transform/AsyncTransformHelper.java b/src/main/java/org/codehaus/groovy/transform/AsyncTransformHelper.java
new file mode 100644
index 0000000000..b027f91cfc
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/transform/AsyncTransformHelper.java
@@ -0,0 +1,412 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.codehaus.groovy.transform;
+
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.CodeVisitorSupport;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.expr.ArgumentListExpression;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.DeclarationExpression;
+import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.ExpressionStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.ast.stmt.TryCatchStatement;
+import org.codehaus.groovy.syntax.Token;
+import org.codehaus.groovy.syntax.Types;
+
+import static org.codehaus.groovy.ast.tools.GeneralUtils.args;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.block;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.callX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.varX;
+
+/**
+ * Shared AST utilities for the {@code async}/{@code await} language feature.
+ * <p>
+ * Both the ANTLR4 parser ({@code AstBuilder}) and the annotation-driven
+ * {@link AsyncASTTransformation} need to detect, rewrite, and generate AST
+ * nodes for {@code await}, {@code yield return}, {@code defer}, and other
+ * async constructs. This helper class centralises those operations behind
+ * a clean API so that:
+ * <ul>
+ * <li>Internal constants (method names, variable names, type references)
+ * remain encapsulated — callers never reference them directly.</li>
+ * <li>The two code paths (parser and annotation transform) stay in sync
+ * with no duplicated logic.</li>
+ * </ul>
+ * <p>
+ * <b>AST factory methods</b> (e.g.
+ * {@link #buildAwaitCall(Expression)},
+ * {@link #buildYieldReturnCall(Expression)},
+ * {@link #buildDeferCall(Expression)})
+ * construct fully-formed AST nodes that reference the internal
+ * {@code AsyncSupport} runtime class, shielding callers from the underlying
+ * method names and class references.
+ * <p>
+ * <b>Statement scanners</b> ({@link #containsYieldReturn(Statement)},
+ * {@link #containsDefer(Statement)}) detect the presence of generator and
+ * defer constructs without descending into nested
+ * {@link ClosureExpression}s, since each nested {@code async { ... }} closure
+ * manages its own scope independently.
+ * <p>
+ * <b>Rewriting utilities</b>
+ * ({@link #injectGenParamIntoYieldReturnCalls(Statement, Parameter)},
+ * {@link #wrapWithDeferScope(Statement)}) perform targeted AST mutations
+ * that transform high-level syntax into the lower-level runtime calls.
+ *
+ * @see AsyncASTTransformation
+ * @since 6.0.0
+ */
+public final class AsyncTransformHelper {
+
+ // ---- internal constants (all private) --------------------------------
+
+ private static final String ASYNC_SUPPORT_CLASS = "org.apache.groovy.runtime.async.AsyncSupport";
+ private static final ClassNode ASYNC_SUPPORT_TYPE = ClassHelper.make(ASYNC_SUPPORT_CLASS);
+ private static final String ASYNC_GEN_PARAM_NAME = "$__asyncGen__";
+ private static final String DEFER_SCOPE_VAR = "$__deferScope__";
+
+ private static final String AWAIT_METHOD = "await";
+ private static final String YIELD_RETURN_METHOD = "yieldReturn";
+ private static final String WRAP_ASYNC_METHOD = "wrapAsync";
+ private static final String WRAP_ASYNC_GENERATOR_METHOD = "wrapAsyncGenerator";
+ private static final String GENERATE_ASYNC_STREAM_METHOD = "generateAsyncStream";
+ private static final String EXECUTE_ASYNC_METHOD = "executeAsync";
+ private static final String EXECUTE_ASYNC_VOID_METHOD = "executeAsyncVoid";
+ private static final String TO_ASYNC_STREAM_METHOD = "toAsyncStream";
+ private static final String CLOSE_STREAM_METHOD = "closeStream";
+ private static final String CREATE_DEFER_SCOPE_METHOD = "createDeferScope";
+ private static final String DEFER_METHOD = "defer";
+ private static final String EXECUTE_DEFER_SCOPE_METHOD = "executeDeferScope";
+
+ private AsyncTransformHelper() { }
+
+ // ---- AST factory methods --------------------------------------------
+
+ /**
+ * Ensures the expression is an {@link ArgumentListExpression}.
+ * If it already is one, returns it as-is; otherwise wraps it.
+ */
+ private static Expression ensureArgs(Expression expr) {
+ return expr instanceof ArgumentListExpression ? expr : new ArgumentListExpression(expr);
+ }
+
+ /**
+ * Builds {@code AsyncSupport.await(arg)}.
+ * Accepts either a single expression or a pre-assembled
+ * {@link ArgumentListExpression}.
+ *
+ * @param arg the expression to await
+ * @return an AST node representing the static call
+ */
+ public static Expression buildAwaitCall(Expression arg) {
+ return callX(ASYNC_SUPPORT_TYPE, AWAIT_METHOD, ensureArgs(arg));
+ }
+
+ /**
+ * Returns {@code true} if the given method name is the {@code await}
+ * keyword — used by the {@code @Async} annotation's
+ * {@link AsyncASTTransformation.AwaitCallTransformer AwaitCallTransformer}
+ * to identify {@code await(expr)} method calls that need rewriting.
+ *
+ * @param name the method name to check
+ * @return {@code true} if the name is {@code "await"}
+ */
+ public static boolean isAwaitMethodName(String name) {
+ return AWAIT_METHOD.equals(name);
+ }
+
+ /**
+ * Builds {@code AsyncSupport.yieldReturn(expr)}.
+ *
+ * @param arg the expression to yield
+ * @return an AST node representing the static call
+ */
+ public static Expression buildYieldReturnCall(Expression arg) {
+ return callX(ASYNC_SUPPORT_TYPE, YIELD_RETURN_METHOD, ensureArgs(arg));
+ }
+
+ /**
+ * Builds {@code AsyncSupport.defer($__deferScope__, action)}.
+ * The synthetic defer-scope variable is injected by
+ * {@link #wrapWithDeferScope(Statement)} during AST transformation.
+ * <p>
+ * The parser validates that {@code defer} only appears inside an async
+ * context (async method or async closure); using it elsewhere is a
+ * compile-time error.
+ *
+ * @param action the deferred action expression (typically a closure)
+ * @return an AST node representing the static call
+ */
+ public static Expression buildDeferCall(Expression action) {
+ return callX(ASYNC_SUPPORT_TYPE, DEFER_METHOD,
+ args(varX(DEFER_SCOPE_VAR), action));
+ }
+
+ /**
+ * Builds {@code AsyncSupport.toAsyncStream(source)}.
+ *
+ * @param source the expression to convert to an async stream
+ * @return an AST node representing the static call
+ */
+ public static Expression buildToAsyncStreamCall(Expression source) {
+ return callX(ASYNC_SUPPORT_TYPE, TO_ASYNC_STREAM_METHOD, ensureArgs(source));
+ }
+
+ /**
+ * Builds {@code AsyncSupport.closeStream(stream)}.
+ *
+ * @param stream the stream expression to close
+ * @return an AST node representing the static call
+ */
+ public static Expression buildCloseStreamCall(Expression stream) {
+ return callX(ASYNC_SUPPORT_TYPE, CLOSE_STREAM_METHOD, ensureArgs(stream));
+ }
+
+ /**
+ * Builds {@code AsyncSupport.generateAsyncStream(closure)}.
+ *
+ * @param closure the generator closure expression
+ * @return an AST node representing the static call
+ */
+ public static Expression buildGenerateAsyncStreamCall(Expression closure) {
+ return callX(ASYNC_SUPPORT_TYPE, GENERATE_ASYNC_STREAM_METHOD, ensureArgs(closure));
+ }
+
+ /**
+ * Builds {@code AsyncSupport.wrapAsync(closure)}.
+ *
+ * @param closure the async closure expression
+ * @return an AST node representing the static call
+ */
+ public static Expression buildWrapAsyncCall(Expression closure) {
+ return callX(ASYNC_SUPPORT_TYPE, WRAP_ASYNC_METHOD, ensureArgs(closure));
+ }
+
+ /**
+ * Builds {@code AsyncSupport.wrapAsyncGenerator(closure)}.
+ *
+ * @param closure the generator closure expression
+ * @return an AST node representing the static call
+ */
+ public static Expression buildWrapAsyncGeneratorCall(Expression closure) {
+ return callX(ASYNC_SUPPORT_TYPE, WRAP_ASYNC_GENERATOR_METHOD, ensureArgs(closure));
+ }
+
+ /**
+ * Builds {@code AsyncSupport.executeAsync(closure, executor)}.
+ *
+ * @param closure the async closure expression
+ * @param executor the executor expression
+ * @return an AST node representing the static call
+ */
+ public static Expression buildExecuteAsyncCall(Expression closure, Expression executor) {
+ return callX(ASYNC_SUPPORT_TYPE, EXECUTE_ASYNC_METHOD, args(closure, executor));
+ }
+
+ /**
+ * Builds {@code AsyncSupport.executeAsyncVoid(closure, executor)}.
+ *
+ * @param closure the async void closure expression
+ * @param executor the executor expression
+ * @return an AST node representing the static call
+ */
+ public static Expression buildExecuteAsyncVoidCall(Expression closure, Expression executor) {
+ return callX(ASYNC_SUPPORT_TYPE, EXECUTE_ASYNC_VOID_METHOD, args(closure, executor));
+ }
+
+ /**
+ * Builds {@code AsyncSupport.getExecutor()}.
+ *
+ * @return an AST node representing the static call
+ */
+ public static Expression buildGetExecutorCall() {
+ return callX(ASYNC_SUPPORT_TYPE, "getExecutor", ArgumentListExpression.EMPTY_ARGUMENTS);
+ }
+
+ // ---- statement scanners ---------------------------------------------
+
+ /**
+ * Checks whether the given statement tree contains any
+ * {@code AsyncSupport.yieldReturn()} calls, indicating that the enclosing
+ * body is an async generator (which should produce an
+ * {@link groovy.concurrent.AsyncStream AsyncStream} rather than an
+ * {@link groovy.concurrent.Awaitable Awaitable}).
+ * <p>
+ * The scan does <em>not</em> descend into nested {@link ClosureExpression}s
+ * because each nested {@code async { ... }} closure handles its own
+ * generator wrapping independently.
+ *
+ * @param code the statement tree to scan
+ * @return {@code true} if at least one {@code yieldReturn} call is found
+ */
+ public static boolean containsYieldReturn(Statement code) {
+ boolean[] found = {false};
+ code.visit(new CodeVisitorSupport() {
+ @Override
+ public void visitStaticMethodCallExpression(StaticMethodCallExpression call) {
+ if (YIELD_RETURN_METHOD.equals(call.getMethod())
+ && ASYNC_SUPPORT_CLASS.equals(call.getOwnerType().getName())) {
+ found[0] = true;
+ }
+ if (!found[0]) {
+ super.visitStaticMethodCallExpression(call);
+ }
+ }
+
+ @Override
+ public void visitClosureExpression(ClosureExpression expression) {
+ // Do not descend into nested closures — each manages its own generator
+ }
+ });
+ return found[0];
+ }
+
+ // ---- rewriting utilities --------------------------------------------
+
+ /**
+ * Rewrites {@code AsyncSupport.yieldReturn(expr)} calls to
+ * {@code AsyncSupport.yieldReturn($__asyncGen__, expr)}, injecting the
+ * synthetic generator parameter as the first argument.
+ * <p>
+ * This enables ThreadLocal-free yield semantics: the
+ * {@link org.apache.groovy.runtime.async.AsyncStreamGenerator} instance is
+ * passed explicitly through the closure parameter rather than being
+ * stored in a thread-local variable.
+ * <p>
+ * Does <em>not</em> descend into nested {@link ClosureExpression}s.
+ *
+ * @param code the statement tree to transform
+ * @param genParam the synthetic {@code $__asyncGen__} parameter whose
+ * variable reference will be injected
+ */
+ public static void injectGenParamIntoYieldReturnCalls(Statement code, Parameter genParam) {
+ code.visit(new CodeVisitorSupport() {
+ @Override
+ public void visitExpressionStatement(ExpressionStatement stmt) {
+ Expression expr = stmt.getExpression();
+ if (expr instanceof StaticMethodCallExpression smce
+ && YIELD_RETURN_METHOD.equals(smce.getMethod())
+ && ASYNC_SUPPORT_CLASS.equals(smce.getOwnerType().getName())) {
+ VariableExpression genRef = varX(ASYNC_GEN_PARAM_NAME);
+ genRef.setAccessedVariable(genParam);
+ ArgumentListExpression newArgs = new ArgumentListExpression();
+ newArgs.addExpression(genRef);
+ if (smce.getArguments() instanceof ArgumentListExpression argList) {
+ for (Expression arg : argList.getExpressions()) {
+ newArgs.addExpression(arg);
+ }
+ }
+ StaticMethodCallExpression replacement =
+ new StaticMethodCallExpression(smce.getOwnerType(), YIELD_RETURN_METHOD, newArgs);
+ replacement.setSourcePosition(smce);
+ stmt.setExpression(replacement);
+ }
+ super.visitExpressionStatement(stmt);
+ }
+
+ @Override
+ public void visitClosureExpression(ClosureExpression expression) {
+ // Do not descend into nested closures
+ }
+ });
+ }
+
+ /**
+ * Creates the synthetic {@code $__asyncGen__} parameter used by async
+ * generator closures. The parameter is dynamically typed.
+ *
+ * @return a new {@link Parameter} instance
+ */
+ public static Parameter createGenParam() {
+ return new Parameter(ClassHelper.OBJECT_TYPE, ASYNC_GEN_PARAM_NAME);
+ }
+
+ /**
+ * Checks whether the given statement tree contains any
+ * {@code AsyncSupport.defer()} calls, indicating that the enclosing
+ * method uses the {@code defer} statement and needs a try-finally
+ * wrapper to execute deferred actions on exit.
+ * <p>
+ * Does <em>not</em> descend into nested {@link ClosureExpression}s.
+ *
+ * @param code the statement tree to scan
+ * @return {@code true} if at least one {@code defer} call is found
+ */
+ public static boolean containsDefer(Statement code) {
+ boolean[] found = {false};
+ code.visit(new CodeVisitorSupport() {
+ @Override
+ public void visitStaticMethodCallExpression(StaticMethodCallExpression call) {
+ if (DEFER_METHOD.equals(call.getMethod())
+ && ASYNC_SUPPORT_CLASS.equals(call.getOwnerType().getName())) {
+ found[0] = true;
+ }
+ if (!found[0]) {
+ super.visitStaticMethodCallExpression(call);
+ }
+ }
+
+ @Override
+ public void visitClosureExpression(ClosureExpression expression) {
+ // Do not descend into nested closures
+ }
+ });
+ return found[0];
+ }
+
+ /**
+ * Wraps the given statement body with defer scope management.
+ * Generates the equivalent of:
+ * <pre>{@code
+ * def $__deferScope__ = AsyncSupport.createDeferScope()
+ * try {
+ * <originalBody>
+ * } finally {
+ * AsyncSupport.executeDeferScope($__deferScope__)
+ * }
+ * }</pre>
+ *
+ * @param body the original method or closure body
+ * @return a new block statement with defer scope initialization and try-finally
+ */
+ public static Statement wrapWithDeferScope(Statement body) {
+ // def $__deferScope__ = AsyncSupport.createDeferScope()
+ Expression createCall = callX(ASYNC_SUPPORT_TYPE,
+ CREATE_DEFER_SCOPE_METHOD, ArgumentListExpression.EMPTY_ARGUMENTS);
+ ExpressionStatement initStmt = new ExpressionStatement(
+ new DeclarationExpression(
+ varX(DEFER_SCOPE_VAR),
+ Token.newSymbol(Types.ASSIGN, -1, -1),
+ createCall));
+
+ // finally { AsyncSupport.executeDeferScope($__deferScope__) }
+ Expression executeCall = callX(ASYNC_SUPPORT_TYPE,
+ EXECUTE_DEFER_SCOPE_METHOD,
+ args(varX(DEFER_SCOPE_VAR)));
+ TryCatchStatement tryCatch = new TryCatchStatement(
+ body,
+ block(new ExpressionStatement(executeCall)));
+
+ return block(initStmt, tryCatch);
+ }
+}
diff --git a/src/resources/META-INF/services/groovy.concurrent.AwaitableAdapter b/src/resources/META-INF/services/groovy.concurrent.AwaitableAdapter
new file mode 100644
index 0000000000..a591ba6a55
--- /dev/null
+++ b/src/resources/META-INF/services/groovy.concurrent.AwaitableAdapter
@@ -0,0 +1,16 @@
+# 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.
+
+org.apache.groovy.runtime.async.FlowPublisherAdapter
diff --git a/src/spec/doc/core-async-await.adoc b/src/spec/doc/core-async-await.adoc
new file mode 100644
index 0000000000..d0ebd0d859
--- /dev/null
+++ b/src/spec/doc/core-async-await.adoc
@@ -0,0 +1,1286 @@
+//////////////////////////////////////////
+
+ 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.
+
+//////////////////////////////////////////
+
+= Async/Await
+:jdk: https://docs.oracle.com/en/java/javase/21/docs/api
+
+[[async-await]]
+== Introduction
+
+Groovy provides native `async`/`await` support as a language-level feature, enabling developers to write
+asynchronous code in a sequential, readable style. Inspired by similar constructs in JavaScript, C#, Kotlin,
+Swift, and Go,
+Groovy's async/await integrates seamlessly with the JVM concurrency model while maintaining Groovy's hallmark
+conciseness and expressiveness.
+
+Key capabilities include:
+
+* **`async` methods** — declare methods that execute asynchronously and return an `Awaitable`
+* **`await` expressions** — suspend execution until an asynchronous result is available
+* **Async closures and lambdas** — create reusable asynchronous functions
+* **`for await`** — iterate over asynchronous data sources
+* **`yield return`** — produce asynchronous streams (generators)
+* **`defer`** — schedule Go-style cleanup actions that run when the method completes
+* **Framework integration** — built-in adapters for `CompletableFuture` and `Future`;
+`Flow.Publisher` support via the auto-discovered `FlowPublisherAdapter` (an internal runtime adapter);
+extensible to RxJava, Reactor, and Spring via the adapter registry
+
+On JDK 21+, async methods automatically leverage
+{jdk}/java.base/java/lang/Thread.html#ofVirtual()[virtual threads] for optimal scalability.
+
+[[why-async-await]]
+== Why Groovy Needs Async/Await
+
+Modern applications are overwhelmingly concurrent. Web servers, microservices, data
+pipelines, and interactive UIs all spend most of their time waiting — waiting for
+network responses, database queries, file I/O, or downstream services. The question
+is not _whether_ to write asynchronous code, but _how pleasant the experience will
+be_.
+
+=== The Problem: Callback Complexity on the JVM
+
+Before async/await, JVM developers faced a fundamental tension: synchronous code is
+easy to read but wastes threads, while asynchronous code scales well but becomes
+hard to follow.
+
+Consider a typical three-step workflow — fetch a user ID, look up a name, then load a
+profile — expressed with `CompletableFuture`:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=motivation_callback_hell,indent=0]
+----
+
+This code works, but the business logic is obscured by the plumbing.
+Each `.thenCompose()` introduces a nesting level, exception recovery is
+separated from the code that causes the exception, and the control flow
+reads inside-out rather than top-to-bottom.
+
+With `async`/`await`, exactly the same logic becomes:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=motivation_async_await_clean,indent=0]
+----
+
+The async version reads like a synchronous script. Variables are declared
+at the point of use, the return value is obvious, and there are no
+callbacks, lambdas, or chained combinators.
+
+=== Native Exception Handling
+
+One of the most painful aspects of callback-style async code is exception handling.
+With `CompletableFuture`, exceptions are wrapped in `CompletionException` or
+`ExecutionException`, requiring manual unwrapping. Groovy's `await` _automatically_
+unwraps these layers and re-throws the original exception, making standard
+`try`/`catch` blocks work exactly as they do in synchronous code:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=motivation_exception_handling,indent=0]
+----
+<1> The original `IOException` is caught directly — no `CompletionException` wrapper
+
+This _exception transparency_ guarantee means that switching a synchronous method
+to `async` does not change the exception types visible to callers.
+
+=== Async Streams and Back-Pressure
+
+Asynchronous programming isn't limited to single-value futures. Many real-world
+scenarios involve _streams_ of values — sensor readings, paginated API results,
+log events — where values arrive over time. Groovy's `yield return` and
+`for await` offer a first-class solution:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=motivation_stream_processing,indent=0]
+----
+
+The producer yields values on demand, and the consumer pulls them with
+`for await`. A `SynchronousQueue` sits between the two, providing
+natural **back-pressure**: the producer blocks on each `yield return` until the
+consumer is ready, preventing unbounded memory growth — all without requiring
+the developer to manage queues, signals, or synchronization.
+
+=== Seamless JVM Ecosystem Integration
+
+Groovy does not force developers to abandon their existing async investments.
+The `await` keyword natively understands `CompletableFuture`, `CompletionStage`,
+`Future`, and `Flow.Publisher` (via the auto-discovered `FlowPublisherAdapter` runtime adapter):
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=motivation_interop,indent=0]
+----
+
+For third-party frameworks like RxJava, Reactor, or Spring, the
+`AwaitableAdapterRegistry` SPI allows registering custom adapters so that
+`await` works transparently with _any_ asynchronous type — a single
+`await` keyword is all the application code needs, regardless of the
+underlying async library.
+
+=== Virtual Threads: Free Scalability
+
+On JDK 21+, every `async` method runs on a
+{jdk}/java.base/java/lang/Thread.html#ofVirtual()[virtual thread].
+When the thread blocks on `await`, the JVM automatically parks the virtual thread
+and releases the underlying carrier (OS) thread. This means millions of concurrent
+`async` tasks can coexist on a handful of OS threads — achieving the same practical
+scalability as compiler-generated state machines in C# or Kotlin, but with
+_dramatically simpler_ compiler implementation and full debugging support
+(complete stack traces, breakpoints, step-through debugging all work unmodified).
+
+On JDK 17–20, a bounded cached thread pool with caller-runs back-pressure
+provides stable performance until virtual threads become available.
+
+=== Why a Language Feature, Not a Library?
+
+GPars, RxJava, and Reactor already provide powerful async toolkits for the JVM. So
+why build async/await into the _language_?
+
+[cols="1,3"]
+|===
+| Reason | Explanation
+
+| **Readability**
+| `await` makes asynchronous code indistinguishable from synchronous code.
+Libraries can provide combinators, but they cannot change the way `for`, `try`,
+`if`, and assignment statements compose.
+
+| **Exception transparency**
+| Language-level `await` can intercept and unwrap JVM exception wrappers before they
+reach the programmer. A library-level `await()` method cannot prevent `CompletionException`
+from leaking into user code without bytecode manipulation.
+
+| **Control-flow integration**
+| `for await` integrates with the parser and AST. There is no way for a library to add
+new loop syntax. Similarly, `yield return` requires the compiler to rewrite the method
+into a generator — a transformation that no library can perform.
+
+| **`defer` semantics**
+| Go-style `defer` requires compiler-generated `try`/`finally` wrapping with LIFO
+execution ordering. This cannot be emulated with a simple method call.
+
+| **Tooling**
+| Syntax highlighting, code completion, and static analysis can recognize `async`,
+`await`, `for await`, and `yield return` as first-class constructs. Library-level
+equivalents are just method calls to the IDE.
+|===
+
+In short, async/await elevates asynchronous programming from a _library concern_
+to a _language concern_, giving Groovy the same expressive power that developers
+have come to expect in JavaScript, C#, Kotlin, and Swift.
+
+[[async-methods]]
+== Async Methods
+
+An `async` method executes its body asynchronously and returns a
+`groovy.concurrent.Awaitable` — a promise-like abstraction analogous to C#'s `Task<T>` or JavaScript's
+`Promise<T>`.
+
+=== Basic Declaration
+
+Add the `async` modifier before the method's return type (or `def`):
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=async_method_basic,indent=0]
+----
+
+The caller receives an `Awaitable` immediately; the method body runs on a separate thread.
+Calling `get()` blocks until the result is available.
+
+=== Typed Return Values
+
+Async methods support explicit return types. The compiler wraps the declared type inside `Awaitable` automatically:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=async_method_typed,indent=0]
+----
+
+=== Void Methods
+
+Even `async void` methods return an `Awaitable<Void>`, allowing callers to wait for completion:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=async_method_void,indent=0]
+----
+
+=== Static Methods
+
+The `async` modifier works with static methods:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=async_method_static,indent=0]
+----
+
+=== Script-Level Methods
+
+Async methods can be defined directly in Groovy scripts:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=async_script,indent=0]
+----
+
+[[await-expression]]
+== The `await` Expression
+
+The `await` keyword suspends execution of the enclosing async method until the given asynchronous
+operation completes, then returns its result. It unwraps the value from an `Awaitable`,
+`CompletableFuture`, `CompletionStage`, `Future`, or any type registered with the adapter system.
+
+=== Basic Usage
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=await_basic,indent=0]
+----
+
+=== Awaiting CompletableFuture
+
+Groovy's `await` works directly with `java.util.concurrent.CompletableFuture`:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=await_completable_future,indent=0]
+----
+
+=== Combining Multiple Awaits
+
+Multiple `await` expressions can appear in a single method:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=await_arithmetic,indent=0]
+----
+
+=== Parenthesized Form
+
+Both `await expr` and `await(expr)` are valid syntax:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=await_parenthesized,indent=0]
+----
+
+[NOTE]
+====
+The parenthesized form `await(expr)` is recommended when `await` is used in complex expressions
+(e.g., `await(f1) + await(f2)`) to avoid potential ambiguities.
+====
+
+[[async-closures-lambdas]]
+== Async Closures and Lambdas
+
+The `async` keyword can precede a closure or lambda expression to create a reusable
+asynchronous function. The result is a `Closure` that, when invoked, returns an `Awaitable`.
+
+[IMPORTANT]
+====
+`async { ... }` creates a _closure_, not an immediately executing task. You must explicitly
+invoke the closure (e.g., `asyncTask()`) to start asynchronous execution.
+This is analogous to defining an `async function` in JavaScript without calling it.
+====
+
+=== No-Argument Closure
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=async_closure_basic,indent=0]
+----
+
+=== Parameterized Closure
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=async_closure_params,indent=0]
+----
+
+=== Multiple Parameters
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=async_closure_multi_params,indent=0]
+----
+
+=== Closures in Collections
+
+Async closures are first-class values and can be stored in data structures:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=async_closure_collection,indent=0]
+----
+
+=== Lambda Syntax
+
+The `async` keyword also works with Groovy's lambda syntax:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=async_lambda,indent=0]
+----
+
+[[for-await]]
+== `for await` — Async Iteration
+
+The `for await` loop iterates over asynchronous data sources (`AsyncStream`, `Flow.Publisher`, `Iterable`).
+Each element is resolved asynchronously before the loop body executes. This is analogous to JavaScript's
+`for await...of` and C#'s `await foreach`.
+
+The compiler generates a `try`/`finally` block around the loop that calls `close()` on the
+stream when iteration ends — whether by exhaustion, `break`, `return`, or exception.
+This guarantees that upstream resources (producer threads, `Flow.Publisher` subscriptions)
+are released promptly.
+
+=== Basic Iteration
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=for_await_basic,indent=0]
+----
+
+=== Transformation
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=for_await_transform,indent=0]
+----
+
+=== Early Termination
+
+Standard flow control (`return`, `break`) works inside `for await`:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=for_await_break,indent=0]
+----
+
+=== Colon Notation
+
+`for await` supports both `in` and `:` notation:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=for_await_colon,indent=0]
+----
+
+[[yield-return]]
+== `yield return` — Async Generators
+
+The `yield return` statement enables async methods to produce a stream of values lazily.
+A method containing `yield return` returns an `AsyncStream<T>` instead of a regular `Awaitable<T>`.
+This is analogous to C#'s `yield return` within `IAsyncEnumerable<T>` and JavaScript's `async function*`
+generators.
+
+The producer (generator) and consumer (`for await` loop) coordinate via a
+`SynchronousQueue`, providing natural back-pressure: the producer blocks on each `yield return`
+until the consumer is ready for the next value. This design means:
+
+* The generator body runs on its own thread, executing sequentially like any normal method
+* No values are buffered — the producer suspends until the consumer pulls the next item
+* If the consumer stops early (`break`, `return`, exception), the producer thread is
+interrupted, preventing resource leaks
+* The generator can mix `yield return` with `await`, performing I/O between yields
+
+NOTE: `yield return` can only appear inside an `async` method or `async` closure/lambda.
+Using it elsewhere produces a compile-time error:
+_"`yield return` can only be used inside an async method or async closure/lambda"_.
+
+=== Basic Generator
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=yield_return_basic,indent=0]
+----
+
+=== Generator with Loop
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=yield_return_loop,indent=0]
+----
+
+=== Filtered Generator
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=yield_return_filter,indent=0]
+----
+
+=== Combining `yield return` with `await`
+
+Async generators can perform asynchronous operations between yielding values:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=yield_return_with_await,indent=0]
+----
+
+[[defer]]
+== `defer` — Go-Style Cleanup
+
+The `defer` keyword schedules a block of code to execute when the enclosing async method
+completes, regardless of whether it succeeds or throws an exception. Multiple deferred blocks
+execute in _last-in, first-out_ (LIFO) order, exactly as in
+https://go.dev/blog/defer-panic-and-recover[Go's `defer` statement].
+
+NOTE: `defer` can only appear inside an `async` method or `async` closure. Using it
+elsewhere produces a compile-time error: _"`defer` can only be used inside an async method or
+async closure"_.
+
+=== Basic Usage
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=defer_basic,indent=0]
+----
+
+=== Guaranteed Execution on Exception
+
+Deferred blocks always execute, even when the method throws an exception:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=defer_exception,indent=0]
+----
+
+=== Resource Cleanup Pattern
+
+`defer` is ideal for resource management — acquire a resource, immediately defer its release:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=defer_resource_cleanup,indent=0]
+----
+
+[TIP]
+====
+When an exception occurs in a deferred block and the enclosing method has also thrown,
+the deferred exception is _suppressed_ (attached via `addSuppressed()`) so that the original
+exception remains the primary failure.
+====
+
+[[exception-handling]]
+== Exception Handling
+
+Groovy's async/await provides transparent exception propagation. Exceptions thrown inside
+an async method are captured by the `Awaitable` and re-thrown when the caller invokes `await`.
+The original exception type, message, and stack trace are preserved — wrapper types such as
+`CompletionException` and `ExecutionException` are automatically unwrapped.
+
+=== Exception Transparency
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=exception_transparency,indent=0]
+----
+
+Standard `try`/`catch` blocks work naturally with `await`, catching the original exception type
+(not a wrapper). This is the same transparency guarantee provided by C#'s `await` and
+JavaScript's `await`.
+
+=== Handling Failures from Multiple Tasks
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=exception_multiple_tasks,indent=0]
+----
+
+[[utility-methods]]
+== Combinators
+
+The `groovy.concurrent.Awaitable` interface provides static combinator methods for common async
+coordination patterns, analogous to JavaScript's `Promise.all()` / `Promise.any()` /
+`Promise.allSettled()`, C#'s `Task.WhenAll()` / `Task.WhenAny()`, and Kotlin's coroutine
+coordination helpers.
+
+=== `all` — Parallel Execution
+
+Wait for all tasks to complete and collect their results. Fails fast on the first error:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=await_all,indent=0]
+----
+
+=== `any` — Race Pattern
+
+Return the result of the first task to complete:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=await_any,indent=0]
+----
+
+=== `allSettled` — Inspect All Outcomes
+
+Wait for all tasks to complete, collecting both successes and failures. Never throws —
+each result is wrapped in an `AwaitResult` with `isSuccess()`, `isFailure()`, `value`, and `error` properties:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=await_all_settled,indent=0]
+----
+
+[[await-result]]
+=== `AwaitResult` — Outcome Wrapper
+
+Each element returned by `allSettled` is an `AwaitResult<T>`. This immutable value type
+carries either a success value or a failure throwable, and provides safe accessors:
+
+* `isSuccess()` / `isFailure()` — check outcome type
+* `value` — the result (throws `IllegalStateException` on failure)
+* `error` — the exception (throws `IllegalStateException` on success)
+* `getOrElse(Function)` — recover from failures with a fallback function
+* `toString()` — returns `AwaitResult.Success[value]` or `AwaitResult.Failure[error]`
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=await_result_api,indent=0]
+----
+
+=== Continuation Helpers
+
+Use `thenAccept`, `whenComplete`, and `handle` when you want continuation-style
+composition rather than a direct `await`. This is especially useful at API boundaries
+where you want to attach logging, recovery, or side effects without immediately
+blocking for the result.
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=continuation_helpers,indent=0]
+----
+
+=== `delay` — Non-Blocking Pause
+
+Create an awaitable timer (analogous to JavaScript's `setTimeout` wrapped in a `Promise`):
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=delay_example,indent=0]
+----
+
+=== `orTimeout` / `completeOnTimeout` — Non-Blocking Deadlines
+
+Apply a deadline without blocking the calling thread. `Awaitable.orTimeoutMillis(...)`
+(or `Awaitable.orTimeout(source, duration, unit)` for explicit units)
+fails with `TimeoutException`, while `Awaitable.completeOnTimeoutMillis(...)`
+(or `Awaitable.completeOnTimeout(source, fallback, duration, unit)`) completes with a
+fallback value. The instance forms `orTimeoutMillis(...)` and `completeOnTimeoutMillis(...)`
+provide the same capability on an existing awaitable.
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=timeout_combinators,indent=0]
+----
+
+==== Instance Forms: `orTimeoutMillis` and `completeOnTimeoutMillis`
+
+The instance methods `orTimeoutMillis(millis)` and `completeOnTimeoutMillis(fallback, millis)` provide
+a fluent alternative to the static `Awaitable.orTimeoutMillis()` and `Awaitable.completeOnTimeoutMillis()` forms:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=instance_timeout_methods,indent=0]
+----
+
+[[flow-publisher]]
+== `Flow.Publisher` Integration
+
+Groovy's await system has built-in support for
+{jdk}/java.base/java/util/concurrent/Flow.Publisher.html[`java.util.concurrent.Flow.Publisher`],
+the reactive streams interface standardized in JDK 9. This enables seamless consumption of
+reactive streams from any library that implements the `Flow.Publisher` contract (Project Reactor,
+RxJava 3 via adapters, Vert.x, etc.).
+
+The internal adapter uses a bounded buffer (capacity 256) to bridge the push-based `Publisher`
+model to Groovy's pull-based `AsyncStream` model, preventing unbounded memory growth with
+fast publishers while maintaining throughput. Back-pressure is enforced by requesting exactly
+one item at a time; demand for the next element is signalled _before_ the consumer's
+`moveNext()` awaitable completes, so the publisher can begin producing the next value while
+the consumer processes the current one.
+
+The examples below use deterministic subscriber-handshake waiting with a bounded timeout
+before submitting values, avoiding timing races and unbounded waits in CI environments.
+
+=== Awaiting a Single Value
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=flow_publisher_await,indent=0]
+----
+
+=== Iterating with `for await`
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=flow_publisher_for_await,indent=0]
+----
+
+[NOTE]
+====
+`for await` closes the adapted stream from a compiler-generated `finally` block.
+For `Flow.Publisher` sources, this means an early `break`, `return`, or exception
+cancels the upstream subscription rather than leaving it running in the background.
+This is conceptually similar to JavaScript async iterators' `return()`, C#'s
+`await foreach` disposal, and Swift's `AsyncSequence` early-exit cleanup.
+====
+
+[[adapter-registry]]
+== Adapter Registry
+
+=== Converting External Types with `from()`
+
+The `Awaitable.from(source)` and `AsyncStream.from(source)` static methods are the recommended
+entry points for converting external async types to Groovy's native abstractions. They delegate
+to the `AwaitableAdapterRegistry` internally but provide a more discoverable, user-friendly API:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=from_conversion,indent=0]
+----
+
+`Awaitable.from()` accepts any type supported by a registered adapter: `CompletableFuture`,
+`CompletionStage`, `Future`, `Flow.Publisher`, or any third-party type with a custom adapter.
+If the source is already an `Awaitable`, it is returned as-is. `AsyncStream.from()` works
+similarly for multi-value sources such as `Iterable`, `Iterator`, or `Flow.Publisher`.
+
+=== Built-in Adapters
+
+The `groovy.concurrent.AwaitableAdapterRegistry` allows extending `await` to support additional
+asynchronous types from third-party frameworks. The built-in adapter handles:
+
+* `groovy.concurrent.Awaitable` (native Groovy promise — passthrough)
+* `java.util.concurrent.CompletableFuture` and `CompletionStage`
+* `java.util.concurrent.Future` (blocking, delegated to a configurable executor)
+
+Additionally, `java.util.concurrent.Flow.Publisher` support (single-value `await` and
+multi-value `for await`) is provided by `FlowPublisherAdapter`
+(`org.apache.groovy.runtime.async`), which is auto-discovered via `ServiceLoader`.
+This decoupled design allows applications to override the default
+reactive-streams bridge with a framework-specific adapter (e.g., one backed by Reactor's
+native scheduler) by registering a higher-priority adapter.
+
+=== Registering a Custom Adapter
+
+Implement the `AwaitableAdapter` interface and register it with the registry. The `register()`
+method returns an `AutoCloseable` handle for deregistration, making it easy to use in tests
+or scoped contexts:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=adapter_registration,indent=0]
+----
+
+=== Framework Integration
+
+With appropriate adapters registered, `await` works transparently with any async type.
+Adapter implementations for frameworks such as RxJava 3, Project Reactor, and Spring
+should delegate to the framework's native blocking or conversion methods:
+
+* **RxJava 3**: `Single.blockingGet()`, `Observable.blockingIterable()` (for `AsyncStream`)
+* **Reactor**: `Mono.block()`, `Flux.toIterable()` (for `AsyncStream`)
+* **Spring**: Return `Awaitable` from `@Async` service methods; adapt `ListenableFuture` via `toCompletableFuture()`
+
+=== Automatic Discovery
+
+Adapters can also be discovered automatically via `java.util.ServiceLoader` by placing a
+provider configuration file at `META-INF/services/groovy.concurrent.AwaitableAdapter`.
+SPI-discovered adapters are loaded at class-initialization time and take priority over
+the built-in adapters. Runtime-registered adapters (via `register()`) take highest
+priority, allowing application code to override any default.
+
+[[async-annotation]]
+== `@Async` Annotation
+
+As an alternative to the `async` keyword modifier, the `@Async` annotation provides the same
+functionality in a more traditional Java-style declaration:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=async_annotation,indent=0]
+----
+
+The `async` keyword modifier is the preferred form. The `@Async` annotation is useful when
+interoperating with tools or frameworks that rely on annotation processing. Both forms
+produce identical compiled bytecode.
+
+[[executor-configuration]]
+== Executor Configuration
+
+=== Default Behavior
+
+On **JDK 21+**, async methods run on
+{jdk}/java.base/java/lang/Thread.html#ofVirtual()[virtual threads]
+for optimal scalability.
+On **JDK 17–20**, a cached thread pool is used (up to 256 threads by default, configurable
+via the `groovy.async.parallelism` system property). When all threads are busy, the submitting
+thread executes the task directly (caller-runs policy), providing natural back-pressure instead
+of rejecting the task.
+
+=== Custom Executor
+
+Override the default executor via `Awaitable.setExecutor()`:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=custom_executor,indent=0]
+----
+
+Pass `null` to restore the default executor.
+
+[[patterns]]
+== Common Patterns
+
+=== Parallel Web Requests
+
+Launch multiple I/O tasks concurrently and collect all results:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=parallel_pattern,indent=0]
+----
+
+=== Retry with Exponential Backoff
+
+Automatically retry a transient failure with increasing delays:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=retry_pattern,indent=0]
+----
+
+=== Timeout
+
+Use `Awaitable.orTimeoutMillis()` (or the instance form `orTimeoutMillis()`) to apply a
+deadline without dropping back to manual race logic:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=timeout_pattern,indent=0]
+----
+
+[[cancellation]]
+=== Cancellation
+
+An `Awaitable` can be cancelled via the `cancel()` method. Once cancelled, any subsequent
+`await` on the task throws a `java.util.concurrent.CancellationException`:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=cancellation,indent=0]
+----
+
+[NOTE]
+====
+Cancellation sets the `Awaitable` state to cancelled but does _not_ reliably interrupt the
+underlying thread (a limitation of `CompletableFuture`). For cooperative cancellation in
+long-running async bodies, check `Thread.currentThread().isInterrupted()` periodically.
+====
+
+[[best-practices]]
+== Best Practices
+
+[[async-all-the-way]]
+=== Asynchrony Is Viral — `await` All the Way
+
+Once a method becomes `async`, its callers should also be `async`. Partially async call chains
+— where an intermediate layer blocks with `.get()` — waste threads and risk pool starvation:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=sync_over_async,indent=0]
+----
+
+[[prefer-awaitable-of]]
+=== Prefer `Awaitable.of()` for Pre-Computed Data
+
+When the result is already known, use `Awaitable.of(value)` (equivalent to C#'s
+`Task.FromResult` and JavaScript's `Promise.resolve`) to avoid spawning a thread.
+Use `Awaitable.failed(error)` for pre-computed error results:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=awaitable_of,indent=0]
+----
+
+[[prefer-await-over-then]]
+=== Prefer `await` over `.then()` / `.thenCompose()`
+
+Sequential `await` calls read like synchronous code and provide better stack traces
+than chained `.then()` / `.thenCompose()`:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=prefer_await_over_then,indent=0]
+----
+
+[[async-caching]]
+=== Async Lazy Caching
+
+Store `Awaitable` values in a `ConcurrentHashMap` so that only the first request triggers the
+async operation, and subsequent requests await the same cached result:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=async_caching,indent=0]
+----
+
+[[static-factory-pattern]]
+=== Static Factory for Async Construction
+
+When a class requires asynchronous initialization, use a static `async` factory
+method instead of blocking in the constructor:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=static_factory,indent=0]
+----
+
+[[fire-and-forget]]
+=== Fire-and-Forget Safely
+
+Always capture the `Awaitable` returned by async methods. Uncaptured awaitables may
+lose exceptions silently:
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=fire_and_forget,indent=0]
+----
+
+[[inspection]]
+== Inspecting Awaitable State
+
+An `Awaitable` provides non-blocking inspection methods for checking its state without
+blocking the calling thread:
+
+* `isDone()` — `true` when the awaitable has completed (successfully, exceptionally, or via cancellation)
+* `isCancelled()` — `true` when the awaitable was cancelled via `cancel()`
+* `isCompletedExceptionally()` — `true` when the awaitable completed with an exception (including cancellation)
+
+[source,groovy]
+----
+include::../test/AsyncAwaitSpecTest.groovy[tags=inspection_methods,indent=0]
+----
+
+[[async-modifier-restrictions]]
+== `async` Modifier Restrictions
+
+The `async` keyword is valid only on **method declarations** (including script-level methods)
+and **closure/lambda expressions**. Applying `async` to other declarations is a compile-time error:
+
+* **Class/interface declarations**: `async class Foo {}` → parser error
+* **Field/variable declarations**: `async def x = 1` → compilation error ("async not allowed for variable declarations")
+* **Constructor declarations**: `async Foo() {}` → compilation error ("async not allowed for constructors")
+
+[[implementation-details]]
+== Implementation Notes
+
+This section provides architectural context for Groovy language maintainers and advanced users.
+
+[[implementation-strategy]]
+=== Thread-Based vs State Machine
+
+Languages like C#, Kotlin, and Swift transform async workflows into compiler-managed
+state machines or coroutine frames. Each suspension point becomes resumable state,
+allowing the runtime to suspend the logical task without blocking an OS thread.
+JavaScript takes a different route again, relying on an event loop plus promise
+microtasks.
+
+Groovy takes a different approach: each `async` method body runs on its **own thread**
+(a virtual thread on JDK 21+, or a pooled platform thread on JDK 17–20). The `await`
+keyword blocks the running thread until the awaited result is available.
+
+On JDK 21+, virtual threads make this blocking essentially free — the JVM automatically
+parks the virtual thread and releases the underlying carrier thread, achieving the same
+practical efficiency as a state machine. The thread-based approach has several advantages:
+
+* **Simpler debugging** — stack traces show the complete call chain rather than
+ fragmented state machine frames
+* **Full language support** — all control-flow constructs (`try`/`catch`, loops,
+ `switch`, closures) work naturally inside async methods without special compiler handling
+* **Minimal compiler changes** — no need to rewrite control flow into a switch-based
+ state machine, keeping the implementation maintainable
+* **Natural blocking semantics** — existing blocking APIs (I/O, JDBC, etc.) work
+ transparently inside `async` methods without explicit wrapping
+
+On JDK 17–20, the thread pool is bounded (default 256 threads) with a caller-runs policy
+for back-pressure, ensuring stable performance even without virtual threads.
+
+[[architecture]]
+=== Architecture Overview
+
+The async/await feature is implemented across three layers:
+
+**Grammar layer** (`GroovyLexer.g4`, `GroovyParser.g4`): Defines `async`, `await`, `defer` as contextual
+keywords, along with parser rules for `for await`, `yield return`, and `defer` statements.
+
+**AST transformation layer** (`AstBuilder`, `AsyncASTTransformation`, `AsyncTransformHelper`):
+The `AstBuilder` converts parse trees into AST nodes, rewriting `async` method bodies into calls
+to `AsyncSupport` static methods. The `@Async` annotation is processed by `AsyncASTTransformation`,
+which delegates to the same `AsyncTransformHelper` utility class. This shared helper ensures
+consistent code generation for both the keyword and annotation forms.
+
+**Runtime layer** (`AsyncSupport`, `GroovyPromise`, `AsyncStreamGenerator`,
+`AwaitableAdapterRegistry`): `AsyncSupport` is the central runtime class containing static
+methods for `await`, `async`, `defer`, `yield return`, timeout composition, stream cleanup,
+and combinators. `GroovyPromise` wraps
+`CompletableFuture` to implement the `Awaitable` interface, decoupling the public API from the
+JDK implementation. `AsyncStreamGenerator` implements the producer/consumer pattern for async
+generators using a `SynchronousQueue`. `AwaitableAdapterRegistry` provides the SPI extension
+point for third-party async type support.
+
+==== API Decoupling
+
+The public API that Groovy developers interact with is defined entirely in the
+`groovy.concurrent` package (`Awaitable`, `AsyncStream`, `AwaitResult`,
+`AwaitableAdapter`, `AwaitableAdapterRegistry`). The implementation classes
+(`AsyncSupport`, `GroovyPromise`, `AsyncStreamGenerator`) live in
+`org.apache.groovy.runtime.async` — an internal package.
+
+This separation means:
+
+* User code depends on `Awaitable`, _not_ on `CompletableFuture`. If the JDK async
+ infrastructure evolves (e.g., structured concurrency in future JDK releases), only the
+ internal implementation needs to change — the public `Awaitable` contract remains stable.
+* `GroovyPromise` is the sole bridge between `Awaitable` and `CompletableFuture`.
+ Replacing it (for example, with a structured-concurrency-based implementation) would be
+ transparent to all application code.
+* `toCompletableFuture()` is the one explicit escape hatch for interoperating with Java
+ APIs that require a `CompletableFuture` directly.
+
+[[thread-safety]]
+=== Thread Safety and Robustness
+
+A key design goal is that **thread safety is the framework's responsibility, not the user's**.
+All concurrency control is encapsulated inside the runtime, so application code never needs
+explicit locks, atomics, or volatile annotations.
+
+==== Lock-Free Synchronization
+
+All runtime components employ lock-free or minimal-contention synchronization:
+
+* `AsyncSupport.defaultExecutor` — `volatile` field for safe publication without locks
+* `AwaitableAdapterRegistry.ADAPTERS` — `CopyOnWriteArrayList` for lock-free iteration during adapter lookup;
+writes (adapter registration) are rare, reads (every `await`) are frequent
+* `AwaitableAdapterRegistry.blockingExecutor` — `volatile` field
+* `AsyncStreamGenerator.current` — `volatile` field for cross-thread producer/consumer visibility
+* `AsyncStreamGenerator` close state — `AtomicBoolean` + `AtomicReference<Thread>` for prompt,
+idempotent close/cancellation signalling
+* `Flow.Publisher` adaptation (`FlowPublisherAdapter`) — `AtomicReference<Subscription>` with CAS-guarded
+ onSubscribe (§2.5 compliance), `AtomicBoolean` done-guard for single-value path, and close-aware
+ queue signalling; all signals (`onNext`/`onError`/`onComplete`) use blocking `put()` with a
+ non-blocking `offer()` fallback on interrupt, preventing both silent item loss and consumer deadlock;
+ demand is signalled before `moveNext()` returns to prevent livelock; `moveNext()` uses cached
+ `Awaitable` instances to eliminate per-call allocations on the hot path
+* Defer scopes — per-task `ArrayDeque`, confined to a single thread (no sharing)
+* `DELAY_SCHEDULER` — single daemon thread for non-blocking timer operations
+
+==== Async Generator Safety
+
+The `AsyncStreamGenerator` implements a producer/consumer pattern where a producer thread
+(the generator body) yields values and a consumer thread (the `for await` loop) pulls them.
+Several concurrency hazards are handled transparently:
+
+[cols="2,3"]
+|===
+| Concern | Mechanism
+
+| **Back-pressure**
+| A `SynchronousQueue` between producer and consumer ensures the producer blocks on each
+`yield return` until the consumer calls `moveNext()`. No unbounded buffering can occur.
+
+| **Cooperative cancellation**
+| The producer thread is tracked via `AtomicReference<Thread>`. When the consumer calls
+`close()` (explicitly or via compiler-generated `finally`), the producer thread is interrupted,
+allowing it to clean up promptly even if blocked in I/O or a long computation.
+
+| **TOCTOU race prevention**
+| `moveNext()` uses a double-check pattern: the `closed` flag is re-checked _after_
+registering the consumer thread. This closes a race window where `close()` could execute
+between the initial check and the `consumerThread.set()` call, which would leave the
+consumer stranded in `queue.take()` with no one to interrupt it.
+
+| **Thread-safe consumer tracking**
+| The consumer thread is tracked via `AtomicReference<Thread>` during `moveNext()`. This
+enables `close()` to interrupt a blocked consumer. Note: concurrent `moveNext()` calls
+from multiple threads are not supported and may produce unpredictable results — async
+generators are inherently single-consumer (just like C#'s `IAsyncEnumerator`).
+
+| **Idempotent close**
+| `close()` is guarded by `AtomicBoolean.compareAndSet()`, making it safe to call multiple
+times from any thread without side effects.
+
+| **Signal delivery under interrupt**
+| If the producer's `complete()` or `error()` signal is interrupted and the non-blocking
+fallback delivery fails (no consumer waiting), the generator force-closes itself. This
+prevents the consumer from blocking indefinitely on a subsequent `moveNext()` — a defensive
+measure against unexpected thread interruption outside the normal close path.
+|===
+
+==== Flow.Publisher Adaptation Safety
+
+The `FlowPublisherAdapter` bridges the push-based `Flow.Publisher` protocol into the pull-based
+`AsyncStream` model. Several concurrency hazards are handled transparently:
+
+[cols="2,3"]
+|===
+| Concern | Mechanism
+
+| **Back-pressure**
+| A bounded `LinkedBlockingQueue` (capacity 256) bridges push and pull. Demand is capped at
+one item per `moveNext()` call via `request(1)`. Demand is signalled _before_ `moveNext()`
+returns, preventing livelock when producer and consumer share a thread pool.
+
+| **Interrupt-safe signal delivery**
+| All subscriber callbacks (`onNext`, `onError`, `onComplete`) use blocking `put()` for normal
+delivery, with a non-blocking `offer()` fallback when the publisher thread is interrupted.
+If `offer()` also fails (queue full from a misbehaving publisher), `onNext` cancels the
+upstream subscription and injects an error signal; `onError`/`onComplete` set the `streamClosed`
+flag so the consumer's next `moveNext()` exits immediately.
+
+| **Allocation-free hot path**
+| `moveNext()` returns cached `Awaitable<Boolean>` instances for the value and end-of-stream
+cases, eliminating per-call `CompletableFuture` + `GroovyPromise` allocation. Error signals
+are thrown directly (matching `AsyncStreamGenerator` behaviour) rather than wrapped in a
+failed `CompletableFuture`.
+
+| **Non-blocking close**
+| `close()` uses non-blocking `offer()` (instead of blocking `put()`) to inject the completion
+sentinel after clearing the queue — this cannot throw `InterruptedException` and effectively
+always succeeds because the queue was just drained.
+
+| **Idempotent close**
+| `close()` is guarded by `AtomicBoolean.compareAndSet()`, making it safe to call multiple
+times from any thread without side effects.
+|===
+
+These mechanisms ensure that `yield return` / `for await` code remains as simple as writing
+a synchronous loop, while the runtime handles all cross-thread coordination.
+
+[[cross-language-comparison]]
+== Cross-Language Comparison
+
+The following table compares Groovy's async/await with the corresponding features in
+JavaScript, C#, Kotlin, and Swift, for developers familiar with those languages.
+
+[cols="2,3,3,3,3,3"]
+|===
+| Feature | Groovy | JavaScript | C# | Kotlin | Swift
+
+| **Async declaration**
+| `async foo() { }` / `async returnType foo() { }`
+| `async function foo() { }`
+| `async Task<T> Foo() { }`
+| `suspend fun foo(): T`
+| `func foo() async throws -> T`
+
+| **Await expression**
+| `await expr` / `await(expr)`
+| `await expr`
+| `await expr`
+| `deferred.await()` / suspend call
+| `try await expr`
+
+| **Return type**
+| `Awaitable<T>`
+| `Promise<T>`
+| `Task<T>` / `ValueTask<T>`
+| `Deferred<T>` / suspended `T`
+| `Task<T, Error>` / `async` function returns `T`
+
+| **Async closure/lambda**
+| `async { \-> body }` / `async () \-> { body }`
+| `async () \=> body`
+| `async () \=> body`
+| `scope.async { body }`
+| `Task { body }`
+
+| **Async iteration**
+| `for await (x in src) { }`
+| `for await (x of src) { }`
+| `await foreach (x in src) { }`
+| _(manual via `Flow`, `Channel`, or `Flow.collect`)_
+| `for try await x in seq`
+
+| **Async generator**
+| `yield return expr`
+| `yield expr` (in `async function*`)
+| `yield return expr` (in `IAsyncEnumerable<T>`)
+| `flow { emit(x) }` / channel producers
+| `AsyncStream { continuation.yield(x) }`
+
+| **Deferred cleanup**
+| `defer { cleanup }`
+| _(none; use `try`/`finally`)_
+| `await using` / `try`/`finally`
+| `try`/`finally` / `use`
+| `defer`
+
+| **Wait all**
+| `Awaitable.all(a, b, c)`
+| `Promise.all([a, b, c])`
+| `Task.WhenAll(a, b, c)`
+| `awaitAll(a, b, c)`
+| `async let` / `withTaskGroup`
+
+| **Wait any**
+| `Awaitable.any(a, b, c)`
+| `Promise.any([a, b, c])`
+| `Task.WhenAny(a, b, c)`
+| `select {}` / manual race
+| `TaskGroup.next()`
+
+| **Wait all settled**
+| `Awaitable.allSettled(a, b, c)`
+| `Promise.allSettled([a, b, c])`
+| _(manual; no built-in equivalent)_
+| _(manual; typically `runCatching`)_
+| _(manual; typically `Result`)_
+
+| **Pre-computed result**
+| `Awaitable.of(value)`
+| `Promise.resolve(value)`
+| `Task.FromResult(value)`
+| `CompletableDeferred(value)` / `async { value }`
+| `Task { value }`
+
+| **Pre-computed error**
+| `Awaitable.failed(error)`
+| `Promise.reject(error)`
+| `Task.FromException(ex)`
+| `CompletableDeferred<T>().completeExceptionally(ex)`
+| `Task { throw error }`
+
+| **Delay**
+| `Awaitable.delay(ms)`
+| `new Promise(r => setTimeout(r, ms))`
+| `Task.Delay(ms)`
+| `delay(ms)`
+| `Task.sleep(for:)`
+
+| **Timeout**
+| `Awaitable.orTimeoutMillis(task, ms)` / `task.orTimeoutMillis(ms)`
+| `Promise.race([...])` / `AbortSignal.timeout()`
+| `task.WaitAsync(timeout)` / `CancelAfter`
+| `withTimeout(ms) { ... }`
+| _(manual race with `TaskGroup` / sleep)_
+
+| **Cancellation**
+| `awaitable.cancel()`
+| `AbortController.abort()`
+| `CancellationToken`
+| `Job.cancel()`
+| `Task.cancel()`
+
+| **Executor/scheduler**
+| `Awaitable.setExecutor(exec)`
+| _(event loop; not configurable)_
+| `TaskScheduler`
+| `CoroutineDispatcher`
+| task priority / executor chosen by runtime
+
+| **Third-party support**
+| `AwaitableAdapterRegistry` SPI +
+`Awaitable.from()` / `AsyncStream.from()`
+| _(native `thenable` protocol)_
+| Custom awaiters via `GetAwaiter()`
+| coroutine adapters / bridges
+| `AsyncSequence` / `Task` interop patterns
+
+| **Annotation form**
+| `@Async`
+| _(none)_
+| _(none; attribute-driven async not supported)_
+| _(none)_
+| _(none)_
+
+| **Implementation**
+| Thread-per-task (virtual threads on JDK 21+)
+| Event loop + microtask queue
+| State machine (compiled IL transformation)
+| Coroutine state machine
+| Compiler-generated async state machine
+|===
+
+[[summary]]
+== Summary
+
+[cols="2,5"]
+|===
+| Feature | Syntax
+
+| Async method
+| `async returnType methodName(params) { ... }`
+
+| Async closure
+| `def fn = async { params -> body }; await(fn(args))`
+
+| Async lambda
+| `def fn = async (params) -> { body }; await(fn(args))`
+
+| Await expression
+| `await expr` or `await(expr)`
+
+| For await
+| `for await (item in source) { ... }` or `for await (item : source) { ... }`
+
+| Yield return
+| `yield return expr` (inside async methods)
+
+| Defer
+| `defer { cleanup code }`
+
+| Parallel wait
+| `await Awaitable.all(a, b, c)`
+
+| Race
+| `await Awaitable.any(a, b)`
+
+| All settled
+| `await Awaitable.allSettled(a, b, c)`
+
+| Delay
+| `Awaitable.delay(millis)`
+
+| Pre-computed result
+| `Awaitable.of(value)` / `Awaitable.failed(error)`
+
+| Type conversion
+| `Awaitable.from(source)` / `AsyncStream.from(source)`
+
+| Annotation form
+| `@Async def methodName() { ... }`
+
+| Inspection
+| `awaitable.isDone()` / `isCancelled()` / `isCompletedExceptionally()`
+
+| Timeout (instance)
+| `awaitable.orTimeoutMillis(ms)` / `awaitable.completeOnTimeoutMillis(fallback, ms)`
+
+| AwaitResult
+| `result.isSuccess()` / `result.isFailure()` / `result.getOrElse { fallback }`
+|===
diff --git a/src/spec/test/AsyncAwaitSpecTest.groovy b/src/spec/test/AsyncAwaitSpecTest.groovy
new file mode 100644
index 0000000000..b60988cb4a
--- /dev/null
+++ b/src/spec/test/AsyncAwaitSpecTest.groovy
@@ -0,0 +1,1405 @@
+/*
+ * 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.
+ */
+
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+
+/**
+ * Spec tests for async/await documentation.
+ * <p>
+ * Tagged code snippets in this file are referenced by {@code core-async-await.adoc}
+ * via AsciiDoc {@code include::} directives.
+ */
+class AsyncAwaitSpecTest {
+
+ // =========================================================================
+ // 1. Basic async methods
+ // =========================================================================
+
+ @Test
+ void testAsyncMethodBasic() {
+ assertScript '''
+// tag::async_method_basic[]
+import groovy.concurrent.Awaitable
+
+class GreetingService {
+ async greet(String name) {
+ return "Hello, ${name}!"
+ }
+}
+
+def service = new GreetingService()
+def awaitable = service.greet("World")
+assert awaitable instanceof Awaitable
+assert awaitable.get() == "Hello, World!"
+// end::async_method_basic[]
+ '''
+ }
+
+ @Test
+ void testAsyncMethodTyped() {
+ assertScript '''
+// tag::async_method_typed[]
+class MathService {
+ async int square(int n) { n * n }
+ async String upper(String s) { s.toUpperCase() }
+}
+
+def svc = new MathService()
+assert svc.square(7).get() == 49
+assert svc.upper("groovy").get() == "GROOVY"
+// end::async_method_typed[]
+ '''
+ }
+
+ @Test
+ void testAsyncMethodVoid() {
+ assertScript '''
+// tag::async_method_void[]
+import groovy.concurrent.Awaitable
+import java.util.concurrent.atomic.AtomicReference
+
+class Logger {
+ AtomicReference<String> lastMessage = new AtomicReference<>()
+
+ async void log(String msg) {
+ lastMessage.set(msg)
+ }
+}
+
+def logger = new Logger()
+def a = logger.log("event occurred")
+assert a instanceof Awaitable
+a.get()
+assert logger.lastMessage.get() == "event occurred"
+// end::async_method_void[]
+ '''
+ }
+
+ @Test
+ void testAsyncMethodStatic() {
+ assertScript '''
+// tag::async_method_static[]
+class Util {
+ async static int add(int a, int b) { a + b }
+}
+
+assert Util.add(3, 4).get() == 7
+// end::async_method_static[]
+ '''
+ }
+
+ @Test
+ void testAsyncScript() {
+ assertScript '''
+// tag::async_script[]
+async multiply(int a, int b) { a * b }
+
+def result = multiply(6, 7)
+assert result.get() == 42
+// end::async_script[]
+ '''
+ }
+
+ // =========================================================================
+ // 2. await expression
+ // =========================================================================
+
+ @Test
+ void testAwaitBasic() {
+ assertScript '''
+// tag::await_basic[]
+class DataService {
+ async fetchData() { "database result" }
+
+ async process() {
+ def data = await fetchData()
+ return "Processed: ${data}"
+ }
+}
+
+def result = await(new DataService().process())
+assert result == "Processed: database result"
+// end::await_basic[]
+ '''
+ }
+
+ @Test
+ void testAwaitCompletableFuture() {
+ assertScript '''
+// tag::await_completable_future[]
+import java.util.concurrent.CompletableFuture
+
+class Service {
+ async compute() {
+ def value = await CompletableFuture.supplyAsync { 42 }
+ return value * 2
+ }
+}
+
+assert await(new Service().compute()) == 84
+// end::await_completable_future[]
+ '''
+ }
+
+ @Test
+ void testAwaitArithmetic() {
+ assertScript '''
+// tag::await_arithmetic[]
+import java.util.concurrent.CompletableFuture
+
+class Calculator {
+ async sum() {
+ def a = await(CompletableFuture.supplyAsync { 10 })
+ def b = await(CompletableFuture.supplyAsync { 20 })
+ return a + b
+ }
+}
+
+assert await(new Calculator().sum()) == 30
+// end::await_arithmetic[]
+ '''
+ }
+
+ @Test
+ void testAwaitParenthesized() {
+ assertScript '''
+// tag::await_parenthesized[]
+import java.util.concurrent.CompletableFuture
+
+async compute() {
+ // Both forms are equivalent:
+ def a = await CompletableFuture.supplyAsync { 10 }
+ def b = await(CompletableFuture.supplyAsync { 20 })
+ return a + b
+}
+
+assert await(compute()) == 30
+// end::await_parenthesized[]
+ '''
+ }
+
+ // =========================================================================
+ // 3. Async closures and lambdas
+ // =========================================================================
+
+ @Test
+ void testAsyncClosureBasic() {
+ assertScript '''
+// tag::async_closure_basic[]
+import groovy.concurrent.Awaitable
+
+// async { ... } creates a Closure that returns an Awaitable
+def asyncTask = async { 1 + 2 }
+assert asyncTask instanceof Closure
+
+// Explicit invocation is required to start execution
+def awaitable = asyncTask()
+assert awaitable instanceof Awaitable
+assert await(awaitable) == 3
+// end::async_closure_basic[]
+ '''
+ }
+
+ @Test
+ void testAsyncClosureWithParams() {
+ assertScript '''
+// tag::async_closure_params[]
+def asyncSquare = async { int n -> n * n }
+
+// Call with argument, then await the result
+assert await(asyncSquare(5)) == 25
+assert await(asyncSquare(7)) == 49
+// end::async_closure_params[]
+ '''
+ }
+
+ @Test
+ void testAsyncClosureMultipleParams() {
+ assertScript '''
+// tag::async_closure_multi_params[]
+def asyncConcat = async { a, b, sep -> "${a}${sep}${b}" }
+assert await(asyncConcat("hello", "world", " ")) == "hello world"
+// end::async_closure_multi_params[]
+ '''
+ }
+
+ @Test
+ void testAsyncClosureInCollection() {
+ assertScript '''
+// tag::async_closure_collection[]
+def ops = [
+ add: async { a, b -> a + b },
+ mul: async { a, b -> a * b },
+ sub: async { a, b -> a - b }
+]
+
+assert await(ops.add(3, 4)) == 7
+assert await(ops.mul(3, 4)) == 12
+assert await(ops.sub(10, 4)) == 6
+// end::async_closure_collection[]
+ '''
+ }
+
+ @Test
+ void testAsyncLambda() {
+ assertScript '''
+// tag::async_lambda[]
+def asyncDouble = async (n) -> { n * 2 }
+assert await(asyncDouble(21)) == 42
+
+def asyncGreet = async (name) -> { "Hello, ${name}!" }
+assert await(asyncGreet("Groovy")) == "Hello, Groovy!"
+// end::async_lambda[]
+ '''
+ }
+
+ // =========================================================================
+ // 4. for await — async iteration
+ // =========================================================================
+
+ @Test
+ void testForAwaitBasic() {
+ assertScript '''
+// tag::for_await_basic[]
+class Collector {
+ async collectItems() {
+ def results = []
+ for await (item in [10, 20, 30]) {
+ results << item
+ }
+ return results
+ }
+}
+
+assert await(new Collector().collectItems()) == [10, 20, 30]
+// end::for_await_basic[]
+ '''
+ }
+
+ @Test
+ void testForAwaitWithTransform() {
+ assertScript '''
+// tag::for_await_transform[]
+class Transformer {
+ async doubleAll() {
+ def results = []
+ for await (item in [1, 2, 3, 4]) {
+ results << item * 2
+ }
+ return results
+ }
+}
+
+assert await(new Transformer().doubleAll()) == [2, 4, 6, 8]
+// end::for_await_transform[]
+ '''
+ }
+
+ @Test
+ void testForAwaitWithBreak() {
+ assertScript '''
+// tag::for_await_break[]
+class Finder {
+ async findFirstOver(int threshold) {
+ for await (item in [5, 10, 15, 20, 25]) {
+ if (item > threshold) return item
+ }
+ return -1
+ }
+}
+
+assert await(new Finder().findFirstOver(12)) == 15
+// end::for_await_break[]
+ '''
+ }
+
+ // =========================================================================
+ // 5. yield return — async generators
+ // =========================================================================
+
+ @Test
+ void testYieldReturnBasic() {
+ assertScript '''
+// tag::yield_return_basic[]
+import groovy.concurrent.AsyncStream
+
+class NumberGenerator {
+ async numbers() {
+ yield return 1
+ yield return 2
+ yield return 3
+ }
+}
+
+def stream = new NumberGenerator().numbers()
+assert stream instanceof AsyncStream
+
+def results = []
+for await (n in stream) {
+ results << n
+}
+assert results == [1, 2, 3]
+// end::yield_return_basic[]
+ '''
+ }
+
+ @Test
+ void testYieldReturnLoop() {
+ assertScript '''
+// tag::yield_return_loop[]
+class RangeGenerator {
+ async range(int start, int end) {
+ for (int i = start; i <= end; i++) {
+ yield return i
+ }
+ }
+}
+
+def results = []
+for await (n in new RangeGenerator().range(1, 5)) {
+ results << n
+}
+assert results == [1, 2, 3, 4, 5]
+// end::yield_return_loop[]
+ '''
+ }
+
+ @Test
+ void testYieldReturnFilter() {
+ assertScript '''
+// tag::yield_return_filter[]
+class FilteredGenerator {
+ async evenNumbers(int max) {
+ for (int i = 1; i <= max; i++) {
+ if (i % 2 == 0) {
+ yield return i
+ }
+ }
+ }
+}
+
+def results = []
+for await (n in new FilteredGenerator().evenNumbers(10)) {
+ results << n
+}
+assert results == [2, 4, 6, 8, 10]
+// end::yield_return_filter[]
+ '''
+ }
+
+ @Test
+ void testYieldReturnWithAwait() {
+ assertScript '''
+// tag::yield_return_with_await[]
+import java.util.concurrent.CompletableFuture
+
+class AsyncDataGenerator {
+ async fetchSequence() {
+ for (int i = 1; i <= 3; i++) {
+ def value = await CompletableFuture.supplyAsync { i * 10 }
+ yield return value
+ }
+ }
+}
+
+def results = []
+for await (v in new AsyncDataGenerator().fetchSequence()) {
+ results << v
+}
+assert results == [10, 20, 30]
+// end::yield_return_with_await[]
+ '''
+ }
+
+ // =========================================================================
+ // 6. defer — Go-style cleanup
+ // =========================================================================
+
+ @Test
+ void testDeferBasic() {
+ assertScript '''
+// tag::defer_basic[]
+class DeferExample {
+ async runWithDefer() {
+ def log = []
+ defer { log << "first deferred" }
+ defer { log << "second deferred" }
+ defer { log << "third deferred" }
+ log << "body executed"
+ return log
+ }
+}
+
+def result = await(new DeferExample().runWithDefer())
+// Body runs first; deferred blocks execute in LIFO order
+assert result == ["body executed", "third deferred", "second deferred", "first deferred"]
+// end::defer_basic[]
+ '''
+ }
+
+ @Test
+ void testDeferOnException() {
+ assertScript '''
+// tag::defer_exception[]
+class ResourceHandler {
+ static log = []
+
+ async processWithCleanup() {
+ defer { log << "cleanup done" }
+ throw new RuntimeException("something went wrong")
+ }
+}
+
+try {
+ await(new ResourceHandler().processWithCleanup())
+} catch (RuntimeException e) {
+ assert e.message == "something went wrong"
+}
+// Deferred blocks always execute, even on exception
+assert ResourceHandler.log == ["cleanup done"]
+// end::defer_exception[]
+ '''
+ }
+
+ @Test
+ void testDeferResourceCleanup() {
+ assertScript '''
+// tag::defer_resource_cleanup[]
+class ResourceManager {
+ static resources = []
+ static cleanupLog = []
+
+ async processResources() {
+ def r1 = "database-conn"
+ resources << r1
+ defer { resources.remove(r1); cleanupLog << "closed ${r1}" }
+
+ def r2 = "file-handle"
+ resources << r2
+ defer { resources.remove(r2); cleanupLog << "closed ${r2}" }
+
+ assert resources.size() == 2
+ return "done"
+ }
+}
+
+assert await(new ResourceManager().processResources()) == "done"
+// LIFO order: r2 closed first, then r1
+assert ResourceManager.cleanupLog == ["closed file-handle", "closed database-conn"]
+assert ResourceManager.resources.isEmpty()
+// end::defer_resource_cleanup[]
+ '''
+ }
+
+ // =========================================================================
+ // 7. Exception handling
+ // =========================================================================
+
+ @Test
+ void testExceptionTransparency() {
+ assertScript '''
+// tag::exception_transparency[]
+async fetchData() {
+ throw new java.io.IOException("disk failure")
+}
+
+async caller() {
+ try {
+ await fetchData()
+ assert false : "should not reach here"
+ } catch (java.io.IOException e) {
+ return "Recovered: ${e.message}"
+ }
+}
+
+assert await(caller()) == "Recovered: disk failure"
+// end::exception_transparency[]
+ '''
+ }
+
+ @Test
+ void testExceptionMultipleTasks() {
+ assertScript '''
+// tag::exception_multiple_tasks[]
+async riskyTask(boolean shouldFail) {
+ if (shouldFail) throw new IllegalStateException("task failed")
+ return "success"
+}
+
+async coordinator() {
+ def results = []
+ for (flag in [false, true, false]) {
+ try {
+ results << await(riskyTask(flag))
+ } catch (IllegalStateException e) {
+ results << "error: ${e.message}"
+ }
+ }
+ return results
+}
+
+assert await(coordinator()) == ["success", "error: task failed", "success"]
+// end::exception_multiple_tasks[]
+ '''
+ }
+
+ // =========================================================================
+ // 8. Utility methods — awaitAll, awaitAny, awaitAllSettled, delay
+ // =========================================================================
+
+ @Test
+ void testAwaitAll() {
+ assertScript '''
+// tag::await_all[]
+import groovy.concurrent.Awaitable
+
+async fetchUser() { "Alice" }
+async fetchOrder() { "Order#42" }
+async fetchBalance() { 100.0 }
+
+def results = await(Awaitable.all(fetchUser(), fetchOrder(), fetchBalance()))
+assert results == ["Alice", "Order#42", 100.0]
+// end::await_all[]
+ '''
+ }
+
+ @Test
+ void testAwaitAny() {
+ assertScript '''
+// tag::await_any[]
+import groovy.concurrent.Awaitable
+
+def fast = async { "fast result" }
+def slow = async {
+ await(Awaitable.delay(2000))
+ return "slow result"
+}
+
+def winner = await(Awaitable.any(fast(), slow()))
+assert winner == "fast result"
+// end::await_any[]
+ '''
+ }
+
+ @Test
+ void testAwaitAllSettled() {
+ assertScript '''
+// tag::await_all_settled[]
+import groovy.concurrent.Awaitable
+
+async caller() {
+ def a = Awaitable.of(1)
+ def b = Awaitable.failed(new IOException("network error"))
+ def c = Awaitable.of(3)
+ return await(Awaitable.allSettled(a, b, c))
+}
+
+def results = await(caller())
+assert results.size() == 3
+
+assert results[0].isSuccess() && results[0].value == 1
+assert results[1].isFailure() && results[1].error.message == "network error"
+assert results[2].isSuccess() && results[2].value == 3
+// end::await_all_settled[]
+ '''
+ }
+
+ @Test
+ void testDelay() {
+ assertScript '''
+// tag::delay_example[]
+import groovy.concurrent.Awaitable
+
+async delayedGreeting() {
+ await(Awaitable.delay(100)) // pause for 100 milliseconds
+ return "Hello after delay"
+}
+
+assert await(delayedGreeting()) == "Hello after delay"
+// end::delay_example[]
+ '''
+ }
+
+ @Test
+ void testContinuationHelpers() {
+ assertScript '''
+// tag::continuation_helpers[]
+import groovy.concurrent.Awaitable
+import java.util.concurrent.atomic.AtomicReference
+
+def observed = new AtomicReference()
+await(Awaitable.of("groovy").thenAccept { observed.set(it.toUpperCase()) })
+assert observed.get() == "GROOVY"
+
+def recovered = await(
+ Awaitable.failed(new IOException("boom"))
+ .handle { value, error -> "recovered from ${error.message}" }
+)
+assert recovered == "recovered from boom"
+
+def completion = new AtomicReference()
+assert await(Awaitable.of(7).whenComplete { value, error ->
+ completion.set("${value}:${error}")
+}) == 7
+assert completion.get() == "7:null"
+// end::continuation_helpers[]
+ '''
+ }
+
+ @Test
+ void testTimeoutCombinators() {
+ assertScript '''
+// tag::timeout_combinators[]
+import groovy.concurrent.Awaitable
+import java.util.concurrent.TimeoutException
+
+async slowCall() {
+ await(Awaitable.delay(5_000))
+ return "done"
+}
+
+try {
+ await(Awaitable.orTimeoutMillis(slowCall(), 50))
+ assert false : "should have timed out"
+} catch (TimeoutException e) {
+ assert e.message.contains("Timed out after 50")
+}
+
+assert await(Awaitable.completeOnTimeoutMillis(slowCall(), "cached", 50)) == "cached"
+// end::timeout_combinators[]
+ '''
+ }
+
+ // =========================================================================
+ // 9. Flow.Publisher integration
+ // =========================================================================
+
+ @Test
+ void testFlowPublisherAwait() {
+ assertScript '''
+// tag::flow_publisher_await[]
+import java.util.concurrent.SubmissionPublisher
+
+def publisher = new SubmissionPublisher<String>()
+def waitForSubscriber = { SubmissionPublisher pub, long timeoutMillis = 5_000L ->
+ long deadline = System.nanoTime() + timeoutMillis * 1_000_000L
+ while (pub.numberOfSubscribers == 0 && System.nanoTime() < deadline) {
+ Thread.sleep(1)
+ }
+ assert pub.numberOfSubscribers > 0 : "Timed out waiting for publisher subscription"
+}
+Thread.start {
+ waitForSubscriber(publisher)
+ publisher.submit("hello from publisher")
+ publisher.close()
+}
+
+def result = await(publisher)
+assert result == "hello from publisher"
+// end::flow_publisher_await[]
+ '''
+ }
+
+ @Test
+ void testFlowPublisherForAwait() {
+ assertScript '''
+// tag::flow_publisher_for_await[]
+import java.util.concurrent.SubmissionPublisher
+
+class StreamConsumer {
+ async consumeAll(SubmissionPublisher<Integer> pub) {
+ def results = []
+ for await (item in pub) {
+ results << item
+ }
+ return results
+ }
+}
+
+def publisher = new SubmissionPublisher<Integer>()
+def future = new StreamConsumer().consumeAll(publisher)
+def waitForSubscriber = { SubmissionPublisher pub, long timeoutMillis = 5_000L ->
+ long deadline = System.nanoTime() + timeoutMillis * 1_000_000L
+ while (pub.numberOfSubscribers == 0 && System.nanoTime() < deadline) {
+ Thread.sleep(1)
+ }
+ assert pub.numberOfSubscribers > 0 : "Timed out waiting for publisher subscription"
+}
+Thread.start {
+ waitForSubscriber(publisher)
+ (1..5).each { publisher.submit(it) }
+ publisher.close()
+}
+
+assert await(future) == [1, 2, 3, 4, 5]
+// end::flow_publisher_for_await[]
+ '''
+ }
+
+ // =========================================================================
+ // 10. @Async annotation
+ // =========================================================================
+
+ @Test
+ void testAsyncAnnotation() {
+ assertScript '''
+// tag::async_annotation[]
+import groovy.transform.Async
+import groovy.concurrent.Awaitable
+
+class Service {
+ @Async
+ def fetchData() {
+ return "data loaded"
+ }
+}
+
+def svc = new Service()
+def awaitable = svc.fetchData()
+assert awaitable instanceof Awaitable
+assert awaitable.get() == "data loaded"
+// end::async_annotation[]
+ '''
+ }
+
+ // =========================================================================
+ // 11. Custom executor
+ // =========================================================================
+
+ @Test
+ void testCustomExecutor() {
+ assertScript '''
+// tag::custom_executor[]
+import groovy.concurrent.Awaitable
+import java.util.concurrent.Executors
+
+class WorkService {
+ async work() { Thread.currentThread().name }
+}
+
+def customPool = Executors.newFixedThreadPool(2)
+try {
+ Awaitable.setExecutor(customPool)
+
+ def threadName = await(new WorkService().work())
+ assert threadName.contains("pool")
+} finally {
+ Awaitable.setExecutor(null) // restore default
+ customPool.shutdown()
+}
+// end::custom_executor[]
+ '''
+ }
+
+ // =========================================================================
+ // 12. Comprehensive pattern — parallel web scraping
+ // =========================================================================
+
+ @Test
+ void testParallelPattern() {
+ assertScript '''
+// tag::parallel_pattern[]
+import groovy.concurrent.Awaitable
+
+async fetchPage(String url) {
+ await(Awaitable.delay(10)) // simulate network I/O
+ return "Content of ${url}"
+}
+
+// Launch three tasks in parallel
+def pages = await(Awaitable.all(
+ fetchPage("https://example.com/page1"),
+ fetchPage("https://example.com/page2"),
+ fetchPage("https://example.com/page3")
+))
+
+assert pages.size() == 3
+assert pages.every { it.startsWith("Content of") }
+// end::parallel_pattern[]
+ '''
+ }
+
+ // =========================================================================
+ // 13. Common patterns — retry, timeout, cancellation
+ // =========================================================================
+
+ @Test
+ void testRetryPattern() {
+ assertScript '''
+// tag::retry_pattern[]
+import groovy.concurrent.Awaitable
+import java.util.concurrent.atomic.AtomicInteger
+
+class RetryDemo {
+ final AtomicInteger attempts = new AtomicInteger(0)
+
+ async fetchData() {
+ if (attempts.incrementAndGet() < 3) throw new IOException("transient error")
+ return "data"
+ }
+
+ async fetchWithRetry(int maxRetries) {
+ for (int attempt = 1; attempt <= maxRetries; attempt++) {
+ try {
+ return await fetchData()
+ } catch (Exception e) {
+ if (attempt == maxRetries) throw e
+ await(Awaitable.delay(10 * attempt)) // exponential backoff
+ }
+ }
+ }
+}
+
+def demo = new RetryDemo()
+assert await(demo.fetchWithRetry(5)) == "data"
+assert demo.attempts.get() == 3
+// end::retry_pattern[]
+ '''
+ }
+
+ @Test
+ void testTimeoutPattern() {
+ assertScript '''
+// tag::timeout_pattern[]
+import groovy.concurrent.Awaitable
+import java.util.concurrent.TimeoutException
+
+async longRunningTask() {
+ await(Awaitable.delay(5000))
+ return "completed"
+}
+
+try {
+ await Awaitable.orTimeoutMillis(longRunningTask(), 50)
+ assert false : "should have timed out"
+} catch (TimeoutException e) {
+ assert e.message.contains("Timed out after 50")
+}
+// end::timeout_pattern[]
+ '''
+ }
+
+ @Test
+ void testCancellation() {
+ assertScript '''
+// tag::cancellation[]
+import groovy.concurrent.Awaitable
+import java.util.concurrent.CancellationException
+
+async longRunningTask() {
+ await(Awaitable.delay(10_000))
+ return "done"
+}
+
+def task = longRunningTask()
+task.cancel()
+
+try {
+ await(task)
+ assert false : "should have thrown"
+} catch (CancellationException e) {
+ assert task.isCancelled()
+}
+// end::cancellation[]
+ '''
+ }
+
+ // =========================================================================
+ // 14. Best practices — Awaitable.of, async caching, static factory
+ // =========================================================================
+
+ @Test
+ void testAwaitableOf() {
+ assertScript '''
+// tag::awaitable_of[]
+import groovy.concurrent.Awaitable
+
+// No thread spawned — result is immediately available
+Awaitable<Integer> addAsync(int a, int b) {
+ return Awaitable.of(a + b)
+}
+
+assert await(addAsync(2, 3)) == 5
+
+// Pre-computed error result
+Awaitable<String> failFast() {
+ return Awaitable.failed(new IllegalStateException("not ready"))
+}
+
+try {
+ await failFast()
+ assert false
+} catch (IllegalStateException e) {
+ assert e.message == "not ready"
+}
+// end::awaitable_of[]
+ '''
+ }
+
+ @Test
+ void testAsyncCaching() {
+ assertScript '''
+// tag::async_caching[]
+import groovy.concurrent.Awaitable
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicInteger
+
+class CacheDemo {
+ final AtomicInteger fetchCount = new AtomicInteger(0)
+ final cache = new ConcurrentHashMap<Integer, Awaitable>()
+
+ async fetchPersonAsync(int id) {
+ fetchCount.incrementAndGet()
+ await(Awaitable.delay(10))
+ return "Person-${id}"
+ }
+
+ def getOrFetch(int id) {
+ cache.computeIfAbsent(id, { key -> fetchPersonAsync(key) })
+ }
+}
+
+def demo = new CacheDemo()
+
+// First call triggers actual fetch
+def p1 = await(demo.getOrFetch(42))
+// Second call returns cached Awaitable (no new fetch)
+def p2 = await(demo.getOrFetch(42))
+
+assert p1 == "Person-42"
+assert p2 == "Person-42"
+assert demo.fetchCount.get() == 1 // fetched only once
+// end::async_caching[]
+ '''
+ }
+
+ @Test
+ void testStaticFactoryPattern() {
+ assertScript '''
+// tag::static_factory[]
+import groovy.concurrent.Awaitable
+
+class RemoteService {
+ final String connectionId
+ private RemoteService(String connectionId) { this.connectionId = connectionId }
+
+ static async create() {
+ def connId = await connectToRemote()
+ return new RemoteService(connId)
+ }
+
+ private static async connectToRemote() {
+ await(Awaitable.delay(10)) // simulate I/O
+ return "conn-" + System.nanoTime()
+ }
+}
+
+def service = await(RemoteService.create())
+assert service.connectionId.startsWith("conn-")
+// end::static_factory[]
+ '''
+ }
+
+ // =========================================================================
+ // 15. Best practices — sync-over-async, prefer await, fire-and-forget
+ // =========================================================================
+
+ @Test
+ void testSyncOverAsync() {
+ assertScript '''
+// tag::sync_over_async[]
+import groovy.concurrent.Awaitable
+
+class ConsumerService {
+ // BAD: blocking sync-over-async
+ // def result = computeAsync(5).get()
+
+ // GOOD: propagate await through the call chain
+ async computeAsync(int n) {
+ await(Awaitable.delay(10))
+ return n * 2
+ }
+
+ async doSomething() {
+ def result = await computeAsync(5)
+ return result + 1
+ }
+}
+
+def svc = new ConsumerService()
+assert await(svc.doSomething()) == 11
+// end::sync_over_async[]
+ '''
+ }
+
+ @Test
+ void testPreferAwaitOverThen() {
+ assertScript '''
+// tag::prefer_await_over_then[]
+import groovy.concurrent.Awaitable
+
+class PipelineDemo {
+ async step1() { await(Awaitable.delay(5)); return 10 }
+ async step2(int n) { await(Awaitable.delay(5)); return n * 2 }
+ async step3(int n) { await(Awaitable.delay(5)); return n + 3 }
+
+ // More readable: sequential awaits
+ async pipeline() {
+ def a = await step1()
+ def b = await step2(a)
+ return await step3(b)
+ }
+}
+
+def demo = new PipelineDemo()
+assert await(demo.pipeline()) == 23
+// end::prefer_await_over_then[]
+ '''
+ }
+
+ @Test
+ void testFireAndForget() {
+ assertScript '''
+// tag::fire_and_forget[]
+import groovy.concurrent.Awaitable
+
+def errors = Collections.synchronizedList([])
+
+async backgroundOperation() {
+ await(Awaitable.delay(10))
+ throw new RuntimeException("background error")
+}
+
+// GOOD: capture for tracking
+def task = backgroundOperation()
+Thread.sleep(100) // wait for completion
+assert task.isCompletedExceptionally()
+// end::fire_and_forget[]
+ '''
+ }
+
+ @Test
+ void testForAwaitColonNotation() {
+ assertScript '''
+// tag::for_await_colon[]
+class ColonNotationDemo {
+ async generateNumbers() {
+ for (int i = 1; i <= 3; i++) {
+ yield return i
+ }
+ }
+}
+
+def demo = new ColonNotationDemo()
+def results = []
+// for await supports both `in` and `:` notation
+for await (n : demo.generateNumbers()) {
+ results << n
+}
+assert results == [1, 2, 3]
+// end::for_await_colon[]
+ '''
+ }
+
+ // =========================================================================
+ // 16. AwaitResult API
+ // =========================================================================
+
+ @Test
+ void testAwaitResultApi() {
+ assertScript '''
+// tag::await_result_api[]
+import groovy.concurrent.Awaitable
+import groovy.concurrent.AwaitResult
+
+async caller() {
+ def a = Awaitable.of(42)
+ def b = Awaitable.failed(new IOException("disk full"))
+ return await(Awaitable.allSettled(a, b))
+}
+
+def results = await(caller())
+
+// Inspect each outcome
+AwaitResult success = results[0]
+assert success.isSuccess()
+assert success.value == 42
+assert !success.isFailure()
+
+AwaitResult failure = results[1]
+assert failure.isFailure()
+assert failure.error.message == "disk full"
+assert !failure.isSuccess()
+
+// getOrElse: recover from failures with a fallback function
+assert success.getOrElse { -1 } == 42
+assert failure.getOrElse { err -> "recovered: ${err.message}" } == "recovered: disk full"
+
+// toString for debugging
+assert success.toString() == "AwaitResult.Success[42]"
+assert failure.toString().startsWith("AwaitResult.Failure[")
+// end::await_result_api[]
+ '''
+ }
+
+ // =========================================================================
+ // 17. Instance timeout methods
+ // =========================================================================
+
+ @Test
+ void testInstanceTimeoutMethods() {
+ assertScript '''
+// tag::instance_timeout_methods[]
+import groovy.concurrent.Awaitable
+import java.util.concurrent.TimeoutException
+
+async slowTask() {
+ await(Awaitable.delay(5_000))
+ return "done"
+}
+
+// orTimeout: fails with TimeoutException if not completed in time
+try {
+ await(slowTask().orTimeoutMillis(50))
+ assert false : "should have timed out"
+} catch (TimeoutException e) {
+ assert e.message.contains("50")
+}
+
+// completeOnTimeout: completes with a fallback value instead of failing
+assert await(slowTask().completeOnTimeoutMillis("fallback", 50)) == "fallback"
+// end::instance_timeout_methods[]
+ '''
+ }
+
+ // =========================================================================
+ // 18. Inspection methods
+ // =========================================================================
+
+ @Test
+ void testInspectionMethods() {
+ assertScript '''
+// tag::inspection_methods[]
+import groovy.concurrent.Awaitable
+
+// Check state without blocking
+def completed = Awaitable.of(42)
+assert completed.isDone()
+assert !completed.isCancelled()
+
+def failed = Awaitable.failed(new RuntimeException("oops"))
+assert failed.isDone()
+assert failed.isCompletedExceptionally()
+
+// Cancel an awaitable
+def task = Awaitable.delay(10_000)
+task.cancel()
+assert task.isCancelled()
+assert task.isDone()
+// end::inspection_methods[]
+ '''
+ }
+
+ // =========================================================================
+ // 19. Type conversion with from()
+ // =========================================================================
+
+ @Test
+ void testFromConversion() {
+ assertScript '''
+// tag::from_conversion[]
+import groovy.concurrent.Awaitable
+import groovy.concurrent.AsyncStream
+import java.util.concurrent.CompletableFuture
+
+// Convert a CompletableFuture to Awaitable
+def cf = CompletableFuture.completedFuture("hello")
+Awaitable<String> awaitable = Awaitable.from(cf)
+assert awaitable.get() == "hello"
+
+// If the source is already an Awaitable, it is returned as-is
+def original = Awaitable.of(42)
+assert Awaitable.from(original).is(original)
+
+// Convert an Iterable to AsyncStream
+AsyncStream<String> stream = AsyncStream.from(["a", "b", "c"])
+def items = []
+assert stream.moveNext().get() == true
+items << stream.current
+assert stream.moveNext().get() == true
+items << stream.current
+assert stream.moveNext().get() == true
+items << stream.current
+assert stream.moveNext().get() == false
+assert items == ["a", "b", "c"]
+// end::from_conversion[]
+ '''
+ }
+
+ // =========================================================================
+ // 20. Custom adapter registration
+ // =========================================================================
+
+ @Test
+ void testAdapterRegistration() {
+ assertScript '''
+// tag::adapter_registration[]
+import groovy.concurrent.AwaitableAdapterRegistry
+import groovy.concurrent.AwaitableAdapter
+import groovy.concurrent.Awaitable
+
+// A simple custom async type (simulating a third-party framework)
+class CustomFuture<T> {
+ private final T value
+ CustomFuture(T value) { this.value = value }
+ T blockingGet() { return value }
+}
+
+// Register adapter for the custom type
+def handle = AwaitableAdapterRegistry.register(new AwaitableAdapter() {
+ boolean supportsAwaitable(Class<?> type) { CustomFuture.isAssignableFrom(type) }
+ Awaitable toAwaitable(Object source) { Awaitable.of(((CustomFuture) source).blockingGet()) }
+})
+
+try {
+ def future = new CustomFuture<String>("hello from adapter")
+ assert await(future) == "hello from adapter"
+} finally {
+ handle.close() // deregister when done
+}
+// end::adapter_registration[]
+ '''
+ }
+
+ // =========================================================================
+ // Motivation: Why Groovy Needs Async/Await
+ // =========================================================================
+
+ @Test
+ void testMotivationCallbackHell() {
+ assertScript '''
+// tag::motivation_callback_hell[]
+import java.util.concurrent.CompletableFuture
+
+// ------ Before: chained CompletableFuture (callback style) ------
+def result = CompletableFuture.supplyAsync { 42 }
+ .thenCompose { userId ->
+ CompletableFuture.supplyAsync { "User-${userId}" }
+ }
+ .thenCompose { userName ->
+ CompletableFuture.supplyAsync { "${userName}: premium" }
+ }
+ .thenApply { profile ->
+ profile.toUpperCase()
+ }
+ .exceptionally { ex ->
+ "ERROR: ${ex.message}"
+ }
+ .get()
+
+assert result == "USER-42: PREMIUM"
+// end::motivation_callback_hell[]
+ '''
+ }
+
+ @Test
+ void testMotivationAsyncAwaitClean() {
+ assertScript '''
+// tag::motivation_async_await_clean[]
+import groovy.concurrent.Awaitable
+
+// ------ After: sequential async/await (same logic, flat and readable) ------
+async def fetchProfile(int userId) {
+ def name = await Awaitable.of("User-${userId}")
+ def profile = await Awaitable.of("${name}: premium")
+ return profile.toUpperCase()
+}
+
+assert await(fetchProfile(42)) == "USER-42: PREMIUM"
+// end::motivation_async_await_clean[]
+ '''
+ }
+
+ @Test
+ void testMotivationExceptionHandling() {
+ assertScript '''
+// tag::motivation_exception_handling[]
+import groovy.concurrent.Awaitable
+
+async def riskyOperation() {
+ throw new IOException("network timeout")
+}
+
+// Exception handling works exactly like synchronous code
+try {
+ await riskyOperation()
+ assert false : "should not reach here"
+} catch (IOException e) {
+ assert e.message == "network timeout" // <1>
+}
+// end::motivation_exception_handling[]
+ '''
+ }
+
+ @Test
+ void testMotivationStreamProcessing() {
+ assertScript '''
+// tag::motivation_stream_processing[]
+import groovy.concurrent.Awaitable
+
+// Async generator produces values on-demand with back-pressure
+async def fibonacci(int count) {
+ long a = 0, b = 1
+ for (int i = 0; i < count; i++) {
+ yield return a
+ (a, b) = [b, a + b]
+ }
+}
+
+def results = []
+for await (num in fibonacci(8)) {
+ results << num
+}
+assert results == [0, 1, 1, 2, 3, 5, 8, 13]
+// end::motivation_stream_processing[]
+ '''
+ }
+
+ @Test
+ void testMotivationInteropCF() {
+ assertScript '''
+// tag::motivation_interop[]
+import java.util.concurrent.CompletableFuture
+import groovy.concurrent.Awaitable
+
+// Groovy's await understands CompletableFuture, CompletionStage,
+// Future, and Flow.Publisher out of the box — no conversion needed
+async def mixedSources() {
+ def fromCF = await CompletableFuture.supplyAsync { "cf" }
+ def fromAwaitable = await Awaitable.of("awaitable")
+ return "${fromCF}+${fromAwaitable}"
+}
+
+assert await(mixedSources()) == "cf+awaitable"
+// end::motivation_interop[]
+ '''
+ }
+}
diff --git a/src/test/groovy/org/apache/groovy/runtime/async/AsyncConcurrencyTest.groovy b/src/test/groovy/org/apache/groovy/runtime/async/AsyncConcurrencyTest.groovy
new file mode 100644
index 0000000000..7e74e0bf82
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/runtime/async/AsyncConcurrencyTest.groovy
@@ -0,0 +1,539 @@
+/*
+ * 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.Test
+import org.junit.jupiter.api.Timeout
+import org.junit.jupiter.api.DisplayName
+
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.CyclicBarrier
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Thread-safety verification tests for AsyncStreamGenerator.
+ * These tests verify that the generator correctly handles concurrent access patterns
+ * and detects unsafe operations before they cause deadlocks or data corruption.
+ *
+ * @since 6.0.0
+ */
+@DisplayName("AsyncStreamGenerator Concurrency Tests")
+class AsyncConcurrencyTest {
+
+ /**
+ * Two threads calling moveNext() concurrently on the same generator.
+ *
+ * Expected behavior: Should fail fast (IllegalStateException or similar),
+ * NOT deadlock one thread or overwrite consumer reference.
+ */
+ @Test
+ @Timeout(5)
+ @DisplayName("Concurrent moveNext() calls should fail fast, not deadlock")
+ void testConcurrentMoveNext_ShouldFailFast() {
+ AsyncStreamGenerator<Integer> gen = new AsyncStreamGenerator<>()
+
+ // Set up producer to emit values
+ Thread producer = Thread.start {
+ gen.attachProducer(Thread.currentThread())
+ try {
+ gen.yield(1)
+ gen.yield(2)
+ gen.yield(3)
+ gen.complete()
+ } finally {
+ gen.detachProducer(Thread.currentThread())
+ }
+ }
+
+ CyclicBarrier barrier = new CyclicBarrier(2)
+ AtomicReference<Exception> error1 = new AtomicReference<>(null)
+ AtomicReference<Exception> error2 = new AtomicReference<>(null)
+
+ Thread consumer1 = Thread.start {
+ try {
+ barrier.await(5, TimeUnit.SECONDS) // Synchronize both consumers
+ gen.moveNext()
+ } catch (Exception e) {
+ error1.set(e)
+ }
+ }
+
+ Thread consumer2 = Thread.start {
+ try {
+ barrier.await(5, TimeUnit.SECONDS) // Synchronize both consumers
+ gen.moveNext()
+ } catch (Exception e) {
+ error2.set(e)
+ }
+ }
+
+ // Wait for both consumers to complete (or error)
+ consumer1.join(3000)
+ consumer2.join(3000)
+ producer.join(3000)
+
+ // Verify that both consumers should not be alive (no deadlock) - CRITICAL
+ assert !consumer1.isAlive() : "Consumer 1 should not deadlock"
+ assert !consumer2.isAlive() : "Consumer 2 should not deadlock"
+
+ // Verify we're testing the right thing: if one got an error, it should be IllegalStateException
+ if (error1.get() != null) {
+ assert error1.get() instanceof IllegalStateException : \
+ "Consumer 1 error should be IllegalStateException, got: ${error1.get()?.class?.simpleName}"
+ }
+ if (error2.get() != null) {
+ assert error2.get() instanceof IllegalStateException : \
+ "Consumer 2 error should be IllegalStateException, got: ${error2.get()?.class?.simpleName}"
+ }
+ }
+
+ /**
+ * Interrupt the producer thread while blocked in yield().
+ *
+ * Expected behavior: Consumer should receive signal or complete,
+ * NOT hang.
+ */
+ @Test
+ @Timeout(5)
+ @DisplayName("Interrupted producer thread should signal consumer, not hang")
+ void testInterruptedProducer_ShouldSignalConsumer() {
+ AsyncStreamGenerator<Integer> gen = new AsyncStreamGenerator<>()
+
+ CountDownLatch producerBlocked = new CountDownLatch(1)
+ CountDownLatch consumerStarted = new CountDownLatch(1)
+ AtomicReference<Exception> consumerError = new AtomicReference<>(null)
+
+ // Producer yields one value, then blocks on the second yield
+ Thread producer = Thread.start {
+ gen.attachProducer(Thread.currentThread())
+ try {
+ gen.yield(1)
+ producerBlocked.countDown() // Signal that we're about to block
+ Thread.sleep(100) // Small delay to ensure consumer reads the first value
+ gen.yield(2) // This will block indefinitely without a consumer
+ gen.complete()
+ } catch (Exception e) {
+ // Producer may receive InterruptedException wrapped in CancellationException
+ consumerError.set(e)
+ } finally {
+ gen.detachProducer(Thread.currentThread())
+ }
+ }
+
+ // Consumer reads values until stream ends
+ Thread consumer = Thread.start {
+ try {
+ consumerStarted.countDown()
+ // Try to read all values from the stream
+ while (gen.moveNext().get()) {
+ gen.getCurrent()
+ }
+ } catch (Exception e) {
+ // Consumer may get an exception
+ consumerError.set(e)
+ }
+ }
+
+ // Wait for producer to be blocked in yield()
+ producerBlocked.await(2, TimeUnit.SECONDS)
+ consumerStarted.await(2, TimeUnit.SECONDS)
+
+ Thread.sleep(200) // Ensure producer is blocked
+
+ // Interrupt producer to simulate external interruption
+ producer.interrupt()
+
+ // Give threads time to react
+ producer.join(3000)
+ consumer.join(3000)
+
+ // Verify consumer didn't hang - this is the main assertion
+ assert !consumer.isAlive() : "Consumer should not hang after producer interruption"
+
+ // Verify producer also terminated
+ assert !producer.isAlive() : "Producer should be interrupted and cleanup"
+ }
+
+ /**
+ * Early exit from consumer (simulating break in for-await loop).
+ *
+ * Expected behavior: Producer should be unblocked via interrupt and cleanup.
+ */
+ @Test
+ @Timeout(5)
+ @DisplayName("Early consumer exit should cleanup producer thread")
+ void testEarlyConsumerExit_ShouldCleanupProducer() {
+ AsyncStreamGenerator<Integer> gen = new AsyncStreamGenerator<>()
+
+ CountDownLatch producerStarted = new CountDownLatch(1)
+ CountDownLatch producerBlocked = new CountDownLatch(1)
+
+ Thread producer = Thread.start {
+ gen.attachProducer(Thread.currentThread())
+ producerStarted.countDown()
+ try {
+ for (int i = 1; i <= 10; i++) {
+ if (i == 5) {
+ producerBlocked.countDown() // Signal we're about to block on item 5
+ }
+ gen.yield(i)
+ }
+ gen.complete()
+ } catch (Exception e) {
+ // Expected: InterruptedException wrapped in CancellationException
+ // Producer received expected exception
+ } finally {
+ gen.detachProducer(Thread.currentThread())
+ }
+ }
+
+ // Consumer exits early after reading first 2 values
+ Thread consumer = Thread.start {
+ try {
+ gen.moveNext() // Read 1
+ gen.getCurrent()
+ gen.moveNext() // Read 2
+ gen.getCurrent()
+ // Exit early - should trigger cleanup of producer
+ gen.close()
+ } catch (Exception e) {
+ // Consumer error during close
+ }
+ }
+
+ consumer.join(3000)
+ producerStarted.await(2, TimeUnit.SECONDS)
+
+ // If producer reaches blocked state, give it a moment
+ producerBlocked.await(3, TimeUnit.SECONDS)
+ Thread.sleep(200)
+
+ producer.join(3000)
+
+ // Verify producer is not still running
+ assert !producer.isAlive() : "Producer should be interrupted and cleanup when consumer closes"
+ }
+
+ /**
+ * Multiple close() calls should be idempotent and safe.
+ */
+ @Test
+ @Timeout(5)
+ @DisplayName("Multiple close() calls should be safe and idempotent")
+ void testMultipleClose_ShouldBeIdempotent() {
+ AsyncStreamGenerator<Integer> gen = new AsyncStreamGenerator<>()
+
+ Thread producer = Thread.start {
+ gen.attachProducer(Thread.currentThread())
+ try {
+ gen.yield(1)
+ gen.complete()
+ } finally {
+ gen.detachProducer(Thread.currentThread())
+ }
+ }
+
+ // Multiple threads closing simultaneously
+ List<Thread> closers = (1..5).collect { idx ->
+ Thread.start {
+ try {
+ gen.close()
+ } catch (Exception e) {
+ // Close error
+ }
+ }
+ }
+
+ producer.join(3000)
+ closers.each { it.join(3000) }
+
+ closers.each { assert !it.isAlive() : "Close threads should not hang" }
+ }
+
+ /**
+ * Producer completing vs consumer exiting - race condition test.
+ *
+ * Expected behavior: Both should complete cleanly without deadlock.
+ */
+ @Test
+ @Timeout(5)
+ @DisplayName("Producer completion and consumer exit should not deadlock")
+ void testProducerCompletionVsConsumerExit_NoDeadlock() {
+ for (int iteration = 0; iteration < 10; iteration++) {
+ AsyncStreamGenerator<Integer> gen = new AsyncStreamGenerator<>()
+
+ Thread producer = Thread.start {
+ gen.attachProducer(Thread.currentThread())
+ try {
+ gen.yield(1)
+ gen.yield(2)
+ gen.complete()
+ } finally {
+ gen.detachProducer(Thread.currentThread())
+ }
+ }
+
+ Thread consumer = Thread.start {
+ try {
+ gen.moveNext()
+ gen.getCurrent()
+ gen.moveNext()
+ gen.getCurrent()
+ gen.moveNext()
+ } catch (Exception e) {
+ // Consumer exception
+ }
+ }
+
+ producer.join(3000)
+ consumer.join(3000)
+
+ assert !producer.isAlive() : "Iteration $iteration: Producer deadlocked"
+ assert !consumer.isAlive() : "Iteration $iteration: Consumer deadlocked"
+ }
+ }
+
+ // =================================================================
+ // moveNext() branch coverage tests
+ // =================================================================
+
+ /**
+ * Branch: same thread re-entry.
+ *
+ * The CAS guard in moveNext() allows the same thread to call moveNext()
+ * multiple times sequentially (the expected usage pattern). This test
+ * explicitly verifies that no ISE is thrown when the same thread drives
+ * multiple iterations.
+ */
+ @Test
+ @Timeout(5)
+ @DisplayName("Same thread can call moveNext() sequentially without ISE")
+ void testSameThreadSequentialMoveNext() {
+ def gen = new AsyncStreamGenerator<Integer>()
+ Thread.start {
+ gen.yield(1)
+ gen.yield(2)
+ gen.yield(3)
+ gen.complete()
+ }
+
+ def items = []
+ // All three moveNext() + getCurrent() calls happen on the SAME thread.
+ while (gen.moveNext().get()) {
+ items << gen.getCurrent()
+ }
+ assert items == [1, 2, 3]
+ }
+
+ /**
+ * Branch: InterruptedException caught while closed flag is true.
+ *
+ * When moveNext() is blocked in queue.take() and close() fires (setting
+ * closed=true and interrupting the consumer), the InterruptedException
+ * handler checks closed.get(). If true, it returns Awaitable.of(false)
+ * instead of throwing CancellationException.
+ *
+ * This test deterministically triggers this branch by:
+ * 1. Starting moveNext() which blocks on the empty queue
+ * 2. Calling close() from another thread which sets closed=true and
+ * interrupts the consumer
+ * 3. Verifying that moveNext() returns false (not an exception)
+ */
+ @Test
+ @Timeout(5)
+ @DisplayName("moveNext() returns false when interrupted after close()")
+ void testMoveNextInterruptedWhileClosed() {
+ def gen = new AsyncStreamGenerator<Integer>()
+ def moveNextResult = new AtomicReference<Boolean>()
+ def moveNextError = new AtomicReference<Throwable>()
+ def consumerStarted = new CountDownLatch(1)
+
+ def consumer = Thread.start {
+ try {
+ consumerStarted.countDown()
+ // This will block because no producer is yielding anything
+ def result = gen.moveNext().get()
+ moveNextResult.set(result)
+ } catch (Throwable t) {
+ moveNextError.set(t)
+ }
+ }
+
+ // Wait for consumer to start blocking in moveNext()
+ consumerStarted.await(2, TimeUnit.SECONDS)
+ Thread.sleep(50) // Brief pause to ensure queue.take() is entered
+
+ // close() sets closed=true and interrupts the consumer thread
+ gen.close()
+
+ consumer.join(3000)
+ assert !consumer.isAlive() : "Consumer should have exited"
+
+ // The IE handler should detect closed=true and return false
+ assert moveNextResult.get() == false : "Expected false from moveNext() after close()"
+ assert moveNextError.get() == null : "Expected no exception, got: ${moveNextError.get()}"
+ }
+
+ /**
+ * Branch: double-check after CAS detects close() race.
+ *
+ * If close() fires between the initial closed.get() check and the
+ * consumerThread CAS, the consumer reference is not yet visible to close().
+ * The double-check after CAS detects this and returns false immediately
+ * without entering queue.take().
+ *
+ * This test uses a CyclicBarrier to synchronize close() and moveNext()
+ * on the same timing boundary. We run multiple iterations because the
+ * exact race window is non-deterministic.
+ */
+ @Test
+ @Timeout(10)
+ @DisplayName("Double-check after CAS detects close() race")
+ void testDoubleCheckAfterCasDetectsCloseRace() {
+ int detected = 0
+ for (int i = 0; i < 200; i++) {
+ def gen = new AsyncStreamGenerator<Integer>()
+ def barrier = new CyclicBarrier(2)
+ def result = new AtomicReference<Object>()
+
+ def consumer = Thread.start {
+ try {
+ barrier.await(2, TimeUnit.SECONDS)
+ def r = gen.moveNext().get()
+ result.set(r)
+ } catch (Throwable t) {
+ result.set(t)
+ }
+ }
+
+ def closer = Thread.start {
+ try {
+ barrier.await(2, TimeUnit.SECONDS)
+ gen.close()
+ } catch (Throwable t) {
+ // ignore
+ }
+ }
+
+ consumer.join(3000)
+ closer.join(3000)
+
+ def r = result.get()
+ // Either:
+ // 1. moveNext() returns false (caught by initial check OR double-check)
+ // 2. moveNext() throws CancellationException (interrupted in take())
+ // Both are valid outcomes of the race — but it must NEVER hang.
+ if (r == false || r == Boolean.FALSE) {
+ detected++
+ }
+ }
+ // At least some iterations should hit the false-return path
+ assert detected > 0 : "Expected at least one iteration where moveNext() returned false"
+ }
+
+ /**
+ * Branch: moveNext() on already-closed generator returns false immediately.
+ *
+ * After close() is called, all subsequent moveNext() calls should return
+ * Awaitable.of(false) without blocking.
+ */
+ @Test
+ @Timeout(5)
+ @DisplayName("moveNext() after close returns false without blocking")
+ void testMoveNextAfterCloseReturnsFalse() {
+ def gen = new AsyncStreamGenerator<Integer>()
+ gen.close()
+
+ // Should return false immediately (no producer needed)
+ assert gen.moveNext().get() == false
+ assert gen.moveNext().get() == false
+ assert gen.moveNext().get() == false
+ }
+
+ /**
+ * Branch: ErrorItem with Error subclass (thrown directly, not via sneakyThrow).
+ *
+ * When the producer yields an Error (not Exception), moveNext() should
+ * throw it directly as an Error, not wrap it.
+ */
+ @Test
+ @Timeout(5)
+ @DisplayName("moveNext() propagates Error subclass directly")
+ void testMoveNextPropagatesErrorDirectly() {
+ def gen = new AsyncStreamGenerator<Integer>()
+ Thread.start {
+ gen.error(new StackOverflowError("test error"))
+ }
+
+ try {
+ gen.moveNext().get()
+ assert false : "Expected StackOverflowError"
+ } catch (StackOverflowError e) {
+ assert e.message == "test error"
+ }
+ }
+
+ /**
+ * Branch: ErrorItem with Exception (thrown via sneakyThrow).
+ *
+ * When the producer yields a checked Exception, moveNext() should throw
+ * it via sneakyThrow (bypassing checked exception compiler enforcement).
+ */
+ @Test
+ @Timeout(5)
+ @DisplayName("moveNext() propagates Exception via sneakyThrow")
+ void testMoveNextPropagatesExceptionViaSneakyThrow() {
+ def gen = new AsyncStreamGenerator<Integer>()
+ Thread.start {
+ gen.error(new java.io.IOException("disk fail"))
+ }
+
+ try {
+ gen.moveNext().get()
+ assert false : "Expected IOException"
+ } catch (java.io.IOException e) {
+ assert e.message == "disk fail"
+ }
+ }
+
+ /**
+ * Branch: DONE sentinel marks stream exhausted.
+ *
+ * After the producer calls complete(), the next moveNext() should
+ * receive the DONE sentinel and return false.
+ */
+ @Test
+ @Timeout(5)
+ @DisplayName("moveNext() returns false on DONE sentinel after complete()")
+ void testMoveNextReturnsFalseOnDoneSentinel() {
+ def gen = new AsyncStreamGenerator<Integer>()
+ Thread.start {
+ gen.yield(42)
+ gen.complete()
+ }
+
+ assert gen.moveNext().get() == true
+ assert gen.getCurrent() == 42
+ assert gen.moveNext().get() == false
+ // Subsequent calls should also return false (closed state)
+ assert gen.moveNext().get() == false
+ }
+}
diff --git a/src/test/groovy/org/codehaus/groovy/transform/AsyncApiTest.groovy b/src/test/groovy/org/codehaus/groovy/transform/AsyncApiTest.groovy
new file mode 100644
index 0000000000..07072bccbb
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/transform/AsyncApiTest.groovy
@@ -0,0 +1,2461 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.codehaus.groovy.transform
+
+import groovy.concurrent.AsyncStream
+import groovy.concurrent.AwaitResult
+import groovy.concurrent.Awaitable
+import groovy.concurrent.AwaitableAdapter
+import groovy.concurrent.AwaitableAdapterRegistry
+import org.apache.groovy.runtime.async.AsyncStreamGenerator
+import org.apache.groovy.runtime.async.AsyncSupport
+import org.apache.groovy.runtime.async.GroovyPromise
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+
+import java.util.concurrent.Callable
+import java.util.concurrent.CancellationException
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.CompletionException
+import java.util.concurrent.CompletionStage
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.Flow
+import java.util.concurrent.FutureTask
+import java.util.concurrent.SubmissionPublisher
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Pure API verification tests for Groovy's async/await runtime classes.
+ * <p>
+ * Every test in this class exercises the public API of {@code AsyncSupport},
+ * {@code AsyncStreamGenerator}, {@code GroovyPromise}, {@code Awaitable},
+ * {@code AwaitResult}, and {@code AwaitableAdapterRegistry} directly —
+ * without {@code assertScript} or compiler-driven async transformation.
+ * <p>
+ * Pure API surface verification that runs independently of the AST
+ * transformation tests.
+ */
+class AsyncApiTest {
+
+ @AfterEach
+ void cleanup() {
+ Awaitable.setExecutor(null)
+ }
+
+ // ================================================================
+ // AsyncSupport.closeStream
+ // ================================================================
+
+ @Test
+ void testCloseStreamHelperClosesStream() {
+ AtomicBoolean closed = new AtomicBoolean(false)
+ AsyncSupport.closeStream(new AsyncStream<String>() {
+ @Override
+ Awaitable<Boolean> moveNext() {
+ Awaitable.of(false)
+ }
+
+ @Override
+ String getCurrent() {
+ null
+ }
+
+ @Override
+ void close() {
+ closed.set(true)
+ }
+ })
+ assert closed.get()
+ AsyncSupport.closeStream(null)
+ }
+
+ // ================================================================
+ // AsyncSupport.await overloads
+ // ================================================================
+
+ @Test
+ void testAwaitAwaitableInterruptedThrowsCancellation() {
+ def cf = new CompletableFuture<String>()
+ def awaitable = new GroovyPromise<String>(cf)
+ Thread.currentThread().interrupt()
+ try {
+ AsyncSupport.await(awaitable)
+ assert false : 'Should have thrown'
+ } catch (CancellationException e) {
+ assert e.message.contains('Interrupted')
+ assert e.cause instanceof InterruptedException
+ } finally {
+ Thread.interrupted() // clear interrupt flag
+ cf.cancel(true)
+ }
+ }
+
+ @Test
+ void testAwaitCancelledCompletableFuture() {
+ def cf = new CompletableFuture<String>()
+ cf.cancel(true)
+ try {
+ AsyncSupport.await(cf)
+ assert false
+ } catch (CancellationException ignored) {
+ // expected — covers line 217-218
+ }
+ }
+
+ @Test
+ void testAwaitExceptionalCompletableFuture() {
+ def cf = new CompletableFuture<String>()
+ cf.completeExceptionally(new IOException('test-io'))
+ try {
+ AsyncSupport.await(cf)
+ assert false
+ } catch (IOException e) {
+ assert e.message == 'test-io'
+ }
+ }
+
+ @Test
+ void testAwaitCompletionStage() {
+ // Create a pure CompletionStage wrapper so Groovy dispatches to await(CompletionStage)
+ def cf = CompletableFuture.completedFuture('from-stage')
+ CompletionStage<String> stage = new CompletionStage<String>() {
+ @Delegate CompletionStage<String> delegate = cf
+ }
+ assert AsyncSupport.await(stage) == 'from-stage'
+ }
+
+ @Test
+ void testAwaitPlainFutureSuccess() {
+ def ft = new FutureTask<String>({ 'hello' } as Callable<String>)
+ ft.run()
+ assert AsyncSupport.await((java.util.concurrent.Future<String>) ft) == 'hello'
+ }
+
+ @Test
+ void testAwaitPlainFutureFailed() {
+ def ft = new FutureTask<String>({ throw new IOException('future-error') } as Callable<String>)
+ ft.run()
+ try {
+ AsyncSupport.await((java.util.concurrent.Future<String>) ft)
+ assert false
+ } catch (IOException e) {
+ assert e.message == 'future-error'
+ }
+ }
+
+ @Test
+ void testAwaitPlainFutureCancelled() {
+ def ft = new FutureTask<String>({ 'never' } as Callable<String>)
+ ft.cancel(true)
+ try {
+ AsyncSupport.await((java.util.concurrent.Future<String>) ft)
+ assert false
+ } catch (CancellationException ignored) {}
+ }
+
+ @Test
+ void testAwaitPlainFutureInterrupted() {
+ // FutureTask that blocks, allowing interrupt to fire
+ def ft = new FutureTask<String>({
+ Thread.sleep(60_000)
+ 'never'
+ } as Callable<String>)
+ // Don't run it — it will block on get(), and we set interrupt
+ Thread.currentThread().interrupt()
+ try {
+ AsyncSupport.await((java.util.concurrent.Future<String>) ft)
+ assert false
+ } catch (CancellationException e) {
+ assert e.message.contains('Interrupted')
+ assert e.cause instanceof InterruptedException
+ } finally {
+ Thread.interrupted()
+ }
+ }
+
+ @Test
+ void testAwaitFutureWithCompletableFuture() {
+ // Pass CF as Future to hit line 245: instanceof CompletableFuture shortcut
+ def cf = CompletableFuture.completedFuture('via-cf')
+ assert AsyncSupport.await((java.util.concurrent.Future<String>) cf) == 'via-cf'
+ }
+
+ @Test
+ void testAwaitObjectNull() {
+ assert AsyncSupport.await((Object) null) == null
+ }
+
+ @Test
+ void testAwaitObjectAwaitable() {
+ assert AsyncSupport.await((Object) Awaitable.of('aw')) == 'aw'
+ }
+
+ @Test
+ void testAwaitObjectCompletableFuture() {
+ assert AsyncSupport.await((Object) CompletableFuture.completedFuture('cf')) == 'cf'
+ }
+
+ @Test
+ void testAwaitObjectCompletionStage() {
+ // Wrap so it's NOT instanceof CompletableFuture — hits line 277
+ def cf = CompletableFuture.completedFuture('cs!')
+ CompletionStage<String> stage = new CompletionStage<String>() {
+ @Delegate CompletionStage<String> delegate = cf
+ }
+ assert AsyncSupport.await((Object) stage) == 'cs!'
+ }
+
+ @Test
+ void testAwaitObjectPlainFuture() {
+ def ft = new FutureTask<String>({ 'ft' } as Callable<String>)
+ ft.run()
+ assert AsyncSupport.await((Object) ft) == 'ft'
+ }
+
+ @Test
+ void testAwaitObjectClosureThrows() {
+ try {
+ AsyncSupport.await((Object) { 42 })
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('Cannot await a Closure')
+ assert e.message.contains('myClosure()')
+ }
+ }
+
+ @Test
+ void testAwaitObjectViaAdapter() {
+ // SubmissionPublisher is a Flow.Publisher — hits adapter fallback
+ def pub = new SubmissionPublisher<String>()
+ Thread.start {
+ Thread.sleep(100)
+ pub.submit('adapted')
+ pub.close()
+ }
+ assert AsyncSupport.await((Object) pub) == 'adapted'
+ }
+
+ @Test
+ void testAwaitAwaitableExecutionException() {
+ // Create Awaitable whose get() throws the exception directly
+ def cf = new CompletableFuture<String>()
+ cf.completeExceptionally(new IOException('wrapped'))
+ def aw = new GroovyPromise<String>(cf)
+ try {
+ AsyncSupport.await(aw)
+ assert false
+ } catch (IOException e) {
+ assert e.message == 'wrapped'
+ }
+ }
+
+ @Test
+ void testAwaitCompletableFutureWithWrappedException() {
+ def cf = new CompletableFuture<String>()
+ cf.completeExceptionally(new ArithmeticException('math'))
+ try {
+ AsyncSupport.await(cf)
+ assert false
+ } catch (ArithmeticException e) {
+ assert e.message == 'math'
+ }
+ }
+
+ @Test
+ void testAwaitPlainFutureExecutionException() {
+ def ft = new FutureTask<String>({ throw new ArithmeticException('div-zero') } as Callable<String>)
+ ft.run()
+ try {
+ AsyncSupport.await((java.util.concurrent.Future<String>) ft)
+ assert false
+ } catch (ArithmeticException e) {
+ assert e.message == 'div-zero'
+ }
+ }
+
+ @Test
+ void testAwaitClosureDirectlyThrowsIllegalArgument() {
+ def ex = shouldFail(IllegalArgumentException) {
+ AsyncSupport.await((Object) { -> 42 })
+ }
+ assert ex.message.contains('Cannot await a Closure directly')
+ assert ex.message.contains('Call the closure first')
+ }
+
+ @Test
+ void testAwaitRawFutureNonCompletableFuture() {
+ // Use a FutureTask (implements Future but is not CompletableFuture)
+ def ft = new FutureTask<>({ 'raw-future-result' } as Callable)
+ Thread.start { ft.run() }
+ def result = AsyncSupport.await(ft)
+ assert result == 'raw-future-result'
+ }
+
+ @Test
+ void testAwaitRawFutureExecutionException() {
+ def ft = new FutureTask<>({
+ throw new IOException('raw future error')
+ } as Callable)
+ Thread.start { ft.run() }
+ def ex = shouldFail(IOException) {
+ AsyncSupport.await(ft)
+ }
+ assert ex.message == 'raw future error'
+ }
+
+ @Test
+ void testAwaitRawFutureCancellation() {
+ def ft = new FutureTask<>({ Thread.sleep(10000); 'never' } as Callable)
+ Thread.start { ft.run() }
+ ft.cancel(true)
+ shouldFail(CancellationException) {
+ AsyncSupport.await(ft)
+ }
+ }
+
+ @Test
+ void testAwaitAllWithFlowPublisher() {
+ // SubmissionPublisher goes through adapter fallback in toCompletableFuture
+ def pub = new SubmissionPublisher<String>()
+ Thread.start {
+ Thread.sleep(100)
+ pub.submit('pub-val')
+ pub.close()
+ }
+ def results = AsyncSupport.awaitAll(Awaitable.of('aw'), pub)
+ assert results == ['aw', 'pub-val']
+ }
+
+ @Test
+ void testAwaitAllWithCompletionStage() {
+ // Blocking awaitAll with pure CompletionStage — hits toCompletableFuture line 534
+ def cf = CompletableFuture.completedFuture('val')
+ CompletionStage<String> stage = new CompletionStage<String>() {
+ @Delegate CompletionStage<String> delegate = cf
+ }
+ def results = AsyncSupport.awaitAll(stage)
+ assert results == ['val']
+ }
+
+ // ================================================================
+ // AsyncSupport.async / executeAsyncVoid
+ // ================================================================
+
+ @Test
+ void testAsyncClosure() {
+ Awaitable<Integer> aw = AsyncSupport.async({ 42 + 8 })
+ assert aw.get() == 50
+ }
+
+ @Test
+ void testExecuteAsyncVoidSuccess() {
+ def flag = new CompletableFuture<Boolean>()
+ Awaitable<Void> aw = AsyncSupport.executeAsyncVoid({ flag.complete(true) }, AsyncSupport.getExecutor())
+ aw.get()
+ assert flag.get() == true
+ }
+
+ @Test
+ void testExecuteAsyncVoidError() {
+ Awaitable<Void> aw = AsyncSupport.executeAsyncVoid(
+ { throw new RuntimeException('boom') }, AsyncSupport.getExecutor())
+ try {
+ aw.get()
+ assert false
+ } catch (ExecutionException e) {
+ assert e.cause instanceof RuntimeException
+ assert e.cause.message == 'boom'
+ }
+ }
+
+ // ================================================================
+ // Combinators: awaitAll / awaitAny / awaitAllSettled
+ // ================================================================
+
+ @Test
+ void testAwaitAllHappyPath() {
+ def results = AsyncSupport.awaitAll(Awaitable.of('a'), Awaitable.of('b'))
+ assert results == ['a', 'b']
+ }
+
+ @Test
+ void testAwaitAllEmpty() {
+ assert AsyncSupport.awaitAll() == []
+ }
+
+ @Test
+ void testAwaitAllNullArray() {
+ assert AsyncSupport.awaitAll(null as Object[]) == []
+ }
+
+ @Test
+ void testAwaitAllNullElement() {
+ try {
+ AsyncSupport.awaitAll(Awaitable.of(1), null)
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('index 1')
+ }
+ }
+
+ @Test
+ void testAwaitAllWithFailure() {
+ try {
+ AsyncSupport.awaitAll(Awaitable.of('ok'), Awaitable.failed(new IOException('fail')))
+ assert false
+ } catch (IOException e) {
+ assert e.message == 'fail'
+ }
+ }
+
+ @Test
+ void testAwaitAnyHappyPath() {
+ def result = AsyncSupport.awaitAny(Awaitable.of('first'), Awaitable.delay(5000))
+ assert result == 'first'
+ }
+
+ @Test
+ void testAwaitAnyEmpty() {
+ try {
+ AsyncSupport.awaitAny()
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('at least one')
+ }
+ }
+
+ @Test
+ void testAwaitAnyNullElement() {
+ try {
+ AsyncSupport.awaitAny(Awaitable.of(1), null)
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('index 1')
+ }
+ }
+
+ @Test
+ void testAwaitAnyWithFailure() {
+ try {
+ AsyncSupport.awaitAny(Awaitable.failed(new ArithmeticException('math')))
+ assert false
+ } catch (ArithmeticException e) {
+ assert e.message == 'math'
+ }
+ }
+
+ @Test
+ void testAwaitAllSettledMixed() {
+ def results = AsyncSupport.awaitAllSettled(
+ Awaitable.of('good'),
+ Awaitable.failed(new IOException('bad'))
+ )
+ assert results.size() == 2
+ assert results[0].isSuccess()
+ assert results[0].value == 'good'
+ assert results[1].isFailure()
+ assert results[1].error instanceof IOException
+ }
+
+ @Test
+ void testAwaitAllSettledEmpty() {
+ assert AsyncSupport.awaitAllSettled() == []
+ }
+
+ @Test
+ void testAwaitAllSettledNullArray() {
+ assert AsyncSupport.awaitAllSettled(null as Object[]) == []
+ }
+
+ @Test
+ void testAwaitAllSettledNullElement() {
+ try {
+ AsyncSupport.awaitAllSettled(Awaitable.of(1), null)
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('index 1')
+ }
+ }
+
+ @Test
+ void testAwaitAllSettledWithCancellation() {
+ def cf = new CompletableFuture<String>()
+ cf.cancel(true)
+ def results = AsyncSupport.awaitAllSettled(Awaitable.of('ok'), cf)
+ assert results.size() == 2
+ assert results[0].isSuccess()
+ assert results[1].isFailure()
+ assert results[1].error instanceof CancellationException
+ }
+
+ // ================================================================
+ // Async combinators: anyAsync / allSettledAsync / allAsync
+ // ================================================================
+
+ @Test
+ void testAnyAsyncNullElement() {
+ try {
+ AsyncSupport.anyAsync(Awaitable.of(1), null)
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('index 1')
+ }
+ }
+
+ @Test
+ void testAnyAsyncEmptyThrows() {
+ try {
+ AsyncSupport.anyAsync()
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('at least one')
+ }
+ }
+
+ @Test
+ void testAllSettledAsyncNullElement() {
+ try {
+ AsyncSupport.allSettledAsync(Awaitable.of(1), null)
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('index 1')
+ }
+ }
+
+ @Test
+ void testAllSettledAsyncEmpty() {
+ def aw = AsyncSupport.allSettledAsync()
+ assert aw.get() == []
+ }
+
+ @Test
+ void testAllSettledAsyncMixed() {
+ def cf = new CompletableFuture<String>()
+ cf.cancel(true)
+ def aw = AsyncSupport.allSettledAsync(Awaitable.of('ok'), cf)
+ def results = aw.get()
+ assert results[0].isSuccess()
+ assert results[0].value == 'ok'
+ assert results[1].isFailure()
+ assert results[1].error instanceof CancellationException
+ }
+
+ @Test
+ void testAllAsyncHappyPath() {
+ def aw = AsyncSupport.allAsync(Awaitable.of('a'), Awaitable.of('b'))
+ assert aw.get() == ['a', 'b']
+ }
+
+ @Test
+ void testAllAsyncEmpty() {
+ def aw = AsyncSupport.allAsync()
+ assert aw.get() == []
+ }
+
+ @Test
+ void testAllAsyncNullElement() {
+ try {
+ AsyncSupport.allAsync(Awaitable.of(1), null)
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('index 1')
+ }
+ }
+
+ @Test
+ void testAllAsyncWithCompletionStage() {
+ // Pure CompletionStage wrapper — hits toCompletableFuture line 534
+ def cf = CompletableFuture.completedFuture('cs')
+ CompletionStage<String> stage = new CompletionStage<String>() {
+ @Delegate CompletionStage<String> delegate = cf
+ }
+ def aw = AsyncSupport.allAsync(stage, Awaitable.of('aw'))
+ assert aw.get() == ['cs', 'aw']
+ }
+
+ // ================================================================
+ // AsyncSupport.delay
+ // ================================================================
+
+ @Test
+ void testDelayNullUnit() {
+ try {
+ AsyncSupport.delay(1, null)
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('null')
+ }
+ }
+
+ @Test
+ void testDelayNegative() {
+ try {
+ AsyncSupport.delay(-1)
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('negative')
+ }
+ }
+
+ @Test
+ void testDelayZero() {
+ def aw = AsyncSupport.delay(0, TimeUnit.MILLISECONDS)
+ assert aw.get() == null
+ }
+
+ @Test
+ void testDelayPositive() {
+ def aw = AsyncSupport.delay(10, TimeUnit.MILLISECONDS)
+ assert aw.get() == null
+ }
+
+ @Test
+ void testDelayZeroDurationReturnsImmediately() {
+ long start = System.nanoTime()
+ def result = AsyncSupport.delay(0, TimeUnit.MILLISECONDS)
+ assert result.get() == null
+ long elapsed = (System.nanoTime() - start) / 1_000_000
+ assert elapsed < 500 // should be nearly instant
+ }
+
+ @Test
+ void testDelayNullUnitThrows() {
+ def ex = shouldFail(IllegalArgumentException) {
+ AsyncSupport.delay(100, null)
+ }
+ assert ex.message.contains('TimeUnit must not be null')
+ }
+
+ @Test
+ void testDelayNegativeDurationThrows() {
+ def ex = shouldFail(IllegalArgumentException) {
+ AsyncSupport.delay(-1, TimeUnit.MILLISECONDS)
+ }
+ assert ex.message.contains('must not be negative')
+ }
+
+ // ================================================================
+ // AsyncSupport.yieldReturn
+ // ================================================================
+
+ @Test
+ void testYieldReturnNullGenerator() {
+ try {
+ AsyncSupport.yieldReturn(null, 'x')
+ assert false
+ } catch (IllegalStateException e) {
+ assert e.message.contains('outside')
+ }
+ }
+
+ @Test
+ void testYieldReturnOutsideContext() {
+ try {
+ AsyncSupport.yieldReturn('value')
+ assert false
+ } catch (IllegalStateException e) {
+ assert e.message.contains('yield return')
+ }
+ }
+
+ @Test
+ void testYieldReturnWithValidGenerator() {
+ def gen = new AsyncStreamGenerator<String>()
+ // yield blocks until consumer takes, so run in background
+ Thread.start { AsyncSupport.yieldReturn(gen, 'hello') ; gen.complete() }
+ assert gen.moveNext().get() == true
+ assert gen.getCurrent() == 'hello'
+ assert gen.moveNext().get() == false
+ }
+
+ // ================================================================
+ // AsyncSupport.defer / executeDeferScope
+ // ================================================================
+
+ @Test
+ void testDeferNullScope() {
+ try {
+ AsyncSupport.defer(null, { println 'cleanup' })
+ assert false
+ } catch (IllegalStateException e) {
+ assert e.message.contains('async method')
+ }
+ }
+
+ @Test
+ void testDeferNullAction() {
+ try {
+ AsyncSupport.defer(new ArrayDeque(), null)
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('null')
+ }
+ }
+
+ @Test
+ void testDeferAndExecuteScope() {
+ def scope = AsyncSupport.createDeferScope()
+ def log = []
+ AsyncSupport.defer(scope, { log << 'first' })
+ AsyncSupport.defer(scope, { log << 'second' })
+ AsyncSupport.executeDeferScope(scope)
+ // LIFO order
+ assert log == ['second', 'first']
+ }
+
+ @Test
+ void testExecuteDeferScopeMultipleExceptions() {
+ def scope = AsyncSupport.createDeferScope()
+ scope.push({ throw new IOException('first') })
+ scope.push({ throw new ArithmeticException('second') })
+ try {
+ AsyncSupport.executeDeferScope(scope)
+ assert false
+ } catch (ArithmeticException e) {
+ // second runs first (LIFO)
+ assert e.message == 'second'
+ assert e.suppressed.length == 1
+ assert e.suppressed[0] instanceof IOException
+ assert e.suppressed[0].message == 'first'
+ }
+ }
+
+ @Test
+ void testExecuteDeferScopeNull() {
+ // Should be no-op
+ AsyncSupport.executeDeferScope(null)
+ }
+
+ @Test
+ void testExecuteDeferScopeEmpty() {
+ AsyncSupport.executeDeferScope(new ArrayDeque())
+ }
+
+ // ================================================================
+ // AsyncSupport.rethrowUnwrapped / wrapForFuture / deepUnwrap
+ // ================================================================
+
+ @Test
+ void testRethrowUnwrappedWithError() {
+ def cf = new CompletableFuture<String>()
+ cf.completeExceptionally(new StackOverflowError('soe'))
+ try {
+ AsyncSupport.await(cf)
+ assert false
+ } catch (StackOverflowError e) {
+ assert e.message == 'soe'
+ }
+ }
+
+ @Test
+ void testRethrowUnwrappedWithCheckedException() {
+ def cf = new CompletableFuture<String>()
+ cf.completeExceptionally(new IOException('checked'))
+ try {
+ AsyncSupport.await(cf)
+ assert false
+ } catch (IOException e) {
+ assert e.message == 'checked'
+ }
+ }
+
+ @Test
+ void testWrapForFutureWithCompletionException() {
+ // wrapForFuture returns existing CompletionException as-is
+ def ce = new CompletionException('already-wrapped', null)
+ Awaitable<String> aw = AsyncSupport.executeAsync(
+ { throw ce } as Closure<String>, AsyncSupport.getExecutor())
+ try {
+ aw.get()
+ assert false
+ } catch (ExecutionException e) {
+ // CompletionException(null cause) → ExecutionException wraps the CE itself
+ assert e.cause instanceof CompletionException
+ }
+ }
+
+ @Test
+ void testWrapForFutureWithRegularException() {
+ // wrapForFuture wraps non-CE into CompletionException(original)
+ Awaitable<String> aw = AsyncSupport.executeAsync(
+ { throw new RuntimeException('plain') } as Closure<String>, AsyncSupport.getExecutor())
+ try {
+ aw.get()
+ assert false
+ } catch (ExecutionException e) {
+ // CF.get() wraps CompletionException in ExecutionException; the original is the cause chain
+ assert e.cause instanceof RuntimeException
+ assert e.cause.message == 'plain'
+ }
+ }
+
+ @Test
+ void testDeepUnwrap() {
+ def root = new IOException('root')
+ def wrapped = new CompletionException(new ExecutionException(root))
+ assert AsyncSupport.deepUnwrap(wrapped).is(root)
+ }
+
+ @Test
+ void testDeepUnwrapNoWrapping() {
+ def ex = new RuntimeException('plain')
+ assert AsyncSupport.deepUnwrap(ex).is(ex)
+ }
+
+ @Test
+ void testDeepUnwrapNestedExceptions() {
+ def root = new RuntimeException('root cause')
+ def wrapped = new CompletionException(
+ new ExecutionException(root))
+ assert AsyncSupport.deepUnwrap(wrapped) == root
+ }
+
+ @Test
+ void testDeepUnwrapUnrelatedExceptionNotUnwrapped() {
+ def ex = new IOException('not wrapped')
+ assert AsyncSupport.deepUnwrap(ex) == ex
+ }
+
+ @Test
+ void testRethrowUnwrappedThrowsError() {
+ // Test indirectly via await(Future) — errors are rethrown directly
+ def ft = new FutureTask<>({
+ throw new StackOverflowError('test error')
+ } as Callable)
+ Thread.start { ft.run() }
+ Thread.sleep(50)
+ shouldFail(StackOverflowError) {
+ AsyncSupport.await(ft)
+ }
+ }
+
+ // ================================================================
+ // AsyncStreamGenerator
+ // ================================================================
+
+ @Test
+ void testAsyncStreamGeneratorYieldAndComplete() {
+ def gen = new AsyncStreamGenerator<String>()
+ Thread.start {
+ gen.yield('a')
+ gen.yield('b')
+ gen.complete()
+ }
+ def items = []
+ while (gen.moveNext().get()) {
+ items << gen.getCurrent()
+ }
+ assert items == ['a', 'b']
+ }
+
+ @Test
+ void testAsyncStreamGeneratorErrorItem() {
+ def gen = new AsyncStreamGenerator<String>()
+ Thread.start {
+ gen.yield('ok')
+ gen.error(new IOException('gen-err'))
+ }
+ assert gen.moveNext().get() == true
+ assert gen.getCurrent() == 'ok'
+ try {
+ gen.moveNext()
+ assert false
+ } catch (IOException e) {
+ assert e.message == 'gen-err'
+ }
+ }
+
+ @Test
+ void testAsyncStreamGeneratorErrorWithErrorType() {
+ def gen = new AsyncStreamGenerator<String>()
+ Thread.start {
+ gen.error(new StackOverflowError('test-soe'))
+ }
+ try {
+ gen.moveNext()
+ assert false
+ } catch (StackOverflowError e) {
+ assert e.message == 'test-soe'
+ }
+ }
+
+ @Test
+ void testAsyncStreamGeneratorErrorWithNull() {
+ def gen = new AsyncStreamGenerator<String>()
+ Thread.start {
+ gen.error(null) // should be treated as NullPointerException
+ }
+ try {
+ gen.moveNext()
+ assert false
+ } catch (NullPointerException e) {
+ assert e.message.contains('null error')
+ }
+ }
+
+ @Test
+ void testAsyncStreamGeneratorYieldInterrupted() {
+ def gen = new AsyncStreamGenerator<String>()
+ // Interrupt before yield — the put() will throw InterruptedException
+ def result = new CompletableFuture<String>()
+ Thread.start {
+ Thread.currentThread().interrupt()
+ try {
+ gen.yield('x')
+ result.complete('no-exception')
+ } catch (CancellationException e) {
+ result.complete(e.message)
+ }
+ }
+ assert result.get().contains('Interrupted')
+ }
+
+ @Test
+ void testAsyncStreamGeneratorCompleteInterrupted() {
+ def gen = new AsyncStreamGenerator<String>()
+ // complete() catches InterruptedException and uses offer()
+ def done = new CompletableFuture<Boolean>()
+ Thread.start {
+ Thread.currentThread().interrupt()
+ gen.complete()
+ done.complete(true)
+ }
+ // The complete might use offer, so try to consume; the gen should signal done
+ done.get(5, TimeUnit.SECONDS)
+ }
+
+ @Test
+ void testAsyncStreamGeneratorErrorInterrupted() {
+ def gen = new AsyncStreamGenerator<String>()
+ def done = new CompletableFuture<Boolean>()
+ Thread.start {
+ Thread.currentThread().interrupt()
+ gen.error(new IOException('err'))
+ done.complete(true)
+ }
+ done.get(5, TimeUnit.SECONDS)
+ }
+
+ /**
+ * Demonstrates why producer thread tracking is essential.
+ *
+ * Scenario: the producer is blocked in {@code yield()} (waiting for the
+ * consumer to call {@code moveNext()}) when the consumer exits the
+ * {@code for await} loop early and calls {@code close()}.
+ *
+ * Without thread tracking, {@code close()} has no way to interrupt the
+ * producer's blocking {@code SynchronousQueue.put()}, and the producer
+ * thread would hang indefinitely — a permanent thread leak.
+ *
+ * With thread tracking, {@code close()} interrupts the producer thread,
+ * which detects the closed state and exits promptly via
+ * {@code CancellationException("Async stream was closed")}.
+ */
+ @Test
+ void testCloseInterruptsBlockedProducerThread() {
+ def gen = new AsyncStreamGenerator<Integer>()
+ def producerExit = new CompletableFuture<String>()
+ def producerBlocked = new java.util.concurrent.CountDownLatch(1)
+
+ def producerThread = Thread.start {
+ gen.attachProducer(Thread.currentThread())
+ try {
+ gen.yield(1) // will be consumed
+ producerBlocked.countDown()
+ gen.yield(2) // will block — consumer won't take this
+ producerExit.complete('no-exception')
+ } catch (CancellationException e) {
+ producerExit.complete(e.message)
+ } finally {
+ gen.detachProducer(Thread.currentThread())
+ }
+ }
+
+ // Consume the first element only
+ assert gen.moveNext().get() == true
+ assert gen.getCurrent() == 1
+
+ // Wait until the producer is blocked on yield(2)
+ producerBlocked.await(5, TimeUnit.SECONDS)
+ Thread.sleep(50) // give put() time to block
+
+ // Close the stream — this must interrupt the blocked producer
+ gen.close()
+
+ // The producer thread should exit promptly with the expected message
+ assert producerExit.get(5, TimeUnit.SECONDS) == 'Async stream was closed'
+ producerThread.join(5000)
+ assert !producerThread.isAlive() : 'Producer thread should have terminated'
+ }
+
+ /**
+ * Demonstrates why consumer thread tracking is essential.
+ *
+ * Scenario: the consumer is blocked in {@code moveNext()} (waiting for
+ * the producer to yield the next element) when an external agent calls
+ * {@code close()} from a different thread.
+ *
+ * Without thread tracking, the consumer's blocking
+ * {@code SynchronousQueue.take()} cannot be interrupted and would hang
+ * indefinitely.
+ *
+ * With thread tracking, {@code close()} interrupts the consumer, and
+ * {@code moveNext()} detects the closed state and returns
+ * {@code Awaitable.of(false)} instead of throwing.
+ */
+ @Test
+ void testCloseInterruptsBlockedConsumerThread() {
+ def gen = new AsyncStreamGenerator<Integer>()
+ def consumerResult = new CompletableFuture<Boolean>()
+ def consumerBlocked = new java.util.concurrent.CountDownLatch(1)
+
+ // Start a consumer thread that will block in moveNext()
+ // because no producer will ever yield anything
+ Thread.start {
+ consumerBlocked.countDown()
+ def hasNext = gen.moveNext().get() // blocks waiting for producer
+ consumerResult.complete(hasNext)
+ }
+
+ // Wait until the consumer is blocking in moveNext()
+ consumerBlocked.await(5, TimeUnit.SECONDS)
+ Thread.sleep(50) // give take() time to block
+
+ // Close from a different thread — must interrupt the blocked consumer
+ gen.close()
+
+ // moveNext() should return false (not throw), indicating stream exhaustion
+ assert consumerResult.get(5, TimeUnit.SECONDS) == false
+ }
+
+ @Test
+ void testAsyncStreamGeneratorMoveNextInterrupted() {
+ def gen = new AsyncStreamGenerator<String>()
+ // queue.take() will throw InterruptedException when interrupted
+ Thread.currentThread().interrupt()
+ try {
+ gen.moveNext()
+ assert false
+ } catch (CancellationException e) {
+ assert e.message.contains('Interrupted during moveNext')
+ } finally {
+ Thread.interrupted() // clear flag
+ }
+ }
+
+ @Test
+ void testAsyncStreamGeneratorErrorWithNullCoversNPECreation() {
+ // Tests the null-guarding branch: t != null ? t : new NullPointerException(...)
+ def gen = new AsyncStreamGenerator<String>()
+ Thread.start {
+ gen.error(null)
+ }
+ try {
+ gen.moveNext()
+ assert false : 'expected NullPointerException'
+ } catch (NullPointerException e) {
+ assert e != null
+ }
+ }
+
+ @Test
+ void testAsyncStreamGeneratorYieldAfterClose() {
+ def gen = new AsyncStreamGenerator<String>()
+ def yieldFailed = new CompletableFuture<Boolean>()
+ def readyToClose = new CountDownLatch(1)
+ Thread.start {
+ gen.attachProducer(Thread.currentThread())
+ try {
+ AsyncSupport.yieldReturn(gen, 'first')
+ // Signal that producer is past the first yield and ready for close
+ readyToClose.countDown()
+ try {
+ // This will either block in queue.put() (if close hasn't happened
+ // yet — close() will interrupt it) or throw immediately (if closed)
+ AsyncSupport.yieldReturn(gen, 'second')
+ yieldFailed.complete(false)
+ } catch (ignored) {
+ yieldFailed.complete(true)
+ }
+ } catch (ignored) {
+ // yield of 'first' was interrupted by close — still counts as failure
+ yieldFailed.complete(true)
+ } finally {
+ gen.detachProducer(Thread.currentThread())
+ }
+ }
+ assert gen.moveNext().get() == true
+ assert gen.getCurrent() == 'first'
+ // Consumer closes the stream — close() never self-interrupts the calling thread
+ readyToClose.await(2, TimeUnit.SECONDS)
+ gen.close()
+ assert yieldFailed.get(2, TimeUnit.SECONDS) == true
+ }
+
+ /**
+ * Verifies the moveNext() double-check-after-registration fix:
+ * an external thread calling close() between the initial closed.get()
+ * and consumerThread.set() must not cause moveNext() to block forever.
+ */
+ @Test
+ void testMoveNextTOCTOURaceWithExternalClose() {
+ for (int iteration = 0; iteration < 200; iteration++) {
+ def gen = new AsyncStreamGenerator<Integer>()
+ def producerStarted = new CountDownLatch(1)
+
+ // Producer that yields one item, then waits to be interrupted
+ Thread.start {
+ gen.attachProducer(Thread.currentThread())
+ try {
+ producerStarted.countDown()
+ AsyncSupport.yieldReturn(gen, 1)
+ // Block indefinitely — close() should interrupt us
+ Thread.sleep(60_000)
+ gen.complete()
+ } catch (ignored) {
+ // Expected: CancellationException or InterruptedException from close()
+ } finally {
+ gen.detachProducer(Thread.currentThread())
+ }
+ }
+
+ producerStarted.await(2, TimeUnit.SECONDS)
+ // Consume the first item
+ assert gen.moveNext().get() == true
+ assert gen.getCurrent() == 1
+
+ // Close from an external thread while consumer is about to call moveNext()
+ def closeThread = Thread.start { gen.close() }
+ closeThread.join(2000)
+
+ // Without the double-check fix, this would block forever
+ assert gen.moveNext().get(2, TimeUnit.SECONDS) == false
+ }
+ }
+
+ // ================================================================
+ // Awaitable.from / AsyncStream.from / AwaitableAdapterRegistry
+ // ================================================================
+
+ @Test
+ void testToAwaitableNull() {
+ try {
+ Awaitable.from(null)
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('null')
+ }
+ }
+
+ @Test
+ void testToAsyncStreamNull() {
+ try {
+ AsyncStream.from(null)
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('null')
+ }
+ }
+
+ @Test
+ void testToAsyncStreamPassthrough() {
+ AsyncStream<String> stream = AsyncStream.empty()
+ assert AsyncStream.from(stream).is(stream)
+ }
+
+ @Test
+ void testToAwaitableUnsupportedType() {
+ try {
+ Awaitable.from(new Object())
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('No AwaitableAdapter found')
+ }
+ }
+
+ @Test
+ void testToAsyncStreamUnsupportedType() {
+ try {
+ AsyncStream.from(42)
+ assert false
+ } catch (IllegalArgumentException e) {
+ assert e.message.contains('No AsyncStream adapter')
+ }
+ }
+
+ @Test
+ void testToAwaitableNullThrows() {
+ def ex = shouldFail(IllegalArgumentException) {
+ Awaitable.from(null)
+ }
+ assert ex.message.contains('Cannot convert null to Awaitable')
+ }
+
+ @Test
+ void testToAwaitableUnknownTypeThrows() {
+ def ex = shouldFail(IllegalArgumentException) {
+ Awaitable.from(new StringBuilder("test"))
+ }
+ assert ex.message.contains('No AwaitableAdapter found for type')
+ assert ex.message.contains('StringBuilder')
+ }
+
+ @Test
+ void testToAsyncStreamNullThrows() {
+ def ex = shouldFail(IllegalArgumentException) {
+ AsyncStream.from(null)
+ }
+ assert ex.message.contains('Cannot convert null to AsyncStream')
+ }
+
+ @Test
+ void testToAsyncStreamUnknownTypeThrows() {
+ def ex = shouldFail(IllegalArgumentException) {
+ AsyncStream.from(new StringBuilder("test"))
+ }
+ assert ex.message.contains('No AsyncStream adapter found for type')
+ assert ex.message.contains('StringBuilder')
+ }
+
+ @Test
+ void testToAwaitablePassthrough() {
+ Awaitable<String> aw = Awaitable.of('pass')
+ assert Awaitable.from(aw).is(aw)
+ }
+
+ @Test
+ void testAdapterRegisterAndUnregister() {
+ def adapter = new AwaitableAdapter() {
+ boolean supportsAwaitable(Class<?> type) { false }
+ def <T> Awaitable<T> toAwaitable(Object source) { null }
+ }
+ def handle = AwaitableAdapterRegistry.register(adapter)
+ handle.close()
+ assert !AwaitableAdapterRegistry.unregister(adapter) // already removed
+ }
+
+ // ================================================================
+ // Awaitable.from() and AsyncStream.from()
+ // ================================================================
+
+ @Test
+ void testAwaitableFromCompletableFuture() {
+ def cf = CompletableFuture.completedFuture("hello")
+ Awaitable<String> aw = Awaitable.from(cf)
+ assert aw.get() == "hello"
+ }
+
+ @Test
+ void testAwaitableFromCompletionStage() {
+ CompletionStage<String> stage = CompletableFuture.completedFuture("stage-value")
+ Awaitable<String> aw = Awaitable.from(stage)
+ assert aw.get() == "stage-value"
+ }
+
+ @Test
+ void testAwaitableFromFuture() {
+ def ft = new FutureTask<>({ -> "future-result" } as Callable)
+ ft.run()
+ Awaitable<String> aw = Awaitable.from(ft)
+ assert aw.get() == "future-result"
+ }
+
+ @Test
+ void testAwaitableFromPassthrough() {
+ Awaitable<String> original = Awaitable.of("original")
+ assert Awaitable.from(original).is(original)
+ }
+
+ @Test
+ void testAwaitableFromNull() {
+ def ex = shouldFail(IllegalArgumentException) {
+ Awaitable.from(null)
+ }
+ assert ex.message.contains('null')
+ }
+
+ @Test
+ void testAwaitableFromUnsupportedType() {
+ def ex = shouldFail(IllegalArgumentException) {
+ Awaitable.from(new Object())
+ }
+ assert ex.message.contains('No AwaitableAdapter found')
+ }
+
+ @Test
+ void testAwaitableFromFailedFuture() {
+ def cf = CompletableFuture.<String>failedFuture(new IOException("broken"))
+ Awaitable<String> aw = Awaitable.from(cf)
+ assert aw.isCompletedExceptionally()
+ try {
+ aw.get()
+ assert false
+ } catch (ExecutionException e) {
+ assert e.cause instanceof IOException
+ assert e.cause.message == "broken"
+ }
+ }
+
+ @Test
+ void testAsyncStreamFromIterable() {
+ AsyncStream<String> stream = AsyncStream.from(["x", "y", "z"])
+ def items = []
+ while (stream.moveNext().get()) {
+ items << stream.current
+ }
+ assert items == ["x", "y", "z"]
+ }
+
+ @Test
+ void testAsyncStreamFromIterator() {
+ def iter = ["a", "b"].iterator()
+ AsyncStream<String> stream = AsyncStream.from(iter)
+ assert stream.moveNext().get() == true
+ assert stream.current == "a"
+ assert stream.moveNext().get() == true
+ assert stream.current == "b"
+ assert stream.moveNext().get() == false
+ }
+
+ @Test
+ void testAsyncStreamFromPassthrough() {
+ AsyncStream<String> original = AsyncStream.empty()
+ assert AsyncStream.from(original).is(original)
+ }
+
+ @Test
+ void testAsyncStreamFromNull() {
+ def ex = shouldFail(IllegalArgumentException) {
+ AsyncStream.from(null)
+ }
+ assert ex.message.contains('null')
+ }
+
+ @Test
+ void testAsyncStreamFromUnsupportedType() {
+ def ex = shouldFail(IllegalArgumentException) {
+ AsyncStream.from(42)
+ }
+ assert ex.message.contains('No AsyncStream adapter')
+ }
+
+ @Test
+ void testAsyncStreamFromEmptyIterable() {
+ AsyncStream<String> stream = AsyncStream.from([])
+ assert stream.moveNext().get() == false
+ }
+
+ @Test
+ void testAsyncStreamFromFlowPublisher() {
+ def pub = new SubmissionPublisher<String>()
+ Thread.start {
+ Thread.sleep(50)
+ pub.submit("item1")
+ pub.submit("item2")
+ pub.close()
+ }
+ AsyncStream<String> stream = AsyncStream.from(pub)
+ def items = []
+ while (stream.moveNext().get(2, TimeUnit.SECONDS)) {
+ items << stream.current
+ }
+ stream.close()
+ assert items == ["item1", "item2"]
+ }
+
+ @Test
+ void testAwaitableFromFlowPublisher() {
+ def pub = new SubmissionPublisher<String>()
+ Awaitable<String> aw = Awaitable.from(pub)
+ pub.submit("first")
+ pub.close()
+ assert aw.get(2, TimeUnit.SECONDS) == "first"
+ }
+
+ // ================================================================
+ // AwaitableAdapter default methods
+ // ================================================================
+
+ @Test
+ void testAwaitableAdapterDefaultToAsyncStreamThrows() {
+ def adapter = new AwaitableAdapter() {
+ boolean supportsAwaitable(Class<?> type) { false }
+ def <T> Awaitable<T> toAwaitable(Object source) { null }
+ }
+ try {
+ adapter.toAsyncStream('anything')
+ assert false
+ } catch (UnsupportedOperationException e) {
+ assert e.message.contains('not supported')
+ }
+ }
+
+ @Test
+ void testAwaitableAdapterDefaultSupportsAsyncStream() {
+ def adapter = new AwaitableAdapter() {
+ boolean supportsAwaitable(Class<?> type) { false }
+ def <T> Awaitable<T> toAwaitable(Object source) { null }
+ }
+ assert adapter.supportsAsyncStream(String) == false
+ }
+
+ // ================================================================
+ // Flow.Publisher adapters
+ // ================================================================
+
+ @Test
+ void testFlowPublisherToAwaitableOnNext() {
+ def pub = new SubmissionPublisher<String>()
+ Awaitable<String> aw = Awaitable.from(pub)
+ pub.submit('hello')
+ pub.close()
+ assert aw.get() == 'hello'
+ }
+
+ @Test
+ void testFlowPublisherToAwaitableOnError() {
+ def pub = new Flow.Publisher<String>() {
+ void subscribe(Flow.Subscriber<? super String> s) {
+ s.onSubscribe(new Flow.Subscription() {
+ void request(long n) {}
+ void cancel() {}
+ })
+ s.onError(new IOException('pub-error'))
+ }
+ }
+ Awaitable<String> aw = Awaitable.from(pub)
+ try {
+ aw.get()
+ assert false
+ } catch (ExecutionException e) {
+ assert e.cause instanceof IOException
+ assert e.cause.message == 'pub-error'
+ }
+ }
+
+ @Test
+ void testFlowPublisherToAwaitableOnCompleteWithoutValue() {
+ def cancelled = new AtomicBoolean(false)
+ def pub = new Flow.Publisher<String>() {
+ void subscribe(Flow.Subscriber<? super String> s) {
+ s.onSubscribe(new Flow.Subscription() {
+ void request(long n) {}
+ void cancel() { cancelled.set(true) }
+ })
+ s.onComplete()
+ }
+ }
+ Awaitable<String> aw = Awaitable.from(pub)
+ assert aw.get() == null
+ assert cancelled.get() : 'Subscription should be cancelled/cleared on terminal completion'
+ }
+
+ @Test
+ void testFlowPublisherAsyncStreamIgnoresLateOnNextAfterComplete() {
+ def producerRef = new AtomicReference<Thread>()
+ def pub = new Flow.Publisher<Integer>() {
+ void subscribe(Flow.Subscriber<? super Integer> s) {
+ s.onSubscribe(new Flow.Subscription() {
+ void request(long n) {}
+ void cancel() {}
+ })
+ producerRef.set(Thread.start {
+ // Non-compliant publisher: emits onNext after terminal signal.
+ s.onComplete()
+ for (int i = 0; i < 1000; i++) {
+ s.onNext(i)
+ }
+ })
+ }
+ }
+ AsyncStream<Integer> stream = AsyncStream.from(pub)
+ Thread producer = producerRef.get()
+ assert producer != null
+ try {
+ producer.join(1000)
+ assert !producer.isAlive() : 'late onNext after terminal should not block producer'
+ assert stream.moveNext().get() == false
+ assert stream.moveNext().get() == false
+ } finally {
+ stream.close()
+ if (producer != null && producer.isAlive()) {
+ producer.interrupt()
+ producer.join(1000)
+ }
+ }
+ }
+
+ @Test
+ void testFlowPublisherToAsyncStreamDirect() {
+ def pub = new SubmissionPublisher<String>()
+ AsyncStream<String> stream = AsyncStream.from(pub)
+ Thread.start {
+ Thread.sleep(50)
+ pub.submit('a')
+ pub.submit('b')
+ pub.close()
+ }
+ def items = []
+ while (stream.moveNext().get()) {
+ items << stream.getCurrent()
+ }
+ assert items == ['a', 'b']
+ }
+
+ @Test
+ void testFlowPublisherToAsyncStreamWithError() {
+ def pub = new Flow.Publisher<String>() {
+ void subscribe(Flow.Subscriber<? super String> s) {
+ s.onSubscribe(new Flow.Subscription() {
+ int count = 0
+ void request(long n) {
+ count++
+ if (count == 1) s.onNext('item1')
+ else s.onError(new IOException('stream-err'))
+ }
+ void cancel() {}
+ })
+ }
+ }
+ AsyncStream<String> stream = AsyncStream.from(pub)
+ assert stream.moveNext().get() == true
+ assert stream.getCurrent() == 'item1'
+ // moveNext() throws the error directly (matching AsyncStreamGenerator
+ // behaviour) rather than wrapping it in a failed Awaitable
+ try {
+ stream.moveNext()
+ assert false
+ } catch (IOException e) {
+ assert e.message == 'stream-err'
+ }
+ }
+
+ @Test
+ void testFlowPublisherOnCompleteNoItems() {
+ def pub = new SubmissionPublisher<String>()
+ Awaitable<String> aw = Awaitable.from(pub)
+ // Close immediately — triggers onComplete with no onNext
+ pub.close()
+ assert aw.get() == null
+ }
+
+ @Test
+ void testFlowPublisherOnError() {
+ def pub = new SubmissionPublisher<String>()
+ Awaitable<String> aw = Awaitable.from(pub)
+ Thread.start {
+ Thread.sleep(50)
+ pub.closeExceptionally(new IOException('pub-err'))
+ }
+ try {
+ aw.get()
+ assert false
+ } catch (Exception e) {
+ // The IOException is wrapped by publisherToAwaitable
+ assert e instanceof IOException || e.cause instanceof IOException
+ }
+ }
+
+ @Test
+ void testFlowPublisherAsyncStreamMoveNextInterrupted() {
+ def pub = new SubmissionPublisher<String>()
+ AsyncStream<String> stream = AsyncStream.from(pub)
+ // Interrupt before moveNext to trigger InterruptedException in queue.take()
+ Thread.currentThread().interrupt()
+ try {
+ stream.moveNext().get()
+ assert false : 'Expected CancellationException'
+ } catch (CancellationException e) {
+ // moveNext() throws CancellationException directly (not via CF),
+ // ensuring deterministic message across all JDK versions.
+ assert e.message.contains('Interrupted')
+ assert e.cause instanceof InterruptedException
+ // Interrupt flag is restored per Java convention.
+ assert Thread.currentThread().isInterrupted()
+ } finally {
+ Thread.interrupted() // clear flag
+ pub.close()
+ }
+ }
+
+ @Test
+ void testFlowPublisherAsyncStreamOnError() {
+ def pub = new SubmissionPublisher<String>()
+ AsyncStream<String> stream = AsyncStream.from(pub)
+ Thread.start {
+ Thread.sleep(50)
+ pub.closeExceptionally(new IOException('stream-err'))
+ }
+ try {
+ stream.moveNext().get()
+ assert false
+ } catch (Exception e) {
+ assert e instanceof IOException || e.cause instanceof IOException
+ }
+ }
+
+ @Test
+ void testFlowPublisherAsyncStreamOnComplete() {
+ def pub = new SubmissionPublisher<String>()
+ AsyncStream<String> stream = AsyncStream.from(pub)
+ Thread.start {
+ Thread.sleep(50)
+ pub.submit('item1')
+ Thread.sleep(50)
+ pub.close()
+ }
+ assert stream.moveNext().get() == true
+ assert stream.getCurrent() == 'item1'
+ assert stream.moveNext().get() == false
+ }
+
+ // ================================================================
+ // BuiltInAdapter — plain Future
+ // ================================================================
+
+ @Test
+ void testFutureAdapterAlreadyDone() {
+ def ft = new FutureTask<String>({ 'already-done' } as Callable<String>)
+ ft.run()
+ Awaitable<String> aw = Awaitable.from(ft)
+ assert aw.get() == 'already-done'
+ }
+
+ @Test
+ void testFutureAdapterNotYetDone() {
+ def ft = new FutureTask<String>({ 'async-done' } as Callable<String>)
+ Awaitable<String> aw = Awaitable.from(ft)
+ Thread.start { Thread.sleep(50); ft.run() }
+ assert aw.get() == 'async-done'
+ }
+
+ @Test
+ void testFutureAdapterWithException() {
+ def ft = new FutureTask<String>({ throw new ArithmeticException('div-zero') } as Callable<String>)
+ ft.run()
+ Awaitable<String> aw = Awaitable.from(ft)
+ try {
+ aw.get()
+ assert false
+ } catch (ExecutionException e) {
+ assert e.cause instanceof ArithmeticException
+ }
+ }
+
+ @Test
+ void testFutureAdapterWithBlockingExecutor() {
+ // Test the blockingExecutor path
+ def ft = new FutureTask<String>({ 'with-executor' } as Callable<String>)
+ AwaitableAdapterRegistry.setBlockingExecutor(AsyncSupport.getExecutor())
+ try {
+ Awaitable<String> aw = Awaitable.from(ft)
+ Thread.start { Thread.sleep(50); ft.run() }
+ assert aw.get() == 'with-executor'
+ } finally {
+ AwaitableAdapterRegistry.setBlockingExecutor(null)
+ }
+ }
+
+ @Test
+ void testFutureAdapterCompleteFromException() {
+ def ft = new FutureTask<String>({ throw new IOException('ft-err') } as Callable<String>)
+ ft.run()
+ Awaitable<String> aw = Awaitable.from(ft)
+ try {
+ aw.get()
+ assert false
+ } catch (Exception e) {
+ // IOException wrapped in ExecutionException, then unwrapped
+ assert e instanceof IOException || e instanceof java.util.concurrent.ExecutionException
+ }
+ }
+
+ @Test
+ void testFutureAdapterNotDone() {
+ def ft = new FutureTask<String>({ 'delayed' } as Callable<String>)
+ Awaitable<String> aw = Awaitable.from(ft)
+ // Run the FutureTask after adapter wraps it
+ Thread.start {
+ Thread.sleep(50)
+ ft.run()
+ }
+ assert aw.get() == 'delayed'
+ }
+
+ // ================================================================
+ // Executor configuration
+ // ================================================================
+
+ @Test
+ void testGetSetExecutor() {
+ def original = AsyncSupport.getExecutor()
+ assert original != null
+ def custom = { Runnable r -> r.run() } as java.util.concurrent.Executor
+ AsyncSupport.setExecutor(custom)
+ assert AsyncSupport.getExecutor().is(custom)
+ AsyncSupport.setExecutor(null) // reset
+ assert AsyncSupport.getExecutor() != null
+ }
+
+ // ================================================================
+ // AsyncSupport.toAsyncStream(Object)
+ // ================================================================
+
+ @Test
+ void testAsyncSupportToAsyncStreamNull() {
+ AsyncStream<String> s = AsyncSupport.toAsyncStream(null)
+ assert s.is(AsyncStream.empty())
+ }
+
+ @Test
+ void testToAsyncStreamPassthroughViaAsyncSupport() {
+ AsyncStream<String> stream = AsyncStream.empty()
+ assert AsyncSupport.toAsyncStream(stream).is(stream)
+ }
+
+ @Test
+ void testToAsyncStreamFromIterable() {
+ AsyncStream<Integer> stream = AsyncSupport.toAsyncStream([1, 2, 3])
+ def items = []
+ while (stream.moveNext().get()) {
+ items << stream.getCurrent()
+ }
+ assert items == [1, 2, 3]
+ }
+
+ @Test
+ void testToAsyncStreamFromIterator() {
+ AsyncStream<Integer> stream = AsyncSupport.toAsyncStream([4, 5, 6].iterator())
+ def items = []
+ while (stream.moveNext().get()) {
+ items << stream.getCurrent()
+ }
+ assert items == [4, 5, 6]
+ }
+
+ // ================================================================
+ // AsyncSupport.generateAsyncStream / wrapAsync / wrapAsyncGenerator
+ // ================================================================
+
+ @Test
+ void testGenerateAsyncStream() {
+ AsyncStream<Integer> stream = AsyncSupport.generateAsyncStream({ gen ->
+ gen.yield(1)
+ gen.yield(2)
+ })
+ def items = []
+ while (stream.moveNext().get()) {
+ items << stream.getCurrent()
+ }
+ assert items == [1, 2]
+ }
+
+ @Test
+ void testGenerateAsyncStreamWithError() {
+ AsyncStream<Integer> stream = AsyncSupport.generateAsyncStream({ gen ->
+ gen.yield(1)
+ throw new IOException('gen-fail')
+ })
+ assert stream.moveNext().get() == true
+ assert stream.getCurrent() == 1
+ try {
+ stream.moveNext()
+ assert false
+ } catch (IOException e) {
+ assert e.message == 'gen-fail'
+ }
+ }
+
+ @Test
+ void testWrapAsync() {
+ def wrapped = AsyncSupport.wrapAsync({ x -> x * 2 })
+ Awaitable<Integer> aw = wrapped.call(21)
+ assert aw.get() == 42
+ }
+
+ @Test
+ void testWrapAsyncError() {
+ def wrapped = AsyncSupport.wrapAsync({ throw new RuntimeException('wrap-err') })
+ Awaitable<Object> aw = wrapped.call()
+ try {
+ aw.get()
+ assert false
+ } catch (ExecutionException e) {
+ assert e.cause instanceof RuntimeException
+ assert e.cause.message == 'wrap-err'
+ }
+ }
+
+ @Test
+ void testWrapAsyncGenerator() {
+ def wrapped = AsyncSupport.wrapAsyncGenerator({ gen, Object[] args ->
+ gen.yield('x')
+ gen.yield('y')
+ })
+ AsyncStream<String> stream = wrapped.call()
+ def items = []
+ while (stream.moveNext().get()) {
+ items << stream.getCurrent()
+ }
+ assert items == ['x', 'y']
+ }
+
+ // ================================================================
+ // AwaitResult
+ // ================================================================
+
+ @Test
+ void testAwaitResultSuccess() {
+ def r = AwaitResult.success('val')
+ assert r.isSuccess()
+ assert !r.isFailure()
+ assert r.value == 'val'
+ assert r.getOrElse({ 'fallback' }) == 'val'
+ assert r.toString().contains('Success')
+ try { r.error; assert false } catch (IllegalStateException ignored) {}
+ }
+
+ @Test
+ void testAwaitResultFailure() {
+ def ex = new IOException('err')
+ def r = AwaitResult.failure(ex)
+ assert r.isFailure()
+ assert !r.isSuccess()
+ assert r.error.is(ex)
+ assert r.getOrElse({ t -> t.message }) == 'err'
+ assert r.toString().contains('Failure')
+ try { r.value; assert false } catch (IllegalStateException ignored) {}
+ }
+
+ // ================================================================
+ // Awaitable factory methods and combinators
+ // ================================================================
+
+ @Test
+ void testAwaitableOf() {
+ def aw = Awaitable.of('hello')
+ assert aw.isDone()
+ assert !aw.isCancelled()
+ assert !aw.isCompletedExceptionally()
+ assert aw.get() == 'hello'
+ assert aw.get(1, TimeUnit.SECONDS) == 'hello'
+ }
+
+ @Test
+ void testAwaitableFailed() {
+ def aw = Awaitable.failed(new IOException('err'))
+ assert aw.isDone()
+ assert aw.isCompletedExceptionally()
+ try {
+ aw.get()
+ assert false
+ } catch (ExecutionException e) {
+ assert e.cause instanceof IOException
+ }
+ }
+
+ @Test
+ void testAwaitableAll() {
+ def aw = Awaitable.all(Awaitable.of('x'), Awaitable.of('y'))
+ assert aw.get() == ['x', 'y']
+ }
+
+ @Test
+ void testAwaitableAny() {
+ def aw = Awaitable.any(Awaitable.of('first'), Awaitable.delay(5000))
+ assert aw.get() == 'first'
+ }
+
+ @Test
+ void testAwaitableAllSettled() {
+ def aw = Awaitable.allSettled(
+ Awaitable.of('ok'),
+ Awaitable.failed(new IOException('err'))
+ )
+ def results = aw.get()
+ assert results[0].isSuccess()
+ assert results[1].isFailure()
+ }
+
+ @Test
+ void testAwaitableDelay() {
+ def aw = Awaitable.delay(10)
+ assert aw.get() == null
+ }
+
+ // ================================================================
+ // Awaitable instance methods: then / thenCompose / exceptionally
+ // ================================================================
+
+ @Test
+ void testAwaitableThen() {
+ def aw = Awaitable.of(5).then({ it * 2 })
+ assert aw.get() == 10
+ }
+
+ @Test
+ void testAwaitableThenCompose() {
+ def aw = Awaitable.of(5).thenCompose({ Awaitable.of(it + 1) })
+ assert aw.get() == 6
+ }
+
+ @Test
+ void testAwaitableExceptionally() {
+ def aw = Awaitable.failed(new IOException('err')).exceptionally({ 'recovered' })
+ assert aw.get() == 'recovered'
+ }
+
+ @Test
+ void testAwaitableToCompletableFuture() {
+ def cf = Awaitable.of('val').toCompletableFuture()
+ assert cf instanceof CompletableFuture
+ assert cf.get() == 'val'
+ }
+
+ @Test
+ void testAwaitableCancel() {
+ def cf = new CompletableFuture<String>()
+ def aw = new GroovyPromise<String>(cf)
+ aw.cancel()
+ assert aw.isCancelled()
+ }
+
+ // ================================================================
+ // Awaitable default methods: whenComplete / handle / orTimeout / completeOnTimeout
+ // ================================================================
+
+ @Test
+ void testAwaitableWhenCompleteSuccess() {
+ def awaitable = Awaitable.of(42)
+ def captured = []
+ def result = awaitable.whenComplete { value, error ->
+ captured << value
+ captured << error
+ }
+ assert result.get() == 42
+ assert captured == [42, null]
+ }
+
+ @Test
+ void testAwaitableWhenCompleteFailure() {
+ def cf = new CompletableFuture<>()
+ cf.completeExceptionally(new IOException('fail'))
+ def awaitable = GroovyPromise.of(cf)
+ def captured = []
+ def result = awaitable.whenComplete { value, error ->
+ captured << value
+ captured << error?.class?.simpleName
+ }
+ try { result.get() } catch (ignored) {}
+ Thread.sleep(50)
+ assert captured.contains('IOException')
+ }
+
+ @Test
+ void testAwaitableHandleSuccess() {
+ def awaitable = Awaitable.of('hello')
+ def result = awaitable.handle { value, error ->
+ error == null ? value.toUpperCase() : 'fallback'
+ }
+ assert result.get() == 'HELLO'
+ }
+
+ @Test
+ void testAwaitableHandleFailure() {
+ def cf = new CompletableFuture<String>()
+ cf.completeExceptionally(new IOException('broken'))
+ def awaitable = GroovyPromise.of(cf)
+ def result = awaitable.handle { value, error ->
+ error != null ? "recovered: ${error.message}" : value
+ }
+ assert result.get() == 'recovered: broken'
+ }
+
+ @Test
+ void testAwaitableOrTimeoutInstanceMethod() {
+ def cf = new CompletableFuture<>()
+ def awaitable = GroovyPromise.of(cf)
+ def withTimeout = awaitable.orTimeoutMillis(50)
+ def ex = shouldFail(ExecutionException) {
+ withTimeout.get()
+ }
+ assert ex.cause instanceof java.util.concurrent.TimeoutException
+ }
+
+ @Test
+ void testAwaitableOrTimeoutWithUnitInstanceMethod() {
+ def cf = new CompletableFuture<>()
+ def awaitable = GroovyPromise.of(cf)
+ def withTimeout = awaitable.orTimeout(50, TimeUnit.MILLISECONDS)
+ def ex = shouldFail(ExecutionException) {
+ withTimeout.get()
+ }
+ assert ex.cause instanceof java.util.concurrent.TimeoutException
+ }
+
+ @Test
+ void testAwaitableCompleteOnTimeoutInstanceMethod() {
+ def cf = new CompletableFuture<>()
+ def awaitable = GroovyPromise.of(cf)
+ def withFallback = awaitable.completeOnTimeoutMillis('default', 50)
+ assert withFallback.get() == 'default'
+ }
+
+ @Test
+ void testAwaitableCompleteOnTimeoutWithUnitInstanceMethod() {
+ def cf = new CompletableFuture<>()
+ def awaitable = GroovyPromise.of(cf)
+ def withFallback = awaitable.completeOnTimeout('default', 50, TimeUnit.MILLISECONDS)
+ assert withFallback.get() == 'default'
+ }
+
+ // ================================================================
+ // GroovyPromise
+ // ================================================================
+
+ @Test
+ void testGroovyPromiseToStringPending() {
+ def cf = new CompletableFuture<>()
+ def promise = GroovyPromise.of(cf)
+ assert promise.toString() == 'GroovyPromise{pending}'
+ }
+
+ @Test
+ void testGroovyPromiseToStringCompleted() {
+ def cf = CompletableFuture.completedFuture(42)
+ def promise = GroovyPromise.of(cf)
+ assert promise.toString() == 'GroovyPromise{completed}'
+ }
+
+ @Test
+ void testGroovyPromiseToStringFailed() {
+ def cf = new CompletableFuture<>()
+ cf.completeExceptionally(new RuntimeException('oops'))
+ def promise = GroovyPromise.of(cf)
+ assert promise.toString() == 'GroovyPromise{failed}'
+ }
+
+ @Test
+ void testGroovyPromiseGetWithTimeout() {
+ def cf = CompletableFuture.completedFuture('ok')
+ def promise = GroovyPromise.of(cf)
+ assert promise.get(1, TimeUnit.SECONDS) == 'ok'
+ }
+
+ @Test
+ void testGroovyPromiseGetWithTimeoutExpired() {
+ def cf = new CompletableFuture<>()
+ def promise = GroovyPromise.of(cf)
+ shouldFail(java.util.concurrent.TimeoutException) {
+ promise.get(50, TimeUnit.MILLISECONDS)
+ }
+ }
+
+ @Test
+ void testGroovyPromiseGetWithTimeoutCancelled() {
+ def cf = new CompletableFuture<>()
+ cf.cancel(true)
+ def promise = GroovyPromise.of(cf)
+ shouldFail(CancellationException) {
+ promise.get(1, TimeUnit.SECONDS)
+ }
+ }
+
+ @Test
+ void testGroovyPromiseIsCompletedExceptionally() {
+ def cf = new CompletableFuture<>()
+ def promise = GroovyPromise.of(cf)
+ assert !promise.isCompletedExceptionally()
+ cf.completeExceptionally(new RuntimeException())
+ assert promise.isCompletedExceptionally()
+ }
+
+ @Test
+ void testGroovyPromiseConstructorNullThrows() {
+ shouldFail(NullPointerException) {
+ new GroovyPromise(null)
+ }
+ }
+
+ // ================================================================
+ // AsyncStreamGenerator: complete()/error() robustness under interrupt
+ // ================================================================
+
+ /**
+ * Verifies that when a producer's complete() signal is interrupted and
+ * the best-effort offer() fails (no consumer waiting), the generator
+ * force-closes so a subsequent moveNext() returns false instead of
+ * blocking indefinitely.
+ */
+ @Test
+ void testGeneratorCompleteForceClosesOnOfferFailure() {
+ def gen = new AsyncStreamGenerator<Integer>()
+ def producerThread = Thread.currentThread()
+ gen.attachProducer(producerThread)
+
+ // Interrupt the current thread so that queue.put(DONE) inside
+ // complete() throws InterruptedException. Since no consumer is
+ // blocked in take(), the non-blocking offer(DONE) will also fail,
+ // triggering the force-close path.
+ producerThread.interrupt()
+ gen.complete()
+
+ // Clear the interrupt flag set by the force-close path
+ Thread.interrupted()
+
+ gen.detachProducer(producerThread)
+
+ // Consumer should see a cleanly closed stream — not block forever
+ def result = gen.moveNext()
+ assert !AsyncSupport.await(result) : "moveNext() should return false after force-close"
+ }
+
+ /**
+ * Verifies that when a producer's error() signal is interrupted and
+ * the best-effort offer() fails, the generator force-closes so the
+ * consumer does not hang.
+ */
+ @Test
+ void testGeneratorErrorForceClosesOnOfferFailure() {
+ def gen = new AsyncStreamGenerator<Integer>()
+ def producerThread = Thread.currentThread()
+ gen.attachProducer(producerThread)
+
+ producerThread.interrupt()
+ gen.error(new RuntimeException("test error"))
+
+ Thread.interrupted()
+ gen.detachProducer(producerThread)
+
... 12333 lines suppressed ...