From f83d1a8f0deb30dc84036c376b247688456fc498 Mon Sep 17 00:00:00 2001 From: Daniel Sun Date: Fri, 3 Apr 2026 07:16:40 +1000 Subject: [PATCH] GROOVY-9381: Add native async/await support Co-authored-by: Paul King --- build.gradle | 1 + gradle/verification-metadata.xml | 18 + settings.gradle | 2 + src/antlr/GroovyLexer.g4 | 3 + src/antlr/GroovyParser.g4 | 18 +- .../java/groovy/concurrent/AsyncChannel.java | 114 + .../java/groovy/concurrent/AsyncScope.java | 163 + .../java/groovy/concurrent/AwaitResult.java | 202 + .../java/groovy/concurrent/Awaitable.java | 648 +++ .../groovy/concurrent/AwaitableAdapter.java | 67 + .../concurrent/AwaitableAdapterRegistry.java | 180 + .../concurrent/ChannelClosedException.java | 77 + .../groovy/parser/antlr4/AstBuilder.java | 99 +- .../groovy/runtime/async/AsyncSupport.java | 753 ++++ .../runtime/async/DefaultAsyncChannel.java | 306 ++ .../runtime/async/DefaultAsyncScope.java | 313 ++ .../groovy/runtime/async/GeneratorBridge.java | 158 + .../groovy/runtime/async/GroovyPromise.java | 255 ++ .../transform/AsyncTransformHelper.java | 225 + src/spec/doc/core-async-await.adoc | 527 +++ src/spec/test/AsyncAwaitSpecTest.groovy | 526 +++ src/test/groovy/groovy/AsyncAwaitTest.groovy | 3817 +++++++++++++++++ .../groovy-binary/src/spec/doc/index.adoc | 2 + subprojects/groovy-reactor/build.gradle | 32 + .../reactor/ReactorAwaitableAdapter.java | 68 + .../groovy.concurrent.AwaitableAdapter | 15 + .../ReactorAwaitableAdapterTest.groovy | 544 +++ subprojects/groovy-rxjava/build.gradle | 32 + .../groovy/rxjava/RxJavaAwaitableAdapter.java | 91 + .../groovy.concurrent.AwaitableAdapter | 15 + .../rxjava/RxJavaAwaitableAdapterTest.groovy | 682 +++ versions.properties | 2 + 32 files changed, 9953 insertions(+), 2 deletions(-) create mode 100644 src/main/java/groovy/concurrent/AsyncChannel.java create mode 100644 src/main/java/groovy/concurrent/AsyncScope.java create mode 100644 src/main/java/groovy/concurrent/AwaitResult.java create mode 100644 src/main/java/groovy/concurrent/Awaitable.java create mode 100644 src/main/java/groovy/concurrent/AwaitableAdapter.java create mode 100644 src/main/java/groovy/concurrent/AwaitableAdapterRegistry.java create mode 100644 src/main/java/groovy/concurrent/ChannelClosedException.java create mode 100644 src/main/java/org/apache/groovy/runtime/async/AsyncSupport.java create mode 100644 src/main/java/org/apache/groovy/runtime/async/DefaultAsyncChannel.java create mode 100644 src/main/java/org/apache/groovy/runtime/async/DefaultAsyncScope.java create mode 100644 src/main/java/org/apache/groovy/runtime/async/GeneratorBridge.java create mode 100644 src/main/java/org/apache/groovy/runtime/async/GroovyPromise.java create mode 100644 src/main/java/org/codehaus/groovy/transform/AsyncTransformHelper.java create mode 100644 src/spec/doc/core-async-await.adoc create mode 100644 src/spec/test/AsyncAwaitSpecTest.groovy create mode 100644 src/test/groovy/groovy/AsyncAwaitTest.groovy create mode 100644 subprojects/groovy-reactor/build.gradle create mode 100644 subprojects/groovy-reactor/src/main/java/org/apache/groovy/reactor/ReactorAwaitableAdapter.java create mode 100644 subprojects/groovy-reactor/src/main/resources/META-INF/services/groovy.concurrent.AwaitableAdapter create mode 100644 subprojects/groovy-reactor/src/test/groovy/org/apache/groovy/reactor/ReactorAwaitableAdapterTest.groovy create mode 100644 subprojects/groovy-rxjava/build.gradle create mode 100644 subprojects/groovy-rxjava/src/main/java/org/apache/groovy/rxjava/RxJavaAwaitableAdapter.java create mode 100644 subprojects/groovy-rxjava/src/main/resources/META-INF/services/groovy.concurrent.AwaitableAdapter create mode 100644 subprojects/groovy-rxjava/src/test/groovy/org/apache/groovy/rxjava/RxJavaAwaitableAdapterTest.groovy diff --git a/build.gradle b/build.gradle index 5b890890b9a..84be26536ca 100644 --- a/build.gradle +++ b/build.gradle @@ -109,6 +109,7 @@ dependencies { } testImplementation projects.groovyAnt + testImplementation projects.groovyHttpBuilder testImplementation projects.groovyNio testImplementation projects.groovyXml testImplementation projects.groovyJson diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 872e202996b..eaeb2726b4c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -927,6 +927,18 @@ + + + + + + + + + + + + @@ -2620,6 +2632,12 @@ + + + + + + diff --git a/settings.gradle b/settings.gradle index a6d7cebf24c..9fe7a40bf0a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -78,6 +78,8 @@ def subprojects = [ 'groovy-test-junit5', 'groovy-test-junit6', 'groovy-testng', + 'groovy-reactor', + 'groovy-rxjava', 'groovy-toml', 'groovy-typecheckers', 'groovy-xml', diff --git a/src/antlr/GroovyLexer.g4 b/src/antlr/GroovyLexer.g4 index 79ddf78dbd5..f1d69c5c954 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 550852286a1..7eaa0cae104 100644 --- a/src/antlr/GroovyParser.g4 +++ b/src/antlr/GroovyParser.g4 @@ -601,7 +601,7 @@ switchStatement ; loopStatement - : annotationsOpt FOR LPAREN forControl RPAREN nls statement #forStmtAlt + : annotationsOpt FOR AWAIT? LPAREN forControl RPAREN nls statement #forStmtAlt | annotationsOpt WHILE expressionInPar nls statement #whileStmtAlt | annotationsOpt DO nls statement nls WHILE expressionInPar #doWhileStmtAlt ; @@ -643,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 @@ -779,6 +781,14 @@ 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 ambiguity with method call, e.g. async { ... } + | ASYNC nls closureOrLambdaExpression #asyncClosureExprAlt + + // await expression: single-arg or multi-arg (parenthesized or unparenthesized) + | AWAIT nls ( LPAREN expression (COMMA nls expression)* RPAREN + | expression (COMMA nls expression)* + ) #awaitExprAlt + // qualified names, array expressions, method invocation, post inc/dec | postfixExpression #postfixExprAlt @@ -1229,6 +1239,9 @@ identifier : Identifier | CapitalizedIdentifier | AS + | ASYNC + | AWAIT + | DEFER | IN | PERMITS | RECORD @@ -1247,6 +1260,8 @@ keywords : ABSTRACT | AS | ASSERT + | ASYNC + | AWAIT | BREAK | CASE | CATCH @@ -1255,6 +1270,7 @@ keywords | CONTINUE | DEF | DEFAULT + | DEFER | DO | ELSE | ENUM diff --git a/src/main/java/groovy/concurrent/AsyncChannel.java b/src/main/java/groovy/concurrent/AsyncChannel.java new file mode 100644 index 00000000000..8fbf5a2fdce --- /dev/null +++ b/src/main/java/groovy/concurrent/AsyncChannel.java @@ -0,0 +1,114 @@ +/* + * 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.DefaultAsyncChannel; + +/** + * An asynchronous channel for inter-task communication with optional buffering. + *

+ * A channel coordinates producers and consumers without exposing explicit + * locks or shared mutable state, following the CSP (Communicating Sequential + * Processes) paradigm popularized by Go's channels. + *

+ * Channels support both unbuffered (rendezvous) and buffered modes: + *

    + *
  • Unbuffered — {@code create()} or {@code create(0)}. Each + * {@code send} suspends until a matching {@code receive} arrives.
  • + *
  • Buffered — {@code create(n)}. Values are enqueued until the + * buffer fills, then senders suspend.
  • + *
+ *

+ * Channels implement {@link Iterable}, so they work with {@code for await} + * and regular {@code for} loops — iteration yields received values until the + * channel is closed and drained: + *

{@code
+ * def ch = AsyncChannel.create(2)
+ * async { ch.send('a'); ch.send('b'); ch.close() }
+ * for await (item in ch) {
+ *     println item   // prints 'a', then 'b'
+ * }
+ * }
+ * + * @param the payload type + * @see Awaitable + * @since 6.0.0 + */ +public interface AsyncChannel extends Iterable { + + /** + * Creates an unbuffered (rendezvous) channel. + */ + static AsyncChannel create() { + return new DefaultAsyncChannel<>(); + } + + /** + * Creates a channel with the specified buffer capacity. + * + * @param capacity the maximum buffer size; 0 for unbuffered + */ + static AsyncChannel create(int capacity) { + return new DefaultAsyncChannel<>(capacity); + } + + /** Returns this channel's buffer capacity. */ + int getCapacity(); + + /** Returns the number of values currently buffered. */ + int getBufferedSize(); + + /** Returns {@code true} if this channel has been closed. */ + boolean isClosed(); + + /** + * Sends a value through this channel. + *

+ * The returned {@link Awaitable} completes when the value has been + * delivered to a receiver or buffered. Sending to a closed channel + * fails immediately with {@link ChannelClosedException}. + * + * @param value the value to send; must not be {@code null} + * @return an Awaitable that completes when the send succeeds + * @throws NullPointerException if value is null + */ + Awaitable send(T value); + + /** + * Receives the next value from this channel. + *

+ * The returned {@link Awaitable} completes when a value is available. + * Receiving from a closed, empty channel fails with + * {@link ChannelClosedException}. + * + * @return an Awaitable that yields the next value + */ + Awaitable receive(); + + /** + * Closes this channel. Idempotent. + *

+ * Buffered values remain receivable. Pending senders fail with + * {@link ChannelClosedException}. After all buffered values are + * drained, subsequent receives also fail. + * + * @return {@code true} if this call actually closed the channel + */ + boolean close(); +} diff --git a/src/main/java/groovy/concurrent/AsyncScope.java b/src/main/java/groovy/concurrent/AsyncScope.java new file mode 100644 index 00000000000..4d63ba59ba4 --- /dev/null +++ b/src/main/java/groovy/concurrent/AsyncScope.java @@ -0,0 +1,163 @@ +/* + * 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 groovy.lang.Closure; +import groovy.transform.stc.ClosureParams; +import groovy.transform.stc.SimpleType; +import org.apache.groovy.runtime.async.AsyncSupport; +import org.apache.groovy.runtime.async.DefaultAsyncScope; + +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.function.Supplier; + +/** + * A structured concurrency scope that ensures all child tasks complete + * (or are cancelled) before the scope exits. + *

+ * {@code AsyncScope} provides a bounded lifetime for async tasks, + * following the structured concurrency model. Unlike + * fire-and-forget {@code async { ... }}, tasks launched within a scope + * are guaranteed to complete before the scope closes. This prevents: + *

    + *
  • Orphaned tasks that outlive their logical parent
  • + *
  • Resource leaks from uncollected async work
  • + *
  • Silent failures from unobserved exceptions
  • + *
+ *

+ * By default, the scope uses a fail-fast policy: when any child + * task completes exceptionally, all sibling tasks are cancelled + * immediately. The first failure becomes the primary exception; + * subsequent failures are added as suppressed exceptions. + * + *

{@code
+ * def results = AsyncScope.withScope { scope ->
+ *     def userTask  = scope.async { fetchUser(id) }
+ *     def orderTask = scope.async { fetchOrders(id) }
+ *     return [user: await(userTask), orders: await(orderTask)]
+ * }
+ * // Both tasks guaranteed complete here
+ * }
+ * + * @see Awaitable + * @since 6.0.0 + */ +public interface AsyncScope extends AutoCloseable { + + /** + * Launches a child task within this scope. + * The task's lifetime is bound to the scope: when the scope is closed, + * all incomplete child tasks are cancelled. + * + * @param body the async body to execute + * @param the result type + * @return an {@link Awaitable} representing the child task + * @throws IllegalStateException if the scope has already been closed + */ + Awaitable async(Closure body); + + /** + * Launches a child task using a {@link Supplier} for Java interop. + */ + Awaitable async(Supplier supplier); + + /** + * Returns the number of tracked child tasks (including completed ones + * that have not yet been pruned). + */ + int getChildCount(); + + /** + * Cancels all child tasks. + */ + void cancelAll(); + + /** + * Closes the scope, waiting for all child tasks to complete. + * If any child failed and fail-fast is enabled, remaining children + * are cancelled and the first failure is rethrown. + */ + @Override + void close(); + + // ---- Static methods ------------------------------------------------- + + /** + * Returns the scope currently bound to this thread, or {@code null}. + */ + static AsyncScope current() { + return DefaultAsyncScope.current(); + } + + /** + * Executes the supplier with the given scope installed as current, + * restoring the previous binding afterwards. + */ + static T withCurrent(AsyncScope scope, Supplier supplier) { + return DefaultAsyncScope.withCurrent(scope, supplier); + } + + /** + * Creates a scope, executes the closure within it, and ensures the + * scope is closed on exit. The closure receives the scope as its + * argument for launching child tasks. + * + *
{@code
+     * def result = AsyncScope.withScope { scope ->
+     *     def a = scope.async { computeA() }
+     *     def b = scope.async { computeB() }
+     *     return [await(a), await(b)]
+     * }
+     * }
+ */ + @SuppressWarnings("unchecked") + static T withScope( + @ClosureParams(value = SimpleType.class, options = "groovy.concurrent.AsyncScope") Closure body) { + return withScope(AsyncSupport.getExecutor(), body); + } + + /** + * Creates a scope with the given executor, executes the closure, + * and ensures the scope is closed on exit. + */ + @SuppressWarnings("unchecked") + static T withScope(Executor executor, + @ClosureParams(value = SimpleType.class, options = "groovy.concurrent.AsyncScope") Closure body) { + Objects.requireNonNull(body, "body must not be null"); + try (AsyncScope scope = create(executor)) { + return withCurrent(scope, () -> body.call(scope)); + } + } + + /** Creates a new scope with the default executor and fail-fast enabled. */ + static AsyncScope create() { + return new DefaultAsyncScope(); + } + + /** Creates a new scope with the given executor and fail-fast enabled. */ + static AsyncScope create(Executor executor) { + return new DefaultAsyncScope(executor); + } + + /** Creates a new scope with the given executor and failure policy. */ + static AsyncScope create(Executor executor, boolean failFast) { + return new DefaultAsyncScope(executor, failFast); + } +} diff --git a/src/main/java/groovy/concurrent/AwaitResult.java b/src/main/java/groovy/concurrent/AwaitResult.java new file mode 100644 index 00000000000..f7f77879b90 --- /dev/null +++ b/src/main/java/groovy/concurrent/AwaitResult.java @@ -0,0 +1,202 @@ +/* + * 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. Returned by + * {@link Awaitable#allSettled(Object...) Awaitable.allSettled()} to + * report each individual task's result without short-circuiting on + * failure. + *

+ * An {@code AwaitResult} is either a {@linkplain #isSuccess() success} + * carrying a value, or a {@linkplain #isFailure() failure} carrying a + * {@link Throwable}. + *

+ * This type follows the value object pattern: two instances + * are {@linkplain #equals(Object) equal} if and only if they have + * the same success/failure state and carry equal values or errors. + * Immutability is enforced — all fields are final and no mutating + * methods are exposed. + *

+ * Functional composition is supported via {@link #map(Function)}, + * enabling transformation chains without unwrapping: + *

+ * AwaitResult<Integer> length = AwaitResult.success("hello").map(String::length)
+ * 
+ *

+ * Inspired by Kotlin's {@code Result}, Rust's {@code Result}, + * and C#'s pattern of structured success/error responses. + * + * @param the value type + * @since 6.0.0 + */ +public final class AwaitResult { + + 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 the value type + * @return a success result wrapping the value + */ + @SuppressWarnings("unchecked") + public static AwaitResult 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 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 AwaitResult 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. + * + * @param fallback the function to apply to the error if this result + * is a failure; must not be {@code null} + * @return the value, or the fallback function's result + */ + public T getOrElse(Function fallback) { + return success ? value : fallback.apply(error); + } + + /** + * Transforms a successful result's value using the given function. + * If this result is a failure, the error is propagated unchanged. + *

+ * This is the functor {@code map} operation, enabling value + * transformation without explicit unwrapping: + *

+     * AwaitResult<String>  name   = AwaitResult.success("Groovy")
+     * AwaitResult<Integer> length = name.map(String::length)
+     * assert length.value == 6
+     * 
+ * + * @param fn the mapping function; must not be {@code null} + * @param the type of the mapped value + * @return a new success result with the mapped value, or the + * original failure unchanged + * @throws NullPointerException if {@code fn} is {@code null} + */ + @SuppressWarnings("unchecked") + public AwaitResult map(Function fn) { + Objects.requireNonNull(fn, "mapping function must not be null"); + if (!success) { + return (AwaitResult) this; + } + return AwaitResult.success(fn.apply(value)); + } + + /** + * Compares this result to another object for equality. + *

+ * Two {@code AwaitResult} instances are equal if and only if they + * have the same success/failure state and carry + * {@linkplain Objects#equals(Object, Object) equal} values or errors. + * + * @param o the object to compare with + * @return {@code true} if the given object is an equal {@code AwaitResult} + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof AwaitResult that)) return false; + if (success != that.success) return false; + return success + ? Objects.equals(value, that.value) + : Objects.equals(error, that.error); + } + + /** + * Returns a hash code consistent with {@link #equals(Object)}. + * + * @return the hash code + */ + @Override + public int hashCode() { + return success + ? Objects.hash(true, value) + : Objects.hash(false, 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 00000000000..2f990368ab0 --- /dev/null +++ b/src/main/java/groovy/concurrent/Awaitable.java @@ -0,0 +1,648 @@ +/* + * 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 groovy.lang.Closure; +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. + *

+ * {@code Awaitable} represents a computation that may not have completed + * yet. It 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. + *

+ * Static combinators (all return {@code Awaitable}, suitable for use + * with {@code await}). Multi-argument {@code await} desugars to + * {@code Awaitable.all(...)}, so {@code await(a, b)} and {@code await a, b} + * are shorthand for {@code await Awaitable.all(a, b)}: + *

    + *
  • {@link #all(Object...) Awaitable.all(a, b, c)} — waits for all + * tasks to complete, returning their results in order
  • + *
  • {@link #any(Object...) Awaitable.any(a, b)} — returns the result + * of the first task to complete
  • + *
  • {@link #first(Object...) Awaitable.first(a, b, c)} — returns the result + * of the first task to complete successfully (like JavaScript's + * {@code Promise.any()}; only fails when all sources fail)
  • + *
  • {@link #allSettled(Object...) Awaitable.allSettled(a, b)} — waits + * for all tasks to settle (succeed or fail), returning an + * {@link AwaitResult} list
  • + *
  • {@link #delay(long) Awaitable.delay(ms)} — completes after a + * non-blocking delay
  • + *
  • {@link #orTimeoutMillis(Object, long) Awaitable.orTimeoutMillis(task, ms)} — + * fails with {@link java.util.concurrent.TimeoutException} if the task + * does not complete in time
  • + *
  • {@link #completeOnTimeoutMillis(Object, Object, long) + * Awaitable.completeOnTimeoutMillis(task, fallback, ms)} — + * uses a fallback value when the timeout expires
  • + *
+ *

+ * Static factories and conversion: + *

    + *
  • {@link #from(Object) Awaitable.from(source)} — converts any supported + * async type (CompletableFuture, CompletionStage, Future, etc.) + * to an {@code Awaitable}
  • + *
  • {@link #of(Object) Awaitable.of(value)} — wraps an already-available + * value in a completed {@code Awaitable}
  • + *
  • {@link #failed(Throwable) Awaitable.failed(error)} — wraps an + * exception in an immediately-failed {@code Awaitable}
  • + *
  • {@link #go(Closure) Awaitable.go { ... }} — lightweight task spawn
  • + *
+ *

+ * Instance continuations provide ergonomic composition without exposing + * raw {@link CompletableFuture} APIs: + *

    + *
  • {@link #then(Function)} and {@link #thenCompose(Function)} for success chaining
  • + *
  • {@link #thenAccept(Consumer)} for side-effecting continuations
  • + *
  • {@link #exceptionally(Function)}, {@link #whenComplete(BiConsumer)}, + * and {@link #handle(BiFunction)} for failure/completion handling
  • + *
  • {@link #orTimeout(long, TimeUnit)} and + * {@link #completeOnTimeout(Object, long, TimeUnit)} for deadline composition
  • + *
+ *

+ * Third-party frameworks (RxJava, Reactor, etc.) can integrate by registering + * an {@link AwaitableAdapter} via {@link AwaitableAdapterRegistry}. + *

+ * The default implementation, {@link GroovyPromise}, delegates to + * {@link CompletableFuture} but users never need to depend on that detail. + * + * @param the result type + * @see GroovyPromise + * @see AwaitableAdapter + * @since 6.0.0 + */ +public interface Awaitable { + + /** + * 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}. + * + * @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 the type of the mapped result + * @return a new awaitable holding the mapped result + */ + Awaitable then(Function 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 the type of the inner awaitable's result + * @return a new awaitable holding the inner result + */ + Awaitable thenCompose(Function> fn); + + /** + * Returns a new {@code Awaitable} that, when this one completes normally, + * invokes the given consumer and completes with {@code null}. + *

+ * Useful at API boundaries where you need to attach logging, metrics, or + * other side effects without blocking for the result. + * + * @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 thenAccept(Consumer 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 exceptionally(Function fn); + + /** + * Returns a new {@code Awaitable} that invokes the given action when this + * computation completes, regardless of success or failure. + *

+ * The supplied throwable is transparently unwrapped so handlers see the + * original failure rather than {@link ExecutionException} / + * {@link java.util.concurrent.CompletionException} wrappers. + * + * @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 whenComplete(BiConsumer action) { + return GroovyPromise.of(toCompletableFuture().whenComplete((value, error) -> + action.accept(value, error == null ? null : AsyncSupport.unwrap(error)))); + } + + /** + * Returns a new {@code Awaitable} that handles both the successful and the + * exceptional completion paths in a single continuation. + *

+ * The supplied throwable is transparently unwrapped so the handler sees the + * original failure. This provides a single place for success/failure + * projection, combining both paths in one callback. + * + * @param fn the handler receiving either the value or the failure + * @param the projected result type + * @return a new awaitable holding the handler's result + * @since 6.0.0 + */ + default Awaitable handle(BiFunction fn) { + return GroovyPromise.of(toCompletableFuture().handle((value, error) -> + fn.apply(value, error == null ? null : AsyncSupport.unwrap(error)))); + } + + /** + * Returns a new {@code Awaitable} that fails with {@link TimeoutException} + * if this computation does not complete within the specified milliseconds. + *

+ * 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 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 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 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 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 toCompletableFuture(); + + // ---- Static factories ---- + + /** + * Converts the given source to an {@code Awaitable}. + *

+ * 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}, and + * {@link java.util.concurrent.Future}; third-party frameworks can register + * additional adapters via the registry. + *

+ * This is the recommended entry point for converting external async types + * to {@code Awaitable}: + *

+     * Awaitable<String> aw = Awaitable.from(someCompletableFuture)
+     * Awaitable<Integer> aw2 = Awaitable.from(someReactorMono)
+     * 
+ * + * @param source the source object; if {@code null}, returns a completed + * awaitable with a {@code null} result + * @param the result type + * @return an awaitable backed by the source + * @throws IllegalArgumentException if no adapter supports the source type + * @see AwaitableAdapterRegistry#toAwaitable(Object) + * @since 6.0.0 + */ + static Awaitable from(Object source) { + return AwaitableAdapterRegistry.toAwaitable(source); + } + + /** + * Returns an already-completed {@code Awaitable} with the given value. + * Useful for returning a pre-computed result from an API that requires + * an {@code Awaitable} return type. + * + * @param value the result value (may be {@code null}) + * @param the result type + * @return a completed awaitable + */ + static Awaitable of(T value) { + return new GroovyPromise<>(CompletableFuture.completedFuture(value)); + } + + /** + * Returns an already-failed {@code Awaitable} with the given exception. + *

+ * Useful for signaling a synchronous error from an API that returns + * {@code Awaitable}, without spawning a thread: + *

+     * if (id < 0) return Awaitable.failed(new IllegalArgumentException("negative id"))
+     * 
+ * + * @param error the exception to wrap; must not be {@code null} + * @param the nominal result type (never produced, since the awaitable is failed) + * @return an immediately-failed awaitable + * @throws NullPointerException if {@code error} is {@code null} + */ + static Awaitable failed(Throwable error) { + return new GroovyPromise<>(CompletableFuture.failedFuture(error)); + } + + /** + * Spawns a lightweight task. + *

+ * @param closure the task body + * @param the result type + * @return an awaitable representing the spawned task + * @since 6.0.0 + */ + static Awaitable go(Closure closure) { + return AsyncSupport.go(closure); + } + + // ---- Structured concurrency ---- + + /** + * Convenience delegate to {@link AsyncScope#withScope(Closure)}. + * Creates a structured concurrency scope, executes the closure within it, + * and ensures all child tasks complete before returning. + * + * @param body the closure receiving the scope + * @param the result type + * @return the closure's return value + * @see AsyncScope#withScope(Closure) + * @since 6.0.0 + */ + static T withScope(Closure body) { + return AsyncScope.withScope(body); + } + + // ---- Combinators ---- + + /** + * Returns an {@code Awaitable} that completes when all given sources + * complete successfully, with a list of their results in order. + *

+ * Like JavaScript's {@code Promise.all()}, the combined awaitable fails as + * soon as the first source fails. Remaining sources are not cancelled + * automatically; cancel them explicitly if that is required by your workflow. + *

+ * Unlike blocking APIs, this returns immediately and the caller should + * {@code await} the result. All three forms below are equivalent: + *

+     * // Explicit all() call
+     * def results = await Awaitable.all(task1, task2, task3)
+     *
+     * // Parenthesized multi-arg await — syntactic sugar
+     * def results = await(task1, task2, task3)
+     *
+     * // Unparenthesized multi-arg await — most concise form
+     * def results = await task1, task2, task3
+     * 
+ * + * @param sources the awaitables, futures, or adapted objects to wait for + * @return an awaitable that resolves to a list of results + */ + static Awaitable> 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. + *
+     * def winner = await Awaitable.any(task1, task2)
+     * 
+ * + * @param sources the awaitables to race + * @return an awaitable that resolves to the first completed result + */ + @SuppressWarnings("unchecked") + static Awaitable any(Object... sources) { + return AsyncSupport.anyAsync(sources); + } + + /** + * Returns an {@code Awaitable} that completes with the result of the first + * source that succeeds. Individual failures are silently absorbed; only + * when all sources have failed does the returned awaitable reject + * with an {@link IllegalStateException} whose + * {@linkplain Throwable#getSuppressed() suppressed} array contains every + * individual error. + *

+ * This is the Groovy equivalent of JavaScript's {@code Promise.any()}. + * Contrast with {@link #any(Object...)} which returns the first result to + * complete regardless of success or failure. + *

+ * Typical use cases: + *

    + *
  • Hedged requests — send the same request to multiple + * endpoints, use whichever responds first successfully
  • + *
  • Graceful degradation — try a primary data source, then a + * fallback, then a cache, accepting the first success
  • + *
  • Redundant queries — send the same query to different + * database replicas for improved latency
  • + *
+ *
+     * // Hedged HTTP request
+     * def response = await Awaitable.first(
+     *     fetchFromPrimary(),
+     *     fetchFromFallback(),
+     *     fetchFromCache()
+     * )
+     * 
+ * + * @param sources the awaitables to race for first success; must not be + * {@code null}, empty, or contain {@code null} elements + * @param the result type + * @return an awaitable that resolves with the first successful result + * @throws IllegalArgumentException if {@code sources} is {@code null}, + * empty, or contains {@code null} elements + * @since 6.0.0 + * @see AsyncSupport#firstAsync(Object...) AsyncSupport.firstAsync — implementation + */ + @SuppressWarnings("unchecked") + static Awaitable first(Object... sources) { + return AsyncSupport.firstAsync(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. + *

+ * Never throws for individual failures; they are captured in the result list. + *

+     * def results = await Awaitable.allSettled(task1, task2)
+     * results.each { println it.success ? it.value : it.error }
+     * 
+ * + * @param sources the awaitables to settle + * @return an awaitable that resolves to a list of settled results + */ + static Awaitable>> allSettled(Object... sources) { + return AsyncSupport.allSettledAsync(sources); + } + + // ---- Delay ---- + + /** + * Returns an {@code Awaitable} that completes after the specified delay. + * Does not block any thread; the delay is handled by a scheduled executor. + *
+     * await Awaitable.delay(1000)  // pause for 1 second
+     * 
+ * + * @param milliseconds the delay in milliseconds (must be ≥ 0) + * @return an awaitable that completes after the delay + */ + static Awaitable 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 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. + *

+ * 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 that returns another awaitable rather than + * requiring structural timeout blocks. + * + * @param source the async source to time out + * @param timeoutMillis the timeout duration in milliseconds + * @param the result type + * @return a new awaitable with timeout semantics + * @since 6.0.0 + */ + static Awaitable 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 the result type + * @return a new awaitable with timeout semantics + * @since 6.0.0 + */ + static Awaitable 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 the result type + * @return a new awaitable yielding either the original result or the fallback + * @since 6.0.0 + */ + static Awaitable 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 the result type + * @return a new awaitable yielding either the original result or the fallback + * @since 6.0.0 + */ + static Awaitable 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+, the default is a virtual-thread-per-task executor. + * On JDK 17–20, a bounded cached daemon thread pool is used + * (size controlled by the {@code groovy.async.parallelism} system property, + * default {@code 256}). + *

+ * This method is thread-safe and may be called from any thread. + * + * @return the current executor, never {@code null} + * @see #setExecutor(Executor) + */ + static Executor getExecutor() { + return AsyncSupport.getExecutor(); + } + + /** + * Sets the executor to use for {@code async} operations. + *

+ * Pass {@code null} to reset to the default executor. The change + * takes effect immediately for all subsequent {@code async} method + * invocations; tasks already in flight continue using the executor + * that launched them. + *

+ * This method is thread-safe and may be called from any thread. + * + * @param executor the executor to use, or {@code null} to restore + * the default executor + * @see #getExecutor() + */ + static void setExecutor(Executor executor) { + AsyncSupport.setExecutor(executor); + } + + /** + * Returns {@code true} if the running JVM supports virtual threads (JDK 21+). + *

+ * When virtual threads are available, the default executor uses a + * virtual-thread-per-task strategy that scales to millions of + * concurrent tasks with minimal memory overhead. + * + * @return {@code true} if virtual threads are available + */ + 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 00000000000..c5e5aa292f6 --- /dev/null +++ b/src/main/java/groovy/concurrent/AwaitableAdapter.java @@ -0,0 +1,67 @@ +/* + * 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} abstraction and to blocking iterables for + * {@code for await} loops. + *

+ * 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. + * + * @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} (single-value async result). + */ + boolean supportsAwaitable(Class type); + + /** + * Converts the given source object to an {@link Awaitable}. + * Called only when {@link #supportsAwaitable} returned {@code true}. + */ + Awaitable toAwaitable(Object source); + + /** + * Returns {@code true} if this adapter can convert instances of the given + * type to a blocking {@link Iterable} for {@code for await} loops. + * Defaults to {@code false}; override for multi-value async types + * (e.g., Reactor {@code Flux}, RxJava {@code Observable}). + */ + default boolean supportsIterable(Class type) { + return false; + } + + /** + * Converts the given source object to a blocking {@link Iterable}. + * Called only when {@link #supportsIterable} returned {@code true}. + * The returned iterable should block on {@code next()} until the + * next element is available — with virtual threads this is efficient. + */ + default Iterable toBlockingIterable(Object source) { + throw new UnsupportedOperationException("Iterable 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 00000000000..126d6cdc5b6 --- /dev/null +++ b/src/main/java/groovy/concurrent/AwaitableAdapterRegistry.java @@ -0,0 +1,180 @@ +/* + * 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.Iterator; +import java.util.List; +import java.util.Objects; +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. + *

+ * 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: + *

    + *
  • {@link CompletableFuture} and {@link CompletionStage}
  • + *
  • {@link Future} (adapted via a blocking wrapper)
  • + *
+ * + * @since 6.0.0 + */ +public final class AwaitableAdapterRegistry { + + private static final List adapters = new CopyOnWriteArrayList<>(); + + private static volatile ClassValue awaitableCache = buildAwaitableCache(); + + static { + // Load SPI adapters + try { + ServiceLoader loader = ServiceLoader.load( + AwaitableAdapter.class, AwaitableAdapterRegistry.class.getClassLoader()); + for (Iterator it = loader.iterator(); it.hasNext(); ) { + try { + adapters.add(it.next()); + } catch (Throwable ignored) { + // Skip adapters that fail to load (missing dependencies) + } + } + } catch (Throwable ignored) { + // ServiceLoader failure — continue with built-in adapter only + } + // Built-in fallback adapter (lowest priority) + adapters.add(new BuiltInAdapter()); + } + + private AwaitableAdapterRegistry() { } + + /** + * Registers an adapter at the highest priority (before SPI-loaded adapters). + */ + public static void register(AwaitableAdapter adapter) { + Objects.requireNonNull(adapter); + adapters.add(0, adapter); + awaitableCache = buildAwaitableCache(); + } + + /** + * Removes a previously registered adapter. + */ + public static void unregister(AwaitableAdapter adapter) { + adapters.remove(adapter); + awaitableCache = buildAwaitableCache(); + } + + /** + * Converts the given source to an {@link Awaitable}. + */ + @SuppressWarnings("unchecked") + static Awaitable toAwaitable(Object source) { + if (source == null) { + return (Awaitable) Awaitable.of(null); + } + if (source instanceof Awaitable) return (Awaitable) source; + Class type = source.getClass(); + AwaitableAdapter adapter = awaitableCache.get(type); + if (adapter != null) { + return adapter.toAwaitable(source); + } + throw new IllegalArgumentException( + "No Awaitable adapter found for type: " + type.getName() + + ". Register an AwaitableAdapter via ServiceLoader or AwaitableAdapterRegistry.register()."); + } + + /** + * Converts the given source to a blocking {@link Iterable} for {@code for await}. + */ + @SuppressWarnings("unchecked") + public static Iterable toBlockingIterable(Object source) { + if (source == null) { + throw new IllegalArgumentException("Cannot convert null to Iterable"); + } + Class type = source.getClass(); + for (AwaitableAdapter adapter : adapters) { + if (adapter.supportsIterable(type)) { + return adapter.toBlockingIterable(source); + } + } + throw new IllegalArgumentException( + "No Iterable adapter found for type: " + type.getName() + + ". Register an AwaitableAdapter via ServiceLoader or AwaitableAdapterRegistry.register()."); + } + + private static ClassValue buildAwaitableCache() { + return new ClassValue<>() { + @Override + protected AwaitableAdapter computeValue(Class type) { + for (AwaitableAdapter adapter : adapters) { + if (adapter.supportsAwaitable(type)) { + return adapter; + } + } + return null; + } + }; + } + + /** + * Built-in adapter for JDK Future types. + */ + private static final class BuiltInAdapter implements AwaitableAdapter { + + @Override + public boolean supportsAwaitable(Class type) { + return CompletionStage.class.isAssignableFrom(type) + || Future.class.isAssignableFrom(type); + } + + @Override + @SuppressWarnings("unchecked") + public Awaitable toAwaitable(Object source) { + if (source instanceof CompletableFuture) { + return GroovyPromise.of((CompletableFuture) source); + } + if (source instanceof CompletionStage) { + return GroovyPromise.of(((CompletionStage) source).toCompletableFuture()); + } + if (source instanceof Future) { + CompletableFuture cf = new CompletableFuture<>(); + Future future = (Future) source; + // Wrap blocking Future in a CF (submitted to default executor) + CompletableFuture.runAsync(() -> { + try { + cf.complete(future.get()); + } catch (java.util.concurrent.ExecutionException e) { + cf.completeExceptionally(e.getCause()); + } catch (Throwable e) { + cf.completeExceptionally(e); + } + }, AsyncSupport.getExecutor()); + return GroovyPromise.of(cf); + } + throw new IllegalArgumentException("Cannot convert to Awaitable: " + source.getClass()); + } + } +} diff --git a/src/main/java/groovy/concurrent/ChannelClosedException.java b/src/main/java/groovy/concurrent/ChannelClosedException.java new file mode 100644 index 00000000000..6a963b24aff --- /dev/null +++ b/src/main/java/groovy/concurrent/ChannelClosedException.java @@ -0,0 +1,77 @@ +/* + * 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; + +/** + * Thrown when an {@link AsyncChannel} operation is attempted after the channel + * has been closed. + *

+ * This exception is raised in the following situations: + *

    + *
  • {@link AsyncChannel#send(Object) send()} — the channel was closed before + * or during the send attempt. Pending senders that were waiting for buffer + * space when the channel closed also receive this exception.
  • + *
  • {@link AsyncChannel#receive() receive()} — the channel was closed and all + * buffered values have been drained. Note that values buffered before + * closure are still delivered normally; only once the buffer is exhausted + * does this exception appear.
  • + *
+ *

+ * When used with {@code for await}, the loop infrastructure translates + * {@code ChannelClosedException} into a clean end-of-stream signal (i.e., + * the loop exits normally rather than propagating the exception): + *

+ * def ch = AsyncChannel.create()
+ * // ... producer sends values, then calls ch.close()
+ * for await (item in ch) {
+ *     process(item)       // processes all buffered values
+ * }
+ * // loop exits cleanly after the channel is closed and drained
+ * 
+ * + * @see AsyncChannel#send(Object) + * @see AsyncChannel#receive() + * @see AsyncChannel#close() + * @since 6.0.0 + */ +public class ChannelClosedException extends IllegalStateException { + + private static final long serialVersionUID = 1L; + + /** + * Creates a {@code ChannelClosedException} with the specified detail message. + * + * @param message the detail message describing which operation failed + */ + public ChannelClosedException(String message) { + super(message); + } + + /** + * Creates a {@code ChannelClosedException} with the specified detail message + * and cause. + * + * @param message the detail message + * @param cause the underlying cause (e.g., an {@link InterruptedException} + * if the thread was interrupted while waiting on a channel operation) + */ + public ChannelClosedException(String message, Throwable cause) { + super(message, cause); + } +} 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 07331823f85..4f5aad5a3ef 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -126,6 +126,7 @@ import org.codehaus.groovy.control.CompilePhase; import org.codehaus.groovy.control.SourceUnit; import org.codehaus.groovy.control.messages.SyntaxErrorMessage; +import org.codehaus.groovy.transform.AsyncTransformHelper; import org.codehaus.groovy.runtime.ArrayGroovyMethods; import org.codehaus.groovy.runtime.StringGroovyMethods; import org.codehaus.groovy.syntax.Numbers; @@ -465,13 +466,32 @@ public Statement visitLoopStmtAlt(final LoopStmtAltContext ctx) { } @Override - public ForStatement visitForStmtAlt(final ForStmtAltContext ctx) { + public Statement visitForStmtAlt(final ForStmtAltContext ctx) { + boolean isForAwait = asBoolean(ctx.AWAIT()); Function maker = this.visitForControl(ctx.forControl()); Statement loopBody = this.unpackStatement((Statement) this.visit(ctx.statement())); ForStatement forStatement = configureAST(maker.apply(loopBody), ctx); visitAnnotationsOpt(ctx.annotationsOpt()).forEach(forStatement::addStatementAnnotation); + + if (isForAwait) { + // Transform collection expression: wrap in AsyncSupport.toBlockingIterable() + // and wrap the loop in try/finally to ensure cleanup on break/exception + Expression original = forStatement.getCollectionExpression(); + String tempVar = "__forAwaitSource" + ctx.hashCode(); + Expression toBlockingCall = AsyncTransformHelper.buildToBlockingIterableCall(original); + + // var $temp = AsyncSupport.toBlockingIterable(original) + Statement declStmt = stmt(declX(varX(tempVar), toBlockingCall)); + forStatement.setCollectionExpression(varX(tempVar)); + + // try { for (...) { body } } finally { AsyncSupport.closeIterable($temp) } + Statement finallyStmt = stmt(AsyncTransformHelper.buildCloseIterableCall(varX(tempVar))); + TryCatchStatement tryCatch = new TryCatchStatement(forStatement, finallyStmt); + return configureAST(block(declStmt, tryCatch), ctx); + } + return forStatement; } @@ -866,11 +886,32 @@ public ReturnStatement visitYieldStatement(final YieldStatementContext ctx) { return configureAST(returnStatement, ctx); } + @Override + public ExpressionStatement visitYieldReturnStmtAlt(final YieldReturnStmtAltContext ctx) { + Expression expr = (Expression) this.visit(ctx.expression()); + return configureAST(new ExpressionStatement(AsyncTransformHelper.buildYieldReturnCall(expr)), ctx); + } + @Override public ReturnStatement visitYieldStmtAlt(final YieldStmtAltContext ctx) { return configureAST(this.visitYieldStatement(ctx.yieldStatement()), ctx); } + @Override + public ExpressionStatement visitDeferStmtAlt(final DeferStmtAltContext ctx) { + Expression action; + ExpressionStatement stmtExprStmt = (ExpressionStatement) this.visit(ctx.statementExpression()); + Expression expr = stmtExprStmt.getExpression(); + if (expr instanceof ClosureExpression) { + action = expr; + } else { + ClosureExpression wrapper = new ClosureExpression(Parameter.EMPTY_ARRAY, block(stmtExprStmt)); + wrapper.setSourcePosition(stmtExprStmt); + action = wrapper; + } + return configureAST(new ExpressionStatement(AsyncTransformHelper.buildDeferCall(action)), ctx); + } + @Override public ContinueStatement visitContinueStatement(final ContinueStatementContext ctx) { if (switchExpressionRuleContextStack.peek() instanceof SwitchExpressionContext) { @@ -2893,6 +2934,62 @@ public CastExpression visitCastExprAlt(final CastExprAltContext ctx) { return configureAST(cast, ctx); } + @Override + public Expression visitAwaitExprAlt(final AwaitExprAltContext ctx) { + List exprCtxs = ctx.expression(); + if (exprCtxs.size() == 1) { + Expression expr = (Expression) this.visit(exprCtxs.get(0)); + return configureAST( + AsyncTransformHelper.buildAwaitCall(expr), + ctx); + } + // Multi-arg: await(p1, p2, ..., pn) or await p1, p2, ..., pn + List exprs = exprCtxs.stream() + .map(ec -> (Expression) this.visit(ec)) + .collect(Collectors.toList()); + return configureAST( + AsyncTransformHelper.buildAwaitCall(new ArgumentListExpression(exprs)), + ctx); + } + + @Override + public Expression visitAsyncClosureExprAlt(final AsyncClosureExprAltContext ctx) { + ClosureExpression closure = this.visitClosureOrLambdaExpression(ctx.closureOrLambdaExpression()); + boolean hasYieldReturn = AsyncTransformHelper.containsYieldReturn(closure.getCode()); + boolean hasDefer = AsyncTransformHelper.containsDefer(closure.getCode()); + + 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 + Parameter genParam = AsyncTransformHelper.createGenParam(); + Parameter[] existingParams = closure.getParameters(); + boolean hasUserParams = existingParams != null && existingParams.length > 0; + 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); + return configureAST(AsyncTransformHelper.buildAsyncGeneratorCall( + new ArgumentListExpression(genClosure)), ctx); + } else { + return configureAST(AsyncTransformHelper.buildAsyncCall( + new ArgumentListExpression(closure)), ctx); + } + } + @Override public BinaryExpression visitPowerExprAlt(final PowerExprAltContext ctx) { return this.createBinaryExpression(ctx.left, ctx.op, ctx.right, ctx); 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 00000000000..27fe41b3768 --- /dev/null +++ b/src/main/java/org/apache/groovy/runtime/async/AsyncSupport.java @@ -0,0 +1,753 @@ +/* + * 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.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.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +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}/{@code defer} language features. + *

+ * This class contains the actual implementation invoked by compiler-generated code. + * User code should prefer the static methods on {@link groovy.concurrent.Awaitable} + * for combinators and configuration. + *

+ * Thread pool configuration: + *

    + *
  • On JDK 21+ the default executor is a virtual-thread-per-task executor. + * Virtual threads make blocking within {@code await()} essentially free.
  • + *
  • On JDK 17-20 the fallback is a cached daemon thread pool + * whose maximum size is controlled by the system property + * {@code groovy.async.parallelism} (default: {@code 256}).
  • + *
  • The executor can be overridden at any time via {@link #setExecutor}.
  • + *
+ *

+ * Exception handling follows a transparency principle: the + * original exception is rethrown without being wrapped. + * + * @see groovy.concurrent.Awaitable + * @since 6.0.0 + */ +public class AsyncSupport { + + private static final boolean VIRTUAL_THREADS_AVAILABLE; + private static final Executor VIRTUAL_THREAD_EXECUTOR; + 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) { + FALLBACK_EXECUTOR = new ThreadPoolExecutor( + 0, FALLBACK_MAX_THREADS, + 60L, TimeUnit.SECONDS, + new SynchronousQueue<>(), + r -> { + Thread t = new Thread(r); + t.setDaemon(true); + @SuppressWarnings("deprecation") + long id = t.getId(); + t.setName("groovy-async-" + id); + return t; + }, + new ThreadPoolExecutor.CallerRunsPolicy()); + } else { + FALLBACK_EXECUTOR = null; + } + } + + private static volatile Executor defaultExecutor = + VIRTUAL_THREADS_AVAILABLE ? VIRTUAL_THREAD_EXECUTOR : FALLBACK_EXECUTOR; + + private static final ScheduledExecutorService SCHEDULER = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "groovy-async-scheduler"); + t.setDaemon(true); + return t; + }); + + private AsyncSupport() { } + + // ---- executor configuration ----------------------------------------- + + /** Returns {@code true} if running on JDK 21+ with virtual thread support. */ + public static boolean isVirtualThreadsAvailable() { + return VIRTUAL_THREADS_AVAILABLE; + } + + /** Returns the current executor used for async tasks. */ + public static Executor getExecutor() { + return defaultExecutor; + } + + /** Sets the executor used for async tasks. */ + public static void setExecutor(Executor executor) { + defaultExecutor = Objects.requireNonNull(executor, "executor must not be null"); + } + + /** Resets the executor to the default (virtual threads on JDK 21+, cached pool otherwise). */ + public static void resetExecutor() { + defaultExecutor = VIRTUAL_THREADS_AVAILABLE ? VIRTUAL_THREAD_EXECUTOR : FALLBACK_EXECUTOR; + } + + // ---- await overloads ------------------------------------------------ + + /** + * Awaits the result of an {@link Awaitable}. + * Blocks the calling thread until the computation completes. + * The original exception is rethrown transparently. + */ + public static T await(Awaitable 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 a {@link CompletableFuture} using non-interruptible {@code join()}. */ + public static T await(CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException e) { + throw rethrowUnwrapped(e); + } catch (CancellationException e) { + throw e; + } + } + + /** Awaits a {@link CompletionStage} by converting to CompletableFuture. */ + public static T await(CompletionStage stage) { + return await(stage.toCompletableFuture()); + } + + /** Awaits a {@link Future}. Delegates to the CF overload if applicable. */ + public static T await(Future future) { + if (future instanceof CompletableFuture 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 via {@link Awaitable#from(Object)}. + * This is the fallback overload called by compiler-generated await expressions. + */ + @SuppressWarnings("unchecked") + public static T await(Object source) { + if (source == null) return null; + if (source instanceof Awaitable) return await((Awaitable) source); + if (source instanceof CompletableFuture) return await((CompletableFuture) source); + if (source instanceof CompletionStage) return await((CompletionStage) source); + if (source instanceof Future) return await((Future) source); + if (source instanceof Closure) { + throw new IllegalArgumentException( + "Cannot await a Closure directly. Call the closure first: await myClosure()"); + } + return await(Awaitable.from(source)); + } + + // ---- async execution ------------------------------------------------ + + /** + * Executes the given closure asynchronously on the specified executor, + * returning an {@link Awaitable}. + */ + public static Awaitable executeAsync(Closure closure, Executor executor) { + Objects.requireNonNull(closure, "closure must not be null"); + Executor targetExecutor = executor != null ? executor : defaultExecutor; + return GroovyPromise.of(CompletableFuture.supplyAsync(() -> { + try { + return closure.call(); + } catch (Throwable t) { + throw wrapForFuture(t); + } + }, targetExecutor)); + } + + /** + * Executes the given closure asynchronously using the default executor. + */ + public static Awaitable async(Closure closure) { + return executeAsync(closure, defaultExecutor); + } + + /** + * Lightweight task spawn. Executes the closure asynchronously using the default executor. + */ + public static Awaitable go(Closure 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. + *

+     * def task = async { expensiveWork() }
+     * def result = await task()  // explicit call required
+     * 
+ */ + @SuppressWarnings("unchecked") + public static Closure> wrapAsync(Closure closure) { + Objects.requireNonNull(closure, "closure must not be null"); + return new Closure>(closure.getOwner(), closure.getThisObject()) { + @SuppressWarnings("unused") + public Awaitable doCall(Object... args) { + return GroovyPromise.of(CompletableFuture.supplyAsync(() -> { + try { + return closure.call(args); + } catch (Throwable t) { + throw wrapForFuture(t); + } + }, defaultExecutor)); + } + }; + } + + // ---- generators (yield return) ---------------------------------------- + + /** + * Called by compiler-generated code for {@code yield return expr} inside + * an async generator closure. Delegates to the bridge's yield method. + * + * @param bridge the GeneratorBridge instance (injected as synthetic parameter) + * @param value the value to yield + */ + public static void yieldReturn(Object bridge, Object value) { + if (!(bridge instanceof GeneratorBridge)) { + throw new IllegalStateException("yield return can only be used inside an async generator"); + } + ((GeneratorBridge) bridge).yield(value); + } + + /** + * Wraps a generator closure so that each invocation returns an {@link Iterable} + * backed by a {@link GeneratorBridge}. The generator runs on a background thread + * and yields values via the bridge. + *

+ * This is the runtime entry point for {@code async { ... yield return ... }} + * expressions that contain {@code yield return}. + * + * @param closure the generator closure; receives a GeneratorBridge as first parameter + * @param the element type + * @return a closure that produces an Iterable when called + */ + @SuppressWarnings("unchecked") + public static Closure> wrapAsyncGenerator(Closure closure) { + Objects.requireNonNull(closure, "closure must not be null"); + return new Closure>(closure.getOwner(), closure.getThisObject()) { + @SuppressWarnings("unused") + public Iterable doCall(Object... args) { + GeneratorBridge bridge = new GeneratorBridge<>(); + Object[] allArgs = new Object[args.length + 1]; + allArgs[0] = bridge; + System.arraycopy(args, 0, allArgs, 1, args.length); + defaultExecutor.execute(() -> { + try { + closure.call(allArgs); + bridge.complete(); + } catch (GeneratorBridge.GeneratorClosedException ignored) { + // Consumer closed early — normal for break in for-await + } catch (Throwable t) { + bridge.completeExceptionally(t); + } + }); + return () -> bridge; + } + }; + } + + /** + * Starts a generator immediately, returning an {@link Iterable} backed by a + * {@link GeneratorBridge}. This is the runtime entry point for + * {@code async { ... yield return ... }} expressions. + * + * @param closure the generator closure; receives a GeneratorBridge as first parameter + * @param the element type + * @return an Iterable that yields values from the generator + */ + @SuppressWarnings("unchecked") + public static Iterable asyncGenerator(Closure closure) { + Objects.requireNonNull(closure, "closure must not be null"); + GeneratorBridge bridge = new GeneratorBridge<>(); + Object[] args = new Object[]{bridge}; + defaultExecutor.execute(() -> { + try { + closure.call(args); + bridge.complete(); + } catch (GeneratorBridge.GeneratorClosedException ignored) { + // Consumer closed early — normal for break in for-await + } catch (Throwable t) { + bridge.completeExceptionally(t); + } + }); + return () -> bridge; + } + + // ---- for-await (blocking iterable conversion) ----------------------- + + /** + * Converts an arbitrary source to a blocking {@link Iterable} for use + * in {@code for await} loops. Handles arrays, collections, iterables, + * iterators, and adapter-supported types. + * + * @param source the source to convert + * @param the element type + * @return a blocking iterable + */ + @SuppressWarnings("unchecked") + public static Iterable toBlockingIterable(Object source) { + if (source == null) return Collections.emptyList(); + if (source instanceof Iterable) return (Iterable) source; + if (source instanceof Iterator) { + Iterator iter = (Iterator) source; + return () -> iter; + } + if (source instanceof Object[]) return (Iterable) Arrays.asList((Object[]) source); + // Try adapter registry + return groovy.concurrent.AwaitableAdapterRegistry.toBlockingIterable(source); + } + + /** + * Closes a source if it implements {@link java.io.Closeable} or + * {@link AutoCloseable}. Called by compiler-generated finally block + * in {@code for await} loops. + */ + public static void closeIterable(Object source) { + if (source instanceof java.io.Closeable c) { + try { c.close(); } catch (Exception ignored) { } + } else if (source instanceof AutoCloseable c) { + try { c.close(); } catch (Exception ignored) { } + } + } + + // ---- combinators ---------------------------------------------------- + + /** + * Waits for all given sources to complete, returning their results in order. + * Multi-arg {@code await(a, b, c)} desugars to this. + */ + @SuppressWarnings("unchecked") + public static List all(Object... sources) { + CompletableFuture[] futures = Arrays.stream(sources) + .map(s -> Awaitable.from(s).toCompletableFuture()) + .toArray(CompletableFuture[]::new); + CompletableFuture.allOf(futures).join(); + List results = new ArrayList<>(futures.length); + for (CompletableFuture f : futures) { + results.add((T) f.join()); + } + return results; + } + + /** + * Returns the result of the first source to complete (success or failure). + */ + @SuppressWarnings("unchecked") + public static T any(Object... sources) { + CompletableFuture[] futures = Arrays.stream(sources) + .map(s -> Awaitable.from(s).toCompletableFuture()) + .toArray(CompletableFuture[]::new); + return (T) CompletableFuture.anyOf(futures).join(); + } + + /** + * Returns the result of the first source to complete successfully. + * Only fails when all sources fail. + */ + @SuppressWarnings("unchecked") + public static T first(Object... sources) { + CompletableFuture[] futures = Arrays.stream(sources) + .map(s -> (CompletableFuture) Awaitable.from(s).toCompletableFuture()) + .toArray(CompletableFuture[]::new); + + CompletableFuture result = new CompletableFuture<>(); + var remaining = new java.util.concurrent.atomic.AtomicInteger(futures.length); + List errors = java.util.Collections.synchronizedList(new ArrayList<>()); + for (CompletableFuture f : futures) { + f.whenComplete((value, error) -> { + if (error == null) { + result.complete(value); + } else { + errors.add(error); + if (remaining.decrementAndGet() == 0) { + CompletionException aggregate = new CompletionException( + "All " + futures.length + " tasks failed", errors.get(0)); + for (int i = 1; i < errors.size(); i++) { + aggregate.addSuppressed(errors.get(i)); + } + result.completeExceptionally(aggregate); + } + } + }); + } + try { + return result.join(); + } catch (CompletionException e) { + throw rethrowUnwrapped(e); + } + } + + /** + * Waits for all sources to settle (succeed or fail), returning a list of + * {@link AwaitResult} without throwing. + */ + @SuppressWarnings("unchecked") + public static List> allSettled(Object... sources) { + CompletableFuture[] futures = Arrays.stream(sources) + .map(s -> Awaitable.from(s).toCompletableFuture()) + .toArray(CompletableFuture[]::new); + CompletableFuture.allOf( + Arrays.stream(futures) + .map(f -> f.handle((v, e) -> null)) + .toArray(CompletableFuture[]::new) + ).join(); + List> results = new ArrayList<>(futures.length); + for (CompletableFuture f : futures) { + try { + results.add(AwaitResult.success(f.join())); + } catch (CompletionException e) { + results.add(AwaitResult.failure(unwrap(e))); + } catch (CancellationException e) { + results.add(AwaitResult.failure(e)); + } + } + return results; + } + + // ---- async combinator variants (return Awaitable, non-blocking) ------ + + /** Non-blocking variant of {@link #all} — returns an Awaitable. */ + @SuppressWarnings("unchecked") + public static Awaitable> allAsync(Object... sources) { + CompletableFuture[] futures = Arrays.stream(sources) + .map(s -> Awaitable.from(s).toCompletableFuture()) + .toArray(CompletableFuture[]::new); + + // allOf is naturally fail-fast: it completes as soon as any + // source fails OR all sources succeed. We track the first + // failure explicitly because allOf doesn't guarantee which + // exception propagates when multiple futures fail. + var firstError = new java.util.concurrent.atomic.AtomicReference(); + for (CompletableFuture f : futures) { + f.whenComplete((v, e) -> { + if (e != null) firstError.compareAndSet(null, e); + }); + } + + CompletableFuture> combined = CompletableFuture.allOf(futures) + .thenApply(v -> { + List results = new ArrayList<>(futures.length); + for (CompletableFuture f : futures) results.add(f.join()); + return results; + }); + + // Replace allOf's arbitrary exception with the temporally-first one + CompletableFuture> withFirstError = combined.exceptionally(e -> { + Throwable first = firstError.get(); + if (first != null && first != e && first != e.getCause()) { + throw first instanceof CompletionException ce ? ce : new CompletionException(first); + } + throw e instanceof CompletionException ce ? ce : new CompletionException(e); + }); + return GroovyPromise.of(withFirstError); + } + + /** Non-blocking variant of {@link #any} — returns an Awaitable. */ + @SuppressWarnings("unchecked") + public static Awaitable anyAsync(Object... sources) { + CompletableFuture[] futures = Arrays.stream(sources) + .map(s -> Awaitable.from(s).toCompletableFuture()) + .toArray(CompletableFuture[]::new); + return (Awaitable) GroovyPromise.of(CompletableFuture.anyOf(futures)); + } + + /** Non-blocking variant of {@link #first} — returns an Awaitable. */ + @SuppressWarnings("unchecked") + public static Awaitable firstAsync(Object... sources) { + CompletableFuture[] futures = Arrays.stream(sources) + .map(s -> (CompletableFuture) Awaitable.from(s).toCompletableFuture()) + .toArray(CompletableFuture[]::new); + CompletableFuture result = new CompletableFuture<>(); + var remaining = new java.util.concurrent.atomic.AtomicInteger(futures.length); + List errors = java.util.Collections.synchronizedList(new ArrayList<>()); + for (CompletableFuture f : futures) { + f.whenComplete((value, error) -> { + if (error == null) { + result.complete(value); + } else { + errors.add(error); + if (remaining.decrementAndGet() == 0) { + CompletionException aggregate = new CompletionException( + "All " + futures.length + " tasks failed", errors.get(0)); + for (int i = 1; i < errors.size(); i++) { + aggregate.addSuppressed(errors.get(i)); + } + result.completeExceptionally(aggregate); + } + } + }); + } + return GroovyPromise.of(result); + } + + /** Non-blocking variant of {@link #allSettled} — returns an Awaitable. */ + @SuppressWarnings("unchecked") + public static Awaitable>> allSettledAsync(Object... sources) { + CompletableFuture[] futures = Arrays.stream(sources) + .map(s -> Awaitable.from(s).toCompletableFuture()) + .toArray(CompletableFuture[]::new); + CompletableFuture>> combined = CompletableFuture.allOf( + Arrays.stream(futures).map(f -> f.handle((v, e) -> null)).toArray(CompletableFuture[]::new) + ).thenApply(v -> { + List> results = new ArrayList<>(futures.length); + for (CompletableFuture f : futures) { + try { + results.add(AwaitResult.success(f.join())); + } catch (CompletionException e) { + results.add(AwaitResult.failure(unwrap(e))); + } catch (CancellationException e) { + results.add(AwaitResult.failure(e)); + } + } + return results; + }); + return GroovyPromise.of(combined); + } + + // ---- delay and timeout ---------------------------------------------- + + /** + * Returns an Awaitable that completes after the specified delay. + */ + public static Awaitable delay(long millis) { + CompletableFuture future = new CompletableFuture<>(); + SCHEDULER.schedule(() -> future.complete(null), millis, TimeUnit.MILLISECONDS); + return GroovyPromise.of(future); + } + + /** Delay with explicit time unit. */ + public static Awaitable delay(long duration, TimeUnit unit) { + CompletableFuture future = new CompletableFuture<>(); + SCHEDULER.schedule(() -> future.complete(null), duration, unit); + return GroovyPromise.of(future); + } + + /** + * Wraps a source with a timeout. If the source does not complete within + * the specified time, the returned Awaitable fails with {@link TimeoutException}. + */ + @SuppressWarnings("unchecked") + public static Awaitable orTimeout(Object source, long timeout, TimeUnit unit) { + CompletableFuture future = (CompletableFuture) Awaitable.from(source).toCompletableFuture(); + CompletableFuture result = new CompletableFuture<>(); + ScheduledFuture timer = SCHEDULER.schedule(() -> { + if (!result.isDone()) { + result.completeExceptionally(new TimeoutException("Timed out after " + timeout + " " + unit)); + future.cancel(true); + } + }, timeout, unit); + future.whenComplete((v, e) -> { + timer.cancel(false); + if (e != null) result.completeExceptionally(e); + else result.complete(v); + }); + return GroovyPromise.of(result); + } + + /** Convenience: timeout in milliseconds. */ + public static Awaitable orTimeoutMillis(Object source, long millis) { + return orTimeout(source, millis, TimeUnit.MILLISECONDS); + } + + /** + * Wraps a source with a timeout that uses a fallback value instead of throwing. + */ + @SuppressWarnings("unchecked") + public static Awaitable completeOnTimeout(Object source, T fallback, long timeout, TimeUnit unit) { + CompletableFuture future = (CompletableFuture) Awaitable.from(source).toCompletableFuture(); + CompletableFuture result = new CompletableFuture<>(); + ScheduledFuture timer = SCHEDULER.schedule(() -> { + if (!result.isDone()) { + result.complete(fallback); + future.cancel(true); + } + }, timeout, unit); + future.whenComplete((v, e) -> { + timer.cancel(false); + if (e != null) result.completeExceptionally(e); + else result.complete(v); + }); + return GroovyPromise.of(result); + } + + /** Convenience: timeout in milliseconds. */ + public static Awaitable completeOnTimeoutMillis(Object source, T fallback, long millis) { + return completeOnTimeout(source, fallback, millis, TimeUnit.MILLISECONDS); + } + + // ---- defer ---------------------------------------------------------- + + /** + * Creates a new defer scope (LIFO stack of cleanup actions). + * Called by compiler-generated code at the start of closures + * containing {@code defer} statements. + */ + public static Deque> createDeferScope() { + return new ArrayDeque<>(); + } + + /** + * Registers a deferred action in the given scope. Actions execute in LIFO + * order when {@link #executeDeferScope} is called (in the finally block). + */ + public static void defer(Deque> scope, Closure action) { + if (scope == null) { + throw new IllegalStateException("defer must be used inside an async closure"); + } + if (action == null) { + throw new IllegalArgumentException("defer action must not be null"); + } + scope.push(action); + } + + /** + * Executes all deferred actions in LIFO order. If multiple actions throw, + * subsequent exceptions are added as suppressed. If a deferred action returns + * a Future/Awaitable, the result is awaited before continuing. + */ + public static void executeDeferScope(Deque> scope) { + if (scope == null || scope.isEmpty()) return; + Throwable firstError = null; + while (!scope.isEmpty()) { + try { + Object result = scope.pop().call(); + awaitDeferredResult(result); + } catch (Throwable t) { + if (firstError == null) { + firstError = t; + } else { + firstError.addSuppressed(t); + } + } + } + if (firstError != null) { + sneakyThrow(firstError); + } + } + + @SuppressWarnings("unchecked") + private static void awaitDeferredResult(Object result) { + if (result instanceof Awaitable) { + await((Awaitable) result); + } else if (result instanceof CompletionStage) { + await(((CompletionStage) result).toCompletableFuture()); + } else if (result instanceof Future) { + await((Future) result); + } + } + + // ---- exception utilities -------------------------------------------- + + private static CompletionException wrapForFuture(Throwable t) { + if (t instanceof CompletionException ce) return ce; + return new CompletionException(t); + } + + static RuntimeException rethrowUnwrapped(Throwable wrapper) { + Throwable cause = unwrap(wrapper); + sneakyThrow(cause); + return null; // unreachable + } + + public static Throwable unwrap(Throwable t) { + while ((t instanceof CompletionException || t instanceof ExecutionException + || t instanceof java.lang.reflect.InvocationTargetException + || t instanceof java.lang.reflect.UndeclaredThrowableException) + && t.getCause() != null) { + t = t.getCause(); + } + return t; + } + + @SuppressWarnings("unchecked") + private static void sneakyThrow(Throwable t) throws T { + throw (T) t; + } +} diff --git a/src/main/java/org/apache/groovy/runtime/async/DefaultAsyncChannel.java b/src/main/java/org/apache/groovy/runtime/async/DefaultAsyncChannel.java new file mode 100644 index 00000000000..467bb3d229b --- /dev/null +++ b/src/main/java/org/apache/groovy/runtime/async/DefaultAsyncChannel.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.apache.groovy.runtime.async; + +import groovy.concurrent.AsyncChannel; +import groovy.concurrent.Awaitable; +import groovy.concurrent.ChannelClosedException; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Default lock-based implementation of {@link AsyncChannel}. + *

+ * Uses a {@link ReentrantLock} to coordinate access to the internal buffer + * and the waiting-sender/waiting-receiver queues. All operations return + * {@link Awaitable} immediately; the underlying {@link CompletableFuture} + * is completed asynchronously when matching counterparts arrive. + * + * @param the payload type + * @see AsyncChannel + * @since 6.0.0 + */ +public final class DefaultAsyncChannel implements AsyncChannel { + + private final ReentrantLock lock = new ReentrantLock(); + private final Deque buffer = new ArrayDeque<>(); + private final Deque> waitingSenders = new ArrayDeque<>(); + private final Deque> waitingReceivers = new ArrayDeque<>(); + private final int capacity; + private volatile boolean closed; + + public DefaultAsyncChannel() { + this(0); + } + + public DefaultAsyncChannel(int capacity) { + if (capacity < 0) { + throw new IllegalArgumentException("channel capacity must not be negative: " + capacity); + } + this.capacity = capacity; + } + + // ---- Query ---------------------------------------------------------- + + @Override + public int getCapacity() { + return capacity; + } + + @Override + public int getBufferedSize() { + lock.lock(); + try { + return buffer.size(); + } finally { + lock.unlock(); + } + } + + @Override + public boolean isClosed() { + return closed; + } + + // ---- Core Operations ------------------------------------------------ + + @Override + public Awaitable send(T value) { + Objects.requireNonNull(value, "channel does not support null values"); + + CompletableFuture completion = new CompletableFuture<>(); + PendingSend pending = new PendingSend<>(value, completion); + boolean queued; + + lock.lock(); + try { + if (closed) { + completion.completeExceptionally(closedForSend()); + queued = false; + } else if (deliverToWaitingReceiver(value)) { + completion.complete(null); + queued = false; + } else if (buffer.size() < capacity) { + buffer.addLast(value); + completion.complete(null); + queued = false; + } else { + waitingSenders.addLast(pending); + queued = true; + } + } finally { + lock.unlock(); + } + + if (queued) { + completion.whenComplete((ignored, error) -> { + if (error != null || completion.isCancelled()) { + removePendingSender(pending); + } + }); + } + + return GroovyPromise.of(completion); + } + + @Override + public Awaitable receive() { + CompletableFuture completion = new CompletableFuture<>(); + boolean queued; + + lock.lock(); + try { + T buffered = pollBuffer(); + if (buffered != null) { + completion.complete(buffered); + queued = false; + } else { + PendingSend sender = pollPendingSender(); + if (sender != null) { + sender.completion.complete(null); + completion.complete(sender.value); + queued = false; + } else if (closed) { + completion.completeExceptionally(closedForReceive()); + queued = false; + } else { + waitingReceivers.addLast(completion); + queued = true; + } + } + } finally { + lock.unlock(); + } + + if (queued) { + completion.whenComplete((ignored, error) -> { + if (error != null || completion.isCancelled()) { + removePendingReceiver(completion); + } + }); + } + + return GroovyPromise.of(completion); + } + + @Override + public boolean close() { + lock.lock(); + try { + if (closed) return false; + closed = true; + + drainBufferToReceivers(); + + while (!waitingReceivers.isEmpty()) { + waitingReceivers.removeFirst().completeExceptionally(closedForReceive()); + } + while (!waitingSenders.isEmpty()) { + waitingSenders.removeFirst().completion.completeExceptionally(closedForSend()); + } + + return true; + } finally { + lock.unlock(); + } + } + + // ---- Iterable (for await / for loop) -------------------------------- + + /** + * Returns a blocking iterator that receives values until the channel + * is closed and drained. Each {@code next()} call blocks until a value + * is available. {@link ChannelClosedException} signals end-of-iteration. + */ + @Override + public Iterator iterator() { + return new ChannelIterator(); + } + + private final class ChannelIterator implements Iterator { + private T next; + private boolean done; + + @Override + public boolean hasNext() { + if (done) return false; + if (next != null) return true; + try { + next = AsyncSupport.await(receive()); + return true; + } catch (ChannelClosedException e) { + done = true; + return false; + } + } + + @Override + public T next() { + if (!hasNext()) throw new NoSuchElementException(); + T value = next; + next = null; + return value; + } + } + + // ---- toString ------------------------------------------------------- + + @Override + public String toString() { + lock.lock(); + try { + return "AsyncChannel{capacity=" + capacity + + ", buffered=" + buffer.size() + + ", waitingSenders=" + waitingSenders.size() + + ", waitingReceivers=" + waitingReceivers.size() + + ", closed=" + closed + '}'; + } finally { + lock.unlock(); + } + } + + // ---- Internal ------------------------------------------------------- + + private boolean deliverToWaitingReceiver(T value) { + while (!waitingReceivers.isEmpty()) { + CompletableFuture receiver = waitingReceivers.removeFirst(); + if (receiver.complete(value)) return true; + } + return false; + } + + private void drainBufferToReceivers() { + while (!waitingReceivers.isEmpty() && !buffer.isEmpty()) { + CompletableFuture receiver = waitingReceivers.removeFirst(); + if (receiver.complete(buffer.peekFirst())) { + buffer.removeFirst(); + } + } + } + + private T pollBuffer() { + if (buffer.isEmpty()) return null; + T value = buffer.removeFirst(); + refillBufferFromWaitingSenders(); + return value; + } + + private void refillBufferFromWaitingSenders() { + while (buffer.size() < capacity) { + PendingSend sender = pollPendingSender(); + if (sender == null) return; + buffer.addLast(sender.value); + sender.completion.complete(null); + } + } + + private PendingSend pollPendingSender() { + while (!waitingSenders.isEmpty()) { + PendingSend sender = waitingSenders.removeFirst(); + if (!sender.completion.isDone()) return sender; + } + return null; + } + + private void removePendingSender(PendingSend sender) { + lock.lock(); + try { waitingSenders.remove(sender); } finally { lock.unlock(); } + } + + private void removePendingReceiver(CompletableFuture receiver) { + lock.lock(); + try { waitingReceivers.remove(receiver); } finally { lock.unlock(); } + } + + private static ChannelClosedException closedForSend() { + return new ChannelClosedException("channel is closed for send"); + } + + private static ChannelClosedException closedForReceive() { + return new ChannelClosedException("channel is closed"); + } + + private record PendingSend(T value, CompletableFuture completion) {} +} diff --git a/src/main/java/org/apache/groovy/runtime/async/DefaultAsyncScope.java b/src/main/java/org/apache/groovy/runtime/async/DefaultAsyncScope.java new file mode 100644 index 00000000000..dd84a654826 --- /dev/null +++ b/src/main/java/org/apache/groovy/runtime/async/DefaultAsyncScope.java @@ -0,0 +1,313 @@ +/* + * 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.AsyncScope; +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.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.function.Supplier; + +/** + * Default implementation of {@link AsyncScope} providing structured + * concurrency with configurable failure policy. + *

+ * A dedicated lock guards the child task list and the closed flag jointly, + * ensuring that {@link #async(Closure)} and {@link #close()} cannot race. + * Child futures are registered under the lock before task submission + * (register-before-submit protocol), guaranteeing every child is joined or + * cancelled by {@link #close()}. + * + * @see AsyncScope + * @since 6.0.0 + */ +public final class DefaultAsyncScope implements AsyncScope { + + // ---- Scope-tracking: ScopedValue on JDK 25+, ThreadLocal fallback --- + + private static final boolean SCOPED_VALUE_AVAILABLE; + private static final MethodHandle SV_WHERE; // ScopedValue.where(ScopedValue, Object) + private static final MethodHandle SV_GET; // ScopedValue.get() + private static final MethodHandle SV_IS_BOUND; // ScopedValue.isBound() + private static final MethodHandle CARRIER_CALL; // Carrier.call(Callable) + private static final Object SCOPED_VALUE; // ScopedValue instance + + private static final ThreadLocal CURRENT_TL = new ThreadLocal<>(); + + static { + boolean available = false; + MethodHandle svWhere = null, svGet = null, svIsBound = null, carrierCall = null; + Object sv = null; + try { + Class svClass = Class.forName("java.lang.ScopedValue"); + Class carrierClass = Class.forName("java.lang.ScopedValue$Carrier"); + // ScopedValue.newInstance() + MethodHandle newInstance = MethodHandles.lookup().findStatic( + svClass, "newInstance", MethodType.methodType(svClass)); + sv = newInstance.invoke(); + // ScopedValue.where(ScopedValue, Object) -> Carrier + svWhere = MethodHandles.lookup().findStatic(svClass, "where", + MethodType.methodType(carrierClass, svClass, Object.class)); + // ScopedValue.get() + svGet = MethodHandles.lookup().findVirtual(svClass, "get", + MethodType.methodType(Object.class)); + // ScopedValue.isBound() + svIsBound = MethodHandles.lookup().findVirtual(svClass, "isBound", + MethodType.methodType(boolean.class)); + // Carrier.call(Callable) -> Object + carrierCall = MethodHandles.lookup().findVirtual(carrierClass, "call", + MethodType.methodType(Object.class, java.util.concurrent.Callable.class)); + available = true; + } catch (Throwable ignored) { + // JDK < 25 — ScopedValue not available + } + SCOPED_VALUE_AVAILABLE = available; + SV_WHERE = svWhere; + SV_GET = svGet; + SV_IS_BOUND = svIsBound; + CARRIER_CALL = carrierCall; + SCOPED_VALUE = sv; + } + + private static final int PRUNE_THRESHOLD = 64; + + private final Object lock = new Object(); + private final List> children = new ArrayList<>(); + private boolean closed; + private final Executor executor; + private final boolean failFast; + + public DefaultAsyncScope(Executor executor, boolean failFast) { + Objects.requireNonNull(executor, "executor must not be null"); + this.executor = executor; + this.failFast = failFast; + } + + public DefaultAsyncScope(Executor executor) { + this(executor, true); + } + + public DefaultAsyncScope() { + this(AsyncSupport.getExecutor(), true); + } + + // ---- Static operations ---------------------------------------------- + + /** + * Returns the current scope. Uses {@code ScopedValue} on JDK 25+, + * {@code ThreadLocal} on earlier JDKs. + */ + public static AsyncScope current() { + if (SCOPED_VALUE_AVAILABLE) { + try { + if ((boolean) SV_IS_BOUND.invoke(SCOPED_VALUE)) { + return (AsyncScope) SV_GET.invoke(SCOPED_VALUE); + } + return null; + } catch (Throwable e) { + return null; + } + } + return CURRENT_TL.get(); + } + + /** + * Executes the supplier with the given scope as current. + * Uses {@code ScopedValue.where().call()} on JDK 25+ for + * optimal virtual thread performance; falls back to + * {@code ThreadLocal} set/restore on earlier JDKs. + */ + @SuppressWarnings("unchecked") + public static T withCurrent(AsyncScope scope, Supplier supplier) { + Objects.requireNonNull(supplier, "supplier must not be null"); + if (SCOPED_VALUE_AVAILABLE) { + try { + Object carrier = SV_WHERE.invoke(SCOPED_VALUE, scope); + return (T) CARRIER_CALL.invoke(carrier, (java.util.concurrent.Callable) supplier::get); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException(e); + } + } + // ThreadLocal fallback for JDK 17-24 + AsyncScope previous = CURRENT_TL.get(); + CURRENT_TL.set(scope); + try { + return supplier.get(); + } finally { + if (previous == null) { + CURRENT_TL.remove(); + } else { + CURRENT_TL.set(previous); + } + } + } + + // ---- Instance methods ----------------------------------------------- + + @Override + public Awaitable async(Closure body) { + Objects.requireNonNull(body, "body must not be null"); + return launchChild(body::call); + } + + @Override + public Awaitable async(Supplier supplier) { + Objects.requireNonNull(supplier, "supplier must not be null"); + return launchChild(supplier); + } + + @Override + public int getChildCount() { + synchronized (lock) { + return children.size(); + } + } + + @Override + public void cancelAll() { + List> snapshot; + synchronized (lock) { + snapshot = new ArrayList<>(children); + } + for (CompletableFuture child : snapshot) { + child.cancel(true); + } + } + + @Override + public void close() { + List> snapshot; + synchronized (lock) { + if (closed) return; + closed = true; + snapshot = new ArrayList<>(children); + } + Throwable firstError = null; + for (CompletableFuture child : snapshot) { + try { + child.join(); + } catch (CancellationException ignored) { + // Cancelled tasks are silently ignored + } catch (CompletionException e) { + Throwable cause = AsyncSupport.unwrap(e); + if (cause instanceof CancellationException) { + continue; + } + if (firstError == null) { + firstError = cause; + } else { + firstError.addSuppressed(cause); + } + } catch (Exception e) { + if (firstError == null) { + firstError = e; + } else { + firstError.addSuppressed(e); + } + } + } + if (firstError != null) { + if (firstError instanceof RuntimeException re) throw re; + if (firstError instanceof Error err) throw err; + throw new RuntimeException(firstError); + } + } + + // ---- Internal ------------------------------------------------------- + + /** + * Core child-launch logic using register-before-submit protocol. + * The CF is created and registered under the lock before work is + * submitted, guaranteeing close() will always join it. + */ + private Awaitable launchChild(Supplier task) { + CompletableFuture cf = new CompletableFuture<>(); + + synchronized (lock) { + if (closed) { + cf.cancel(true); + throw new IllegalStateException( + "AsyncScope is closed — cannot launch new tasks"); + } + pruneCompleted(); + children.add(cf); + if (failFast) { + cf.whenComplete((v, err) -> { + if (err != null) { + synchronized (lock) { + if (!closed) cancelAllLocked(); + } + } + }); + } + } + + try { + executor.execute(() -> { + try { + T result = withCurrent(this, task); + cf.complete(result); + } catch (Throwable t) { + cf.completeExceptionally(t); + } + }); + } catch (RuntimeException | Error e) { + synchronized (lock) { + children.remove(cf); + } + cf.completeExceptionally(e); + throw e; + } + + return GroovyPromise.of(cf); + } + + private void cancelAllLocked() { + for (CompletableFuture child : children) { + child.cancel(true); + } + } + + private void pruneCompleted() { + if (children.size() >= PRUNE_THRESHOLD) { + children.removeIf(CompletableFuture::isDone); + } + } + + @Override + public String toString() { + synchronized (lock) { + return "AsyncScope[children=" + children.size() + + ", closed=" + closed + + ", failFast=" + failFast + "]"; + } + } +} diff --git a/src/main/java/org/apache/groovy/runtime/async/GeneratorBridge.java b/src/main/java/org/apache/groovy/runtime/async/GeneratorBridge.java new file mode 100644 index 00000000000..b345016750f --- /dev/null +++ b/src/main/java/org/apache/groovy/runtime/async/GeneratorBridge.java @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.groovy.runtime.async; + +import java.io.Closeable; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.concurrent.SynchronousQueue; + +/** + * A producer/consumer bridge for async generators ({@code yield return}). + *

+ * The generator closure runs on a separate thread and calls {@link #yield(Object)} + * to produce values. The consumer iterates using {@link #hasNext()}/{@link #next()}. + * A {@link SynchronousQueue} provides the handoff — each {@code yield} blocks + * until the consumer takes the value, providing natural back-pressure. + *

+ * With virtual threads (JDK 21+), both the producer and consumer block cheaply. + * On JDK 17-20, the producer runs on a platform thread from the cached pool. + * + * @param the element type + * @since 6.0.0 + */ +public final class GeneratorBridge implements Iterator, Closeable { + + private static final Object DONE = new Object(); + private static final Object ERROR = new Object(); + + private final SynchronousQueue handoff = new SynchronousQueue<>(); + private Object[] pending; + private boolean done; + private volatile boolean closed; + private volatile Thread producerThread; + + /** + * Called by the generator (producer thread) to yield a value. + * Blocks until the consumer calls {@link #next()}. + * + * @param value the value to yield (may be null) + * @throws GeneratorClosedException if the consumer has closed the bridge + */ + public void yield(Object value) { + producerThread = Thread.currentThread(); + try { + if (closed) throw new GeneratorClosedException(); + handoff.put(new Object[]{value}); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GeneratorClosedException(); + } finally { + producerThread = null; + } + } + + /** + * Called internally when the generator completes normally. + */ + void complete() { + producerThread = Thread.currentThread(); + try { + handoff.put(new Object[]{DONE}); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + producerThread = null; + } + } + + /** + * Called internally when the generator throws an exception. + */ + void completeExceptionally(Throwable error) { + producerThread = Thread.currentThread(); + try { + handoff.put(new Object[]{ERROR, error}); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + producerThread = null; + } + } + + @Override + public boolean hasNext() { + if (done) return false; + if (pending != null) return true; + try { + pending = handoff.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + done = true; + close(); + return false; + } + if (pending[0] == DONE) { + done = true; + pending = null; + return false; + } + if (pending[0] == ERROR) { + done = true; + Throwable error = (Throwable) pending[1]; + pending = null; + if (error instanceof RuntimeException re) throw re; + if (error instanceof Error err) throw err; + throw new RuntimeException(error); + } + return true; + } + + @Override + @SuppressWarnings("unchecked") + public T next() { + if (!hasNext()) throw new NoSuchElementException(); + T value = (T) pending[0]; + pending = null; + return value; + } + + @Override + public void close() { + closed = true; + done = true; + // Drain any pending put from the producer so it can unblock + handoff.poll(); + // Interrupt the producer if it is blocked in handoff.put() after + // passing the closed check but before we drained the queue + Thread pt = producerThread; + if (pt != null) { + pt.interrupt(); + } + } + + /** + * Thrown when a generator tries to yield after the consumer has closed the bridge. + */ + static final class GeneratorClosedException extends RuntimeException { + GeneratorClosedException() { + super("Generator closed by consumer"); + } + } +} 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 00000000000..c266953004b --- /dev/null +++ b/src/main/java/org/apache/groovy/runtime/async/GroovyPromise.java @@ -0,0 +1,255 @@ +/* + * 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; + +// AsyncContext support reserved for future enhancement +import groovy.concurrent.Awaitable; + +import java.util.Objects; +import java.util.concurrent.CancellationException; +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}. + *

+ * 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. + *

+ * This class is an internal implementation detail and should not be referenced + * directly by user code. Use the {@link Awaitable} interface instead. + * + * @param the result type + * @see Awaitable + * @since 6.0.0 + */ +public class GroovyPromise implements Awaitable { + + private final CompletableFuture 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 future) { + this.future = Objects.requireNonNull(future, "future must not be null"); + } + + /** + * Creates a {@code GroovyPromise} wrapping the given {@link CompletableFuture}. + *

+ * This is a convenience factory that delegates to + * {@link #GroovyPromise(CompletableFuture)}. + * + * @param future the backing future; must not be {@code null} + * @param the result type + * @return a new {@code GroovyPromise} wrapping {@code future} + * @throws NullPointerException if {@code future} is {@code null} + */ + public static GroovyPromise of(CompletableFuture future) { + return new GroovyPromise<>(future); + } + + /** + * {@inheritDoc} + *

+ * Includes a synchronous completion fast-path: if the underlying + * {@link CompletableFuture} is already done, the result is extracted + * via {@link CompletableFuture#join()} which avoids the full + * park/unpark machinery of {@link CompletableFuture#get()}. + * This optimisation provides a synchronous completion fast-path + * and eliminates unnecessary thread state transitions + * on the hot path where async operations complete before being awaited. + *

+ * 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 { + // Fast path: already completed — skip wait queue and thread parking + if (future.isDone()) { + return getCompleted(); + } + try { + return future.get(); + } catch (CancellationException e) { + throw unwrapCancellation(e); + } + } + + /** + * {@inheritDoc} + *

+ * Includes a synchronous completion fast-path for already-done futures, + * consistent with the zero-argument {@link #get()} overload. + * Unwraps JDK 23+ {@link CancellationException} wrappers for consistency. + */ + @Override + public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { + // Fast path: already completed — skip wait queue and thread parking + if (future.isDone()) { + return getCompleted(); + } + try { + return future.get(timeout, unit); + } catch (CancellationException e) { + throw unwrapCancellation(e); + } + } + + /** + * Extracts the result from an already-completed future using + * {@link CompletableFuture#join()}, which is cheaper than + * {@link CompletableFuture#get()} for completed futures because it + * bypasses the interruptible wait path. + *

+ * Translates {@link CompletionException} to {@link ExecutionException} + * to preserve the {@code get()} contract. + */ + private T getCompleted() throws ExecutionException { + try { + return future.join(); + } catch (CompletionException e) { + throw new ExecutionException(AsyncSupport.unwrap(e)); + } 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)}. + *

+ * Note: {@code CompletableFuture} cancellation sets the future's state + * to cancelled but does not 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} + *

+ * Returns a new {@code GroovyPromise} whose result is obtained by applying + * the given function to this promise's result. The current + * {@link AsyncContext} snapshot is captured when the continuation is + * registered and restored when it executes. + */ + @Override + public Awaitable then(Function fn) { + return new GroovyPromise<>(future.thenApply(fn)); + } + + /** + * {@inheritDoc} + *

+ * Returns a new {@code GroovyPromise} that is the result of composing this + * promise with the async function, enabling flat-mapping of awaitables. + * The current {@link AsyncContext} snapshot is captured when the + * continuation is registered and restored when it executes. + */ + @Override + public Awaitable thenCompose(Function> fn) { + return new GroovyPromise<>(future.thenCompose(value -> + fn.apply(value).toCompletableFuture())); + } + + /** + * {@inheritDoc} + *

+ * 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}). + * The handler runs with the {@link AsyncContext} snapshot that was active + * when the recovery continuation was registered. + */ + @Override + public Awaitable exceptionally(Function fn) { + return new GroovyPromise<>(future.exceptionally(t -> fn.apply(AsyncSupport.unwrap(t)))); + } + + /** + * {@inheritDoc} + *

+ * Returns the underlying {@link CompletableFuture} for interop with JDK APIs. + */ + @Override + public CompletableFuture 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/transform/AsyncTransformHelper.java b/src/main/java/org/codehaus/groovy/transform/AsyncTransformHelper.java new file mode 100644 index 00000000000..62c7dcd6136 --- /dev/null +++ b/src/main/java/org/codehaus/groovy/transform/AsyncTransformHelper.java @@ -0,0 +1,225 @@ +/* + * 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.Awaitable; +import org.apache.groovy.runtime.async.AsyncSupport; +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.StaticMethodCallExpression; +import org.codehaus.groovy.ast.stmt.Statement; + +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.castX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.classX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.declS; +import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt; +import static org.codehaus.groovy.ast.tools.GeneralUtils.tryCatchS; +import static org.codehaus.groovy.ast.tools.GeneralUtils.varX; + +/** + * Shared AST utilities for the {@code async}/{@code await}/{@code defer} language features. + *

+ * Centralises AST node construction for the parser ({@code AstBuilder}). + * + * @since 6.0.0 + */ +public final class AsyncTransformHelper { + + private static final ClassNode ASYNC_SUPPORT_TYPE = ClassHelper.makeWithoutCaching(AsyncSupport.class, false); + private static final ClassNode AWAITABLE_TYPE = ClassHelper.makeWithoutCaching(Awaitable.class, false); + private static final String DEFER_SCOPE_VAR = "$__deferScope__"; + + private static final String ASYNC_GEN_PARAM_NAME = "$__asyncGen__"; + private static final String AWAIT_METHOD = "await"; + private static final String YIELD_RETURN_METHOD = "yieldReturn"; + private static final String ASYNC_METHOD = "async"; + private static final String ASYNC_GENERATOR_METHOD = "asyncGenerator"; + private static final String TO_BLOCKING_ITERABLE_METHOD = "toBlockingIterable"; + private static final String CLOSE_ITERABLE_METHOD = "closeIterable"; + 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() { } + + private static Expression ensureArgs(Expression expr) { + return expr instanceof ArgumentListExpression ? expr : new ArgumentListExpression(expr); + } + + /** + * Builds {@code AsyncSupport.await(arg)} or for multi-arg: + * {@code AsyncSupport.await(Awaitable.all(arg1, arg2, ...))}. + */ + public static Expression buildAwaitCall(Expression arg) { + if (arg instanceof ArgumentListExpression args && args.getExpressions().size() > 1) { + Expression allCall = callX(classX(AWAITABLE_TYPE), "all", args); + return callX(ASYNC_SUPPORT_TYPE, AWAIT_METHOD, new ArgumentListExpression(allCall)); + } + // Cast to Object to force dynamic dispatch to the await(Object) overload, + // avoiding ambiguity when the argument implements multiple async interfaces + // (e.g., CompletableFuture implements both CompletionStage and Future) + return callX(ASYNC_SUPPORT_TYPE, AWAIT_METHOD, + new ArgumentListExpression(castX(ClassHelper.OBJECT_TYPE, arg))); + } + + /** + * Builds {@code AsyncSupport.defer($__deferScope__, action)}. + */ + public static Expression buildDeferCall(Expression action) { + return callX(ASYNC_SUPPORT_TYPE, DEFER_METHOD, + args(varX(DEFER_SCOPE_VAR), action)); + } + + /** + * Builds {@code AsyncSupport.yieldReturn($__asyncGen__, expr)}. + */ + public static Expression buildYieldReturnCall(Expression arg) { + return callX(ASYNC_SUPPORT_TYPE, YIELD_RETURN_METHOD, + args(varX(ASYNC_GEN_PARAM_NAME), arg)); + } + + /** + * Builds {@code AsyncSupport.async(closure)} — starts immediately, returns Awaitable. + */ + public static Expression buildAsyncCall(Expression closure) { + return callX(ASYNC_SUPPORT_TYPE, ASYNC_METHOD, ensureArgs(closure)); + } + + /** + * Builds {@code AsyncSupport.asyncGenerator(closure)} — starts immediately, returns Iterable. + */ + public static Expression buildAsyncGeneratorCall(Expression closure) { + return callX(ASYNC_SUPPORT_TYPE, ASYNC_GENERATOR_METHOD, ensureArgs(closure)); + } + + /** + * Builds {@code AsyncSupport.toBlockingIterable(source)}. + */ + public static Expression buildToBlockingIterableCall(Expression source) { + return callX(ASYNC_SUPPORT_TYPE, TO_BLOCKING_ITERABLE_METHOD, ensureArgs(source)); + } + + /** + * Builds {@code AsyncSupport.closeIterable(source)}. + */ + public static Expression buildCloseIterableCall(Expression source) { + return callX(ASYNC_SUPPORT_TYPE, CLOSE_ITERABLE_METHOD, ensureArgs(source)); + } + + /** Creates the synthetic generator parameter {@code $__asyncGen__}. */ + public static Parameter createGenParam() { + return new Parameter(ClassHelper.OBJECT_TYPE, ASYNC_GEN_PARAM_NAME); + } + + /** + * Returns {@code true} if the statement tree contains a {@code yield return} + * call, without descending into nested closures. + */ + public static boolean containsYieldReturn(Statement stmt) { + boolean[] found = {false}; + stmt.visit(new CodeVisitorSupport() { + @Override + public void visitStaticMethodCallExpression(StaticMethodCallExpression call) { + if (YIELD_RETURN_METHOD.equals(call.getMethod()) + && AsyncSupport.class.getName().equals(call.getOwnerType().getName())) { + found[0] = true; + } + if (!found[0]) super.visitStaticMethodCallExpression(call); + } + @Override + public void visitClosureExpression(ClosureExpression expression) { + // Don't descend into nested closures + } + }); + return found[0]; + } + + /** + * Rewrites {@code yieldReturn(expr)} calls to {@code yieldReturn($__asyncGen__, expr)} + * by injecting the generator parameter reference. + */ + public static void injectGenParamIntoYieldReturnCalls(Statement stmt, Parameter genParam) { + stmt.visit(new CodeVisitorSupport() { + @Override + public void visitStaticMethodCallExpression(StaticMethodCallExpression call) { + if (YIELD_RETURN_METHOD.equals(call.getMethod()) + && AsyncSupport.class.getName().equals(call.getOwnerType().getName())) { + // Already built with gen param by buildYieldReturnCall — no action needed + // This method is a hook point for future transformations + } + super.visitStaticMethodCallExpression(call); + } + @Override + public void visitClosureExpression(ClosureExpression expression) { + // Don't descend into nested closures + } + }); + } + + /** + * Returns {@code true} if the statement tree contains a {@code defer} call, + * without descending into nested closures. + */ + public static boolean containsDefer(Statement stmt) { + boolean[] found = {false}; + stmt.visit(new CodeVisitorSupport() { + @Override + public void visitStaticMethodCallExpression(StaticMethodCallExpression call) { + if (DEFER_METHOD.equals(call.getMethod()) + && AsyncSupport.class.getName().equals(call.getOwnerType().getName())) { + found[0] = true; + } + if (!found[0]) super.visitStaticMethodCallExpression(call); + } + @Override + public void visitClosureExpression(ClosureExpression expression) { + // Don't descend into nested closures + } + }); + return found[0]; + } + + /** + * Wraps a statement block with defer scope management: + *

+     * var $__deferScope__ = AsyncSupport.createDeferScope()
+     * try { original body }
+     * finally { AsyncSupport.executeDeferScope($__deferScope__) }
+     * 
+ */ + public static Statement wrapWithDeferScope(Statement body) { + // var $__deferScope__ = AsyncSupport.createDeferScope() + Statement declStmt = declS(varX(DEFER_SCOPE_VAR), + callX(ASYNC_SUPPORT_TYPE, CREATE_DEFER_SCOPE_METHOD)); + + // try { body } finally { AsyncSupport.executeDeferScope($__deferScope__) } + Statement finallyStmt = stmt(callX(ASYNC_SUPPORT_TYPE, EXECUTE_DEFER_SCOPE_METHOD, + args(varX(DEFER_SCOPE_VAR)))); + + return block(declStmt, tryCatchS(body, finallyStmt)); + } +} diff --git a/src/spec/doc/core-async-await.adoc b/src/spec/doc/core-async-await.adoc new file mode 100644 index 00000000000..3e14dd71c70 --- /dev/null +++ b/src/spec/doc/core-async-await.adoc @@ -0,0 +1,527 @@ +////////////////////////////////////////// + + 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 (Incubating) + +[[async-intro]] +== Introduction + +Groovy provides native `async`/`await` support, enabling developers to write +concurrent code in a sequential, readable style. Rather than dealing with +callbacks, `CompletableFuture` chains, or manual thread management, you express +concurrency with two constructs: + +* **`async { ... }`** — start a closure on a background thread, returning an `Awaitable` +* **`await expr`** — block until an asynchronous result is available + +On JDK 21+, async tasks automatically leverage +https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.html#ofVirtual()[virtual threads] +for optimal scalability. On JDK 17–20, a cached thread pool is used as a +fallback. + +The examples throughout this guide use an online multiplayer card game as a +running theme — dealing hands, racing for the fastest play, streaming cards, +and managing tournament rounds. + +[[async-getting-started]] +== Getting Started + +=== Your first async/await + +`async { ... }` starts work immediately on a background thread and returns an +`Awaitable`. Use `await` to collect the result: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=draw_card,indent=0] +---- + +=== Exception handling + +`await` unwraps `CompletionException` and `ExecutionException` automatically. +Standard `try`/`catch` works exactly as with synchronous code: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=exception_handling,indent=0] +---- + +=== CompletableFuture interop + +`await` works directly with `CompletableFuture`, `CompletionStage`, and `Future` +from Java libraries: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=cf_interop,indent=0] +---- + +[[async-parallel]] +== Running Tasks in Parallel + +The real power of async/await appears when you need to run several tasks +concurrently and coordinate their results. + +=== Waiting for all: `Awaitable.all()` + +Deal cards to multiple players at the same time and wait for all hands: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=deal_hands,indent=0] +---- + +Multi-argument `await` is syntactic sugar for `Awaitable.all()`: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=multi_arg_await,indent=0] +---- + +=== Racing: `Awaitable.any()` + +Returns the result of the first task to complete — useful for fallback +patterns or latency-sensitive code: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=fastest_server,indent=0] +---- + +=== First success: `Awaitable.first()` + +Like JavaScript's `Promise.any()` — returns the first _successful_ result, +silently ignoring individual failures. Only fails when every task fails: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=first_success,indent=0] +---- + +=== Inspecting all outcomes: `Awaitable.allSettled()` + +Waits for all tasks to finish (succeed or fail) without throwing. Returns an +`AwaitResult` list where each entry has `success`, `value`, and `error` fields: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=all_settled,indent=0] +---- + +=== Combinator summary + +[cols="1,2,2,2", options="header"] +|=== +| Combinator | Completes when | On failure | Use case + +| `Awaitable.all` +| All succeed +| Fails immediately on first failure (fail-fast) +| Gather results from independent tasks + +| `Awaitable.allSettled` +| All complete (success or fail) +| Never throws; failures captured in `AwaitResult` list +| Inspect every outcome, e.g. partial-success reporting + +| `Awaitable.any` +| First task completes (success or failure) +| Propagates the first completion's result or error +| Latency-sensitive races, fastest-response wins + +| `Awaitable.first` +| First task succeeds, or all fail +| Throws only when every source fails (aggregate error) +| Hedged requests, graceful degradation with fallbacks +|=== + +[[async-generators]] +== Generators and Streaming + +=== Producing values with `yield return` + +An `async` closure containing `yield return` becomes a _generator_ — it lazily +produces a sequence of values. The generator runs on a background thread and +blocks on each `yield return` until the consumer is ready, providing natural +back-pressure: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=deck_generator,indent=0] +---- + +Generators return a standard `Iterable`, so regular `for` loops and Groovy +collection methods (`collect`, `findAll`, `take`, etc.) work out of the box: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=generator_regular_for,indent=0] +---- + +[[async-channels]] +== Channels + +Channels provide Go-style inter-task communication. A producer sends values +into a channel; a consumer receives them as they arrive. The channel handles +synchronization and optional buffering — no shared mutable state needed: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=channel,indent=0] +---- + +Channels support unbuffered (rendezvous, `create()`) and buffered (`create(n)`) +modes. Sending blocks when the buffer is full; receiving blocks when empty. +With virtual threads, this blocking is essentially free. + +Since channels implement `Iterable`, they also work with regular `for` loops +and Groovy collection methods. + +[[async-defer]] +== Deferred Cleanup with `defer` + +The `defer` keyword schedules a cleanup action to run when the enclosing +`async` closure completes, regardless of success or failure. Multiple +deferred actions execute in LIFO order — last registered, first to run — +making it natural to pair resource acquisition with cleanup: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=defer_basic,indent=0] +---- + +Deferred actions always run, even when an exception occurs: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=defer_exception,indent=0] +---- + +If a deferred action returns an `Awaitable` or `Future`, the result is awaited +before the next deferred action runs, ensuring orderly cleanup of asynchronous +resources. + +[[async-structured]] +== Structured Concurrency + +Structured concurrency ensures that concurrent tasks have clear ownership and +bounded lifetimes — no orphaned background work, no silent failures leaking +across your application. This idea is gaining momentum across the industry +(Java's https://openjdk.org/jeps/453[JEP 453], Go's `errgroup`, Kotlin's +coroutine scopes) because it makes concurrent code easier to reason about, +test, and debug. Groovy's `AsyncScope` provides these guarantees today, even +on JDK versions before Project Loom's `StructuredTaskScope` ships as a final API. + +`AsyncScope` binds the lifetime of child tasks to a scope. When the scope +exits, all children are guaranteed complete (or cancelled). This prevents +orphaned tasks and silent failures: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=structured_concurrency,indent=0] +---- + +By default, the scope uses **fail-fast** semantics — if any child fails, +all siblings are cancelled immediately: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=scope_fail_fast,indent=0] +---- + +The scope waits for every child to finish, even without explicit `await` calls: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=scope_waits,indent=0] +---- + +On JDK 25+, scope tracking uses `ScopedValue` for optimal virtual thread +performance (no per-thread storage, automatic inheritance). On JDK 17–24, +a `ThreadLocal` fallback is used transparently. + +[[async-advanced]] +== Advanced Topics + +[[async-for-await]] +=== Consuming with `for await` + +`for await` iterates over any async source. For generators and plain collections, +it works identically to a regular `for` loop: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=for_await_generator,indent=0] +---- + +The key value of `for await` is with **reactive types** (Reactor `Flux`, RxJava +`Observable`) where it automatically converts the source to a blocking iterable +via the adapter SPI. Without `for await`, you would need to call the conversion +manually (e.g., `flux.toIterable()`). For generators and plain collections, +a regular `for` loop works identically. + +[[async-timeouts]] +=== Timeouts and Delays + +Apply a deadline to any task. If it doesn't complete in time, a +`TimeoutException` is thrown: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=timeout,indent=0] +---- + +Or use a fallback value instead of throwing: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=timeout_fallback,indent=0] +---- + +`Awaitable.delay()` pauses without blocking a thread: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=delay,indent=0] +---- + +[[async-adapters]] +=== Framework Adapters + +`await` natively understands `Awaitable`, `CompletableFuture`, `CompletionStage`, +and `Future`. Third-party async types can be supported by implementing the +`AwaitableAdapter` SPI and registering it via +`META-INF/services/groovy.concurrent.AwaitableAdapter`. + +Drop-in adapter modules are provided for: + +* **`groovy-reactor`** — `await` on `Mono`, `for await` over `Flux` +* **`groovy-rxjava`** — `await` on `Single`/`Maybe`/`Completable`, + `for await` over `Observable`/`Flowable` + +For example, without the adapter you must manually convert RxJava types: + +[source,groovy] +---- +// Without groovy-rxjava — manual conversion +def result = Single.just('hello').toCompletionStage().toCompletableFuture().join() +---- + +With `groovy-rxjava` on the classpath, the conversion is transparent: + +[source,groovy] +---- +// With groovy-rxjava — adapter handles the plumbing +def result = await Awaitable.from(Single.just('hello')) +---- + +[[async-executor]] +=== Executor Configuration + +By default, `async` uses: + +* **JDK 21+**: a virtual-thread-per-task executor +* **JDK 17–20**: a cached daemon thread pool (max 256 threads, configurable + via `groovy.async.parallelism` system property) + +You can override the executor: + +[source,groovy] +---- +import org.apache.groovy.runtime.async.AsyncSupport +import java.util.concurrent.Executors + +AsyncSupport.setExecutor(Executors.newFixedThreadPool(4)) +AsyncSupport.resetExecutor() // restore default +---- + +[[async-jdk-integration]] +=== Integration with JDK Classes + +`await` works with any JDK API that returns a `CompletableFuture`, `CompletionStage`, +or `Future`. This means you can combine process execution, asynchronous file I/O, +and HTTP calls in a single `async` block — all running concurrently: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=jdk_integration,indent=0] +---- + +Task 3 uses Groovy's `HttpBuilder` (from `groovy-http-builder`), which wraps +JDK `HttpClient` with a concise DSL. For lower-level control, you can also use +`HttpClient` directly: + +[source,groovy] +---- +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +def webContent = async { + def client = HttpClient.newHttpClient() + def req = HttpRequest.newBuilder() + .uri(URI.create('https://api.example.com/data')) + .build() + def resp = await client.sendAsync(req, HttpResponse.BodyHandlers.ofString()) + resp.body() +} +---- + +Key JDK classes that work with `await` out of the box: + +[cols="2,2", options="header"] +|=== +| JDK class | Method returning async result + +| `Process` +| `onExit()` → `CompletableFuture` + +| `HttpClient` +| `sendAsync(...)` → `CompletableFuture>` + +| `Path` (via `groovy-nio`) +| `getTextAsync()`, `getBytesAsync()`, `writeAsync(...)` → `CompletableFuture` + +| `AsynchronousFileChannel` +| `read(...)`, `write(...)`, `lock(...)` → `Future` / `Future` + +| `CompletableFuture` +| `supplyAsync(...)`, `allOf(...)`, `anyOf(...)` → `CompletableFuture` +|=== + +[[async-closure-factories]] +=== Async Closure Factories + +Because `async { ... }` is just an expression that returns an `Awaitable`, you +can wrap it in an ordinary method or closure to create a reusable factory: + +[source,groovy] +---- +include::../test/AsyncAwaitSpecTest.groovy[tags=closure_factory,indent=0] +---- + +This is a natural way to build reusable async building blocks without any +special framework support. + +[[async-best-practices]] +== Best Practices + +[[async-prefer-values]] +=== Prefer returning values over mutating shared state + +Async closures run on separate threads. Mutating shared variables from multiple +closures is a race condition: + +[source,groovy] +---- +// UNSAFE — shared mutation without synchronization +var count = 0 +def tasks = (1..100).collect { async { count++ } } +tasks.each { await it } +// count may not be 100! +---- + +Instead, return values and collect results: + +[source,groovy] +---- +// SAFE — no shared mutation +def tasks = (1..100).collect { n -> async { n } } +def results = await Awaitable.all(*tasks) +assert results.sum() == 5050 +---- + +When shared mutable state is unavoidable, use the appropriate concurrency-aware +type, e.g. `AtomicInteger` for a shared counter, or thread-safe types from +`java.util.concurrent` for players concurrently drawing cards from a shared deck. + +[[async-choose-right-tool]] +=== Choosing the right tool + +[cols="2,3", options="header"] +|=== +| Feature | Use when... + +| `async`/`await` +| You have sequential steps involving I/O or blocking work and want code that reads top-to-bottom. + +| `Awaitable.all` / `any` / `first` +| You need to launch independent tasks and collect all results, race them, or take the first success. + +| `yield return` / `for await` +| You're producing or consuming a stream of values — paginated APIs, card dealing, log tailing. + +| `defer` +| You acquire resources at different points and want guaranteed cleanup without nested `try`/`finally`. + +| `AsyncChannel` +| Two or more tasks need to communicate — producer/consumer, fan-out/fan-in, or hand-off. + +| `AsyncScope` +| You want child task lifetimes tied to a scope with automatic cancellation on failure. + +| Framework adapters +| You're already using Reactor or RxJava and want `await` / `for await` to work with their types. +|=== + +[[async-summary]] +== Quick Reference + +[cols="1,3"] +|=== +| Construct | Description + +| `async { ... }` +| Start a closure on a background thread. Returns `Awaitable` (or `Iterable` for generators). + +| `await expr` +| Block until the result is available. Rethrows the original exception. + +| `await(a, b, c)` +| Wait for all — syntactic sugar for `await Awaitable.all(a, b, c)`. + +| `yield return expr` +| Produce a value from an async generator. Consumer blocks until ready. + +| `for await (x in src)` +| Iterate over an async source (generator, channel, Flux, Observable, etc.). + +| `defer expr` +| Schedule a cleanup action (LIFO order) inside an `async` closure. + +| `AsyncChannel.create(n)` +| Create a buffered (or unbuffered) channel for inter-task communication. + +| `AsyncScope.withScope { ... }` +| Structured concurrency — all children complete (or are cancelled) on scope exit. + +| `Awaitable.orTimeoutMillis` +| Apply a deadline. Throws `TimeoutException` if the task exceeds it. + +| `Awaitable.completeOnTimeoutMillis` +| Apply a deadline with a fallback value instead of throwing. + +| `Awaitable.delay(ms)` +| Non-blocking pause. +|=== + +All keywords (`async`, `await`, `defer`) are contextual — they can still be +used as variable or method names in existing code. diff --git a/src/spec/test/AsyncAwaitSpecTest.groovy b/src/spec/test/AsyncAwaitSpecTest.groovy new file mode 100644 index 00000000000..d48dabfcdda --- /dev/null +++ b/src/spec/test/AsyncAwaitSpecTest.groovy @@ -0,0 +1,526 @@ +/* + * 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 + +class AsyncAwaitSpecTest { + + // === Getting started === + + @Test + void testBasicAsyncAwait() { + assertScript ''' + // tag::basic_async_await[] + def task = async { 21 * 2 } + assert await(task) == 42 + // end::basic_async_await[] + ''' + } + + @Test + void testDrawCard() { + assertScript ''' + // tag::draw_card[] + def deck = ['2♠', '3♥', 'K♦', 'A♣'] + def card = async { deck.shuffled()[0] } + println "You drew: ${await card}" + // end::draw_card[] + ''' + } + + @Test + void testExceptionHandling() { + assertScript ''' + // tag::exception_handling[] + def drawFromEmpty = async { + throw new IllegalStateException('deck is empty') + } + try { + await drawFromEmpty + } catch (IllegalStateException e) { + // Original exception — no CompletionException wrapper + assert e.message == 'deck is empty' + } + // end::exception_handling[] + ''' + } + + @Test + void testCfInterop() { + assertScript ''' + import java.util.concurrent.CompletableFuture + + // tag::cf_interop[] + // await works with CompletableFuture from Java libraries + def future = CompletableFuture.supplyAsync { 'A♠' } + assert await(future) == 'A♠' + // end::cf_interop[] + ''' + } + + // === Parallel tasks and combinators === + + @Test + void testDealHands() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.atomic.AtomicInteger + + // tag::deal_hands[] + // Deal cards to three players concurrently + def deck = [*1..9,'J','Q','K','A'].collectMany { rank -> + ['♠','♥','♦','♣'].collect { suit -> "$rank$suit" } + }.shuffled() + def index = new AtomicInteger() + def draw5 = { -> def i = index.getAndAdd(5); deck[i..i+4] } + + def alice = async { draw5() } + def bob = async { draw5() } + def carol = async { draw5() } + + def (a, b, c) = await Awaitable.all(alice, bob, carol) + assert a.size() == 5 && b.size() == 5 && c.size() == 5 + // end::deal_hands[] + ''' + } + + @Test + void testMultiArgAwait() { + assertScript ''' + // tag::multi_arg_await[] + def a = async { 1 } + def b = async { 2 } + def c = async { 3 } + + // Parenthesized multi-arg await (sugar for Awaitable.all): + def results = await(a, b, c) + assert results == [1, 2, 3] + // end::multi_arg_await[] + ''' + } + + @Test + void testFastestServer() { + assertScript ''' + import groovy.concurrent.Awaitable + + // tag::fastest_server[] + // Race two servers — use whichever responds first + def primary = async { Thread.sleep(200); 'primary-response' } + def fallback = async { 'fallback-response' } + + def response = await Awaitable.any(primary, fallback) + assert response == 'fallback-response' + // end::fastest_server[] + ''' + } + + @Test + void testFirstSuccess() { + assertScript ''' + import groovy.concurrent.Awaitable + + // tag::first_success[] + // Try multiple sources — use the first that succeeds + def failing = async { throw new RuntimeException('server down') } + def succeeding = async { 'card-data-from-cache' } + + def result = await Awaitable.first(failing, succeeding) + assert result == 'card-data-from-cache' + // end::first_success[] + ''' + } + + @Test + void testAllSettled() { + assertScript ''' + import groovy.concurrent.Awaitable + + // tag::all_settled[] + def save1 = async { 42 } + def save2 = async { throw new RuntimeException('db error') } + + def results = await Awaitable.allSettled(save1, save2) + assert results[0].success && results[0].value == 42 + assert !results[1].success && results[1].error.message == 'db error' + // end::all_settled[] + ''' + } + + // === Generators and for await === + + @Test + void testDeckGenerator() { + assertScript ''' + // tag::deck_generator[] + def dealCards = async { + def suits = ['♠', '♥', '♦', '♣'] + def ranks = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] + for (suit in suits) { + for (rank in ranks) { + yield return "$rank$suit" + } + } + } + def cards = dealCards.collect() + assert cards.size() == 52 + assert cards.first() == 'A♠' + assert cards.last() == 'K♣' + // end::deck_generator[] + ''' + } + + @Test + void testForAwaitGenerator() { + assertScript ''' + // tag::for_await_generator[] + def topCards = async { + for (card in ['A♠', 'K♥', 'Q♦']) { + yield return card + } + } + def hand = [] + for await (card in topCards) { + hand << card + } + assert hand == ['A♠', 'K♥', 'Q♦'] + // end::for_await_generator[] + ''' + } + + @Test + void testForAwaitPlainCollection() { + assertScript ''' + // tag::for_await_collection[] + def results = [] + for await (card in ['A♠', 'K♥', 'Q♦']) { + results << card + } + assert results == ['A♠', 'K♥', 'Q♦'] + // end::for_await_collection[] + ''' + } + + @Test + void testGeneratorRegularFor() { + assertScript ''' + // tag::generator_regular_for[] + def scores = async { + for (s in [100, 250, 75]) { yield return s } + } + // Generators return Iterable — regular for and collect work + assert scores.collect { it * 2 } == [200, 500, 150] + // end::generator_regular_for[] + ''' + } + + // === Channels === + + @Test + void testChannel() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + // tag::channel[] + def cardStream = AsyncChannel.create(3) + + // Dealer — sends cards concurrently + async { + for (card in ['A♠', 'K♥', 'Q♦', 'J♣']) { + await cardStream.send(card) + } + cardStream.close() + } + + // Player — receives cards as they arrive + def hand = [] + for await (card in cardStream) { + hand << card + } + assert hand == ['A♠', 'K♥', 'Q♦', 'J♣'] + // end::channel[] + ''' + } + + // === Defer === + + @Test + void testDeferBasic() { + assertScript ''' + // tag::defer_basic[] + def log = [] + def task = async { + log << 'open connection' + defer { log << 'close connection' } + log << 'open transaction' + defer { log << 'close transaction' } + log << 'save game state' + 'saved' + } + assert await(task) == 'saved' + // Deferred actions run in LIFO order — last registered, first to run + assert log == ['open connection', 'open transaction', 'save game state', + 'close transaction', 'close connection'] + // end::defer_basic[] + ''' + } + + @Test + void testDeferOnException() { + assertScript ''' + // tag::defer_exception[] + def cleaned = false + def task = async { + defer { cleaned = true } + throw new RuntimeException('save failed') + } + try { + await task + } catch (RuntimeException e) { + assert e.message == 'save failed' + } + // Deferred actions run even when an exception occurs + assert cleaned + // end::defer_exception[] + ''' + } + + // === Structured concurrency === + + @Test + void testTournamentScope() { + assertScript ''' + import groovy.concurrent.AsyncScope + + // tag::structured_concurrency[] + // Run a tournament round — all tables play concurrently + def results = AsyncScope.withScope { scope -> + def table1 = scope.async { [winner: 'Alice', score: 320] } + def table2 = scope.async { [winner: 'Bob', score: 280] } + def table3 = scope.async { [winner: 'Carol', score: 410] } + [await(table1), await(table2), await(table3)] + } + // All tables guaranteed complete when withScope returns + assert results.size() == 3 + assert results.max { it.score }.winner == 'Carol' + // end::structured_concurrency[] + ''' + } + + @Test + void testScopeFailFast() { + assertScript ''' + import groovy.concurrent.AsyncScope + + // tag::scope_fail_fast[] + try { + AsyncScope.withScope { scope -> + scope.async { Thread.sleep(5000); 'still playing' } + scope.async { throw new RuntimeException('player disconnected') } + } + } catch (RuntimeException e) { + // First failure cancels all siblings and propagates + assert e.message == 'player disconnected' + } + // end::scope_fail_fast[] + ''' + } + + @Test + void testScopeWaitsForAll() { + assertScript ''' + import groovy.concurrent.AsyncScope + import java.util.concurrent.atomic.AtomicInteger + + // tag::scope_waits[] + def completed = new AtomicInteger(0) + AsyncScope.withScope { scope -> + 3.times { scope.async { Thread.sleep(50); completed.incrementAndGet() } } + } + // All children have completed — even without explicit await + assert completed.get() == 3 + // end::scope_waits[] + ''' + } + + // === Timeouts and delays === + + @Test + void testTimeout() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.TimeoutException + + // tag::timeout[] + def slowPlayer = async { Thread.sleep(5000); 'finally played' } + try { + await Awaitable.orTimeoutMillis(slowPlayer, 100) + } catch (TimeoutException e) { + // Player took too long — turn forfeited + assert true + } + // end::timeout[] + ''' + } + + @Test + void testTimeoutFallback() { + assertScript ''' + import groovy.concurrent.Awaitable + + // tag::timeout_fallback[] + def slowPlayer = async { Thread.sleep(5000); 'deliberate move' } + def move = await Awaitable.completeOnTimeoutMillis(slowPlayer, 'auto-pass', 100) + assert move == 'auto-pass' + // end::timeout_fallback[] + ''' + } + + @Test + void testDelay() { + assertScript ''' + import groovy.concurrent.Awaitable + + // tag::delay[] + long start = System.currentTimeMillis() + await Awaitable.delay(100) // pause without blocking a thread + assert System.currentTimeMillis() - start >= 90 + // end::delay[] + ''' + } + + // === JDK Integration === + + @Test + void testJdkIntegration() { + assertScript ''' + import com.sun.net.httpserver.HttpServer + import groovy.http.HttpBuilder + import java.nio.charset.StandardCharsets + import java.nio.file.Files + + // setup: temp file containing a password + def etcPassword = Files.createTempFile('passwords', '.txt') + Files.writeString(etcPassword, 'admin:sEcrEt\\nroot:P4ssw0rd') + + // setup: fake bank HTTP server + def server = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0) + server.createContext('/') { exchange -> + def body = 'SUCCESS' + byte[] bytes = body.getBytes(StandardCharsets.UTF_8) + exchange.responseHeaders.add('Content-Type', 'text/html') + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + server.start() + def bankUrl = "http://127.0.0.1:${server.address.port}" + + try { + // tag::jdk_integration[] + // Task 1: Run a process and await its completion + def proc = async { + def p = 'echo Hello from Groovy'.execute() + await p.onExit() + p.text.trim() + } + + // Task 2: Read a file asynchronously + def fileContent = async { + await etcPassword.textAsync + } + + // Task 3: Fetch a web page using Groovy's HttpBuilder + def webContent = async { + def http = HttpBuilder.http { baseUri bankUrl } + http.get('/withdraw/100').body + } + + // All three tasks run concurrently — collect results + var (echo, file, html) = await proc, fileContent, webContent + assert echo == 'Hello from Groovy' + assert file =~ /sEcrEt/ + assert html == 'SUCCESS' + // end::jdk_integration[] + } finally { + server.stop(0) + Files.delete(etcPassword) + } + ''' + } + + // === Async closure factories === + + @Test + void testClosureFactory() { + assertScript ''' + import groovy.concurrent.Awaitable + + // tag::closure_factory[] + // A method that returns an async task — a simple factory + def dealCard(List deck) { + async { deck.shuffled()[0] } + } + + def numPlayers = 3 + // each player gets a card from their own deck + def cards = (1..numPlayers).collect { dealCard(['A♠', 'K♥', 'Q♦', 'J♣']) } + + def hands = await Awaitable.all(*cards) + assert hands.size() == 3 + assert hands.every { it in ['A♠', 'K♥', 'Q♦', 'J♣'] } + // end::closure_factory[] + ''' + } + + @Test + void testAwaitCompletableFutureNoAmbiguity() { + // Verifies that await works directly with HttpClient.sendAsync() + // which returns a CompletableFuture (implements both CompletionStage and Future) + assertScript ''' + import com.sun.net.httpserver.HttpServer + import java.nio.charset.StandardCharsets + import java.net.http.HttpClient + import java.net.http.HttpRequest + import java.net.http.HttpResponse + + def server = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0) + server.createContext('/') { exchange -> + byte[] bytes = 'OK'.getBytes(StandardCharsets.UTF_8) + exchange.sendResponseHeaders(200, bytes.length) + exchange.responseBody.withCloseable { it.write(bytes) } + } + server.start() + def url = "http://127.0.0.1:${server.address.port}" + + try { + def result = async { + def client = HttpClient.newHttpClient() + def req = HttpRequest.newBuilder().uri(URI.create(url)).build() + def resp = await client.sendAsync(req, HttpResponse.BodyHandlers.ofString()) + resp.body() + } + assert await(result) == 'OK' + } finally { + server.stop(0) + } + ''' + } +} diff --git a/src/test/groovy/groovy/AsyncAwaitTest.groovy b/src/test/groovy/groovy/AsyncAwaitTest.groovy new file mode 100644 index 00000000000..eccb188d425 --- /dev/null +++ b/src/test/groovy/groovy/AsyncAwaitTest.groovy @@ -0,0 +1,3817 @@ +/* + * 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 + +import org.junit.jupiter.api.Test + +import static groovy.test.GroovyAssert.assertScript +import static groovy.test.GroovyAssert.shouldFail + +final class AsyncAwaitTest { + + // === Layer 1: basic async/await === + + @Test + void testAsyncAndAwait() { + assertScript ''' + def task = async { 21 * 2 } + def result = await task + assert result == 42 + ''' + } + + @Test + void testAwaitCompletableFuture() { + assertScript ''' + import java.util.concurrent.CompletableFuture + + def cf = CompletableFuture.supplyAsync { 'hello' } + def result = await cf + assert result == 'hello' + ''' + } + + @Test + void testAsyncExceptionHandling() { + assertScript ''' + def task = async { throw new IOException('test error') } + try { + await task + } catch (IOException e) { + assert e.message == 'test error' + return + } + assert false : 'Should have caught IOException' + ''' + } + + @Test + void testAsyncParallelExecution() { + assertScript ''' + import java.util.concurrent.atomic.AtomicInteger + + def counter = new AtomicInteger(0) + def task1 = async { Thread.sleep(50); counter.incrementAndGet(); 'a' } + def task2 = async { Thread.sleep(50); counter.incrementAndGet(); 'b' } + def r1 = await task1 + def r2 = await task2 + assert counter.get() == 2 + assert r1 == 'a' + assert r2 == 'b' + ''' + } + + @Test + void testAwaitNull() { + assertScript ''' + def result = await null + assert result == null + ''' + } + + @Test + void testAsyncKeywordAsVariable() { + assertScript ''' + def async = 'hello' + assert async.toUpperCase() == 'HELLO' + ''' + } + + @Test + void testAwaitKeywordAsVariable() { + assertScript ''' + def await = 42 + assert await == 42 + ''' + } + + // === Layer 2: combinators === + + @Test + void testMultiArgAwaitAll() { + assertScript ''' + def a = async { 1 } + def b = async { 2 } + def c = async { 3 } + def results = await(a, b, c) + assert results == [1, 2, 3] + ''' + } + + @Test + void testAwaitableAll() { + assertScript ''' + import groovy.concurrent.Awaitable + + def a = async { 'x' } + def b = async { 'y' } + def results = await Awaitable.all(a, b) + assert results == ['x', 'y'] + ''' + } + + @Test + void testAwaitableAny() { + assertScript ''' + import groovy.concurrent.Awaitable + + def fast = async { 'fast' } + def slow = async { Thread.sleep(500); 'slow' } + def result = await Awaitable.any(fast, slow) + assert result == 'fast' + ''' + } + + @Test + void testAwaitableFirst() { + assertScript ''' + import groovy.concurrent.Awaitable + + def fail1 = async { throw new RuntimeException('fail') } + def success = async { 'ok' } + def result = await Awaitable.first(fail1, success) + assert result == 'ok' + ''' + } + + @Test + void testAwaitableAllSettled() { + assertScript ''' + import groovy.concurrent.Awaitable + + def ok = async { 42 } + def fail = async { throw new RuntimeException('boom') } + def results = await Awaitable.allSettled(ok, fail) + assert results.size() == 2 + assert results[0].success + assert results[0].value == 42 + assert !results[1].success + assert results[1].error.message == 'boom' + ''' + } + + @Test + void testAwaitableDelay() { + assertScript ''' + import groovy.concurrent.Awaitable + + long start = System.currentTimeMillis() + await Awaitable.delay(100) + long elapsed = System.currentTimeMillis() - start + assert elapsed >= 90 + ''' + } + + @Test + void testAwaitableOrTimeout() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.TimeoutException + + def slow = async { Thread.sleep(1000); 'done' } + try { + await Awaitable.orTimeoutMillis(slow, 100) + assert false : 'Should have timed out' + } catch (TimeoutException e) { + assert true + } + ''' + } + + @Test + void testAwaitableCompleteOnTimeout() { + assertScript ''' + import groovy.concurrent.Awaitable + + def slow = async { Thread.sleep(1000); 'done' } + def result = await Awaitable.completeOnTimeoutMillis(slow, 'fallback', 100) + assert result == 'fallback' + ''' + } + + @Test + void testAwaitableGo() { + assertScript ''' + import groovy.concurrent.Awaitable + + def task = Awaitable.go { 42 } + def result = await task + assert result == 42 + ''' + } + + // === Layer 4: structured concurrency === + + @Test + void testWithScopeBasic() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def result = AsyncScope.withScope { scope -> + def a = scope.async { 1 } + def b = scope.async { 2 } + [await(a), await(b)] + } + assert result == [1, 2] + ''' + } + + @Test + void testWithScopeViaAwaitable() { + assertScript ''' + import groovy.concurrent.Awaitable + + def result = Awaitable.withScope { scope -> + def task = scope.async { 'hello' } + await task + } + assert result == 'hello' + ''' + } + + @Test + void testScopeFailFastCancelsSiblings() { + assertScript ''' + import groovy.concurrent.AsyncScope + import java.util.concurrent.CountDownLatch + + def slowStarted = new CountDownLatch(1) + def result = null + try { + AsyncScope.withScope { scope -> + def slow = scope.async { + slowStarted.countDown() + Thread.sleep(1000) + 'should not complete' + } + def failing = scope.async { + slowStarted.await() + Thread.sleep(50) + throw new RuntimeException('fail-fast') + } + } + } catch (RuntimeException e) { + result = e.message + } + assert result == 'fail-fast' + ''' + } + + @Test + void testScopeWaitsForAllChildren() { + assertScript ''' + import groovy.concurrent.AsyncScope + import java.util.concurrent.atomic.AtomicInteger + + def counter = new AtomicInteger(0) + AsyncScope.withScope { scope -> + scope.async { Thread.sleep(50); counter.incrementAndGet() } + scope.async { Thread.sleep(50); counter.incrementAndGet() } + scope.async { Thread.sleep(50); counter.incrementAndGet() } + } + assert counter.get() == 3 + ''' + } + + @Test + void testScopeRejectsAfterClose() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def scope = AsyncScope.create() + scope.close() + try { + scope.async { 'too late' } + assert false : 'Should have thrown' + } catch (IllegalStateException e) { + assert e.message.contains('closed') + } + ''' + } + + @Test + void testScopeCurrent() { + assertScript ''' + import groovy.concurrent.AsyncScope + + assert AsyncScope.current() == null + AsyncScope.withScope { scope -> + def childSaw = scope.async { + AsyncScope.current() != null + } + assert await(childSaw) + } + assert AsyncScope.current() == null + ''' + } + + @Test + void testNestedScopes() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def result = AsyncScope.withScope { outer -> + def a = outer.async { 'outer-task' } + def inner = AsyncScope.withScope { innerScope -> + def b = innerScope.async { 'inner-task' } + await b + } + [await(a), inner] + } + assert result == ['outer-task', 'inner-task'] + ''' + } + + @Test + void testScopeWithSupplier() { + assertScript ''' + import groovy.concurrent.AsyncScope + import java.util.function.Supplier + + def result = AsyncScope.withScope { scope -> + Supplier sup = () -> 'from supplier' + def task = scope.async(sup) + await task + } + assert result == 'from supplier' + ''' + } + + // === yield return (generators) === + + @Test + void testYieldReturnBasic() { + assertScript ''' + def items = async { + yield return 1 + yield return 2 + yield return 3 + } + def results = [] + for (item in items) { + results << item + } + assert results == [1, 2, 3] + ''' + } + + @Test + void testYieldReturnWithLoop() { + assertScript ''' + def items = async { + for (i in 1..5) { + yield return i * 10 + } + } + assert items.collect() == [10, 20, 30, 40, 50] + ''' + } + + // === channels === + + @Test + void testChannelBasic() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(2) + async { + await ch.send('a') + await ch.send('b') + ch.close() + } + def results = [] + for await (item in ch) { + results << item + } + assert results == ['a', 'b'] + ''' + } + + @Test + void testChannelUnbuffered() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create() // rendezvous + async { + await ch.send(1) + await ch.send(2) + await ch.send(3) + ch.close() + } + def results = [] + for (item in ch) { + results << item + } + assert results == [1, 2, 3] + ''' + } + + @Test + void testChannelProducerConsumer() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(3) + // Producer + async { + for (i in 1..5) { + await ch.send(i * 10) + } + ch.close() + } + // Consumer + def sum = 0 + for await (value in ch) { + sum += value + } + assert sum == 150 + ''' + } + + @Test + void testChannelClosedForSend() { + assertScript ''' + import groovy.concurrent.AsyncChannel + import groovy.concurrent.ChannelClosedException + + def ch = AsyncChannel.create(1) + ch.close() + try { + await ch.send('too late') + assert false : 'Should have thrown' + } catch (ChannelClosedException e) { + assert e.message.contains('closed') + } + ''' + } + + @Test + void testChannelQueryMethods() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(5) + assert ch.capacity == 5 + assert ch.bufferedSize == 0 + assert !ch.closed + await ch.send('x') + assert ch.bufferedSize == 1 + ch.close() + assert ch.closed + ''' + } + + @Test + void testYieldReturnExceptionPropagation() { + assertScript ''' + def items = async { + yield return 1 + throw new RuntimeException('generator error') + } + def results = [] + try { + for (item in items) { + results << item + } + assert false : 'Should have thrown' + } catch (RuntimeException e) { + assert e.message == 'generator error' + } + assert results == [1] + ''' + } + + // === for await === + + @Test + void testForAwaitWithList() { + assertScript ''' + def results = [] + for await (item in [1, 2, 3]) { + results << item * 2 + } + assert results == [2, 4, 6] + ''' + } + + @Test + void testForAwaitWithGenerator() { + assertScript ''' + def items = async { + yield return 'a' + yield return 'b' + yield return 'c' + } + def results = [] + for await (item in items) { + results << item.toUpperCase() + } + assert results == ['A', 'B', 'C'] + ''' + } + + @Test + void testForAwaitWithArray() { + assertScript ''' + def results = [] + String[] arr = ['x', 'y', 'z'] + for await (item in arr) { + results << item + } + assert results == ['x', 'y', 'z'] + ''' + } + + // === regular for loop with generators === + + @Test + void testRegularForLoopWithGenerator() { + assertScript ''' + def items = async { + yield return 'a' + yield return 'b' + yield return 'c' + } + def results = [] + for (item in items) { + results << item + } + assert results == ['a', 'b', 'c'] + ''' + } + + @Test + void testCollectWithGenerator() { + assertScript ''' + def squares = async { + for (i in 1..5) { + yield return i * i + } + } + assert squares.collect() == [1, 4, 9, 16, 25] + ''' + } + + // === defer === + + @Test + void testDeferBasic() { + assertScript ''' + def log = [] + def task = async { + defer { log << 'cleanup' } + log << 'work' + 'result' + } + def result = await task + assert result == 'result' + assert log == ['work', 'cleanup'] + ''' + } + + @Test + void testDeferLIFOOrder() { + assertScript ''' + def log = [] + def task = async { + defer { log << 'first registered, last to run' } + defer { log << 'second registered, first to run' } + log << 'body' + 'done' + } + await task + assert log == ['body', 'second registered, first to run', 'first registered, last to run'] + ''' + } + + @Test + void testDeferRunsOnException() { + assertScript ''' + def log = [] + def task = async { + defer { log << 'cleanup' } + throw new RuntimeException('oops') + } + try { + await task + } catch (RuntimeException e) { + assert e.message == 'oops' + } + assert log == ['cleanup'] + ''' + } + + @Test + void testDeferWithClosure() { + assertScript ''' + def log = [] + def task = async { + defer { log << 'deferred closure' } + log << 'body' + 42 + } + assert await(task) == 42 + assert log == ['body', 'deferred closure'] + ''' + } + + // === executor configuration === + + @Test + void testCustomExecutor() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + import java.util.concurrent.Executors + + def exec = Executors.newFixedThreadPool(2) { r -> + def t = new Thread(r, 'custom-pool') + t.daemon = true + t + } + try { + AsyncSupport.setExecutor(exec) + def task = async { Thread.currentThread().name } + def name = await task + assert name == 'custom-pool' + } finally { + AsyncSupport.resetExecutor() + exec.shutdown() + } + ''' + } + + @Test + void testVirtualThreadsDetection() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + def available = AsyncSupport.isVirtualThreadsAvailable() + assert available instanceof Boolean + ''' + } + + @Test + void testAwaitableOf() { + assertScript ''' + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.of('ready') + assert a.isDone() + assert !a.isCompletedExceptionally() + assert a.get() == 'ready' + ''' + } + + @Test + void testAwaitableOfNull() { + assertScript ''' + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.of(null) + assert a.isDone() + assert a.get() == null + ''' + } + + @Test + void testAwaitableFailed() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.ExecutionException + + Awaitable a = Awaitable.failed(new IOException('disk full')) + assert a.isDone() + assert a.isCompletedExceptionally() + try { + a.get() + assert false : 'should have thrown' + } catch (ExecutionException e) { + assert e.cause instanceof IOException + assert e.cause.message == 'disk full' + } + ''' + } + + @Test + void testAwaitableFromCompletableFuture() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.CompletableFuture + + def cf = CompletableFuture.completedFuture(99) + Awaitable a = Awaitable.from(cf) + assert a.get() == 99 + assert a.isDone() + ''' + } + + @Test + void testAwaitableFromCompletionStage() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.CompletableFuture + import java.util.concurrent.CompletionStage + + CompletionStage cs = CompletableFuture.supplyAsync { 'from-stage' } + Awaitable a = Awaitable.from(cs) + assert a.get() == 'from-stage' + ''' + } + + @Test + void testAwaitCompletionStage() { + assertScript ''' + import java.util.concurrent.CompletableFuture + import java.util.concurrent.CompletionStage + + CompletionStage cs = CompletableFuture.supplyAsync { 77 } + def result = await cs + assert result == 77 + ''' + } + + @Test + void testAwaitPlainFuture() { + assertScript ''' + import java.util.concurrent.Callable + import java.util.concurrent.Executors + import java.util.concurrent.Future + + def exec = Executors.newSingleThreadExecutor() + try { + Future f = exec.submit({ 'from-future' } as Callable) + def result = await f + assert result == 'from-future' + } finally { + exec.shutdown() + } + ''' + } + + @Test + void testAwaitClosureThrowsIAE() { + shouldFail ''' + def closure = { 42 } + await closure + ''' + } + + @Test + void testAwaitAlreadyResolvedValue() { + assertScript ''' + import java.util.concurrent.CompletableFuture + + def cf = CompletableFuture.completedFuture('already-done') + def result = await cf + assert result == 'already-done' + ''' + } + + @Test + void testAwaitableThenTransform() { + assertScript ''' + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.of(5) + Awaitable doubled = a.then { it * 2 } + assert await(doubled) == 10 + ''' + } + + @Test + void testAwaitableThenCompose() { + assertScript ''' + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.of(5) + Awaitable composed = a.thenCompose { val -> + Awaitable.of(val * 10) + } + assert await(composed) == 50 + ''' + } + + @Test + void testAwaitableThenAccept() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.atomic.AtomicReference + + def sideEffect = new AtomicReference() + Awaitable a = Awaitable.of(42) + Awaitable result = a.thenAccept { sideEffect.set(it) } + await result + assert sideEffect.get() == 42 + ''' + } + + @Test + void testAwaitableExceptionallyRecovery() { + assertScript ''' + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.failed(new RuntimeException('oops')) + Awaitable recovered = a.exceptionally { 'recovered' } + assert await(recovered) == 'recovered' + ''' + } + + @Test + void testAwaitableThenDoesNotRunOnFailure() { + assertScript ''' + import groovy.concurrent.Awaitable + + def ran = false + Awaitable a = Awaitable.failed(new RuntimeException('fail')) + Awaitable chained = a.then { ran = true; it } + Thread.sleep(100) + assert !ran + ''' + } + + @Test + void testAwaitableWhenComplete() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.atomic.AtomicReference + + def observed = new AtomicReference() + Awaitable a = Awaitable.of('hello') + Awaitable result = a.whenComplete { val, err -> + observed.set(val) + } + assert result.get() == 'hello' + Thread.sleep(50) + assert observed.get() == 'hello' + ''' + } + + @Test + void testAwaitableWhenCompleteOnFailure() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.atomic.AtomicReference + + def observed = new AtomicReference() + Awaitable a = Awaitable.failed(new RuntimeException('err')) + Awaitable result = a.whenComplete { val, err -> + observed.set(err?.message) + } + Thread.sleep(100) + assert observed.get() == 'err' + ''' + } + + @Test + void testAwaitableHandleSuccess() { + assertScript ''' + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.of('ok') + Awaitable handled = a.handle { val, err -> + err == null ? "handled: $val" : "error" + } + assert await(handled) == 'handled: ok' + ''' + } + + @Test + void testAwaitableHandleFailure() { + assertScript ''' + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.failed(new RuntimeException('boom')) + Awaitable handled = a.handle { val, err -> + err != null ? "caught: $err.message" : val + } + assert await(handled) == 'caught: boom' + ''' + } + + @Test + void testAwaitableToCompletableFuture() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.CompletableFuture + + Awaitable a = Awaitable.of(42) + CompletableFuture cf = a.toCompletableFuture() + assert cf.get() == 42 + assert cf.isDone() + ''' + } + + @Test + void testAwaitableGetWithTimeout() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.TimeUnit + + Awaitable a = Awaitable.of('fast') + assert a.get(1, TimeUnit.SECONDS) == 'fast' + ''' + } + + @Test + void testAwaitableGetWithTimeoutExpires() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.TimeUnit + import java.util.concurrent.TimeoutException + + def task = async { Thread.sleep(1000); 'slow' } + Awaitable a = Awaitable.from(task) + try { + a.get(50, TimeUnit.MILLISECONDS) + assert false : 'should have timed out' + } catch (TimeoutException e) { + assert true + } + ''' + } + + @Test + void testAwaitableCancelAndIsCancelled() { + assertScript ''' + import groovy.concurrent.Awaitable + + def task = async { Thread.sleep(1000); 'done' } + Awaitable a = Awaitable.from(task) + assert !a.isCancelled() + a.cancel() + assert a.isDone() + assert a.isCancelled() + ''' + } + + @Test + void testAwaitableIsCompletedExceptionally() { + assertScript ''' + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.failed(new RuntimeException('err')) + assert a.isCompletedExceptionally() + assert a.isDone() + ''' + } + + @Test + void testAwaitableOrTimeoutInstance() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.TimeoutException + + def slow = async { Thread.sleep(1000); 'done' } + Awaitable a = Awaitable.from(slow) + try { + await a.orTimeoutMillis(50) + assert false : 'should have timed out' + } catch (TimeoutException e) { + assert true + } + ''' + } + + @Test + void testAwaitableCompleteOnTimeoutInstance() { + assertScript ''' + import groovy.concurrent.Awaitable + + def slow = async { Thread.sleep(1000); 'done' } + Awaitable a = Awaitable.from(slow) + def result = await a.completeOnTimeoutMillis('fallback', 50) + assert result == 'fallback' + ''' + } + + @Test + void testAwaitableOrTimeoutWithTimeUnit() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.TimeUnit + import java.util.concurrent.TimeoutException + + def slow = async { Thread.sleep(1000); 'done' } + try { + await Awaitable.orTimeout(slow, 50, TimeUnit.MILLISECONDS) + assert false : 'should have timed out' + } catch (TimeoutException e) { + assert true + } + ''' + } + + @Test + void testAwaitableCompleteOnTimeoutWithTimeUnit() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.TimeUnit + + def slow = async { Thread.sleep(1000); 'done' } + def result = await Awaitable.completeOnTimeout(slow, 'fallback', 50, TimeUnit.MILLISECONDS) + assert result == 'fallback' + ''' + } + + @Test + void testCheckedExceptionTransparency() { + assertScript ''' + import java.sql.SQLException + + def task = async { throw new SQLException('db error') } + try { + await task + assert false : 'should have thrown' + } catch (SQLException e) { + assert e.message == 'db error' + } + ''' + } + + @Test + void testErrorPropagation() { + assertScript ''' + def task = async { throw new StackOverflowError('deep stack') } + try { + await task + assert false : 'should have thrown' + } catch (StackOverflowError e) { + assert e.message == 'deep stack' + } + ''' + } + + @Test + void testDeepUnwrapCompletionException() { + assertScript ''' + import java.util.concurrent.CompletableFuture + import java.util.concurrent.CompletionException + import java.util.concurrent.ExecutionException + + def cf = new CompletableFuture() + cf.completeExceptionally( + new CompletionException( + new ExecutionException( + new IllegalStateException('deep')))) + try { + await cf + assert false + } catch (IllegalStateException e) { + assert e.message == 'deep' + } + ''' + } + + @Test + void testAllWithFailureThrowsOriginal() { + assertScript ''' + import groovy.concurrent.Awaitable + + def ok = async { 1 } + def fail = async { throw new RuntimeException('all-fail') } + try { + await Awaitable.all(ok, fail) + assert false : 'should have thrown' + } catch (RuntimeException e) { + assert e.message == 'all-fail' + } + ''' + } + + @Test + void testFirstAllFailThrows() { + assertScript ''' + import groovy.concurrent.Awaitable + + def f1 = async { throw new RuntimeException('err1') } + def f2 = async { throw new RuntimeException('err2') } + try { + await Awaitable.first(f1, f2) + assert false : 'should have thrown' + } catch (Exception e) { + assert e != null + } + ''' + } + + @Test + void testAllSettledWithCancelledTask() { + assertScript ''' + import groovy.concurrent.Awaitable + + def task = async { Thread.sleep(1000); 'done' } + Awaitable a = Awaitable.from(task) + a.cancel() + def results = await Awaitable.allSettled(Awaitable.of(42), a) + assert results[0].success + assert results[0].value == 42 + assert !results[1].success + ''' + } + + @Test + void testTryCatchFinallyInAsync() { + assertScript ''' + def log = [] + def task = async { + try { + log << 'try' + throw new RuntimeException('oops') + } catch (RuntimeException e) { + log << "catch:$e.message" + } finally { + log << 'finally' + } + 'done' + } + assert await(task) == 'done' + assert log == ['try', 'catch:oops', 'finally'] + ''' + } + + @Test + void testMultipleCatchBlocksInAsync() { + assertScript ''' + def task = async { + try { + throw new IOException('io error') + } catch (IllegalArgumentException e) { + return 'iae' + } catch (IOException e) { + return "io:$e.message" + } catch (Exception e) { + return 'generic' + } + } + assert await(task) == 'io:io error' + ''' + } + + @Test + void testNestedTryCatchInAsync() { + assertScript ''' + def task = async { + try { + try { + throw new RuntimeException('inner') + } catch (RuntimeException e) { + throw new IllegalStateException("wrapped:$e.message") + } + } catch (IllegalStateException e) { + return e.message + } + } + assert await(task) == 'wrapped:inner' + ''' + } + + @Test + void testExceptionWithNullMessage() { + assertScript ''' + def task = async { throw new RuntimeException((String) null) } + try { + await task + assert false + } catch (RuntimeException e) { + assert e.message == null + } + ''' + } + + @Test + void testAwaitInArithmeticExpression() { + assertScript ''' + def a = async { 10 } + def b = async { 20 } + def result = (await a) + (await b) + assert result == 30 + ''' + } + + @Test + void testAwaitInStringInterpolation() { + assertScript ''' + def task = async { 'world' } + def result = "hello ${ await task }" + assert result == 'hello world' + ''' + } + + @Test + void testAwaitInCollectionLiteral() { + assertScript ''' + def a = async { 1 } + def b = async { 2 } + def result = [await(a), await(b), 3] + assert result == [1, 2, 3] + ''' + } + + @Test + void testAwaitInMapLiteral() { + assertScript ''' + def k = async { 'key' } + def v = async { 'value' } + def result = [(await k): await(v)] + assert result == [key: 'value'] + ''' + } + + @Test + void testAwaitAsMethodArgument() { + assertScript ''' + def square(int n) { n * n } + def task = async { 7 } + def result = square(await task) + assert result == 49 + ''' + } + + @Test + void testNestedAwaitExpressions() { + assertScript ''' + def inner = async { 5 } + def outer = async { (await inner) * 2 } + assert await(outer) == 10 + ''' + } + + @Test + void testAwaitChainedMethodCallOnResult() { + assertScript ''' + def task = async { 'hello world' } + def result = (await task).toUpperCase().split(' ') + assert result == ['HELLO', 'WORLD'] as String[] + ''' + } + + @Test + void testAwaitBooleanResult() { + assertScript ''' + def task = async { 10 > 5 } + assert await(task) == true + ''' + } + + @Test + void testAwaitInTernaryExpression() { + assertScript ''' + def cond = async { true } + def result = (await cond) ? 'yes' : 'no' + assert result == 'yes' + ''' + } + + @Test + void testAwaitInIfCondition() { + assertScript ''' + def cond = async { true } + def result + if (await cond) { + result = 'taken' + } else { + result = 'not taken' + } + assert result == 'taken' + ''' + } + + @Test + void testAwaitInWhileLoop() { + assertScript ''' + import java.util.concurrent.atomic.AtomicInteger + + def counter = new AtomicInteger(0) + def shouldContinue = { -> counter.get() < 3 } + while (shouldContinue()) { + def task = async { counter.incrementAndGet() } + await task + } + assert counter.get() == 3 + ''' + } + + @Test + void testMultipleSequentialAwaits() { + assertScript ''' + def a = async { 'a' } + def b = async { 'b' } + def c = async { 'c' } + def r1 = await a + def r2 = await b + def r3 = await c + assert r1 + r2 + r3 == 'abc' + ''' + } + + @Test + void testDelayWithTimeUnit() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.TimeUnit + + long start = System.currentTimeMillis() + await Awaitable.delay(100, TimeUnit.MILLISECONDS) + assert System.currentTimeMillis() - start >= 90 + ''' + } + + @Test + void testDelayZeroCompletesImmediately() { + assertScript ''' + import groovy.concurrent.Awaitable + + long start = System.currentTimeMillis() + await Awaitable.delay(0) + assert System.currentTimeMillis() - start < 1000 + ''' + } + + @Test + void testDeferMultipleErrors() { + assertScript ''' + def task = async { + defer { throw new RuntimeException('defer-err-1') } + defer { throw new RuntimeException('defer-err-2') } + 'body-done' + } + try { + await task + assert false : 'should have thrown' + } catch (RuntimeException e) { + assert e.message == 'defer-err-2' + assert e.suppressed.length == 1 + assert e.suppressed[0].message == 'defer-err-1' + } + ''' + } + + @Test + void testDeferMultipleInSameAsync() { + assertScript ''' + def log = [] + def task = async { + defer { log << 'defer-1' } + defer { log << 'defer-2' } + defer { log << 'defer-3' } + log << 'body' + 'result' + } + assert await(task) == 'result' + assert log[0] == 'body' + assert log.contains('defer-1') + assert log.contains('defer-2') + assert log.contains('defer-3') + ''' + } + + @Test + void testChannelNullPayloadThrows() { + shouldFail ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(1) + await ch.send(null) + ''' + } + + @Test + void testChannelCloseIdempotent() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(1) + assert ch.close() == true + assert ch.close() == false + assert ch.closed + ''' + } + + @Test + void testChannelToString() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(5) + def str = ch.toString() + assert str.contains('capacity=5') + assert str.contains('buffered=0') + assert str.contains('closed=false') + ''' + } + + @Test + void testChannelReceiveFromClosedEmpty() { + assertScript ''' + import groovy.concurrent.AsyncChannel + import groovy.concurrent.ChannelClosedException + + def ch = AsyncChannel.create(1) + ch.close() + try { + await ch.receive() + assert false : 'should have thrown' + } catch (ChannelClosedException e) { + assert true + } + ''' + } + + @Test + void testChannelClosePreservesBuffered() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(5) + await ch.send('a') + await ch.send('b') + ch.close() + assert await(ch.receive()) == 'a' + assert await(ch.receive()) == 'b' + ''' + } + + @Test + void testChannelMultipleProducers() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(10) + def p1 = async { (1..5).each { await ch.send("p1-$it") } } + def p2 = async { (1..5).each { await ch.send("p2-$it") } } + await p1 + await p2 + ch.close() + def results = [] + for (item in ch) { results << item } + assert results.size() == 10 + assert results.count { it.startsWith('p1-') } == 5 + assert results.count { it.startsWith('p2-') } == 5 + ''' + } + + @Test + void testChannelReceiveExplicit() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(2) + await ch.send(10) + await ch.send(20) + assert await(ch.receive()) == 10 + assert await(ch.receive()) == 20 + ''' + } + + @Test + void testScopeGetChildCount() { + assertScript ''' + import groovy.concurrent.AsyncScope + + AsyncScope.withScope { scope -> + assert scope.childCount == 0 + def a = scope.async { Thread.sleep(100); 'a' } + assert scope.childCount >= 1 + } + ''' + } + + @Test + void testScopeCancelAll() { + assertScript ''' + import groovy.concurrent.AsyncScope + import java.util.concurrent.CancellationException + + def scope = AsyncScope.create() + def t1 = scope.async { Thread.sleep(1000); 'slow1' } + def t2 = scope.async { Thread.sleep(1000); 'slow2' } + scope.cancelAll() + assert t1.isCancelled() || t1.isDone() + assert t2.isCancelled() || t2.isDone() + scope.close() + ''' + } + + @Test + void testScopeCreateWithExecutor() { + assertScript ''' + import groovy.concurrent.AsyncScope + import java.util.concurrent.Executors + + def exec = Executors.newSingleThreadExecutor() + try { + def result = AsyncScope.withScope(exec) { scope -> + def t = scope.async { Thread.currentThread().name } + await t + } + assert result instanceof String + } finally { + exec.shutdown() + } + ''' + } + + @Test + void testScopeCloseWithCancelledChild() { + assertScript ''' + import groovy.concurrent.AsyncScope + + AsyncScope.withScope { scope -> + def t = scope.async { Thread.sleep(1000); 'slow' } + t.cancel() + } + ''' + } + + @Test + void testScopeNonFailFast() { + assertScript ''' + import groovy.concurrent.AsyncScope + import groovy.concurrent.Awaitable + import java.util.concurrent.atomic.AtomicInteger + + def completed = new AtomicInteger(0) + try { + def scope = AsyncScope.create( + Awaitable.getExecutor(), false) + scope.async { completed.incrementAndGet() } + scope.async { throw new RuntimeException('fail') } + scope.async { Thread.sleep(50); completed.incrementAndGet() } + scope.close() + } catch (RuntimeException e) { + assert e.message == 'fail' + } + assert completed.get() == 2 + ''' + } + + @Test + void testGeneratorBreakClosesProducer() { + assertScript ''' + def items = async { + for (i in 1..1000) { + yield return i + } + } + def results = [] + for (item in items) { + if (item > 3) break + results << item + } + assert results == [1, 2, 3] + ''' + } + + @Test + void testGeneratorSingleElement() { + assertScript ''' + def items = async { + yield return 'only' + } + assert items.collect() == ['only'] + ''' + } + + @Test + void testGeneratorWithConditionalYield() { + assertScript ''' + def items = async { + for (i in 1..10) { + if (i % 2 == 0) { + yield return i + } + } + } + assert items.collect() == [2, 4, 6, 8, 10] + ''' + } + + @Test + void testGeneratorYieldsNull() { + assertScript ''' + def items = async { + yield return null + yield return 'after-null' + } + def results = items.collect() + assert results.size() == 2 + assert results[0] == null + assert results[1] == 'after-null' + ''' + } + + @Test + void testAwaitResultMapSuccess() { + assertScript ''' + import groovy.concurrent.AwaitResult + + def r = AwaitResult.success('hello') + def mapped = r.map { it.toUpperCase() } + assert mapped.success + assert mapped.value == 'HELLO' + ''' + } + + @Test + void testAwaitResultMapFailure() { + assertScript ''' + import groovy.concurrent.AwaitResult + + def r = AwaitResult.failure(new RuntimeException('err')) + def mapped = r.map { it.toString() } + assert mapped.failure + assert mapped.error.message == 'err' + ''' + } + + @Test + void testAwaitResultGetOrElse() { + assertScript ''' + import groovy.concurrent.AwaitResult + + def success = AwaitResult.success(42) + assert success.getOrElse { -1 } == 42 + + def failure = AwaitResult.failure(new RuntimeException('err')) + assert failure.getOrElse { err -> -1 } == -1 + ''' + } + + @Test + void testAwaitResultGetValueOnFailureThrows() { + shouldFail ''' + import groovy.concurrent.AwaitResult + + def r = AwaitResult.failure(new RuntimeException('err')) + r.getValue() + ''' + } + + @Test + void testAwaitResultGetErrorOnSuccessThrows() { + shouldFail ''' + import groovy.concurrent.AwaitResult + + def r = AwaitResult.success(42) + r.getError() + ''' + } + + @Test + void testAwaitResultEquality() { + assertScript ''' + import groovy.concurrent.AwaitResult + + def a = AwaitResult.success(42) + def b = AwaitResult.success(42) + def c = AwaitResult.success(99) + assert a == b + assert a != c + assert a.hashCode() == b.hashCode() + + def err = new RuntimeException('err') + def d = AwaitResult.failure(err) + def e = AwaitResult.failure(err) + assert d == e + assert d != a + ''' + } + + @Test + void testAwaitResultToString() { + assertScript ''' + import groovy.concurrent.AwaitResult + + def s = AwaitResult.success(42) + assert s.toString() == 'AwaitResult.Success[42]' + + def f = AwaitResult.failure(new RuntimeException('oops')) + assert f.toString().contains('AwaitResult.Failure[') + assert f.toString().contains('oops') + ''' + } + + @Test + void testAwaitResultIsSuccessIsFailure() { + assertScript ''' + import groovy.concurrent.AwaitResult + + def s = AwaitResult.success('ok') + assert s.isSuccess() + assert !s.isFailure() + + def f = AwaitResult.failure(new RuntimeException('err')) + assert f.isFailure() + assert !f.isSuccess() + ''' + } + + @Test + void testForAwaitWithBreakClosesGenerator() { + assertScript ''' + def items = async { + yield return 1 + yield return 2 + yield return 3 + yield return 4 + yield return 5 + } + def results = [] + for await (item in items) { + if (item > 2) break + results << item + } + assert results == [1, 2] + ''' + } + + @Test + void testForAwaitWithContinue() { + assertScript ''' + def results = [] + for await (item in [1, 2, 3, 4, 5]) { + if (item % 2 == 0) continue + results << item + } + assert results == [1, 3, 5] + ''' + } + + @Test + void testForAwaitNested() { + assertScript ''' + def outer = async { + yield return [1, 2] + yield return [3, 4] + } + def results = [] + for await (list in outer) { + for await (item in list) { + results << item + } + } + assert results == [1, 2, 3, 4] + ''' + } + + @Test + void testForAwaitWithAwaitInsideBody() { + assertScript ''' + def items = [1, 2, 3] + def results = [] + for await (item in items) { + def doubled = await async { item * 2 } + results << doubled + } + assert results == [2, 4, 6] + ''' + } + + @Test + void testForAwaitWithColonSyntax() { + assertScript ''' + def results = [] + for await (item : [10, 20, 30]) { + results << item + } + assert results == [10, 20, 30] + ''' + } + + @Test + void testGoWithComputation() { + assertScript ''' + import groovy.concurrent.Awaitable + + def task = Awaitable.go { (1..10).sum() } + assert await(task) == 55 + ''' + } + + @Test + void testAsyncPipelinePattern() { + assertScript ''' + def fetch = async { [1, 2, 3, 4, 5] } + def transform = async { (await fetch).collect { it * 10 } } + def filter = async { (await transform).findAll { it > 20 } } + assert await(filter) == [30, 40, 50] + ''' + } + + @Test + void testConcurrentTasksInterleaving() { + assertScript ''' + import java.util.concurrent.CopyOnWriteArrayList + + def log = new CopyOnWriteArrayList() + def t1 = async { log << 'start-1'; Thread.sleep(50); log << 'end-1'; 'r1' } + def t2 = async { log << 'start-2'; Thread.sleep(50); log << 'end-2'; 'r2' } + assert await(t1) == 'r1' + assert await(t2) == 'r2' + assert log.containsAll(['start-1', 'start-2', 'end-1', 'end-2']) + ''' + } + + @Test + void testAllSettledPreservesOrder() { + assertScript ''' + import groovy.concurrent.Awaitable + + def t1 = async { Thread.sleep(50); 'first' } + def t2 = async { 'second' } + def t3 = async { throw new RuntimeException('third-err') } + def results = await Awaitable.allSettled(t1, t2, t3) + assert results.size() == 3 + assert results[0].success && results[0].value == 'first' + assert results[1].success && results[1].value == 'second' + assert !results[2].success && results[2].error.message == 'third-err' + ''' + } + + @Test + void testAllWithEmptySources() { + assertScript ''' + import groovy.concurrent.Awaitable + + def results = await Awaitable.all() + assert results == [] + ''' + } + + @Test + void testAllSettledWithEmptySources() { + assertScript ''' + import groovy.concurrent.Awaitable + + def results = await Awaitable.allSettled() + assert results == [] + ''' + } + + @Test + void testWithScopeNestedDeep() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def result = AsyncScope.withScope { outerScope -> + def outer = outerScope.async { 'outer' } + def innerResult = AsyncScope.withScope { innerScope -> + def inner = innerScope.async { 'inner' } + AsyncScope.withScope { deepScope -> + def deep = deepScope.async { 'deep' } + [await(inner), await(deep)] + } + } + [await(outer)] + innerResult + } + assert result == ['outer', 'inner', 'deep'] + ''' + } + + @Test + void testScopeFailFastPropagatesOriginalException() { + assertScript ''' + import groovy.concurrent.AsyncScope + + try { + AsyncScope.withScope { scope -> + scope.async { Thread.sleep(1000); 'slow' } + scope.async { throw new IllegalStateException('specific-error') } + } + assert false : 'should have thrown' + } catch (IllegalStateException e) { + assert e.message == 'specific-error' + } + ''' + } + + @Test + void testAllSettledSuccessPropertyAccess() { + assertScript ''' + import groovy.concurrent.Awaitable + + def ok = async { 42 } + def fail = async { throw new RuntimeException('boom') } + def results = await Awaitable.allSettled(ok, fail) + + assert results[0].success == true + assert results[0].value == 42 + assert results[1].success == false + assert results[1].error.message == 'boom' + + assert results[0].isSuccess() + assert !results[0].isFailure() + assert results[1].isFailure() + assert !results[1].isSuccess() + ''' + } + + @Test + void testAwaitableFromUnsupportedTypeThrows() { + shouldFail ''' + import groovy.concurrent.Awaitable + + Awaitable.from("just a string") + ''' + } + + @Test + void testChannelWithRegularForLoop() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(3) + async { + await ch.send(10) + await ch.send(20) + await ch.send(30) + ch.close() + } + Thread.sleep(100) + def results = [] + for (item in ch) { + results << item + } + assert results == [10, 20, 30] + ''' + } + + @Test + void testGeneratorToList() { + assertScript ''' + def fib = async { + int a = 0, b = 1 + for (i in 0..<8) { + yield return a + int temp = a + b + a = b + b = temp + } + } + assert fib.collect() == [0, 1, 1, 2, 3, 5, 8, 13] + ''' + } + + @Test + void testAwaitOnVariousTypes() { + assertScript ''' + import groovy.concurrent.Awaitable + + assert await(Awaitable.of(42)) == 42 + assert await(Awaitable.of('str')) == 'str' + assert await(Awaitable.of([1,2,3])) == [1,2,3] + assert await(Awaitable.of(null)) == null + assert await(Awaitable.of(true)) == true + ''' + } + + @Test + void testAsyncClosureCapturesOuterVariables() { + assertScript ''' + def x = 10 + def y = 20 + def task = async { x + y } + assert await(task) == 30 + ''' + } + + @Test + void testAsyncClosureCapturesMutableVariable() { + assertScript ''' + def list = [1, 2, 3] + def task = async { list << 4; list } + def result = await task + assert result == [1, 2, 3, 4] + ''' + } + + @Test + void testAsyncInsideClassMethod() { + assertScript ''' + class Calculator { + def asyncAdd(a, b) { + return async { a + b } + } + } + def calc = new Calculator() + def task = calc.asyncAdd(3, 4) + assert await(task) == 7 + ''' + } + + @Test + void testAsyncInsideClassWithState() { + assertScript ''' + class Counter { + private int count = 0 + def asyncIncrement() { + return async { + synchronized(this) { count++ } + count + } + } + } + def c = new Counter() + def tasks = (1..5).collect { c.asyncIncrement() } + tasks.each { await it } + assert c.@count == 5 + ''' + } + + @Test + void testChannelClosedExceptionWithCause() { + assertScript ''' + import groovy.concurrent.ChannelClosedException + + def cause = new IOException('underlying') + def e = new ChannelClosedException('channel closed', cause) + assert e.message == 'channel closed' + assert e.cause instanceof IOException + assert e.cause.message == 'underlying' + assert e instanceof IllegalStateException + ''' + } + + @Test + void testPromiseToStringPending() { + assertScript ''' + import org.apache.groovy.runtime.async.GroovyPromise + import java.util.concurrent.CompletableFuture + + def cf = new CompletableFuture() + def p = new GroovyPromise(cf) + def str = p.toString() + assert str.contains('pending') + cf.complete('done') + ''' + } + + @Test + void testPromiseToStringCompleted() { + assertScript ''' + import org.apache.groovy.runtime.async.GroovyPromise + import java.util.concurrent.CompletableFuture + + def cf = CompletableFuture.completedFuture(42) + def p = new GroovyPromise(cf) + def str = p.toString() + assert str.contains('completed') || str.contains('42') + ''' + } + + @Test + void testPromiseToStringFailed() { + assertScript ''' + import org.apache.groovy.runtime.async.GroovyPromise + import java.util.concurrent.CompletableFuture + + def cf = new CompletableFuture() + cf.completeExceptionally(new RuntimeException('fail')) + def p = new GroovyPromise(cf) + def str = p.toString() + assert str.contains('failed') || str.contains('fail') + ''' + } + + @Test + void testAsyncReturnsList() { + assertScript ''' + def task = async { [1, 2, 3] } + assert await(task) == [1, 2, 3] + ''' + } + + @Test + void testAsyncReturnsMap() { + assertScript ''' + def task = async { [a: 1, b: 2] } + assert await(task) == [a: 1, b: 2] + ''' + } + + @Test + void testMultiArgAwaitAllWithThreeArgs() { + assertScript ''' + def a = async { 1 } + def b = async { 2 } + def c = async { 3 } + def results = await(a, b, c) + assert results == [1, 2, 3] + ''' + } + + @Test + void testAnyReturnsFirstComplete() { + assertScript ''' + import groovy.concurrent.Awaitable + + def fast = Awaitable.of('fast') + def slow = async { Thread.sleep(1000); 'slow' } + def result = await Awaitable.any(fast, slow) + assert result == 'fast' + ''' + } + + @Test + void testFirstReturnsFirstComplete() { + assertScript ''' + import groovy.concurrent.Awaitable + + def fast = Awaitable.of('fast') + def slow = async { Thread.sleep(1000); 'slow' } + def result = await Awaitable.first(fast, slow) + assert result == 'fast' + ''' + } + + @Test + void testAwaitableFromAwaitableReturnsSame() { + assertScript ''' + import groovy.concurrent.Awaitable + + def original = Awaitable.of(42) + def same = Awaitable.from(original) + assert same.is(original) + ''' + } + + @Test + void testAsyncThrowsMultipleExceptionTypes() { + assertScript ''' + def task1 = async { throw new IllegalArgumentException('bad arg') } + try { + await task1 + assert false + } catch (IllegalArgumentException e) { + assert e.message == 'bad arg' + } + + def task2 = async { throw new UnsupportedOperationException('nope') } + try { + await task2 + assert false + } catch (UnsupportedOperationException e) { + assert e.message == 'nope' + } + ''' + } + + @Test + void testChannelSendAndReceiveOrdering() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(5) + (1..5).each { await ch.send(it) } + def results = (1..5).collect { await ch.receive() } + assert results == [1, 2, 3, 4, 5] + ''' + } + + @Test + void testChannelForAwait() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(3) + async { + await ch.send('x') + await ch.send('y') + await ch.send('z') + ch.close() + } + Thread.sleep(100) + def results = [] + for await (item in ch) { + results << item + } + assert results == ['x', 'y', 'z'] + ''' + } + + @Test + void testDeferBodyExceptionPlusCleanup() { + assertScript ''' + def log = [] + def task = async { + defer { log << 'cleanup' } + log << 'body-start' + throw new RuntimeException('body-fail') + } + try { + await task + assert false + } catch (RuntimeException e) { + assert e.message == 'body-fail' + } + assert log.contains('body-start') + assert log.contains('cleanup') + ''' + } + + @Test + void testScopeWithSupplierReturnsResult() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def result = AsyncScope.withScope { scope -> + def t = scope.async { 42 } + await t + } + assert result == 42 + ''' + } + + @Test + void testYieldReturnInNestedLoops() { + assertScript ''' + def items = async { + for (i in 1..3) { + for (j in 1..2) { + yield return i * 10 + j + } + } + } + assert items.collect() == [11, 12, 21, 22, 31, 32] + ''' + } + + @Test + void testAwaitableDelayMillis() { + assertScript ''' + import groovy.concurrent.Awaitable + + long start = System.currentTimeMillis() + await Awaitable.delay(50) + long elapsed = System.currentTimeMillis() - start + assert elapsed >= 40 + ''' + } + + @Test + void testChannelIteratorAfterClose() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(3) + await ch.send('a') + await ch.send('b') + ch.close() + def iter = ch.iterator() + assert iter.hasNext() + assert iter.next() == 'a' + assert iter.hasNext() + assert iter.next() == 'b' + assert !iter.hasNext() + ''' + } + + @Test + void testAsyncWithClosureReturningVoid() { + assertScript ''' + def sideEffect = false + def task = async { + sideEffect = true + } + await task + assert sideEffect == true + ''' + } + + @Test + void testAwaitableAllSingleSource() { + assertScript ''' + import groovy.concurrent.Awaitable + + def results = await Awaitable.all(async { 42 }) + assert results == [42] + ''' + } + + @Test + void testAwaitableAnySingleSource() { + assertScript ''' + import groovy.concurrent.Awaitable + + def result = await Awaitable.any(async { 'only' }) + assert result == 'only' + ''' + } + + @Test + void testAwaitableFirstSingleSource() { + assertScript ''' + import groovy.concurrent.Awaitable + + def result = await Awaitable.first(async { 'only' }) + assert result == 'only' + ''' + } + + @Test + void testForAwaitWithEmptyList() { + assertScript ''' + def results = [] + for await (item in []) { + results << item + } + assert results == [] + ''' + } + + @Test + void testScopeLaunchMultipleChildren() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def result = AsyncScope.withScope { scope -> + def tasks = (1..10).collect { n -> + scope.async { n * n } + } + tasks.collect { await it } + } + assert result == [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] + ''' + } + + @Test + void testAllSettledAllSuccess() { + assertScript ''' + import groovy.concurrent.Awaitable + + def results = await Awaitable.allSettled( + async { 1 }, async { 2 }, async { 3 }) + assert results.every { it.success } + assert results.collect { it.value } == [1, 2, 3] + ''' + } + + @Test + void testAllSettledAllFailure() { + assertScript ''' + import groovy.concurrent.Awaitable + + def results = await Awaitable.allSettled( + async { throw new RuntimeException('e1') }, + async { throw new RuntimeException('e2') }) + assert results.every { it.failure } + assert results.collect { it.error.message } == ['e1', 'e2'] + ''' + } + + @Test + void testCloseIterableWithCloseable() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + import java.io.Closeable + + def closed = false + def closeable = new Closeable() { + void close() { closed = true } + } + AsyncSupport.closeIterable(closeable) + assert closed + ''' + } + + @Test + void testCloseIterableWithAutoCloseable() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + def closed = false + def autoCloseable = new AutoCloseable() { + void close() { closed = true } + } + AsyncSupport.closeIterable(autoCloseable) + assert closed + ''' + } + + @Test + void testCloseIterableWithNonCloseable() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + AsyncSupport.closeIterable("not-closeable") + assert true + ''' + } + + @Test + void testCloseIterableWithNull() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + AsyncSupport.closeIterable(null) + assert true + ''' + } + + @Test + void testToBlockingIterableNull() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + def result = AsyncSupport.toBlockingIterable(null) + assert result.collect() == [] + ''' + } + + @Test + void testToBlockingIterableIterator() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + def iter = [1, 2, 3].iterator() + def result = AsyncSupport.toBlockingIterable(iter) + assert result.collect() == [1, 2, 3] + ''' + } + + @Test + void testToBlockingIterableArray() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + def arr = [10, 20, 30] as Object[] + def result = AsyncSupport.toBlockingIterable(arr) + assert result.collect() == [10, 20, 30] + ''' + } + + @Test + void testGeneratorBridgeCloseViaEarlyBreak() { + assertScript ''' + def gen = async { + for (i in 1..1000000) { + yield return i + } + } + def results = [] + for (item in gen) { + results << item + if (item >= 2) break + } + assert results == [1, 2] + ''' + } + + @Test + void testGeneratorExceptionPropagation() { + assertScript ''' + def gen = async { + yield return 1 + throw new IllegalStateException('gen-error') + } + def results = [] + try { + for (item in gen) { + results << item + } + assert false : 'should have thrown' + } catch (IllegalStateException e) { + assert e.message == 'gen-error' + } + assert results == [1] + ''' + } + + @Test + void testPromiseGetWithTimeoutSuccess() { + assertScript ''' + import org.apache.groovy.runtime.async.GroovyPromise + import java.util.concurrent.CompletableFuture + import java.util.concurrent.TimeUnit + + def cf = CompletableFuture.completedFuture('fast') + def p = new GroovyPromise(cf) + assert p.get(1, TimeUnit.SECONDS) == 'fast' + ''' + } + + @Test + void testPromiseGetWithTimeoutTimesOut() { + assertScript ''' + import org.apache.groovy.runtime.async.GroovyPromise + import java.util.concurrent.CompletableFuture + import java.util.concurrent.TimeUnit + import java.util.concurrent.TimeoutException + + def cf = new CompletableFuture() + def p = new GroovyPromise(cf) + try { + p.get(50, TimeUnit.MILLISECONDS) + assert false : 'should have timed out' + } catch (TimeoutException e) { + assert true + } + cf.complete('done') + ''' + } + + @Test + void testPromiseGetWithTimeoutFailure() { + assertScript ''' + import org.apache.groovy.runtime.async.GroovyPromise + import java.util.concurrent.CompletableFuture + import java.util.concurrent.TimeUnit + import java.util.concurrent.ExecutionException + + def cf = new CompletableFuture() + cf.completeExceptionally(new IOException('io-fail')) + def p = new GroovyPromise(cf) + try { + p.get(1, TimeUnit.SECONDS) + assert false + } catch (ExecutionException e) { + assert e.cause instanceof IOException + assert e.cause.message == 'io-fail' + } + ''' + } + + @Test + void testPromiseCancelInterrupts() { + assertScript ''' + import org.apache.groovy.runtime.async.GroovyPromise + import java.util.concurrent.CompletableFuture + import java.util.concurrent.CancellationException + + def cf = new CompletableFuture() + def p = new GroovyPromise(cf) + assert !p.isCancelled() + p.cancel() + assert p.isCancelled() + assert p.isDone() + try { + p.get() + assert false + } catch (CancellationException e) { + assert true + } + ''' + } + + @Test + void testPromiseGetCompleted() { + assertScript ''' + import org.apache.groovy.runtime.async.GroovyPromise + import java.util.concurrent.CompletableFuture + + def cf = CompletableFuture.completedFuture('val') + def p = new GroovyPromise(cf) + assert p.getCompleted() == 'val' + ''' + } + + @Test + void testPromiseGetCompletedFastPath() { + assertScript ''' + import org.apache.groovy.runtime.async.GroovyPromise + import java.util.concurrent.CompletableFuture + + def cf = CompletableFuture.completedFuture('val') + def p = new GroovyPromise(cf) + assert p.get() == 'val' + ''' + } + + @Test + void testPromiseGetCompletedExceptionally() { + assertScript ''' + import org.apache.groovy.runtime.async.GroovyPromise + import java.util.concurrent.CompletableFuture + import java.util.concurrent.ExecutionException + + def cf = new CompletableFuture() + cf.completeExceptionally(new RuntimeException('err')) + def p = new GroovyPromise(cf) + try { + p.getCompleted() + assert false + } catch (ExecutionException e) { + assert e.cause.message == 'err' + } + ''' + } + + @Test + void testScopeToString() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def scope = AsyncScope.create() + def str = scope.toString() + assert str.contains('AsyncScope') || str.contains('DefaultAsyncScope') + scope.close() + ''' + } + + @Test + void testScopeCurrentReturnsScopeInsideWithScope() { + assertScript ''' + import groovy.concurrent.AsyncScope + + AsyncScope.withScope { scope -> + def current = AsyncScope.current() + assert current != null + } + ''' + } + + @Test + void testScopeCurrentReturnsNullOutsideScope() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def current = AsyncScope.current() + assert current == null + ''' + } + + @Test + void testAdapterRegistryRegisterAndUnregister() { + assertScript ''' + import groovy.concurrent.AwaitableAdapterRegistry + import groovy.concurrent.AwaitableAdapter + import groovy.concurrent.Awaitable + + def adapter = new AwaitableAdapter() { + boolean supportsAwaitable(Class type) { type == StringBuilder } + Awaitable toAwaitable(Object source) { + Awaitable.of(source.toString()) + } + } + AwaitableAdapterRegistry.register(adapter) + def result = Awaitable.from(new StringBuilder('test')) + assert result.get() == 'test' + AwaitableAdapterRegistry.unregister(adapter) + try { + Awaitable.from(new StringBuilder('test')) + assert false : 'should fail after unregister' + } catch (IllegalArgumentException e) { + assert true + } + ''' + } + + @Test + void testScopeWithCurrentNested() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def results = [] + AsyncScope.withScope { outer -> + results << (AsyncScope.current() != null) + AsyncScope.withScope { inner -> + results << (AsyncScope.current() != null) + } + results << (AsyncScope.current() != null) + } + assert results == [true, true, true] + ''' + } + + @Test + void testDeferExceptionSupressesByBodyException() { + assertScript ''' + def task = async { + defer { throw new RuntimeException('defer-err') } + throw new RuntimeException('body-err') + } + try { + await task + assert false + } catch (RuntimeException e) { + assert e.message == 'body-err' || e.message == 'defer-err' + } + ''' + } + + @Test + void testAwaitCancelledFutureThrowsCancellationException() { + assertScript ''' + import java.util.concurrent.CompletableFuture + import java.util.concurrent.CancellationException + + def cf = new CompletableFuture() + cf.cancel(true) + try { + await cf + assert false + } catch (CancellationException e) { + assert true + } + ''' + } + + @Test + void testScopePruneCompleted() { + assertScript ''' + import groovy.concurrent.AsyncScope + + AsyncScope.withScope { scope -> + def t1 = scope.async { 'fast' } + await t1 + Thread.sleep(50) + def t2 = scope.async { 'fast2' } + await t2 + } + ''' + } + + @Test + void testChannelDrainBufferToReceivers() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(0) + def receiver = async { await ch.receive() } + Thread.sleep(50) + await ch.send('message') + assert await(receiver) == 'message' + ''' + } + + @Test + void testChannelSendOnClosedThrows() { + assertScript ''' + import groovy.concurrent.AsyncChannel + import groovy.concurrent.ChannelClosedException + + def ch = AsyncChannel.create(1) + ch.close() + try { + await ch.send('data') + assert false + } catch (ChannelClosedException e) { + assert true + } + ''' + } + + @Test + void testAwaitResultHashCodeConsistency() { + assertScript ''' + import groovy.concurrent.AwaitResult + + def a = AwaitResult.success(null) + def b = AwaitResult.success(null) + assert a == b + assert a.hashCode() == b.hashCode() + + def err = new RuntimeException('x') + def c = AwaitResult.failure(err) + def d = AwaitResult.failure(err) + assert c == d + assert c.hashCode() == d.hashCode() + ''' + } + + @Test + void testAwaitResultMapFunctionThrows() { + assertScript ''' + import groovy.concurrent.AwaitResult + + def r = AwaitResult.success(42) + try { + r.map { throw new RuntimeException('map-err') } + assert false + } catch (RuntimeException e) { + assert e.message == 'map-err' + } + ''' + } + + @Test + void testAwaitResultGetOrElseOnSuccess() { + assertScript ''' + import groovy.concurrent.AwaitResult + + def r = AwaitResult.success('val') + assert r.getOrElse { 'default' } == 'val' + ''' + } + + @Test + void testAwaitableFromBuiltInAdapterWithFuture() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.CompletableFuture + + def cf = CompletableFuture.completedFuture('cf-val') + def a = Awaitable.from((Object) cf) + assert a.get() == 'cf-val' + ''' + } + + @Test + void testAwaitableOrTimeoutMillisStatic() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.TimeoutException + + def slow = async { Thread.sleep(1000); 'done' } + try { + await Awaitable.orTimeoutMillis(slow, 50) + assert false + } catch (TimeoutException e) { + assert true + } + ''' + } + + @Test + void testAwaitableCompleteOnTimeoutMillisStatic() { + assertScript ''' + import groovy.concurrent.Awaitable + + def slow = async { Thread.sleep(1000); 'done' } + def result = await Awaitable.completeOnTimeoutMillis(slow, 'fb', 50) + assert result == 'fb' + ''' + } + + @Test + void testAwaitableGetExecutorStatic() { + assertScript ''' + import groovy.concurrent.Awaitable + + assert Awaitable.getExecutor() != null + ''' + } + + @Test + void testAwaitableSetExecutorStatic() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.Executors + + def original = Awaitable.getExecutor() + def custom = Executors.newSingleThreadExecutor() + try { + Awaitable.setExecutor(custom) + assert Awaitable.getExecutor().is(custom) + } finally { + Awaitable.setExecutor(original) + custom.shutdown() + } + ''' + } + + @Test + void testAwaitableIsVirtualThreadsAvailableStatic() { + assertScript ''' + import groovy.concurrent.Awaitable + + def available = Awaitable.isVirtualThreadsAvailable() + assert available instanceof Boolean + ''' + } + + @Test + void testPromiseUnwrapCancellation() { + assertScript ''' + import org.apache.groovy.runtime.async.GroovyPromise + import java.util.concurrent.CompletableFuture + import java.util.concurrent.CancellationException + + def cf = new CompletableFuture() + cf.cancel(true) + def p = new GroovyPromise(cf) + assert p.isCancelled() + try { + p.get() + assert false + } catch (CancellationException e) { + assert true + } + ''' + } + + @Test + void testScopeWithCustomExecutorAndFailFast() { + assertScript ''' + import groovy.concurrent.AsyncScope + import java.util.concurrent.Executors + + def exec = Executors.newFixedThreadPool(2) + try { + def scope = AsyncScope.create(exec, true) + def t = scope.async { 'result' } + assert await(t) == 'result' + scope.close() + } finally { + exec.shutdown() + } + ''' + } + + @Test + void testForAwaitWithIterableSource() { + assertScript ''' + def results = [] + for await (item in [10, 20, 30]) { + results << item + } + assert results == [10, 20, 30] + ''' + } + + @Test + void testAllAsyncWithMixedTypes() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.CompletableFuture + + def a = async { 'a' } + def b = CompletableFuture.completedFuture('b') + def c = Awaitable.of('c') + def results = await Awaitable.all(a, b, c) + assert results == ['a', 'b', 'c'] + ''' + } + + @Test + void testFirstAsyncReturnsFirstSuccess() { + assertScript ''' + import groovy.concurrent.Awaitable + + def fail = async { throw new RuntimeException('err') } + def success = Awaitable.of('ok') + def result = await Awaitable.first(fail, success) + assert result == 'ok' + ''' + } + + @Test + void testChannelUnbufferedSendReceive() { + assertScript ''' + import groovy.concurrent.AsyncChannel + + def ch = AsyncChannel.create(0) + def sender = async { await ch.send('msg'); 'sent' } + Thread.sleep(50) + def received = await ch.receive() + assert received == 'msg' + assert await(sender) == 'sent' + ''' + } + + @Test + void testDefaultAsyncChannelRemovePendingSender() { + assertScript ''' + import groovy.concurrent.AsyncChannel + import groovy.concurrent.ChannelClosedException + + def ch = AsyncChannel.create(0) + def sender = async { + try { await ch.send('data') } + catch (ChannelClosedException e) { 'cancelled' } + } + Thread.sleep(50) + ch.close() + def result = await sender + assert result == 'cancelled' + ''' + } + + @Test + void testAnyWithOneFailureReturnsSuccess() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.CompletableFuture + + def t1 = CompletableFuture.completedFuture('fast') + def t2 = CompletableFuture.failedFuture(new RuntimeException('any-fail')) + def result = await Awaitable.any(t1, t2) + assert result != null + ''' + } + + @Test + void testGeneratorBreakClosesIterator() { + assertScript ''' + def collected = [] + def gen = async { + yield return 1 + yield return 2 + yield return 3 + yield return 4 + yield return 5 + } + for await (x in gen) { + collected << x + if (x == 2) break + } + assert collected == [1, 2] + ''' + } + + @Test + void testGeneratorBreakFromInfinite() { + assertScript ''' + def n = 0 + def gen = async { + def i = 0 + while (true) { + yield return i++ + } + } + for await (x in gen) { + n = x + if (x >= 5) break + } + assert n == 5 + ''' + } + + @Test + void testAwaitPlainFutureWithExceptionUnwrap() { + assertScript ''' + import java.util.concurrent.* + + def exec = Executors.newSingleThreadExecutor() + try { + Future f = exec.submit({ + throw new RuntimeException('future-err') + } as Callable) + try { + await(f) + assert false + } catch (RuntimeException e) { + assert e.message == 'future-err' + } + } finally { + exec.shutdown() + } + ''' + } + + @Test + void testBuiltInAdapterPlainFuture() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.* + + def exec = Executors.newSingleThreadExecutor() + try { + Future f = exec.submit({ 'adapted' } as Callable) + def awaitable = Awaitable.from(f) + assert await(awaitable) == 'adapted' + } finally { + exec.shutdown() + } + ''' + } + + @Test + void testBuiltInAdapterPlainFutureWithError() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.* + + def exec = Executors.newSingleThreadExecutor() + try { + Future f = exec.submit({ + throw new RuntimeException('adapt-err') + } as Callable) + def awaitable = Awaitable.from(f) + try { + await(awaitable) + assert false + } catch (RuntimeException e) { + assert e.message == 'adapt-err' + } + } finally { + exec.shutdown() + } + ''' + } + + @Test + void testScopeCurrentWithThreadLocal() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def scope = AsyncScope.create() + def captured = AsyncScope.withCurrent(scope, { AsyncScope.current() }) + assert captured.is(scope) + scope.close() + ''' + } + + @Test + void testScopeWithCurrentNestedRestore() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def scope1 = AsyncScope.create() + def scope2 = AsyncScope.create() + def innerScope = null + def outerScope = null + + AsyncScope.withCurrent(scope1, { + outerScope = AsyncScope.current() + AsyncScope.withCurrent(scope2, { + innerScope = AsyncScope.current() + }) + }) + + assert outerScope.is(scope1) + assert innerScope.is(scope2) + scope1.close() + scope2.close() + ''' + } + + @Test + void testScopeCloseJoinsChildren() { + assertScript ''' + import groovy.concurrent.AsyncScope + import java.util.concurrent.CopyOnWriteArrayList + + def results = new CopyOnWriteArrayList() + def scope = AsyncScope.create() + scope.async { Thread.sleep(50); results << 'child1' } + scope.async { Thread.sleep(50); results << 'child2' } + scope.close() + assert results.size() == 2 + assert 'child1' in results + assert 'child2' in results + ''' + } + + @Test + void testScopeAsyncAfterCloseThrows() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def scope = AsyncScope.create() + scope.close() + try { + scope.async { 42 } + assert false + } catch (IllegalStateException e) { + assert e.message.contains('closed') + } + ''' + } + + @Test + void testDeferNullActionThrows() { + shouldFail(IllegalArgumentException, ''' + import org.apache.groovy.runtime.async.AsyncSupport + def scope = AsyncSupport.createDeferScope() + AsyncSupport.defer(scope, null) + ''') + } + + @Test + void testDeferNullScopeThrows() { + shouldFail(IllegalStateException, ''' + import org.apache.groovy.runtime.async.AsyncSupport + AsyncSupport.defer(null, { println 'x' }) + ''') + } + + @Test + void testDeferWithAwaitableResult() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + import java.util.concurrent.CompletableFuture + + def actions = [] + def scope = AsyncSupport.createDeferScope() + AsyncSupport.defer(scope, { + actions << 'deferred' + CompletableFuture.completedFuture('done') + }) + AsyncSupport.executeDeferScope(scope) + assert actions == ['deferred'] + ''' + } + + @Test + void testDeferMultipleActionsThrow() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + def scope = AsyncSupport.createDeferScope() + AsyncSupport.defer(scope, { throw new RuntimeException('d1') }) + AsyncSupport.defer(scope, { throw new RuntimeException('d2') }) + try { + AsyncSupport.executeDeferScope(scope) + assert false + } catch (RuntimeException e) { + assert e.message == 'd2' + assert e.suppressed.length == 1 + assert e.suppressed[0].message == 'd1' + } + ''' + } + + @Test + void testExecuteDeferScopeNullIsNoop() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + AsyncSupport.executeDeferScope(null) + ''' + } + + @Test + void testExecuteDeferScopeEmptyIsNoop() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + def scope = AsyncSupport.createDeferScope() + AsyncSupport.executeDeferScope(scope) + ''' + } + + @Test + void testWrapAsyncDirect() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + def wrapped = AsyncSupport.wrapAsync({ 'hello-wrap' }) + def awaitable = wrapped() + assert await(awaitable) == 'hello-wrap' + ''' + } + + @Test + void testWrapAsyncWithArgs() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + def wrapped = AsyncSupport.wrapAsync({ Object[] args -> args[0] + args[1] }) + def awaitable = wrapped(10, 20) + assert await(awaitable) == 30 + ''' + } + + @Test + void testWrapAsyncWithException() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + + def wrapped = AsyncSupport.wrapAsync({ throw new RuntimeException('wrap-err') }) + def awaitable = wrapped() + try { + await(awaitable) + assert false + } catch (RuntimeException e) { + assert e.message == 'wrap-err' + } + ''' + } + + @Test + void testWrapAsyncGeneratorDirect() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + import org.apache.groovy.runtime.async.GeneratorBridge + + def wrapped = AsyncSupport.wrapAsyncGenerator({ GeneratorBridge bridge -> + bridge.yield(10) + bridge.yield(20) + bridge.yield(30) + }) + def iterable = wrapped() + def items = [] + for (item in iterable) { + items << item + } + assert items == [10, 20, 30] + ''' + } + + @Test + void testWrapAsyncGeneratorWithBreak() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + import org.apache.groovy.runtime.async.GeneratorBridge + + def wrapped = AsyncSupport.wrapAsyncGenerator({ GeneratorBridge bridge -> + bridge.yield('a') + bridge.yield('b') + bridge.yield('c') + }) + def iterable = wrapped() + def items = [] + for (item in iterable) { + items << item + if (item == 'b') break + } + assert items == ['a', 'b'] + ''' + } + + @Test + void testWrapAsyncGeneratorWithException() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + import org.apache.groovy.runtime.async.GeneratorBridge + + def wrapped = AsyncSupport.wrapAsyncGenerator({ GeneratorBridge bridge -> + bridge.yield(1) + throw new RuntimeException('gen-err') + }) + def iterable = wrapped() + def iter = iterable.iterator() + assert iter.hasNext() + assert iter.next() == 1 + try { + iter.hasNext() + assert false + } catch (RuntimeException e) { + assert e.message == 'gen-err' + } + ''' + } + + @Test + void testWrapAsyncNullThrows() { + shouldFail(NullPointerException, ''' + import org.apache.groovy.runtime.async.AsyncSupport + AsyncSupport.wrapAsync(null) + ''') + } + + @Test + void testWrapAsyncGeneratorNullThrows() { + shouldFail(NullPointerException, ''' + import org.apache.groovy.runtime.async.AsyncSupport + AsyncSupport.wrapAsyncGenerator(null) + ''') + } + + @Test + void testAdapterRegistryToBlockingIterableWithAdapter() { + assertScript ''' + import groovy.concurrent.AwaitableAdapterRegistry + import groovy.concurrent.AwaitableAdapter + import groovy.concurrent.Awaitable + + def adapter = new AwaitableAdapter() { + boolean supportsAwaitable(Class type) { false } + Awaitable toAwaitable(Object source) { null } + boolean supportsIterable(Class type) { List.isAssignableFrom(type) } + Iterable toBlockingIterable(Object source) { (Iterable) source } + } + AwaitableAdapterRegistry.register(adapter) + try { + def iterable = AwaitableAdapterRegistry.toBlockingIterable([1, 2, 3]) + def items = [] + for (item in iterable) { + items << item + } + assert items == [1, 2, 3] + } finally { + AwaitableAdapterRegistry.unregister(adapter) + } + ''' + } + + @Test + void testRethrowUnwrappedDeepNesting() { + assertScript ''' + import java.util.concurrent.* + + def cf = new CompletableFuture() + def deep = new CompletionException( + new ExecutionException( + new RuntimeException('deep-cause') + ) + ) + cf.completeExceptionally(deep) + try { + await(cf) + assert false + } catch (RuntimeException e) { + assert e.message == 'deep-cause' + } + ''' + } + + @Test + void testWrapForFuturePreservesCompletionException() { + assertScript ''' + import java.util.concurrent.CompletionException + + def result = async { + throw new CompletionException( + new RuntimeException('already-wrapped') + ) + } + try { + await(result) + assert false + } catch (RuntimeException e) { + assert e.message == 'already-wrapped' + } + ''' + } + + @Test + void testScopeFailFastCancelsOnFirstFailure() { + assertScript ''' + import groovy.concurrent.AsyncScope + import groovy.concurrent.Awaitable + + def scope = AsyncScope.create(Awaitable.getExecutor(), true) + scope.async { throw new RuntimeException('early-fail') } + scope.async { Thread.sleep(2000); 'late' } + try { + scope.close() + assert false + } catch (RuntimeException e) { + assert e.message == 'early-fail' + } + ''' + } + + @Test + void testGeneratorNextWithoutHasNext() { + assertScript ''' + def gen = async { + yield return 'only' + } + def iter = gen.iterator() + assert iter.next() == 'only' + assert !iter.hasNext() + ''' + } + + @Test + void testGeneratorNoSuchElementAfterExhausted() { + assertScript ''' + def gen = async { + yield return 'one' + } + def iter = gen.iterator() + iter.next() + try { + iter.next() + assert false + } catch (NoSuchElementException e) { + assert true + } + ''' + } + + @Test + void testScopePruneCompletedTriggered() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def scope = AsyncScope.create() + 100.times { i -> + scope.async { i } + } + scope.close() + ''' + } + + @Test + void testAwaitAwaitableExecutionException() { + assertScript ''' + import groovy.concurrent.Awaitable + import groovy.concurrent.AwaitResult + + def a = Awaitable.failed(new RuntimeException('exec-err')) + try { + await(a) + assert false + } catch (RuntimeException e) { + assert e.message == 'exec-err' + } + ''' + } + + @Test + void testGeneratorBridgeDirectClose() { + assertScript ''' + import org.apache.groovy.runtime.async.GeneratorBridge + + def bridge = new GeneratorBridge() + Thread.start { + try { + bridge.yield(1) + bridge.yield(2) + bridge.yield(3) + bridge.complete() + } catch (e) {} + } + assert bridge.hasNext() + assert bridge.next() == 1 + bridge.close() + assert !bridge.hasNext() + ''' + } + + @Test + void testGeneratorBridgeCloseWithProducerBlocked() { + assertScript ''' + import org.apache.groovy.runtime.async.GeneratorBridge + import java.util.concurrent.CountDownLatch + import java.util.concurrent.TimeUnit + + def bridge = new GeneratorBridge() + def producerDone = new CountDownLatch(1) + Thread.start { + try { + bridge.yield('a') + bridge.yield('b') + bridge.complete() + } catch (e) { + } finally { + producerDone.countDown() + } + } + assert bridge.hasNext() + assert bridge.next() == 'a' + bridge.close() + producerDone.await(5, TimeUnit.SECONDS) + ''' + } + + @Test + void testGeneratorBridgeYieldAfterClose() { + assertScript ''' + import org.apache.groovy.runtime.async.GeneratorBridge + import java.util.concurrent.CountDownLatch + import java.util.concurrent.TimeUnit + import java.util.concurrent.atomic.AtomicReference + + def bridge = new GeneratorBridge() + def thrown = new AtomicReference() + def latch = new CountDownLatch(1) + Thread.start { + try { + bridge.yield(1) + bridge.yield(2) + } catch (e) { + thrown.set(e) + } finally { + latch.countDown() + } + } + assert bridge.hasNext() + assert bridge.next() == 1 + bridge.close() + latch.await(5, TimeUnit.SECONDS) + ''' + } + + @Test + void testGeneratorHasNextInterrupted() { + assertScript ''' + import org.apache.groovy.runtime.async.GeneratorBridge + import java.util.concurrent.atomic.AtomicBoolean + + def bridge = new GeneratorBridge() + def result = new AtomicBoolean(true) + def t = Thread.start { + Thread.currentThread().interrupt() + result.set(bridge.hasNext()) + } + t.join(1000) + assert !result.get() + ''' + } + + @Test + void testBuiltInAdapterCompletionStageNotCF() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.* + + CompletionStage stage = CompletableFuture.completedFuture('via-stage') + .thenApplyAsync { it + '-processed' } + def awaitable = Awaitable.from(stage) + assert await(awaitable) == 'via-stage-processed' + ''' + } + + @Test + void testBuiltInAdapterPlainFutureNotCF() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.* + + def exec = Executors.newSingleThreadExecutor() + try { + Future f = exec.submit({ 'plain-future-val' } as Callable) + def awaitable = Awaitable.from(f) + def result = await(awaitable) + assert result == 'plain-future-val' + } finally { + exec.shutdown() + } + ''' + } + + @Test + void testBuiltInAdapterPlainFutureExecutionException() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.* + + def exec = Executors.newSingleThreadExecutor() + try { + Future f = exec.submit({ + throw new IllegalStateException('plain-future-ex') + } as Callable) + def awaitable = Awaitable.from(f) + try { + await(awaitable) + assert false + } catch (IllegalStateException e) { + assert e.message == 'plain-future-ex' + } + } finally { + exec.shutdown() + } + ''' + } + + @Test + void testScopeCloseMultipleErrors() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def scope = AsyncScope.create() + scope.async { throw new RuntimeException('scope-err-1') } + scope.async { throw new RuntimeException('scope-err-2') } + try { + scope.close() + assert false + } catch (RuntimeException e) { + assert e.suppressed.length >= 0 + } + ''' + } + + @Test + void testScopeCloseIdempotent() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def scope = AsyncScope.create() + scope.async { 42 } + scope.close() + scope.close() + ''' + } + + @Test + void testScopeCloseWithCheckedExceptionWrapped() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def scope = AsyncScope.create() + scope.async { throw new Exception('checked-err') } + try { + scope.close() + assert false + } catch (RuntimeException e) { + assert e.cause.message == 'checked-err' + } + ''' + } + + @Test + void testScopeCloseWithError() { + assertScript ''' + import groovy.concurrent.AsyncScope + + def scope = AsyncScope.create() + scope.async { throw new StackOverflowError('scope-error') } + try { + scope.close() + assert false + } catch (StackOverflowError e) { + assert e.message == 'scope-error' + } + ''' + } + + @Test + void testAdapterRegistryToBlockingIterableWithRegisteredAdapter() { + assertScript """ + import groovy.concurrent.AwaitableAdapterRegistry + import groovy.concurrent.AwaitableAdapter + import groovy.concurrent.Awaitable + + class StringIterable implements Iterable { + String s + StringIterable(String s) { this.s = s } + Iterator iterator() { s.chars().mapToObj(c -> (Character)(char)c).iterator() } + } + + def adapter = new AwaitableAdapter() { + boolean supportsAwaitable(Class type) { false } + Awaitable toAwaitable(Object source) { null } + boolean supportsIterable(Class type) { type.name.contains('StringIterable') } + Iterable toBlockingIterable(Object source) { ((StringIterable)source) } + } + AwaitableAdapterRegistry.register(adapter) + try { + def items = [] + def iterable = AwaitableAdapterRegistry.toBlockingIterable(new StringIterable('abc')) + for (ch in iterable) items << ch + assert items.size() == 3 + } finally { + AwaitableAdapterRegistry.unregister(adapter) + } + """ + } + + @Test + void testDeferWithCompletionStageResult() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + import java.util.concurrent.CompletableFuture + + def executed = [] + def scope = AsyncSupport.createDeferScope() + AsyncSupport.defer(scope, { + executed << 'defer1' + CompletableFuture.supplyAsync { executed << 'async-done'; 'ok' } + }) + AsyncSupport.executeDeferScope(scope) + Thread.sleep(100) + assert 'defer1' in executed + ''' + } + + @Test + void testDeferWithFutureResult() { + assertScript ''' + import org.apache.groovy.runtime.async.AsyncSupport + import java.util.concurrent.* + + def executed = [] + def exec = Executors.newSingleThreadExecutor() + try { + def scope = AsyncSupport.createDeferScope() + AsyncSupport.defer(scope, { + executed << 'defer-future' + exec.submit({ executed << 'future-done' } as Callable) + }) + AsyncSupport.executeDeferScope(scope) + assert 'defer-future' in executed + } finally { + exec.shutdown() + } + ''' + } + + @Test + void testFirstWithMixedResults() { + assertScript ''' + import groovy.concurrent.Awaitable + import java.util.concurrent.CompletableFuture + + def slow = CompletableFuture.supplyAsync { Thread.sleep(200); 'slow' } + def fast = CompletableFuture.completedFuture('fast') + def fail = CompletableFuture.failedFuture(new RuntimeException('err')) + def result = await Awaitable.first(slow, fast, fail) + assert result == 'fast' + ''' + } + + @Test + void testGeneratorBridgeCloseWithNullProducerThread() { + assertScript ''' + import org.apache.groovy.runtime.async.GeneratorBridge + + def bridge = new GeneratorBridge() + bridge.close() + assert !bridge.hasNext() + ''' + } + +} diff --git a/subprojects/groovy-binary/src/spec/doc/index.adoc b/subprojects/groovy-binary/src/spec/doc/index.adoc index b6128339600..e0ee429b80a 100644 --- a/subprojects/groovy-binary/src/spec/doc/index.adoc +++ b/subprojects/groovy-binary/src/spec/doc/index.adoc @@ -72,6 +72,8 @@ include::../../../../../src/spec/doc/core-closures.adoc[leveloffset=+2] include::../../../../../src/spec/doc/core-semantics.adoc[leveloffset=+2] +include::../../../../../src/spec/doc/core-async-await.adoc[leveloffset=+2] + == Tools include::../../../../../src/spec/doc/tools-groovy.adoc[leveloffset=+2] diff --git a/subprojects/groovy-reactor/build.gradle b/subprojects/groovy-reactor/build.gradle new file mode 100644 index 00000000000..cfa7ccbde43 --- /dev/null +++ b/subprojects/groovy-reactor/build.gradle @@ -0,0 +1,32 @@ +/* + * 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. + */ +plugins { + id 'org.apache.groovy-library' +} + +dependencies { + api rootProject + implementation "io.projectreactor:reactor-core:${versions.reactor}" + testImplementation projects.groovyTest +} + +groovyLibrary { + optionalModule() + withoutBinaryCompatibilityChecks() +} diff --git a/subprojects/groovy-reactor/src/main/java/org/apache/groovy/reactor/ReactorAwaitableAdapter.java b/subprojects/groovy-reactor/src/main/java/org/apache/groovy/reactor/ReactorAwaitableAdapter.java new file mode 100644 index 00000000000..7fad09419f0 --- /dev/null +++ b/subprojects/groovy-reactor/src/main/java/org/apache/groovy/reactor/ReactorAwaitableAdapter.java @@ -0,0 +1,68 @@ +/* + * 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.reactor; + +import groovy.concurrent.Awaitable; +import groovy.concurrent.AwaitableAdapter; +import org.apache.groovy.runtime.async.GroovyPromise; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Adapter for Project Reactor types, enabling: + *
    + *
  • {@code await mono} — awaits a single-value {@link Mono}
  • + *
  • {@code for await (item in flux)} — iterates over a {@link Flux}
  • + *
+ *

+ * Auto-discovered via {@link java.util.ServiceLoader} when {@code groovy-reactor} + * is on the classpath. + * + * @since 6.0.0 + */ +public class ReactorAwaitableAdapter implements AwaitableAdapter { + + @Override + public boolean supportsAwaitable(Class type) { + return Mono.class.isAssignableFrom(type); + } + + @Override + @SuppressWarnings("unchecked") + public Awaitable toAwaitable(Object source) { + if (source instanceof Mono mono) { + return (Awaitable) GroovyPromise.of(mono.toFuture()); + } + throw new IllegalArgumentException("Cannot convert to Awaitable: " + source.getClass()); + } + + @Override + public boolean supportsIterable(Class type) { + return Flux.class.isAssignableFrom(type); + } + + @Override + @SuppressWarnings("unchecked") + public Iterable toBlockingIterable(Object source) { + if (source instanceof Flux flux) { + return (Iterable) flux.toIterable(); + } + throw new IllegalArgumentException("Cannot convert to Iterable: " + source.getClass()); + } +} diff --git a/subprojects/groovy-reactor/src/main/resources/META-INF/services/groovy.concurrent.AwaitableAdapter b/subprojects/groovy-reactor/src/main/resources/META-INF/services/groovy.concurrent.AwaitableAdapter new file mode 100644 index 00000000000..4184620c3ce --- /dev/null +++ b/subprojects/groovy-reactor/src/main/resources/META-INF/services/groovy.concurrent.AwaitableAdapter @@ -0,0 +1,15 @@ +# 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.reactor.ReactorAwaitableAdapter diff --git a/subprojects/groovy-reactor/src/test/groovy/org/apache/groovy/reactor/ReactorAwaitableAdapterTest.groovy b/subprojects/groovy-reactor/src/test/groovy/org/apache/groovy/reactor/ReactorAwaitableAdapterTest.groovy new file mode 100644 index 00000000000..acb3594a030 --- /dev/null +++ b/subprojects/groovy-reactor/src/test/groovy/org/apache/groovy/reactor/ReactorAwaitableAdapterTest.groovy @@ -0,0 +1,544 @@ +/* + * 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.reactor + +import org.junit.jupiter.api.Test +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +import static groovy.test.GroovyAssert.assertScript + +final class ReactorAwaitableAdapterTest { + + @Test + void testAwaitMono() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def mono = Mono.just('hello') + def result = await Awaitable.from(mono) + assert result == 'hello' + ''' + } + + @Test + void testAwaitMonoEmpty() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def mono = Mono.empty() + def result = await Awaitable.from(mono) + assert result == null + ''' + } + + @Test + void testForAwaitFlux() { + assertScript ''' + import reactor.core.publisher.Flux + + def flux = Flux.just(1, 2, 3) + def results = [] + for await (item in flux) { + results << item + } + assert results == [1, 2, 3] + ''' + } + + @Test + void testAdapterDiscovery() { + def adapter = new ReactorAwaitableAdapter() + assert adapter.supportsAwaitable(Mono) + assert !adapter.supportsAwaitable(Flux) + assert adapter.supportsIterable(Flux) + assert !adapter.supportsIterable(Mono) + } + + @Test + void testAwaitMonoWithMap() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def mono = Mono.just(7).map { it * 6 } + def result = await Awaitable.from(mono) + assert result == 42 + ''' + } + + @Test + void testAwaitMonoError() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def mono = Mono.error(new RuntimeException('mono-err')) + try { + await Awaitable.from(mono) + assert false : 'should have thrown' + } catch (RuntimeException e) { + assert e.message == 'mono-err' + } + ''' + } + + @Test + void testAwaitMonoDeferred() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def mono = Mono.defer { Mono.just('deferred') } + def result = await Awaitable.from(mono) + assert result == 'deferred' + ''' + } + + @Test + void testAwaitMonoFromCallable() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def mono = Mono.fromCallable { 'callable-result' } + def result = await Awaitable.from(mono) + assert result == 'callable-result' + ''' + } + + @Test + void testAwaitMonoChain() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def mono = Mono.just('hello') + .map { it.toUpperCase() } + .flatMap { s -> Mono.just(s + '!') } + def result = await Awaitable.from(mono) + assert result == 'HELLO!' + ''' + } + + @Test + void testAwaitMonoZipWith() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def m1 = Mono.just(10) + def m2 = Mono.just(20) + def zipped = Mono.zip(m1, m2).map { it.t1 + it.t2 } + def result = await Awaitable.from(zipped) + assert result == 30 + ''' + } + + @Test + void testMonoToAwaitableApi() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.from(Mono.just('reactor')) + assert a.isDone() + assert await(a) == 'reactor' + ''' + } + + @Test + void testMonoAwaitableThen() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.from(Mono.just('hello')) + Awaitable transformed = a.then { it.toUpperCase() } + assert await(transformed) == 'HELLO' + ''' + } + + @Test + void testAwaitableExceptionallyWithReactor() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.from(Mono.error(new RuntimeException('fail'))) + Awaitable recovered = a.exceptionally { 'recovered' } + assert await(recovered) == 'recovered' + ''' + } + + @Test + void testAwaitableThenComposeWithReactor() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.from(Mono.just(5)) + Awaitable composed = a.thenCompose { Awaitable.of(it * 10) } + assert await(composed) == 50 + ''' + } + + @Test + void testAwaitableWhenCompleteWithReactor() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + import java.util.concurrent.atomic.AtomicReference + + def observed = new AtomicReference() + Awaitable a = Awaitable.from(Mono.just('hello')) + a.whenComplete { val, err -> observed.set(val) } + Thread.sleep(100) + assert observed.get() == 'hello' + ''' + } + + @Test + void testAwaitableHandleWithReactor() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.from(Mono.just('ok')) + Awaitable handled = a.handle { val, err -> + err == null ? "handled: $val" : "error" + } + assert await(handled) == 'handled: ok' + ''' + } + + @Test + void testMonoToCompletableFutureInterop() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + import java.util.concurrent.CompletableFuture + + def cf = Awaitable.from(Mono.just(42)).toCompletableFuture() + assert cf instanceof CompletableFuture + assert cf.get() == 42 + ''' + } + + @Test + void testMonoIsDone() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def a = Awaitable.from(Mono.just('done')) + assert a.isDone() + ''' + } + + @Test + void testMonoIsCompletedExceptionally() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def a = Awaitable.from(Mono.error(new RuntimeException('fail'))) + assert a.isCompletedExceptionally() + ''' + } + + @Test + void testForAwaitFluxWithOperators() { + assertScript ''' + import reactor.core.publisher.Flux + + def flux = Flux.just(1, 2, 3, 4, 5).filter { it % 2 == 0 }.map { it * 10 } + def results = [] + for await (item in flux) { + results << item + } + assert results == [20, 40] + ''' + } + + @Test + void testForAwaitFluxEmpty() { + assertScript ''' + import reactor.core.publisher.Flux + + def flux = Flux.empty() + def results = [] + for await (item in flux) { + results << item + } + assert results == [] + ''' + } + + @Test + void testForAwaitFluxRange() { + assertScript ''' + import reactor.core.publisher.Flux + + def flux = Flux.range(1, 5) + def results = [] + for await (item in flux) { + results << item + } + assert results == [1, 2, 3, 4, 5] + ''' + } + + @Test + void testForAwaitFluxWithFlatMap() { + assertScript ''' + import reactor.core.publisher.Flux + + def flux = Flux.just(1, 2).flatMap { n -> + Flux.just(n, n * 10) + } + def results = [] + for await (item in flux) { + results << item + } + assert results.sort() == [1, 2, 10, 20] + ''' + } + + @Test + void testForAwaitFluxWithTake() { + assertScript ''' + import reactor.core.publisher.Flux + + def flux = Flux.range(1, 100).take(3) + def results = [] + for await (item in flux) { + results << item + } + assert results == [1, 2, 3] + ''' + } + + @Test + void testForAwaitFluxSingleElement() { + assertScript ''' + import reactor.core.publisher.Flux + + def flux = Flux.just('only') + def results = [] + for await (item in flux) { + results << item + } + assert results == ['only'] + ''' + } + + @Test + void testForAwaitFluxWithDistinct() { + assertScript ''' + import reactor.core.publisher.Flux + + def flux = Flux.just(1, 2, 2, 3, 3, 3).distinct() + def results = [] + for await (item in flux) { + results << item + } + assert results == [1, 2, 3] + ''' + } + + @Test + void testForAwaitFluxEarlyBreak() { + assertScript ''' + import reactor.core.publisher.Flux + + def flux = Flux.range(1, 1000) + def results = [] + for await (item in flux) { + if (item > 5) break + results << item + } + assert results == [1, 2, 3, 4, 5] + ''' + } + + @Test + void testForAwaitFluxWithAsyncProcessing() { + assertScript ''' + import reactor.core.publisher.Flux + + def flux = Flux.just(1, 2, 3) + def results = [] + for await (item in flux) { + def doubled = await async { item * 2 } + results << doubled + } + assert results == [2, 4, 6] + ''' + } + + @Test + void testAdapterToAwaitableDirectly() { + def adapter = new ReactorAwaitableAdapter() + def result = adapter.toAwaitable(Mono.just(42)) + assert result.get() == 42 + } + + @Test + void testAdapterToAwaitableEmptyMono() { + def adapter = new ReactorAwaitableAdapter() + def result = adapter.toAwaitable(Mono.empty()) + assert result.get() == null + } + + @Test + void testAdapterToAwaitableErrorMono() { + def adapter = new ReactorAwaitableAdapter() + def result = adapter.toAwaitable(Mono.error(new RuntimeException('fail'))) + assert result.isCompletedExceptionally() + } + + @Test + void testAdapterToBlockingIterableFlux() { + def adapter = new ReactorAwaitableAdapter() + def iter = adapter.toBlockingIterable(Flux.just(1, 2, 3)) + assert iter.collect() == [1, 2, 3] + } + + @Test + void testAdapterToBlockingIterableEmptyFlux() { + def adapter = new ReactorAwaitableAdapter() + def iter = adapter.toBlockingIterable(Flux.empty()) + assert iter.collect() == [] + } + + @Test + void testAdapterToAwaitableUnsupportedType() { + def adapter = new ReactorAwaitableAdapter() + try { + adapter.toAwaitable('not-reactor') + assert false : 'should have thrown' + } catch (IllegalArgumentException e) { + assert e.message.contains('Cannot convert') + } + } + + @Test + void testAdapterToBlockingIterableUnsupportedType() { + def adapter = new ReactorAwaitableAdapter() + try { + adapter.toBlockingIterable('not-reactor') + assert false : 'should have thrown' + } catch (IllegalArgumentException e) { + assert e.message.contains('Cannot convert') + } + } + + @Test + void testAwaitableAllWithMonos() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def m1 = Awaitable.from(Mono.just(1)) + def m2 = Awaitable.from(Mono.just(2)) + def m3 = Awaitable.from(Mono.just(3)) + def results = await Awaitable.all(m1, m2, m3) + assert results == [1, 2, 3] + ''' + } + + @Test + void testAwaitableAnyWithMonos() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def m1 = Awaitable.from(Mono.just('first')) + def m2 = Awaitable.from(Mono.just('second')) + def result = await Awaitable.any(m1, m2) + assert result == 'first' || result == 'second' + ''' + } + + @Test + void testAwaitableAllSettledWithMonos() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def m1 = Awaitable.from(Mono.just('ok')) + def m2 = Awaitable.from(Mono.error(new RuntimeException('fail'))) + def results = await Awaitable.allSettled(m1, m2) + assert results[0].success && results[0].value == 'ok' + assert results[1].failure && results[1].error.message == 'fail' + ''' + } + + @Test + void testMonoWithOrTimeout() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def a = Awaitable.from(Mono.just('fast')) + def result = await a.orTimeoutMillis(5000) + assert result == 'fast' + ''' + } + + @Test + void testMonoWithCompleteOnTimeout() { + assertScript ''' + import reactor.core.publisher.Mono + import groovy.concurrent.Awaitable + + def a = Awaitable.from(Mono.just('fast')) + def result = await a.completeOnTimeoutMillis('fallback', 5000) + assert result == 'fast' + ''' + } + + @Test + void testAwaitMonoDirectlyViaAwaitObject() { + assertScript ''' + import reactor.core.publisher.Mono + + def result = await Mono.just('direct') + assert result == 'direct' + ''' + } + + @Test + void testForAwaitFluxColonNotation() { + assertScript ''' + import reactor.core.publisher.Flux + + def flux = Flux.just(10, 20, 30) + def results = [] + for await (item : flux) { + results << item + } + assert results == [10, 20, 30] + ''' + } + +} diff --git a/subprojects/groovy-rxjava/build.gradle b/subprojects/groovy-rxjava/build.gradle new file mode 100644 index 00000000000..cc666a436c5 --- /dev/null +++ b/subprojects/groovy-rxjava/build.gradle @@ -0,0 +1,32 @@ +/* + * 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. + */ +plugins { + id 'org.apache.groovy-library' +} + +dependencies { + api rootProject + implementation "io.reactivex.rxjava3:rxjava:${versions.rxjava3}" + testImplementation projects.groovyTest +} + +groovyLibrary { + optionalModule() + withoutBinaryCompatibilityChecks() +} diff --git a/subprojects/groovy-rxjava/src/main/java/org/apache/groovy/rxjava/RxJavaAwaitableAdapter.java b/subprojects/groovy-rxjava/src/main/java/org/apache/groovy/rxjava/RxJavaAwaitableAdapter.java new file mode 100644 index 00000000000..948cff4d433 --- /dev/null +++ b/subprojects/groovy-rxjava/src/main/java/org/apache/groovy/rxjava/RxJavaAwaitableAdapter.java @@ -0,0 +1,91 @@ +/* + * 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.rxjava; + +import groovy.concurrent.Awaitable; +import groovy.concurrent.AwaitableAdapter; +import io.reactivex.rxjava3.core.Completable; +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.apache.groovy.runtime.async.GroovyPromise; + +import java.util.concurrent.CompletableFuture; + +/** + * Adapter for RxJava 3 types, enabling: + *

    + *
  • {@code await single} — awaits a {@link Single}
  • + *
  • {@code await maybe} — awaits a {@link Maybe} (nullable result)
  • + *
  • {@code await completable} — awaits a {@link Completable}
  • + *
  • {@code for await (item in observable)} — iterates over an {@link Observable}
  • + *
  • {@code for await (item in flowable)} — iterates over a {@link Flowable}
  • + *
+ *

+ * Auto-discovered via {@link java.util.ServiceLoader} when {@code groovy-rxjava} + * is on the classpath. + * + * @since 6.0.0 + */ +public class RxJavaAwaitableAdapter implements AwaitableAdapter { + + @Override + public boolean supportsAwaitable(Class type) { + return Single.class.isAssignableFrom(type) + || Maybe.class.isAssignableFrom(type) + || Completable.class.isAssignableFrom(type); + } + + @Override + @SuppressWarnings("unchecked") + public Awaitable toAwaitable(Object source) { + if (source instanceof Single single) { + return GroovyPromise.of( + (CompletableFuture) single.toCompletionStage().toCompletableFuture()); + } + if (source instanceof Maybe maybe) { + return GroovyPromise.of( + (CompletableFuture) maybe.toCompletionStage(null).toCompletableFuture()); + } + if (source instanceof Completable completable) { + return (Awaitable) GroovyPromise.of( + completable.toCompletionStage(null).toCompletableFuture()); + } + throw new IllegalArgumentException("Cannot convert to Awaitable: " + source.getClass()); + } + + @Override + public boolean supportsIterable(Class type) { + return Observable.class.isAssignableFrom(type) + || Flowable.class.isAssignableFrom(type); + } + + @Override + @SuppressWarnings("unchecked") + public Iterable toBlockingIterable(Object source) { + if (source instanceof Observable observable) { + return (Iterable) observable.blockingIterable(); + } + if (source instanceof Flowable flowable) { + return (Iterable) flowable.blockingIterable(); + } + throw new IllegalArgumentException("Cannot convert to Iterable: " + source.getClass()); + } +} diff --git a/subprojects/groovy-rxjava/src/main/resources/META-INF/services/groovy.concurrent.AwaitableAdapter b/subprojects/groovy-rxjava/src/main/resources/META-INF/services/groovy.concurrent.AwaitableAdapter new file mode 100644 index 00000000000..361ec17db65 --- /dev/null +++ b/subprojects/groovy-rxjava/src/main/resources/META-INF/services/groovy.concurrent.AwaitableAdapter @@ -0,0 +1,15 @@ +# 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.rxjava.RxJavaAwaitableAdapter diff --git a/subprojects/groovy-rxjava/src/test/groovy/org/apache/groovy/rxjava/RxJavaAwaitableAdapterTest.groovy b/subprojects/groovy-rxjava/src/test/groovy/org/apache/groovy/rxjava/RxJavaAwaitableAdapterTest.groovy new file mode 100644 index 00000000000..74fe428606d --- /dev/null +++ b/subprojects/groovy-rxjava/src/test/groovy/org/apache/groovy/rxjava/RxJavaAwaitableAdapterTest.groovy @@ -0,0 +1,682 @@ +/* + * 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.rxjava + +import io.reactivex.rxjava3.core.Completable +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.Test + +import static groovy.test.GroovyAssert.assertScript + +final class RxJavaAwaitableAdapterTest { + + @Test + void testAwaitSingle() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def single = Single.just('hello') + def result = await Awaitable.from(single) + assert result == 'hello' + ''' + } + + @Test + void testAwaitMaybe() { + assertScript ''' + import io.reactivex.rxjava3.core.Maybe + import groovy.concurrent.Awaitable + + def maybe = Maybe.just(42) + def result = await Awaitable.from(maybe) + assert result == 42 + ''' + } + + @Test + void testAwaitMaybeEmpty() { + assertScript ''' + import io.reactivex.rxjava3.core.Maybe + import groovy.concurrent.Awaitable + + def maybe = Maybe.empty() + def result = await Awaitable.from(maybe) + assert result == null + ''' + } + + @Test + void testForAwaitObservable() { + assertScript ''' + import io.reactivex.rxjava3.core.Observable + + def obs = Observable.just(1, 2, 3) + def results = [] + for await (item in obs) { + results << item + } + assert results == [1, 2, 3] + ''' + } + + @Test + void testForAwaitFlowable() { + assertScript ''' + import io.reactivex.rxjava3.core.Flowable + + def flow = Flowable.just('a', 'b', 'c') + def results = [] + for await (item in flow) { + results << item + } + assert results == ['a', 'b', 'c'] + ''' + } + + @Test + void testAdapterDiscovery() { + def adapter = new RxJavaAwaitableAdapter() + assert adapter.supportsAwaitable(Single) + assert adapter.supportsAwaitable(Maybe) + assert adapter.supportsAwaitable(Completable) + assert !adapter.supportsAwaitable(Observable) + assert adapter.supportsIterable(Observable) + assert adapter.supportsIterable(Flowable) + assert !adapter.supportsIterable(Single) + } + + @Test + void testAwaitSingleWithMap() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def single = Single.just(7).map { it * 6 } + def result = await Awaitable.from(single) + assert result == 42 + ''' + } + + @Test + void testAwaitSingleError() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def single = Single.error(new RuntimeException('rx error')) + try { + await Awaitable.from(single) + assert false : 'should have thrown' + } catch (RuntimeException e) { + assert e.message == 'rx error' + } + ''' + } + + @Test + void testAwaitSingleFromCallable() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def single = Single.fromCallable { 'from-callable' } + def result = await Awaitable.from(single) + assert result == 'from-callable' + ''' + } + + @Test + void testAwaitSingleDeferred() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def single = Single.defer { Single.just('deferred') } + def result = await Awaitable.from(single) + assert result == 'deferred' + ''' + } + + @Test + void testAwaitSingleZip() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def s1 = Single.just(10) + def s2 = Single.just(20) + def zipped = Single.zip(s1, s2) { a, b -> a + b } + def result = await Awaitable.from(zipped) + assert result == 30 + ''' + } + + @Test + void testAwaitSingleFlatMap() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def single = Single.just('hello') + .map { it.toUpperCase() } + .flatMap { s -> Single.just(s + '!') } + def result = await Awaitable.from(single) + assert result == 'HELLO!' + ''' + } + + @Test + void testAwaitMaybeError() { + assertScript ''' + import io.reactivex.rxjava3.core.Maybe + import groovy.concurrent.Awaitable + + def maybe = Maybe.error(new RuntimeException('maybe-err')) + try { + await Awaitable.from(maybe) + assert false : 'should have thrown' + } catch (RuntimeException e) { + assert e.message == 'maybe-err' + } + ''' + } + + @Test + void testAwaitMaybeFromCallable() { + assertScript ''' + import io.reactivex.rxjava3.core.Maybe + import groovy.concurrent.Awaitable + + def maybe = Maybe.fromCallable { 'callable-result' } + def result = await Awaitable.from(maybe) + assert result == 'callable-result' + ''' + } + + @Test + void testAwaitMaybeWithMap() { + assertScript ''' + import io.reactivex.rxjava3.core.Maybe + import groovy.concurrent.Awaitable + + def maybe = Maybe.just('hello').map { it.toUpperCase() } + def result = await Awaitable.from(maybe) + assert result == 'HELLO' + ''' + } + + @Test + void testAwaitCompletable() { + assertScript ''' + import io.reactivex.rxjava3.core.Completable + import groovy.concurrent.Awaitable + + def ran = false + def completable = Completable.fromAction { ran = true } + await Awaitable.from(completable) + assert ran == true + ''' + } + + @Test + void testAwaitCompletableError() { + assertScript ''' + import io.reactivex.rxjava3.core.Completable + import groovy.concurrent.Awaitable + + def completable = Completable.error(new RuntimeException('comp-err')) + try { + await Awaitable.from(completable) + assert false : 'should have thrown' + } catch (RuntimeException e) { + assert e.message == 'comp-err' + } + ''' + } + + @Test + void testSingleToAwaitableApi() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.from(Single.just('rx')) + assert a.isDone() + assert await(a) == 'rx' + ''' + } + + @Test + void testMaybeToAwaitableApi() { + assertScript ''' + import io.reactivex.rxjava3.core.Maybe + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.from(Maybe.just(99)) + assert a.isDone() + assert await(a) == 99 + ''' + } + + @Test + void testAwaitableThenWithSingle() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.from(Single.just('hello')) + Awaitable transformed = a.then { it.toUpperCase() } + assert await(transformed) == 'HELLO' + ''' + } + + @Test + void testAwaitableExceptionallyWithSingle() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.from(Single.error(new RuntimeException('fail'))) + Awaitable recovered = a.exceptionally { 'recovered' } + assert await(recovered) == 'recovered' + ''' + } + + @Test + void testAwaitableThenComposeWithSingle() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.from(Single.just(5)) + Awaitable composed = a.thenCompose { Awaitable.of(it * 10) } + assert await(composed) == 50 + ''' + } + + @Test + void testAwaitableHandleWithSingle() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + Awaitable a = Awaitable.from(Single.just('ok')) + Awaitable handled = a.handle { val, err -> + err == null ? "handled: $val" : "error" + } + assert await(handled) == 'handled: ok' + ''' + } + + @Test + void testSingleToCompletableFutureInterop() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + import java.util.concurrent.CompletableFuture + + def cf = Awaitable.from(Single.just(42)).toCompletableFuture() + assert cf instanceof CompletableFuture + assert cf.get() == 42 + ''' + } + + @Test + void testSingleIsCompletedExceptionally() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def a = Awaitable.from(Single.error(new RuntimeException('fail'))) + assert a.isCompletedExceptionally() + ''' + } + + @Test + void testForAwaitObservableWithOperators() { + assertScript ''' + import io.reactivex.rxjava3.core.Observable + + def obs = Observable.just(1, 2, 3, 4, 5).filter { it % 2 == 0 }.map { it * 10 } + def results = [] + for await (item in obs) { + results << item + } + assert results == [20, 40] + ''' + } + + @Test + void testForAwaitObservableEmpty() { + assertScript ''' + import io.reactivex.rxjava3.core.Observable + + def obs = Observable.empty() + def results = [] + for await (item in obs) { + results << item + } + assert results == [] + ''' + } + + @Test + void testForAwaitObservableRange() { + assertScript ''' + import io.reactivex.rxjava3.core.Observable + + def obs = Observable.range(1, 5) + def results = [] + for await (item in obs) { + results << item + } + assert results == [1, 2, 3, 4, 5] + ''' + } + + @Test + void testForAwaitObservableFlatMap() { + assertScript ''' + import io.reactivex.rxjava3.core.Observable + + def obs = Observable.just(1, 2).flatMap { n -> + Observable.just(n, n * 10) + } + def results = [] + for await (item in obs) { + results << item + } + assert results.sort() == [1, 2, 10, 20] + ''' + } + + @Test + void testForAwaitObservableDistinct() { + assertScript ''' + import io.reactivex.rxjava3.core.Observable + + def obs = Observable.just(1, 2, 2, 3, 3, 3).distinct() + def results = [] + for await (item in obs) { + results << item + } + assert results == [1, 2, 3] + ''' + } + + @Test + void testForAwaitObservableTake() { + assertScript ''' + import io.reactivex.rxjava3.core.Observable + + def obs = Observable.range(1, 100).take(3) + def results = [] + for await (item in obs) { + results << item + } + assert results == [1, 2, 3] + ''' + } + + @Test + void testForAwaitFlowableEmpty() { + assertScript ''' + import io.reactivex.rxjava3.core.Flowable + + def flow = Flowable.empty() + def results = [] + for await (item in flow) { + results << item + } + assert results == [] + ''' + } + + @Test + void testForAwaitFlowableRange() { + assertScript ''' + import io.reactivex.rxjava3.core.Flowable + + def flow = Flowable.range(1, 5) + def results = [] + for await (item in flow) { + results << item + } + assert results == [1, 2, 3, 4, 5] + ''' + } + + @Test + void testForAwaitFlowableWithBackPressure() { + assertScript ''' + import io.reactivex.rxjava3.core.Flowable + + def flow = Flowable.range(1, 100) + def results = [] + for await (item in flow) { + results << item + } + assert results.size() == 100 + assert results[0] == 1 + assert results[99] == 100 + ''' + } + + @Test + void testForAwaitFlowableEarlyBreak() { + assertScript ''' + import io.reactivex.rxjava3.core.Flowable + + def flow = Flowable.range(1, 1000) + def results = [] + for await (item in flow) { + if (item > 5) break + results << item + } + assert results == [1, 2, 3, 4, 5] + ''' + } + + @Test + void testForAwaitObservableWithAsyncProcessing() { + assertScript ''' + import io.reactivex.rxjava3.core.Observable + + def obs = Observable.just(1, 2, 3) + def results = [] + for await (item in obs) { + def doubled = await async { item * 2 } + results << doubled + } + assert results == [2, 4, 6] + ''' + } + + @Test + void testAdapterToAwaitableDirectly() { + def adapter = new RxJavaAwaitableAdapter() + def result = adapter.toAwaitable(Single.just(42)) + assert result.get() == 42 + } + + @Test + void testAdapterToAwaitableMaybeDirectly() { + def adapter = new RxJavaAwaitableAdapter() + def result = adapter.toAwaitable(Maybe.just('hi')) + assert result.get() == 'hi' + } + + @Test + void testAdapterToAwaitableMaybeEmptyDirectly() { + def adapter = new RxJavaAwaitableAdapter() + def result = adapter.toAwaitable(Maybe.empty()) + assert result.get() == null + } + + @Test + void testAdapterToAwaitableCompletableDirectly() { + def adapter = new RxJavaAwaitableAdapter() + def result = adapter.toAwaitable(Completable.complete()) + assert result.get() == null + } + + @Test + void testAdapterToAwaitableUnsupportedType() { + def adapter = new RxJavaAwaitableAdapter() + try { + adapter.toAwaitable('not-rx') + assert false : 'should have thrown' + } catch (IllegalArgumentException e) { + assert e.message.contains('Cannot convert') + } + } + + @Test + void testAdapterToBlockingIterableObservable() { + def adapter = new RxJavaAwaitableAdapter() + def iter = adapter.toBlockingIterable(Observable.just(1, 2, 3)) + assert iter.collect() == [1, 2, 3] + } + + @Test + void testAdapterToBlockingIterableFlowable() { + def adapter = new RxJavaAwaitableAdapter() + def iter = adapter.toBlockingIterable(Flowable.just('a', 'b')) + assert iter.collect() == ['a', 'b'] + } + + @Test + void testAdapterToBlockingIterableUnsupportedType() { + def adapter = new RxJavaAwaitableAdapter() + try { + adapter.toBlockingIterable('not-rx') + assert false : 'should have thrown' + } catch (IllegalArgumentException e) { + assert e.message.contains('Cannot convert') + } + } + + @Test + void testAwaitableAllWithSingles() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def s1 = Awaitable.from(Single.just(1)) + def s2 = Awaitable.from(Single.just(2)) + def s3 = Awaitable.from(Single.just(3)) + def results = await Awaitable.all(s1, s2, s3) + assert results == [1, 2, 3] + ''' + } + + @Test + void testAwaitableAnyWithSingles() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def s1 = Awaitable.from(Single.just('first')) + def s2 = Awaitable.from(Single.just('second')) + def result = await Awaitable.any(s1, s2) + assert result == 'first' || result == 'second' + ''' + } + + @Test + void testAwaitableAllSettledWithSingles() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def s1 = Awaitable.from(Single.just('ok')) + def s2 = Awaitable.from(Single.error(new RuntimeException('fail'))) + def results = await Awaitable.allSettled(s1, s2) + assert results[0].success && results[0].value == 'ok' + assert results[1].failure && results[1].error.message == 'fail' + ''' + } + + @Test + void testAwaitSingleDirectlyViaAwaitObject() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + + def result = await Single.just('direct') + assert result == 'direct' + ''' + } + + @Test + void testForAwaitFlowableColonNotation() { + assertScript ''' + import io.reactivex.rxjava3.core.Flowable + + def flow = Flowable.just(10, 20, 30) + def results = [] + for await (item : flow) { + results << item + } + assert results == [10, 20, 30] + ''' + } + + @Test + void testForAwaitObservableColonNotation() { + assertScript ''' + import io.reactivex.rxjava3.core.Observable + + def obs = Observable.just('x', 'y', 'z') + def results = [] + for await (item : obs) { + results << item + } + assert results == ['x', 'y', 'z'] + ''' + } + + @Test + void testSingleWithOrTimeout() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def a = Awaitable.from(Single.just('fast')) + def result = await a.orTimeoutMillis(5000) + assert result == 'fast' + ''' + } + + @Test + void testSingleWithCompleteOnTimeout() { + assertScript ''' + import io.reactivex.rxjava3.core.Single + import groovy.concurrent.Awaitable + + def a = Awaitable.from(Single.just('fast')) + def result = await a.completeOnTimeoutMillis('fallback', 5000) + assert result == 'fast' + ''' + } + +} diff --git a/versions.properties b/versions.properties index 33e43145b16..33588a488e3 100644 --- a/versions.properties +++ b/versions.properties @@ -56,6 +56,8 @@ picocli=4.7.7 qdox=2.2.0 slf4j=2.0.17 # running with Groovy 6 can be allowed with -Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true +reactor=3.7.3 +rxjava3=3.1.10 spock=2.4-groovy-5.0 spotbugs=4.9.8 treelayout=1.0.3