[
https://issues.apache.org/jira/browse/GROOVY-9381?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18061875#comment-18061875
]
ASF GitHub Bot commented on GROOVY-9381:
----------------------------------------
Copilot commented on code in PR #2386:
URL: https://github.com/apache/groovy/pull/2386#discussion_r2867843843
##########
src/main/java/org/codehaus/groovy/transform/AsyncASTTransformation.java:
##########
@@ -0,0 +1,306 @@
+/*
+ * 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 groovy.transform.AsyncUtils;
+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.ClassCodeVisitorSupport;
+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.CodeVisitorSupport;
+import org.codehaus.groovy.ast.expr.ArgumentListExpression;
+import org.codehaus.groovy.ast.expr.ClassExpression;
+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.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.control.CompilePhase;
+import org.codehaus.groovy.control.SourceUnit;
+
+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.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} annotation and the {@code
async}
+ * keyword modifier.
+ * <p>
+ * Transforms the annotated method so that:
+ * <ol>
+ * <li>{@code await(expr)} calls are redirected to {@link
AsyncUtils#await}</li>
+ * <li>The method body is wrapped in {@code
GroovyPromise.of(CompletableFuture.supplyAsync(...))}
+ * (or {@code runAsync} for void methods)</li>
+ * <li>The return type becomes {@link Awaitable}{@code <T>}</li>
Review Comment:
This class-level Javadoc says the method body is wrapped in
GroovyPromise.of(CompletableFuture.supplyAsync/runAsync), but the
implementation now delegates to AsyncUtils.executeAsync/executeAsyncVoid (and
uses generateAsyncStream for generators). Please update the description to
reflect the current code path.
```suggestion
* <li>The method body is executed asynchronously via {@link
AsyncUtils#executeAsync}
* (or {@link AsyncUtils#executeAsyncVoid} for {@code void}
methods)</li>
* <li>Generator methods are transformed to use {@link
AsyncUtils#generateAsyncStream},
* returning an {@link AsyncStream}{@code <T>}</li>
* <li>The return type becomes {@link Awaitable}{@code <T>} (or {@link
AsyncStream}{@code <T>} for generators)</li>
```
##########
src/test/groovy/org/codehaus/groovy/transform/AsyncExceptionHandlingTest.groovy:
##########
@@ -0,0 +1,1161 @@
+/*
+ * 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.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+
+/**
+ * Comprehensive exception handling tests for async/await, covering:
+ * <ol>
+ * <li>Exception transparency — checked exceptions pass through {@code await}
+ * without wrapping (like C#/Kotlin/JavaScript)</li>
+ * <li>Deep unwrapping — nested CompletionException/ExecutionException
chains</li>
+ * <li>Cancellation support — {@code cancel()}/{@code isCancelled()}</li>
+ * <li>{@code awaitAll}/{@code awaitAny} with mixed success/failure</li>
+ * <li>{@code awaitAllSettled} — JavaScript's {@code
Promise.allSettled()}</li>
+ * <li>Generator (yield return) exception propagation</li>
+ * <li>Exception chaining across multiple await calls</li>
+ * <li>try/catch/finally in async methods</li>
+ * </ol>
+ *
+ * @since 6.0.0
+ */
+final class AsyncExceptionHandlingTest {
+
+ //
=========================================================================
+ // 1. Exception Transparency — checked exceptions through await
+ //
=========================================================================
+
+ @Test
+ void testAwaitIOExceptionTransparency() {
+ assertScript '''
+ import groovy.transform.Async
+ import static groovy.transform.AsyncUtils.*
+
+ async fetchData() {
+ throw new java.io.IOException("disk failure")
+ }
+
+ async caller() {
+ try {
+ await fetchData()
+ assert false : "should not reach"
+ } catch (java.io.IOException e) {
+ return e.message
+ }
+ }
+
+ assert await(caller()) == "disk failure"
+ '''
+ }
+
+ @Test
+ void testAwaitSQLExceptionTransparency() {
+ assertScript '''
+ import groovy.transform.Async
+ import static groovy.transform.AsyncUtils.*
+
+ async queryDb() {
+ throw new java.sql.SQLException("connection refused", "08001")
+ }
+
+ async caller() {
+ try {
+ await queryDb()
+ assert false : "should not reach"
+ } catch (java.sql.SQLException e) {
+ return e.SQLState
+ }
+ }
+
+ assert await(caller()) == "08001"
+ '''
+ }
+
+ @Test
+ void testAwaitCustomCheckedExceptionTransparency() {
+ assertScript '''
+ import groovy.transform.Async
+ import static groovy.transform.AsyncUtils.*
+
+ class BusinessException extends Exception {
+ int code
+ BusinessException(String msg, int code) {
+ super(msg)
+ this.code = code
+ }
+ }
+
+ async process() {
+ throw new BusinessException("insufficient funds", 402)
+ }
+
+ async caller() {
+ try {
+ await process()
+ assert false
+ } catch (BusinessException e) {
+ return "${e.message}:${e.code}"
+ }
+ }
+
+ assert await(caller()) == "insufficient funds:402"
+ '''
+ }
+
+ @Test
+ void testAwaitRuntimeExceptionTransparency() {
+ assertScript '''
+ import groovy.transform.Async
+ import static groovy.transform.AsyncUtils.*
+
+ async badOp() {
+ throw new IllegalArgumentException("bad arg")
+ }
+
+ async caller() {
+ try {
+ await badOp()
+ assert false
+ } catch (IllegalArgumentException e) {
+ return e.message
+ }
+ }
+
+ assert await(caller()) == "bad arg"
+ '''
+ }
+
+ @Test
+ void testAwaitErrorPropagation() {
+ assertScript '''
+ import groovy.transform.Async
+ import static groovy.transform.AsyncUtils.*
+
+ async oom() {
+ throw new OutOfMemoryError("test OOM")
+ }
+
+ async caller() {
+ try {
+ await oom()
+ assert false
+ } catch (OutOfMemoryError e) {
+ return e.message
+ }
+ }
+
+ assert await(caller()) == "test OOM"
+ '''
+ }
+
+ //
=========================================================================
+ // 2. Deep Unwrapping — nested wrapper chains
+ //
=========================================================================
+
+ @Test
+ void testDeepUnwrapCompletionException() {
+ assertScript '''
+ import groovy.transform.Async
+ import static groovy.transform.AsyncUtils.*
+ import java.util.concurrent.CompletableFuture
+ import java.util.concurrent.CompletionException
+
+ // Simulate deeply nested wrapping
+ async caller() {
+ def original = new java.io.FileNotFoundException("config.yml")
+ def wrapped = new CompletionException(new
java.util.concurrent.ExecutionException(original))
+ def future = new CompletableFuture()
+ future.completeExceptionally(wrapped)
+ try {
+ await future
+ assert false
+ } catch (java.io.FileNotFoundException e) {
+ return e.message
+ }
+ }
+
+ assert await(caller()) == "config.yml"
+ '''
+ }
+
+ @Test
+ void testDeepUnwrapExecutionException() {
+ assertScript '''
+ import groovy.transform.Async
+ import static groovy.transform.AsyncUtils.*
+ import java.util.concurrent.CompletableFuture
+ import java.util.concurrent.CompletionException
+ import java.util.concurrent.ExecutionException
+
+ async caller() {
+ def original = new IllegalStateException("deep cause")
+ // Triple nesting: CompletionException -> ExecutionException
-> CompletionException -> actual
+ def inner = new CompletionException(original)
+ def middle = new ExecutionException(inner)
+ def outer = new CompletionException(middle)
+ def future = new CompletableFuture()
+ future.completeExceptionally(outer)
+ try {
+ await future
+ assert false
+ } catch (IllegalStateException e) {
+ return e.message
+ }
+ }
+
+ assert await(caller()) == "deep cause"
+ '''
+ }
+
+ //
=========================================================================
+ // 3. Cancellation Support
+ //
=========================================================================
+
+ @Test
+ void testCancelAwaitable() {
+ assertScript '''
+ import groovy.transform.Async
+ import static groovy.transform.AsyncUtils.*
+ import java.util.concurrent.CancellationException
+
+ async slowTask() {
+ Thread.sleep(10_000)
Review Comment:
This cancellation test uses a 10-second sleep in the async body and then
immediately cancels. Because CompletableFuture cancellation does not reliably
interrupt the underlying supplier, the sleeping task may continue running in
the background and slow down or destabilize the test suite. Consider rewriting
the test to avoid long sleeps (e.g., coordinate with a latch) and/or only
assert cancellation of the Awaitable without depending on interruption.
```suggestion
Thread.sleep(100)
```
##########
src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java:
##########
@@ -2877,6 +2931,102 @@ public Expression visitUnaryNotExprAlt(final
UnaryNotExprAltContext ctx) {
throw createParsingFailedException("Unsupported unary expression: " +
ctx.getText(), ctx);
}
+ @Override
+ public Expression visitAwaitExprAlt(final AwaitExprAltContext ctx) {
+ Expression expr = (Expression) this.visit(ctx.expression());
+ return configureAST(
+ callX(ClassHelper.make("groovy.transform.AsyncUtils"), "await",
+ new ArgumentListExpression(expr)),
+ ctx);
+ }
+
+ @Override
+ public Expression visitAsyncClosureExprAlt(final
AsyncClosureExprAltContext ctx) {
+ ClosureExpression closure =
this.visitClosureOrLambdaExpression(ctx.closureOrLambdaExpression());
+ boolean hasUserParams = closure.getParameters() != null &&
closure.getParameters().length > 0;
+ boolean hasYieldReturn = containsYieldReturnCalls(closure);
+
+ ClassNode asyncUtils = ClassHelper.make("groovy.transform.AsyncUtils");
+ if (hasYieldReturn) {
+ // Inject synthetic $asyncGen as first parameter — rebuild closure
+ Parameter genParam = new Parameter(ClassHelper.DYNAMIC_TYPE,
"$asyncGen");
+ 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());
Review Comment:
The compiler injects a synthetic parameter named "$asyncGen" into generator
closures. Because Groovy allows user-defined parameters/variables with '$'
names, a user closure that already declares "$asyncGen" would now get a
duplicate/conflicting parameter name. Consider generating a less-collidable
synthetic name (e.g., with a unique suffix) or detecting conflicts and renaming.
##########
src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java:
##########
@@ -1699,6 +1747,12 @@ public MethodNode visitMethodDeclaration(final
MethodDeclarationContext ctx) {
} else { // script method declaration
methodNode = createScriptMethodNode(modifierManager, methodName,
returnType, parameters, exceptions, code);
}
+
+ // Inject @Async annotation for methods declared with the 'async'
keyword modifier
+ if (modifierManager.containsAny(ASYNC)) {
Review Comment:
When the 'async' modifier is present, this unconditionally adds
@groovy.transform.Async. If the user already annotated the method with @Async,
this will add a duplicate annotation, which can lead to the AST transformation
running twice or other unexpected behavior. Please guard against duplicates
before adding the annotation.
```suggestion
if (modifierManager.containsAny(ASYNC)
&&
methodNode.getAnnotations(ClassHelper.make("groovy.transform.Async")).isEmpty())
{
```
##########
src/main/java/groovy/concurrent/GroovyPromise.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;
+
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+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.
+ *
+ * @param <T> the result type
+ * @see Awaitable
+ * @since 6.0.0
+ */
+public class GroovyPromise<T> implements Awaitable<T> {
+
+ private final CompletableFuture<T> future;
+
+ 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);
+ }
+
+ @Override
+ public T get() throws InterruptedException, ExecutionException {
+ return future.get();
+ }
+
+ @Override
+ public T get(long timeout, TimeUnit unit) throws InterruptedException,
ExecutionException, TimeoutException {
+ return future.get(timeout, unit);
+ }
+
+ @Override
+ public boolean isDone() {
+ return future.isDone();
+ }
+
+ @Override
+ public boolean cancel() {
+ return future.cancel(true);
+ }
Review Comment:
GroovyPromise.cancel() delegates to CompletableFuture.cancel(true), but
CompletableFuture does not reliably interrupt/stop work started via
supplyAsync/runAsync. That means cancelled tasks may keep running in the
background (e.g., sleeping), which can exhaust executors or slow test runs. If
cancellation is part of the Awaitable contract, consider implementing
cancellation propagation (e.g., via ExecutorService.submit/Future and linking
cancel to the underlying task).
##########
src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java:
##########
@@ -465,14 +466,52 @@ public Statement visitLoopStmtAlt(final
LoopStmtAltContext ctx) {
}
@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()));
return configureAST(maker.apply(loopBody), ctx);
}
+ 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 = this.visitType(enhCtrl.type());
+ String varName = this.visitIdentifier(enhCtrl.identifier());
+ Expression source = (Expression) this.visit(enhCtrl.expression());
+ Statement loopBody = this.unpackStatement((Statement)
this.visit(ctx.statement()));
+
+ ClassNode asyncUtilsType =
ClassHelper.make("groovy.transform.AsyncUtils");
+
+ // def $asyncStream = AsyncUtils.toAsyncStream(source)
+ Expression toStreamCall = callX(asyncUtilsType, "toAsyncStream", new
ArgumentListExpression(source));
+ ExpressionStatement streamDecl = new
ExpressionStatement(declX(varX("$asyncStream"), toStreamCall));
+
+ // while (AsyncUtils.await($asyncStream.moveNext()))
+ Expression moveNextCall = callX(varX("$asyncStream"), "moveNext");
+ Expression awaitCall = callX(asyncUtilsType, "await", new
ArgumentListExpression(moveNextCall));
+ BooleanExpression condition = new BooleanExpression(awaitCall);
Review Comment:
The generated for-await desugaring uses a fixed synthetic local name
"$asyncStream". User code can legally declare a variable with the same name,
and Groovy’s scoping rules can make this a redeclaration error or change
semantics. Consider generating a unique synthetic name (or marking it synthetic
in the variable scope) to avoid collisions, especially for nested for-await
loops.
##########
src/main/java/groovy/concurrent/AwaitableAdapterRegistry.java:
##########
@@ -0,0 +1,181 @@
+/*
+ * 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.Iterator;
+import java.util.List;
+import java.util.ServiceLoader;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CopyOnWriteArrayList;
+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 for {@link CompletableFuture} and {@link Future} is always present
+ * as the lowest-priority fallback.
+ * <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<>();
+
+ 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.
+ */
+ public static void register(AwaitableAdapter adapter) {
+ ADAPTERS.add(0, adapter);
+ }
+
+ /**
+ * Converts the given source to an {@link Awaitable}.
+ * If the source is already an {@code Awaitable}, it is returned as-is.
+ *
+ * @throws IllegalArgumentException if no adapter supports the source type
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> Awaitable<T> toAwaitable(Object source) {
+ 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.
+ *
+ * @throws IllegalArgumentException if no adapter supports the source type
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> AsyncStream<T> toAsyncStream(Object source) {
+ 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 {
+ CompletableFuture.runAsync(() -> completeFrom(cf, future));
+ }
+ return new GroovyPromise<>(cf);
Review Comment:
The built-in Future adapter completes a CompletableFuture by calling
future.get() inside CompletableFuture.runAsync() with no executor specified (so
it uses ForkJoinPool.commonPool). Because future.get() blocks, this can starve
the common pool and deadlock if the Future’s work also depends on that pool.
Consider using a dedicated blocking-friendly executor (or virtual threads when
available), or using ForkJoinPool.ManagedBlocker to avoid starvation.
##########
src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java:
##########
@@ -2877,6 +2931,102 @@ public Expression visitUnaryNotExprAlt(final
UnaryNotExprAltContext ctx) {
throw createParsingFailedException("Unsupported unary expression: " +
ctx.getText(), ctx);
}
+ @Override
+ public Expression visitAwaitExprAlt(final AwaitExprAltContext ctx) {
+ Expression expr = (Expression) this.visit(ctx.expression());
+ return configureAST(
+ callX(ClassHelper.make("groovy.transform.AsyncUtils"), "await",
+ new ArgumentListExpression(expr)),
+ ctx);
+ }
+
+ @Override
+ public Expression visitAsyncClosureExprAlt(final
AsyncClosureExprAltContext ctx) {
+ ClosureExpression closure =
this.visitClosureOrLambdaExpression(ctx.closureOrLambdaExpression());
+ boolean hasUserParams = closure.getParameters() != null &&
closure.getParameters().length > 0;
+ boolean hasYieldReturn = containsYieldReturnCalls(closure);
+
+ ClassNode asyncUtils = ClassHelper.make("groovy.transform.AsyncUtils");
+ if (hasYieldReturn) {
+ // Inject synthetic $asyncGen as first parameter — rebuild closure
+ Parameter genParam = new Parameter(ClassHelper.DYNAMIC_TYPE,
"$asyncGen");
+ 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)
+ injectGenParamIntoYieldReturnCalls(genClosure.getCode(), genParam);
+ String method = hasUserParams ? "wrapAsyncGenerator" :
"generateAsyncStream";
+ return configureAST(callX(asyncUtils, method, new
ArgumentListExpression(genClosure)), ctx);
+ } else {
+ String method = hasUserParams ? "wrapAsync" : "async";
+ return configureAST(callX(asyncUtils, method, new
ArgumentListExpression(closure)), ctx);
+ }
+ }
+
+ /**
+ * Walks statement tree and transforms {@code
AsyncUtils.yieldReturn(expr)} calls to
+ * {@code AsyncUtils.yieldReturn($asyncGen, expr)}, injecting the
generator parameter
+ * as the first argument. Does not descend into nested closures.
+ */
+ private static void injectGenParamIntoYieldReturnCalls(Statement code,
Parameter genParam) {
+ code.visit(new org.codehaus.groovy.ast.CodeVisitorSupport() {
+ @Override
+ public void
visitExpressionStatement(org.codehaus.groovy.ast.stmt.ExpressionStatement stmt)
{
+ Expression expr = stmt.getExpression();
+ if (expr instanceof
org.codehaus.groovy.ast.expr.StaticMethodCallExpression smce
+ && "yieldReturn".equals(smce.getMethod())
+ &&
"groovy.transform.AsyncUtils".equals(smce.getOwnerType().getName())) {
+ VariableExpression genRef = varX("$asyncGen");
+ genRef.setAccessedVariable(genParam);
+ ArgumentListExpression newArgs = new
ArgumentListExpression();
+ newArgs.addExpression(genRef);
+ if (smce.getArguments() instanceof ArgumentListExpression
argList) {
+ for (Expression arg : argList.getExpressions()) {
+ newArgs.addExpression(arg);
+ }
+ }
+ org.codehaus.groovy.ast.expr.StaticMethodCallExpression
replacement =
+ new
org.codehaus.groovy.ast.expr.StaticMethodCallExpression(
+ smce.getOwnerType(), "yieldReturn",
newArgs);
+ replacement.setSourcePosition(smce);
+ stmt.setExpression(replacement);
+ }
+ super.visitExpressionStatement(stmt);
+ }
+ @Override
+ public void visitClosureExpression(ClosureExpression expression) {
+ // Don't descend into nested closures
+ }
+ });
+ }
+
+ /**
+ * Checks whether a closure's AST body contains {@code
AsyncUtils.yieldReturn()} calls,
+ * used to determine if an async closure is a generator.
+ */
+ private static boolean containsYieldReturnCalls(ClosureExpression closure)
{
+ boolean[] found = {false};
+ closure.getCode().visit(new CodeVisitorSupport() {
+ @Override
+ public void
visitStaticMethodCallExpression(StaticMethodCallExpression call) {
+ if ("yieldReturn".equals(call.getMethod())) {
Review Comment:
containsYieldReturnCalls() treats any StaticMethodCallExpression named
"yieldReturn" as a generator marker, without checking the owner type is
groovy.transform.AsyncUtils. A user calling some other static yieldReturn(...)
could be misclassified as an async generator and get rewritten unexpectedly.
Consider checking call.getOwnerType().getName() (or similar) to ensure only
AsyncUtils.yieldReturn triggers generator handling.
```suggestion
ClassNode ownerType = call.getOwnerType();
if ("yieldReturn".equals(call.getMethod())
&& ownerType != null
&&
"groovy.transform.AsyncUtils".equals(ownerType.getName())) {
```
##########
src/main/java/org/codehaus/groovy/transform/AsyncASTTransformation.java:
##########
@@ -0,0 +1,306 @@
+/*
+ * 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 groovy.transform.AsyncUtils;
+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.ClassCodeVisitorSupport;
+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.CodeVisitorSupport;
+import org.codehaus.groovy.ast.expr.ArgumentListExpression;
+import org.codehaus.groovy.ast.expr.ClassExpression;
+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.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.control.CompilePhase;
+import org.codehaus.groovy.control.SourceUnit;
+
+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.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} annotation and the {@code
async}
+ * keyword modifier.
+ * <p>
+ * Transforms the annotated method so that:
+ * <ol>
+ * <li>{@code await(expr)} calls are redirected to {@link
AsyncUtils#await}</li>
+ * <li>The method body is wrapped in {@code
GroovyPromise.of(CompletableFuture.supplyAsync(...))}
+ * (or {@code runAsync} for void methods)</li>
+ * <li>The return type becomes {@link Awaitable}{@code <T>}</li>
+ * </ol>
+ *
+ * @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);
+ private static final ClassNode ASYNC_UTILS_TYPE =
ClassHelper.make(AsyncUtils.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())) {
+ 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 = callX(ASYNC_UTILS_TYPE, "getExecutor", new
org.codehaus.groovy.ast.expr.ArgumentListExpression());
+ }
+
+ // Step 1: Transform await() method calls to AsyncUtils.await()
+ new AwaitCallTransformer(source).visitMethod(mNode);
+
+ // Step 2: Check if body contains yield return
(AsyncUtils.yieldReturn) calls
+ Statement transformedBody = mNode.getCode();
+ boolean hasYieldReturn = containsYieldReturn(transformedBody, source);
+
+ Parameter[] closureParams;
+ if (hasYieldReturn) {
+ // Generator: inject $asyncGen as closure parameter so yield return
+ // can reference it directly — no ThreadLocal needed
+ closureParams = new Parameter[]{ new
Parameter(ClassHelper.DYNAMIC_TYPE, "$asyncGen") };
+ } 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
+ injectGenParamIntoYieldReturnCalls(transformedBody,
closureParams[0]);
+
+ // Async generator: wrap body in AsyncUtils.generateAsyncStream {
... }
+ Expression genCall = callX(ASYNC_UTILS_TYPE,
"generateAsyncStream", args(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
AsyncUtils.executeAsync/executeAsyncVoid
+ // This ensures identical exception handling with async
closures/lambdas
+ boolean isVoid = ClassHelper.isPrimitiveVoid(originalReturnType);
+ Expression asyncCall;
+ if (isVoid) {
+ asyncCall = callX(ASYNC_UTILS_TYPE, "executeAsyncVoid",
args(closure, executorExpr));
+ } else {
+ asyncCall = callX(ASYNC_UTILS_TYPE, "executeAsync",
args(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 statement tree and transforms {@code
AsyncUtils.yieldReturn(expr)} calls to
+ * {@code AsyncUtils.yieldReturn($asyncGen, expr)}, injecting the
generator parameter
+ * as the first argument. Does not descend into nested closures.
+ */
+ private 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
+ && "yieldReturn".equals(smce.getMethod())
+ &&
"groovy.transform.AsyncUtils".equals(smce.getOwnerType().getName())) {
+ VariableExpression genRef = varX("$asyncGen");
+ 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(), "yieldReturn", newArgs);
+ replacement.setSourcePosition(smce);
+ stmt.setExpression(replacement);
+ }
+ super.visitExpressionStatement(stmt);
+ }
+ @Override
+ public void visitClosureExpression(ClosureExpression expression) {
+ // Don't descend into nested closures
+ }
+ });
+ }
+
+ /**
+ * Checks whether the given statement tree contains any
+ * {@code AsyncUtils.yieldReturn()} calls, indicating this is
+ * an async generator method (returns {@link AsyncStream}).
+ */
+ private static boolean containsYieldReturn(Statement body, SourceUnit
source) {
+ boolean[] found = {false};
+ new ClassCodeVisitorSupport() {
+ @Override
+ protected SourceUnit getSourceUnit() { return source; }
+
+ @Override
+ public void visitExpressionStatement(ExpressionStatement stmt) {
+ Expression expr = stmt.getExpression();
+ if (expr instanceof StaticMethodCallExpression smce
+ && "yieldReturn".equals(smce.getMethod())) {
Review Comment:
containsYieldReturn() marks a method as an async generator if it sees any
StaticMethodCallExpression whose method name is "yieldReturn", without
verifying it’s actually groovy.transform.AsyncUtils.yieldReturn. This can
misclassify methods that call some other static yieldReturn(...) and rewrite
their signature/semantics. Consider checking the owner type name (as
injectGenParamIntoYieldReturnCalls already does).
```suggestion
&& "yieldReturn".equals(smce.getMethod())
&&
"groovy.transform.AsyncUtils".equals(smce.getOwnerType().getName())) {
```
##########
src/test/groovy/org/codehaus/groovy/transform/AsyncFrameworkIntegrationTest.groovy:
##########
@@ -0,0 +1,506 @@
+/*
+ * 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.concurrent.AwaitableAdapter
+import groovy.concurrent.AwaitableAdapterRegistry
+import groovy.concurrent.GroovyPromise
+import static groovy.transform.AsyncUtils.*
+import io.reactivex.rxjava3.core.Flowable
+import io.reactivex.rxjava3.core.Maybe
+import io.reactivex.rxjava3.core.Observable
+import io.reactivex.rxjava3.core.Single
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import reactor.core.publisher.Flux
+import reactor.core.publisher.Mono
+
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.CompletionStage
+
+/**
+ * Integration tests for Groovy async/await with third-party reactive
+ * frameworks: Reactor (Spring WebFlux foundation), RxJava 3, and Spring-style
+ * async patterns.
+ * <p>
+ * These tests demonstrate that the {@link AwaitableAdapter} SPI allows
+ * transparent integration without coupling Groovy's core to any specific
+ * reactive library.
+ */
+class AsyncFrameworkIntegrationTest {
+
+ private ReactorAwaitableAdapter reactorAdapter
+ private RxJavaAwaitableAdapter rxJavaAdapter
+
+ @BeforeEach
+ void registerAdapters() {
+ reactorAdapter = new ReactorAwaitableAdapter()
+ rxJavaAdapter = new RxJavaAwaitableAdapter()
+ AwaitableAdapterRegistry.register(reactorAdapter)
+ AwaitableAdapterRegistry.register(rxJavaAdapter)
+ }
Review Comment:
This test registers adapters in `@BeforeEach` but never removes them;
because AwaitableAdapterRegistry is global/static, adapters will accumulate
across the test JVM and can make tests order-dependent. Once an
unregister/reset API exists, please restore the registry state in `@AfterEach`
(or register once per class).
##########
src/test/groovy/org/codehaus/groovy/transform/AsyncTransformTest.groovy:
##########
@@ -0,0 +1,1399 @@
+/*
+ * 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.control.CompilationFailedException
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.Executors
+
+import static groovy.test.GroovyAssert.assertScript
+import static groovy.test.GroovyAssert.shouldFail
+import static groovy.transform.AsyncUtils.async
+import static groovy.transform.AsyncUtils.await
+import static groovy.transform.AsyncUtils.awaitAll
+import static groovy.transform.AsyncUtils.awaitAny
+import static groovy.transform.AsyncUtils.setExecutor
+
+/**
+ * Tests for async/await: @Async annotation, async/await keywords,
+ * Awaitable abstraction, for-await, and AsyncUtils.
+ */
+class AsyncTransformTest {
+
+ @AfterEach
+ void resetExecutor() {
+ setExecutor(null)
+ }
+
+ // ==== @Async annotation tests ====
+
+ @Test
+ void testBasicAsyncReturnsAwaitable() {
+ assertScript '''
+ import groovy.transform.Async
+ import groovy.concurrent.Awaitable
+
+ class Service {
+ @Async
+ def fetchData() {
+ return "hello"
+ }
+ }
+
+ def service = new Service()
+ def future = service.fetchData()
+ assert future instanceof Awaitable
+ assert future.get() == "hello"
+ '''
+ }
+
+ @Test
+ void testAsyncWithTypedReturnValue() {
+ assertScript '''
+ import groovy.transform.Async
+ import groovy.concurrent.Awaitable
+
+ class Service {
+ @Async
+ String compute() {
+ return "typed-result"
+ }
+ }
+
+ def service = new Service()
+ Awaitable<String> future = service.compute()
+ assert future.get() == "typed-result"
+ '''
+ }
+
+ @Test
+ void testAsyncWithIntReturnValue() {
+ assertScript '''
+ import groovy.transform.Async
+ import groovy.concurrent.Awaitable
+
+ class Calculator {
+ @Async
+ int add(int a, int b) {
+ return a + b
+ }
+ }
+
+ def calc = new Calculator()
+ def future = calc.add(3, 4)
+ assert future instanceof Awaitable
+ assert future.get() == 7
+ '''
+ }
+
+ @Test
+ void testAsyncVoidMethod() {
+ assertScript '''
+ import groovy.transform.Async
+ import groovy.concurrent.Awaitable
+ import java.util.concurrent.atomic.AtomicBoolean
+
+ class Worker {
+ AtomicBoolean executed = new AtomicBoolean(false)
+
+ @Async
+ void doWork() {
+ executed.set(true)
+ }
+ }
+
+ def worker = new Worker()
+ def future = worker.doWork()
+ assert future instanceof Awaitable
+ future.get()
+ assert worker.executed.get()
+ '''
+ }
+
+ @Test
+ void testAsyncStaticMethod() {
+ assertScript '''
+ import groovy.transform.Async
+ import groovy.concurrent.Awaitable
+
+ class Util {
+ @Async
+ static String process(String input) {
+ return input.toUpperCase()
+ }
+ }
+
+ def future = Util.process("hello")
+ assert future instanceof Awaitable
+ assert future.get() == "HELLO"
+ '''
+ }
+
+ // ==== await() call-style tests (backward compat) ====
+
+ @Test
+ void testAwaitCallInAsyncMethod() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ @Async
+ def fetchAndProcess() {
+ def value = await(CompletableFuture.supplyAsync { 42 })
+ return value * 2
+ }
+ }
+
+ def service = new Service()
+ assert service.fetchAndProcess().get() == 84
+ '''
+ }
+
+ @Test
+ void testMultipleSequentialAwaits() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ @Async
+ def combineResults() {
+ def a = await(CompletableFuture.supplyAsync { 10 })
+ def b = await(CompletableFuture.supplyAsync { 20 })
+ def c = await(CompletableFuture.supplyAsync { 30 })
+ return a + b + c
+ }
+ }
+
+ def service = new Service()
+ assert service.combineResults().get() == 60
+ '''
+ }
+
+ @Test
+ void testAwaitWithDependentFutures() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ @Async
+ def pipeline() {
+ def raw = await(CompletableFuture.supplyAsync { "hello
world" })
+ def upper = await(CompletableFuture.supplyAsync {
raw.toUpperCase() })
+ return upper
+ }
+ }
+
+ def service = new Service()
+ assert service.pipeline().get() == "HELLO WORLD"
+ '''
+ }
+
+ @Test
+ void testNestedAwaitInExpression() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ @Async
+ def nestedExpr() {
+ return await(CompletableFuture.supplyAsync { 5 }) +
await(CompletableFuture.supplyAsync { 3 })
+ }
+ }
+
+ def service = new Service()
+ assert service.nestedExpr().get() == 8
+ '''
+ }
+
+ @Test
+ void testAwaitInsideConditional() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ @Async
+ def conditional(boolean flag) {
+ if (flag) {
+ return await(CompletableFuture.supplyAsync { "yes" })
+ } else {
+ return await(CompletableFuture.supplyAsync { "no" })
+ }
+ }
+ }
+
+ def service = new Service()
+ assert service.conditional(true).get() == "yes"
+ assert service.conditional(false).get() == "no"
+ '''
+ }
+
+ @Test
+ void testAwaitInsideLoop() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ @Async
+ def sumAsync(int count) {
+ int sum = 0
+ for (int i = 1; i <= count; i++) {
+ sum += await(CompletableFuture.supplyAsync { i })
+ }
+ return sum
+ }
+ }
+
+ def service = new Service()
+ assert service.sumAsync(5).get() == 15
+ '''
+ }
+
+ @Test
+ void testAwaitInsideTryCatch() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ @Async
+ def safeOperation() {
+ try {
+ def result = await(CompletableFuture.supplyAsync { 42
})
+ return result
+ } catch (Exception e) {
+ return -1
+ }
+ }
+ }
+
+ def service = new Service()
+ assert service.safeOperation().get() == 42
+ '''
+ }
+
+ @Test
+ void testAwaitInsideClosureInAsyncMethod() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ @Async
+ def collectResults() {
+ def futures = [
+ CompletableFuture.supplyAsync { 1 },
+ CompletableFuture.supplyAsync { 2 },
+ CompletableFuture.supplyAsync { 3 }
+ ]
+ return futures.collect { f -> await(f) }
+ }
+ }
+
+ def service = new Service()
+ assert service.collectResults().get() == [1, 2, 3]
+ '''
+ }
+
+ // ==== Exception handling ====
+
+ @Test
+ void testAsyncExceptionPropagation() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.ExecutionException
+
+ class Service {
+ @Async
+ def failingMethod() {
+ throw new IllegalStateException("async failure")
+ }
+ }
+
+ def service = new Service()
+ def future = service.failingMethod()
+ try {
+ future.get()
+ assert false : "should have thrown"
+ } catch (ExecutionException e) {
+ assert e.cause instanceof IllegalStateException
+ assert e.cause.message == "async failure"
+ }
+ '''
+ }
+
+ @Test
+ void testAwaitExceptionFromFuture() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+ import java.util.concurrent.ExecutionException
+
+ class Service {
+ @Async
+ def awaitFailing() {
+ return await(CompletableFuture.supplyAsync {
+ throw new IllegalArgumentException("inner failure")
+ })
+ }
+ }
+
+ def service = new Service()
+ try {
+ service.awaitFailing().get()
+ assert false : "should have thrown"
+ } catch (ExecutionException e) {
+ assert e.cause instanceof IllegalArgumentException
+ assert e.cause.message == "inner failure"
+ }
+ '''
+ }
+
+ @Test
+ void testAwaitExceptionCaughtInTryCatch() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ @Async
+ def recoverFromError() {
+ try {
+ return await(CompletableFuture.supplyAsync {
+ throw new IllegalArgumentException("oops")
+ })
+ } catch (IllegalArgumentException e) {
+ return "recovered: " + e.message
+ }
+ }
+ }
+
+ def service = new Service()
+ assert service.recoverFromError().get() == "recovered: oops"
+ '''
+ }
+
+ // ==== Custom executor ====
+
+ @Test
+ void testAsyncWithCustomExecutor() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.Executors
+
+ class Service {
+ def myPool = Executors.newSingleThreadExecutor()
+
+ @Async(executor = "myPool")
+ def runOnCustomPool() {
+ return Thread.currentThread().name
+ }
+ }
+
+ def service = new Service()
+ def threadName = service.runOnCustomPool().get()
+ assert threadName.contains("pool")
+ service.myPool.shutdown()
+ '''
+ }
+
+ @Test
+ void testAsyncStaticMethodWithStaticExecutor() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.Executors
+
+ class Service {
+ static def pool = Executors.newSingleThreadExecutor()
+
+ @Async(executor = "pool")
+ static def runStatic() {
+ return "static-result"
+ }
+ }
+
+ assert Service.runStatic().get() == "static-result"
+ Service.pool.shutdown()
+ '''
+ }
+
+ // ==== Concurrency ====
+
+ @Test
+ void testAsyncMethodRunsOnDifferentThread() {
+ assertScript '''
+ import groovy.transform.Async
+
+ class Service {
+ @Async
+ def getThreadName() {
+ return Thread.currentThread().name
+ }
+ }
+
+ def service = new Service()
+ def callerThread = Thread.currentThread().name
+ def asyncThread = service.getThreadName().get()
+ assert asyncThread != callerThread ||
asyncThread.contains("ForkJoinPool")
+ '''
+ }
+
+ @Test
+ void testConcurrentAsyncExecution() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CountDownLatch
+ import java.util.concurrent.atomic.AtomicInteger
+
+ class Service {
+ AtomicInteger concurrentCount = new AtomicInteger(0)
+ AtomicInteger maxConcurrent = new AtomicInteger(0)
+
+ @Async
+ def concurrentTask(CountDownLatch startLatch) {
+ int current = concurrentCount.incrementAndGet()
+ maxConcurrent.updateAndGet { max -> Math.max(max, current)
}
+ startLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)
+ concurrentCount.decrementAndGet()
+ return current
+ }
+ }
+
+ def service = new Service()
+ def latch = new CountDownLatch(1)
+ def futures = (1..10).collect { service.concurrentTask(latch) }
+ Thread.sleep(500)
+ latch.countDown()
+ futures.each { it.get(5, java.util.concurrent.TimeUnit.SECONDS) }
+ assert service.maxConcurrent.get() > 1
+ '''
+ }
+
+ @Test
+ void testHighConcurrencyStressTest() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.atomic.AtomicInteger
+
+ class Service {
+ AtomicInteger counter = new AtomicInteger(0)
+
+ @Async
+ def increment() {
+ counter.incrementAndGet()
+ return counter.get()
+ }
+ }
+
+ def service = new Service()
+ int taskCount = 1000
+ def futures = (1..taskCount).collect { service.increment() }
+ futures.each { it.get(10, java.util.concurrent.TimeUnit.SECONDS) }
+ assert service.counter.get() == taskCount
+ '''
+ }
+
+ @Test
+ void testParallelAwaitPattern() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ CompletableFuture<Integer> asyncCompute(int value) {
+ return CompletableFuture.supplyAsync {
+ Thread.sleep(50)
+ return value * 2
+ }
+ }
+
+ @Async
+ def parallelFetch() {
+ def f1 = asyncCompute(1)
+ def f2 = asyncCompute(2)
+ def f3 = asyncCompute(3)
+ return await(f1) + await(f2) + await(f3)
+ }
+ }
+
+ def service = new Service()
+ long start = System.currentTimeMillis()
+ def result = service.parallelFetch().get()
+ long elapsed = System.currentTimeMillis() - start
+ assert result == 12
+ assert elapsed < 500
+ '''
+ }
+
+ // ==== AsyncUtils standalone ====
+
+ @Test
+ void testAsyncUtilsAsyncClosure() {
+ def future = async { 42 }
+ assert future instanceof groovy.concurrent.Awaitable
+ assert future.get() == 42
+ }
+
+ @Test
+ void testAsyncUtilsAwait() {
+ def future = CompletableFuture.supplyAsync { "hello" }
+ def result = await(future)
+ assert result == "hello"
+ }
+
+ @Test
+ void testAsyncUtilsAwaitAll() {
+ def f1 = CompletableFuture.supplyAsync { 1 }
+ def f2 = CompletableFuture.supplyAsync { 2 }
+ def f3 = CompletableFuture.supplyAsync { 3 }
+ def results = awaitAll(f1, f2, f3)
+ assert results == [1, 2, 3]
+ }
+
+ @Test
+ void testAsyncUtilsAwaitAny() {
+ def f1 = CompletableFuture.supplyAsync { "first" }
+ def f2 = new CompletableFuture()
+ def f3 = new CompletableFuture()
+ def result = awaitAny(f1, f2, f3)
+ assert result == "first"
+ }
+
+ @Test
+ void testAsyncUtilsSetCustomExecutor() {
+ def pool = Executors.newSingleThreadExecutor()
+ try {
+ setExecutor(pool)
+ def future = async { Thread.currentThread().name }
+ assert future.get().contains("pool")
+ } finally {
+ setExecutor(null)
+ pool.shutdown()
+ }
+ }
+
+ @Test
+ void testAsyncUtilsAwaitFutureInterface() {
+ def pool = Executors.newSingleThreadExecutor()
+ try {
+ java.util.concurrent.Future<String> future = pool.submit({
"from-executor" } as java.util.concurrent.Callable)
+ def result = await(future)
+ assert result == "from-executor"
+ } finally {
+ pool.shutdown()
+ }
+ }
+
+ @Test
+ void testAsyncUtilsAwaitExceptionUnwrapping() {
+ def future = CompletableFuture.supplyAsync {
+ throw new IllegalStateException("test error")
+ }
+ try {
+ await(future)
+ assert false : "should have thrown"
+ } catch (IllegalStateException e) {
+ assert e.message == "test error"
+ }
+ }
+
+ // ==== Compilation errors ====
+
+ @Test
+ void testAsyncOnAbstractMethodFails() {
+ def err = shouldFail CompilationFailedException, '''
+ import groovy.transform.Async
+
+ abstract class Service {
+ @Async
+ abstract def fetchData()
+ }
+ '''
+ assert err.message.contains("cannot be applied to abstract method")
+ }
+
+ @Test
+ void testAsyncOnAwaitableReturnTypeFails() {
+ def err = shouldFail CompilationFailedException, '''
+ import groovy.transform.Async
+ import groovy.concurrent.Awaitable
+
+ class Service {
+ @Async
+ Awaitable<String> fetchData() {
+ return groovy.concurrent.Awaitable.of("hello")
+ }
+ }
+ '''
+ assert err.message.contains("already returns an async type")
+ }
+
+ @Test
+ void testAsyncWithMissingExecutorFieldFails() {
+ def err = shouldFail CompilationFailedException, '''
+ import groovy.transform.Async
+
+ class Service {
+ @Async(executor = "nonExistentField")
+ def fetchData() {
+ return "hello"
+ }
+ }
+ '''
+ assert err.message.contains("executor field 'nonExistentField' not
found")
+ }
+
+ @Test
+ void testAsyncStaticMethodWithInstanceExecutorFails() {
+ def err = shouldFail CompilationFailedException, '''
+ import groovy.transform.Async
+ import java.util.concurrent.Executors
+
+ class Service {
+ def myPool = Executors.newSingleThreadExecutor()
+
+ @Async(executor = "myPool")
+ static def fetchData() {
+ return "hello"
+ }
+ }
+ '''
+ assert err.message.contains("must be static for static method")
+ }
+
+ // ==== Static import async/await ====
+
+ @Test
+ void testAsyncClosureWithStaticImport() {
+ assertScript '''
+ import static groovy.transform.AsyncUtils.*
+ import java.util.concurrent.CompletableFuture
+
+ def future = async {
+ def a = await(CompletableFuture.supplyAsync { 10 })
+ def b = await(CompletableFuture.supplyAsync { 20 })
+ return a + b
+ }
+ assert future.get() == 30
+ '''
+ }
+
+ @Test
+ void testAsyncClosureExceptionHandling() {
+ assertScript '''
+ import static groovy.transform.AsyncUtils.*
+ import java.util.concurrent.ExecutionException
+
+ def future = async {
+ throw new IllegalStateException("closure error")
+ }
+ try {
+ future.get()
+ assert false : "should have thrown"
+ } catch (ExecutionException e) {
+ assert e.cause instanceof IllegalStateException
+ assert e.cause.message == "closure error"
+ }
+ '''
+ }
+
+ // ==== Integration scenarios ====
+
+ @Test
+ void testAsyncProducerConsumerPattern() {
+ assertScript '''
+ import groovy.transform.Async
+ import groovy.concurrent.Awaitable
+
+ class Producer {
+ @Async
+ def produce(int id) {
+ Thread.sleep(10)
+ return "item-${id}"
+ }
+ }
+
+ class Consumer {
+ @Async
+ def consume(Awaitable<String> itemFuture) {
+ def item = await(itemFuture)
+ return "consumed: ${item}"
+ }
+ }
+
+ def producer = new Producer()
+ def consumer = new Consumer()
+ def item = producer.produce(1)
+ def result = consumer.consume(item)
+ assert result.get() == "consumed: item-1"
+ '''
+ }
+
+ @Test
+ void testAsyncChainedCalls() {
+ assertScript '''
+ import groovy.transform.Async
+ import groovy.concurrent.Awaitable
+
+ class Pipeline {
+ @Async
+ def step1() { return 1 }
+
+ @Async
+ def step2(Awaitable<Integer> input) {
+ return await(input) + 10
+ }
+
+ @Async
+ def step3(Awaitable<Integer> input) {
+ return await(input) * 2
+ }
+ }
+
+ def p = new Pipeline()
+ def r1 = p.step1()
+ def r2 = p.step2(r1)
+ def r3 = p.step3(r2)
+ assert r3.get() == 22
+ '''
+ }
+
+ @Test
+ void testAsyncWithMethodParameters() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ @Async
+ def greet(String name, int times) {
+ def parts = []
+ for (int i = 0; i < times; i++) {
+ parts << await(CompletableFuture.supplyAsync { "Hello
${name}" })
+ }
+ return parts.join(", ")
+ }
+ }
+
+ def service = new Service()
+ assert service.greet("World", 3).get() == "Hello World, Hello
World, Hello World"
+ '''
+ }
+
+ @Test
+ void testAsyncWithCollectionProcessing() {
+ assertScript '''
+ import groovy.transform.Async
+ import java.util.concurrent.CompletableFuture
+
+ class BatchProcessor {
+ @Async
+ def processBatch(List<Integer> items) {
+ def futures = items.collect { item ->
+ CompletableFuture.supplyAsync { item * item }
+ }
+ return futures.collect { f -> await(f) }
+ }
+ }
+
+ def processor = new BatchProcessor()
+ assert processor.processBatch([1, 2, 3, 4, 5]).get() == [1, 4, 9,
16, 25]
+ '''
+ }
+
+ @Test
+ void testAsyncAwaitAllPattern() {
+ assertScript '''
+ import groovy.transform.Async
+ import static groovy.transform.AsyncUtils.*
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ @Async
+ def fetchAll() {
+ def f1 = CompletableFuture.supplyAsync { "a" }
+ def f2 = CompletableFuture.supplyAsync { "b" }
+ def f3 = CompletableFuture.supplyAsync { "c" }
+ return awaitAll(f1, f2, f3)
+ }
+ }
+
+ def service = new Service()
+ assert service.fetchAll().get() == ["a", "b", "c"]
+ '''
+ }
+
+ @Test
+ void testAsyncInScript() {
+ assertScript '''
+ import groovy.transform.Async
+ import groovy.concurrent.Awaitable
+
+ @Async
+ def asyncAdd(int a, int b) {
+ return a + b
+ }
+
+ def future = asyncAdd(3, 4)
+ assert future instanceof Awaitable
+ assert future.get() == 7
+ '''
+ }
+
+ // ==== 'async' keyword modifier ====
+
+ @Test
+ void testAsyncKeywordModifier() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+
+ class Service {
+ async def fetchData() {
+ return "from-async-keyword"
+ }
+ }
+
+ def service = new Service()
+ def result = service.fetchData()
+ assert result instanceof Awaitable
+ assert result.get() == "from-async-keyword"
+ '''
+ }
+
+ @Test
+ void testAsyncKeywordWithAwaitKeyword() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ async def compute() {
+ def a = await CompletableFuture.supplyAsync { 10 }
+ def b = await CompletableFuture.supplyAsync { 20 }
+ return a + b
+ }
+ }
+
+ def service = new Service()
+ assert service.compute().get() == 30
+ '''
+ }
+
+ @Test
+ void testAsyncKeywordStaticMethod() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+
+ class Util {
+ async static process(String input) {
+ return input.toUpperCase()
+ }
+ }
+
+ def result = Util.process("hello")
+ assert result instanceof Awaitable
+ assert result.get() == "HELLO"
+ '''
+ }
+
+ @Test
+ void testAsyncKeywordVoidMethod() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import java.util.concurrent.atomic.AtomicBoolean
+
+ class Worker {
+ AtomicBoolean done = new AtomicBoolean(false)
+
+ async void doWork() {
+ done.set(true)
+ }
+ }
+
+ def worker = new Worker()
+ def result = worker.doWork()
+ assert result instanceof Awaitable
+ result.get()
+ assert worker.done.get()
+ '''
+ }
+
+ @Test
+ void testAsyncKeywordInScript() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+
+ async def add(int a, int b) {
+ return a + b
+ }
+
+ def result = add(5, 7)
+ assert result instanceof Awaitable
+ assert result.get() == 12
+ '''
+ }
+
+ // ==== 'await' keyword expression ====
+
+ @Test
+ void testAwaitKeywordExpression() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ async def fetchAndDouble() {
+ def value = await CompletableFuture.supplyAsync { 21 }
+ return value * 2
+ }
+ }
+
+ def service = new Service()
+ assert service.fetchAndDouble().get() == 42
+ '''
+ }
+
+ @Test
+ void testAwaitKeywordPrecedence() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ async def addAwaits() {
+ // 'await' should bind to the immediate expression, not to
the sum
+ // await a + await b => (await a) + (await b)
+ return (await CompletableFuture.supplyAsync { 10 }) +
(await CompletableFuture.supplyAsync { 5 })
+ }
+ }
+
+ def service = new Service()
+ assert service.addAwaits().get() == 15
+ '''
+ }
+
+ @Test
+ void testAwaitKeywordWithParentheses() {
+ assertScript '''
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ @groovy.transform.Async
+ def compute() {
+ // await(expr) still works as await keyword +
parenthesized expression
+ def a = await(CompletableFuture.supplyAsync { 100 })
+ return a
+ }
+ }
+
+ def service = new Service()
+ assert service.compute().get() == 100
+ '''
+ }
+
+ @Test
+ void testAwaitKeywordWithMethodCall() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ CompletableFuture<String> fetchRemote() {
+ CompletableFuture.supplyAsync { "remote-data" }
+ }
+
+ async def process() {
+ def data = await fetchRemote()
+ return "processed: ${data}"
+ }
+ }
+
+ def service = new Service()
+ assert service.process().get() == "processed: remote-data"
+ '''
+ }
+
+ @Test
+ void testAwaitKeywordInConditional() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import java.util.concurrent.CompletableFuture
+
+ class Service {
+ async def check(boolean flag) {
+ if (flag) {
+ return await CompletableFuture.supplyAsync { "yes" }
+ } else {
+ return await CompletableFuture.supplyAsync { "no" }
+ }
+ }
+ }
+
+ def service = new Service()
+ assert service.check(true).get() == "yes"
+ assert service.check(false).get() == "no"
+ '''
+ }
+
+ // ==== Awaitable abstraction tests ====
+
+ @Test
+ void testAwaitableInterface() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import groovy.concurrent.GroovyPromise
+ import java.util.concurrent.CompletableFuture
+
+ // Test Awaitable.of()
+ def a = Awaitable.of("hello")
+ assert a.get() == "hello"
+ assert a.isDone()
+
+ // Test Awaitable.failed()
+ def f = Awaitable.failed(new RuntimeException("oops"))
+ try {
+ f.get()
+ assert false
+ } catch (java.util.concurrent.ExecutionException e) {
+ assert e.cause.message == "oops"
+ }
+ '''
+ }
+
+ @Test
+ void testGroovyPromiseThen() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import groovy.concurrent.GroovyPromise
+ import java.util.concurrent.CompletableFuture
+
+ def p = GroovyPromise.of(CompletableFuture.supplyAsync { 10 })
+ def p2 = p.then { it * 3 }
+ assert p2.get() == 30
+ '''
+ }
+
+ @Test
+ void testGroovyPromiseThenCompose() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import groovy.concurrent.GroovyPromise
+ import java.util.concurrent.CompletableFuture
+
+ def p = GroovyPromise.of(CompletableFuture.supplyAsync { 5 })
+ def p2 = p.thenCompose { val ->
+ GroovyPromise.of(CompletableFuture.supplyAsync { val * 4 })
+ }
+ assert p2.get() == 20
+ '''
+ }
+
+ @Test
+ void testGroovyPromiseExceptionally() {
+ assertScript '''
+ import groovy.concurrent.GroovyPromise
+ import java.util.concurrent.CompletableFuture
+
+ def p = GroovyPromise.of(CompletableFuture.supplyAsync { throw new
RuntimeException("fail") })
+ def recovered = p.exceptionally { t -> "recovered" }
+ assert recovered.get() == "recovered"
+ '''
+ }
+
+ @Test
+ void testAwaitableToCompletableFuture() {
+ assertScript '''
+ import groovy.concurrent.GroovyPromise
+ import java.util.concurrent.CompletableFuture
+
+ def p =
GroovyPromise.of(CompletableFuture.completedFuture("interop"))
+ CompletableFuture<String> cf = p.toCompletableFuture()
+ assert cf.get() == "interop"
+ '''
+ }
+
+ // ==== AwaitableAdapterRegistry tests ====
+
+ @Test
+ void testAdapterRegistryWithCompletableFuture() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import groovy.concurrent.AwaitableAdapterRegistry
+ import java.util.concurrent.CompletableFuture
+
+ def cf = CompletableFuture.completedFuture("adapted")
+ Awaitable<String> a = AwaitableAdapterRegistry.toAwaitable(cf)
+ assert a.get() == "adapted"
+ '''
+ }
+
+ @Test
+ void testAdapterRegistryPassthroughAwaitable() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import groovy.concurrent.AwaitableAdapterRegistry
+
+ def original = Awaitable.of(42)
+ def adapted = AwaitableAdapterRegistry.toAwaitable(original)
+ assert adapted.is(original)
+ '''
+ }
+
+ @Test
+ void testAdapterRegistryCustomAdapter() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import groovy.concurrent.AwaitableAdapter
+ import groovy.concurrent.AwaitableAdapterRegistry
+ import groovy.concurrent.GroovyPromise
+ import java.util.concurrent.CompletableFuture
+
+ // Simulate a custom async type
+ class CustomPromise {
+ final String value
+ CustomPromise(String v) { this.value = v }
+ }
+
+ // Register custom adapter
+ AwaitableAdapterRegistry.register(new AwaitableAdapter() {
+ boolean supportsAwaitable(Class<?> type) {
CustomPromise.isAssignableFrom(type) }
+ def <T> Awaitable<T> toAwaitable(Object source) {
+ return Awaitable.of(((CustomPromise) source).value)
+ }
+ })
Review Comment:
This test registers a custom AwaitableAdapter into the global
AwaitableAdapterRegistry but never unregisters it. Because the registry is
static, repeated registrations across tests will accumulate and can change
adapter precedence/order over time. Please ensure the registry state is
restored after the script (once an unregister/reset API exists), or avoid
per-test registration.
##########
src/test/groovy/org/codehaus/groovy/transform/AsyncVirtualThreadTest.groovy:
##########
@@ -0,0 +1,1115 @@
+/*
+ * 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.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+
+/**
+ * Tests for virtual thread integration, executor configuration, exception
+ * handling consistency across async methods/closures/lambdas, and coverage
+ * improvements for the async/await feature.
+ *
+ * @since 6.0.0
+ */
+final class AsyncVirtualThreadTest {
+
+ // --
> Support async/await like ES7
> ----------------------------
>
> Key: GROOVY-9381
> URL: https://issues.apache.org/jira/browse/GROOVY-9381
> Project: Groovy
> Issue Type: New Feature
> Reporter: Daniel Sun
> Priority: Major
>
> Here is an example to show proposed syntax and backend API(Java's
> {{CompletableFuture}} or GPars's {{{}Promise{}}}), but I think it's better
> for Groovy to have its own {{Promise}} to decouple with Java API because
> async/await as a language feature should be as stable as possible.
> {{async}} will generate the {{Awaitable}} instance such as Groovy {{Promise}}
> implementing the {{Awaitable}} interface, and {{await}} can wait for any
> {{Awaitable}} instance to complete and unwrap it for the result.
> {code:java}
> /**
> * 1. An async function that simulates a network API call.
> * The 'async' keyword implies it runs asynchronously without blocking.
> */
> async fetchUserData(userId) {
> println "Starting to fetch data for user ${userId}..."
>
> // Simulate a 1-second network delay.
> Thread.sleep(1000)
>
> println "Fetch successful!"
> // The 'async' function implicitly returns a "CompletableFuture" or
> "Promise" containing this value.
> return [userId: userId, name: 'Daniel']
> }
> /**
> * 2. An async function that uses 'await' to consume the result.
> */
> async processUserData() {
> println "Process started, preparing to fetch user data..."
>
> try {
> // 'await' pauses this function until fetchUserData completes
> // and returns the final result directly.
> def user = await fetchUserData(1)
>
> println "Data received: ${user}"
> return "Processing complete for ${user.name}."
>
> } catch (Exception e) {
> return "An error occurred: ${e.message}"
> }
> }
> // --- Execution ---
> println "Script starting..."
> // Kick off the entire asynchronous process.
> def future = processUserData()
> // This line executes immediately, proving the process is non-blocking.
> println "Script continues to run while user data is being fetched in the
> background..."
> def result = future.get()
> println "Script finished: ${result}"
> {code}
> Use async/await with closure or lambda expression:
> {code}
> // use closure
> def c = async {
> println "Process started, preparing to fetch user data..."
>
> try {
> // 'await' pauses this function until fetchUserData completes
> // and returns the final result directly.
> def user = await fetchUserData(1)
>
> println "Data received: ${user}"
> return "Processing complete for ${user.name}."
>
> } catch (Exception e) {
> return "An error occurred: ${e.message}"
> }
> }
> def future = c()
> {code}
> {code}
> // use lambda expression
> def c = async () -> {
> println "Process started, preparing to fetch user data..."
>
> try {
> // 'await' pauses this function until fetchUserData completes
> // and returns the final result directly.
> def user = await fetchUserData(1)
>
> println "Data received: ${user}"
> return "Processing complete for ${user.name}."
>
> } catch (Exception e) {
> return "An error occurred: ${e.message}"
> }
> }
> def future = c()
> {code}
--
This message was sent by Atlassian Jira
(v8.20.10#820010)