├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── .sdkmanrc
├── gradle.properties
├── tasks-jvm
├── src
│ ├── main
│ │ └── java
│ │ │ └── org
│ │ │ └── funfix
│ │ │ └── tasks
│ │ │ └── jvm
│ │ │ ├── package-info.java
│ │ │ ├── DelayedFun.java
│ │ │ ├── ProcessFun.java
│ │ │ ├── AsyncFun.java
│ │ │ ├── UncaughtExceptionHandler.java
│ │ │ ├── TaskCancellationException.java
│ │ │ ├── CancellableFuture.java
│ │ │ ├── CloseableFun.java
│ │ │ ├── TaskUtils.java
│ │ │ ├── ExitCase.java
│ │ │ ├── TaskExecutor.java
│ │ │ ├── Outcome.java
│ │ │ ├── Trampoline.java
│ │ │ ├── Continuation.java
│ │ │ ├── Collections.java
│ │ │ ├── Cancellable.java
│ │ │ ├── TaskExecutors.java
│ │ │ ├── Resource.java
│ │ │ ├── CompletionCallback.java
│ │ │ └── Fiber.java
│ └── test
│ │ └── java
│ │ └── org
│ │ └── funfix
│ │ └── tasks
│ │ └── jvm
│ │ ├── TestSettings.java
│ │ ├── PureTest.java
│ │ ├── TrampolineTest.java
│ │ ├── SysProp.java
│ │ ├── TimedAwait.java
│ │ ├── AwaitSignalTest.java
│ │ ├── OutcomeTest.java
│ │ ├── TaskFromCompletableFutureTest.java
│ │ ├── CollectionsTest.java
│ │ ├── TaskFromBlockingIOTest.java
│ │ ├── TaskExecuteTest.java
│ │ ├── ResourceTest.java
│ │ ├── TaskWithCancellationTest.java
│ │ ├── TaskCreateTest.java
│ │ ├── TaskEnsureExecutorTest.java
│ │ ├── CompletionCallbackTest.java
│ │ ├── LoomTest.java
│ │ ├── TaskFromBlockingFutureTest.java
│ │ ├── TaskExecutorTest.java
│ │ └── TaskWithOnCompletionTest.java
└── build.gradle.kts
├── .editorconfig
├── settings.gradle.kts
├── docs
└── vocabulary.md
├── Makefile
├── .github
└── workflows
│ ├── publish-release.yaml
│ └── build.yaml
├── .gitignore
├── README.md
├── gradlew.bat
└── gradlew
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/funfix/tasks/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/.sdkmanrc:
--------------------------------------------------------------------------------
1 | # Enable auto-env through the sdkman_auto_env config
2 | # Add key=value pairs of SDKs to use below
3 | java=17.0.14-tem
4 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | kotlin.code.style=official
2 |
3 | # TO BE modified whenever a new version is released
4 | project.version=0.3.1
5 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/package-info.java:
--------------------------------------------------------------------------------
1 | @NullMarked
2 | package org.funfix.tasks.jvm;
3 |
4 | import org.jspecify.annotations.NullMarked;
5 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | end_of_line = lf
3 | insert_final_newline = true
4 |
5 | [*.{kt,kts}]
6 | ktlint_code_style = ktlint_official
7 | ktlint_ignore_back_ticked_identifier = true
8 |
9 | indent_size = 4
10 | indent_style = space
11 |
12 | max_line_length = 80
13 |
14 | [Makefile]
15 | indent_style = tab
16 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "tasks"
2 |
3 | include("tasks-jvm")
4 |
5 | pluginManagement {
6 | repositories {
7 | mavenCentral()
8 | gradlePluginPortal()
9 | }
10 | }
11 |
12 | plugins {
13 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
14 | }
15 |
--------------------------------------------------------------------------------
/docs/vocabulary.md:
--------------------------------------------------------------------------------
1 | # Vocabulary
2 |
3 | * `execute` is used for executing a function or a task that may suspend side effects. The project uses `execute` instead of `run` or other synonyms. This is similar to Java's `Executor#execute`.
4 | * `join` is used for waiting for the completion of an already started fiber / thread, but without awaiting a final result.
5 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/DelayedFun.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 |
5 | /**
6 | * Represents a delayed computation (a thunk).
7 | *
8 | * These functions are allowed to trigger side effects, with
9 | * blocking I/O and to throw exceptions.
10 | */
11 | @FunctionalInterface
12 | public interface DelayedFun {
13 | T invoke() throws Exception;
14 | }
15 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/ProcessFun.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 |
5 | /**
6 | * The equivalent of {@code Function} for processing I/O that can
7 | * throw exceptions.
8 | *
9 | * @see DelayedFun for the variant with no parameters
10 | */
11 | @FunctionalInterface
12 | public interface ProcessFun {
13 | Out call(In input) throws Exception;
14 | }
15 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build
2 |
3 | build:
4 | ./gradlew build
5 |
6 | dependency-updates:
7 | ./gradlew dependencyUpdates \
8 | -Drevision=release \
9 | -DoutputFormatter=html \
10 | --refresh-dependencies && \
11 | open build/dependencyUpdates/report.html
12 |
13 | update-gradle:
14 | ./gradlew wrapper --gradle-version latest
15 |
16 | test-watch:
17 | ./gradlew -t check
18 |
19 | test-coverage:
20 | ./gradlew clean build jacocoTestReport koverHtmlReportJvm
21 | open tasks-jvm/build/reports/jacoco/test/html/index.html
22 | open ./tasks-kotlin/build/reports/kover/htmlJvm/index.html
23 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TestSettings.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import java.time.Duration;
4 |
5 | public class TestSettings {
6 | public static final Duration TIMEOUT;
7 | public static final int CONCURRENCY_REPEATS;
8 |
9 | static {
10 | if (System.getenv("CI") != null)
11 | TIMEOUT = Duration.ofSeconds(20);
12 | else
13 | TIMEOUT = Duration.ofSeconds(10);
14 |
15 | if (System.getenv("CI") != null) {
16 | CONCURRENCY_REPEATS = 1000;
17 | } else {
18 | CONCURRENCY_REPEATS = 10000;
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/AsyncFun.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jetbrains.annotations.NonBlocking;
4 | import org.jspecify.annotations.Nullable;
5 |
6 | import java.io.Serializable;
7 | import java.util.concurrent.Executor;
8 |
9 | /**
10 | * A function that is a delayed, asynchronous computation.
11 | *
12 | * This function type is what's needed to describe {@link Task} instances.
13 | */
14 | @FunctionalInterface
15 | @NonBlocking
16 | public interface AsyncFun extends Serializable {
17 | Cancellable invoke(
18 | Executor executor,
19 | CompletionCallback super T> continuation
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/PureTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.junit.jupiter.api.Test;
4 |
5 | import java.io.IOException;
6 | import java.util.concurrent.ExecutionException;
7 |
8 | import static org.junit.jupiter.api.Assertions.assertEquals;
9 |
10 | public class PureTest {
11 | @Test
12 | void pureTask() throws ExecutionException, InterruptedException {
13 | final var task = Task.pure(42);
14 | for (int i = 0; i < 100; i++) {
15 | final var outcome = task.runBlocking();
16 | assertEquals(42, outcome);
17 | }
18 | }
19 |
20 | @Test
21 | void pureResource() throws ExecutionException, InterruptedException {
22 | final var resource = Resource.pure(42);
23 | for (int i = 0; i < 100; i++) {
24 | final var outcome = resource.useBlocking(value -> value);
25 | assertEquals(42, outcome);
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/.github/workflows/publish-release.yaml:
--------------------------------------------------------------------------------
1 | name: Publish Release
2 |
3 | on: workflow_dispatch
4 |
5 | jobs:
6 | publish-release:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | - name: Set up JDK
11 | uses: actions/setup-java@v4
12 | with:
13 | java-version: 17
14 | distribution: 'temurin'
15 | - name: Publish Snapshot
16 | run: ./gradlew -PbuildRelease=true build publish --no-daemon
17 | env:
18 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
19 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_USERNAME }}
20 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_PASSWORD }}
21 | ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }}
22 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }}
23 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }}
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .gradle
2 | build/
3 | !gradle/wrapper/gradle-wrapper.jar
4 | !**/src/main/**/build/
5 | !**/src/test/**/build/
6 |
7 | ### IntelliJ IDEA ###
8 | .idea
9 | *.iws
10 | *.iml
11 | *.ipr
12 | out/
13 | !**/src/main/**/out/
14 | !**/src/test/**/out/
15 |
16 | ### Eclipse ###
17 | .apt_generated
18 | .classpath
19 | .factorypath
20 | .project
21 | .settings
22 | .springBeans
23 | .sts4-cache
24 | bin/
25 | !**/src/main/**/bin/
26 | !**/src/test/**/bin/
27 |
28 | ### NetBeans ###
29 | /nbproject/private/
30 | /nbbuild/
31 | /dist/
32 | /nbdist/
33 | /.nb-gradle/
34 |
35 | ### VS Code ###
36 | .vscode/
37 |
38 | ### Mac OS ###
39 | .DS_Store
40 |
41 | ### Kotlin 2?
42 | /.kotlin
43 | /kotlin-js-store/
44 |
45 | # sbt specific
46 | dist/*
47 | target/
48 | lib_managed/
49 | src_managed/
50 | project/boot/
51 | project/plugins/project/
52 | project/local-plugins.sbt
53 | .history
54 | .ensime
55 | .ensime_cache/
56 | .sbt-scripted/
57 | local.sbt
58 |
59 | # Bloop
60 | .bsp
61 |
62 | # Metals
63 | .bloop/
64 | .metals/
65 | metals.sbt
66 |
67 | ### Secrets
68 | .envrc
69 |
70 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/UncaughtExceptionHandler.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | /**
4 | * Utilities for handling uncaught exceptions.
5 | */
6 | public final class UncaughtExceptionHandler {
7 | public static void rethrowIfFatal(final Throwable e) {
8 | if (e instanceof StackOverflowError) {
9 | // Stack-overflows should be reported as-is, instead of crashing
10 | // the process
11 | return;
12 | }
13 | if (e instanceof Error error) {
14 | throw error;
15 | }
16 | }
17 |
18 | public static void logOrRethrow(final Throwable e) {
19 | rethrowIfFatal(e);
20 | final var thread = Thread.currentThread();
21 | var logger = thread.getUncaughtExceptionHandler();
22 | if (logger == null) {
23 | logger = Thread.getDefaultUncaughtExceptionHandler();
24 | }
25 | if (logger == null) {
26 | logger = (t, e1) -> e1.printStackTrace(System.err);
27 | }
28 | logger.uncaughtException(thread, e);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | versions-plugin = "0.51.0"
3 | publish-plugin = "0.29.0"
4 | errorprone-plugin = "4.3.0"
5 | errorprone = "2.41.0"
6 | errorprone-nullaway = "0.12.8"
7 |
8 | jetbrains-annotations = "26.0.2"
9 | jspecify = "1.0.0"
10 |
11 | [libraries]
12 | # Plugins specified in buildSrc/build.gradle.kts
13 | gradle-versions-plugin = { module = "com.github.ben-manes:gradle-versions-plugin", version.ref = "versions-plugin" }
14 | vanniktech-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "publish-plugin" }
15 | errorprone-gradle-plugin = { module = "net.ltgt.gradle:gradle-errorprone-plugin", version.ref = "errorprone-plugin" }
16 | errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone"}
17 | errorprone-nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "errorprone-nullaway" }
18 |
19 | # Actual libraries
20 | jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" }
21 | jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" }
22 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskCancellationException.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 |
5 | /**
6 | * Represents an exception that is thrown when a task is cancelled.
7 | *
8 | * {@code java.lang.InterruptedException} has the meaning that the current thread was
9 | * interrupted. This is a JVM-specific exception. If the current thread
10 | * is waiting on a concurrent job, if an {@code InterruptedException} is thrown,
11 | * this doesn't mean that the concurrent job was cancelled; such a behavior
12 | * depending on the type of blocking operation being performed.
13 | *
14 | * We need to distinguish between {@code java.lang.InterruptedException} and cancellation,
15 | * because there are cases where a concurrent job is cancelled without
16 | * the current thread being interrupted, and also, the current thread
17 | * might be interrupted without the concurrent job being cancelled.
18 | */
19 | public class TaskCancellationException extends Exception {
20 | public TaskCancellationException() {
21 | super();
22 | }
23 |
24 | public TaskCancellationException(@Nullable String message) {
25 | super(message);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Tasks
2 |
3 | [](https://github.com/funfix/tasks/actions/workflows/build.yaml) [](https://central.sonatype.com/artifact/org.funfix/tasks-jvm) [](https://javadoc.io/doc/org.funfix/tasks-jvm)
4 |
5 | This is a library meant for library authors that want to build libraries that work across Java, Scala, or Kotlin, without having to worry about interoperability with whatever method of I/O that the library is using under the hood.
6 |
7 | ## Usage
8 |
9 | Read the [Javadoc](https://javadoc.io/doc/org.funfix/tasks-jvm/0.3.1/org/funfix/tasks/jvm/package-summary.html).
10 | Better documentation is coming.
11 |
12 | ---
13 |
14 | Maven:
15 | ```xml
16 |
17 | org.funfix
18 | tasks-jvm
19 | 0.3.1
20 |
21 | ```
22 |
23 | Gradle:
24 | ```kotlin
25 | dependencies {
26 | implementation("org.funfix:tasks-jvm:0.3.1")
27 | }
28 | ```
29 |
30 | sbt:
31 | ```scala
32 | libraryDependencies += "org.funfix" % "tasks-jvm" % "0.3.1"
33 | ```
34 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CancellableFuture.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 |
5 | import java.util.concurrent.CompletableFuture;
6 | import java.util.function.Function;
7 |
8 | /**
9 | * This is a wrapper around a {@link CompletableFuture} with a
10 | * {@link Cancellable} reference attached.
11 | *
12 | * A standard Java {@link CompletableFuture} is not connected to its
13 | * asynchronous task and cannot be cancelled. Thus, if we want to cancel
14 | * a task, we need to keep a reference to a {@link Cancellable} object that
15 | * can do the job.
16 | *
17 | * {@code CancellableFuture} is similar to {@link Fiber}, which should
18 | * be preferred because it's more principled. {@code CancellableFuture}
19 | * is useful more for interoperability with Java code.
20 | */
21 | public record CancellableFuture(
22 | CompletableFuture extends T> future,
23 | Cancellable cancellable
24 | ) {
25 | public CancellableFuture transform(
26 | Function super CompletableFuture extends T>, ? extends CompletableFuture extends U>> fn
27 | ) {
28 | return new CancellableFuture<>(fn.apply(future), cancellable);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CloseableFun.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import java.util.function.Function;
4 |
5 | /**
6 | * Blocking/synchronous finalizer of {@link Resource}.
7 | *
8 | * @see Resource#fromBlockingIO(DelayedFun)
9 | */
10 | @FunctionalInterface
11 | public interface CloseableFun extends AutoCloseable {
12 | void close(ExitCase exitCase) throws Exception;
13 |
14 | /**
15 | * Converts this blocking finalizer into an asynchronous one
16 | * that can be used for initializing {@link Resource.Acquired}.
17 | */
18 | default Function> toAsync() {
19 | @SuppressWarnings("NullAway")
20 | final Function> r =
21 | exitCase -> TaskUtils.taskUninterruptibleBlockingIO(() -> {
22 | close(exitCase);
23 | return null;
24 | });
25 | return r;
26 | }
27 |
28 | @Override
29 | default void close() throws Exception {
30 | close(ExitCase.succeeded());
31 | }
32 |
33 | static CloseableFun fromAutoCloseable(AutoCloseable resource) {
34 | return ignored -> resource.close();
35 | }
36 |
37 | /**
38 | * Reusable reference for a no-op {@code CloseableFun}.
39 | */
40 | CloseableFun NOOP = ignored -> {};
41 | }
42 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TrampolineTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.junit.jupiter.api.Test;
4 |
5 | import static org.junit.jupiter.api.Assertions.assertEquals;
6 | import static org.junit.jupiter.api.Assertions.assertTrue;
7 |
8 | public class TrampolineTest {
9 | Runnable recursiveRunnable(int level, int maxLevel, Runnable onComplete) {
10 | return () -> {
11 | if (maxLevel >= level)
12 | Trampoline.execute(onComplete);
13 | else
14 | Trampoline.execute(recursiveRunnable(level + 1, maxLevel, onComplete));
15 | };
16 | }
17 |
18 | @Test
19 | void tasksAreExecutedInFIFOOrder() {
20 | final int[] calls = { 0 };
21 | Trampoline.execute(() -> {
22 | for (int i = 0; i < 100; i++) {
23 | Trampoline.execute(() -> calls[0]++);
24 | }
25 | });
26 | assertEquals(100, calls[0]);
27 | }
28 |
29 | @Test
30 | void testDepth() {
31 | final boolean[] wasExecuted = { false };
32 | Trampoline.execute(recursiveRunnable(
33 | 0, 10000, () -> wasExecuted[0] = true
34 | ));
35 | assertTrue(wasExecuted[0]);
36 | }
37 |
38 | @Test
39 | void testBreath() {
40 | final int[] calls = { 0 };
41 | Trampoline.execute(() -> {
42 | for (int i = 0; i < 10000; i++) {
43 | Trampoline.execute(() -> calls[0]++);
44 | }
45 | });
46 | assertEquals(10000, calls[0]);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/SysProp.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 |
5 | import java.util.concurrent.ConcurrentMap;
6 | import java.util.concurrent.locks.ReentrantLock;
7 |
8 | public class SysProp implements AutoCloseable {
9 | private static final ConcurrentMap locks =
10 | new java.util.concurrent.ConcurrentHashMap<>();
11 |
12 | private final String key;
13 | private final @Nullable String oldValue;
14 | private final ReentrantLock lock;
15 |
16 | public SysProp(final String key, final @Nullable String value) {
17 | this.lock = locks.computeIfAbsent(key, k -> new ReentrantLock());
18 | lock.lock();
19 |
20 | this.key = key;
21 | this.oldValue = System.getProperty(key);
22 | if (value == null) {
23 | System.clearProperty(key);
24 | } else {
25 | System.setProperty(key, value);
26 | }
27 | }
28 |
29 | @Override
30 | public void close() {
31 | if (oldValue == null) {
32 | System.clearProperty(key);
33 | } else {
34 | System.setProperty(key, oldValue);
35 | }
36 | lock.unlock();
37 | }
38 |
39 | public static SysProp with(final String key, final @Nullable String value) {
40 | return new SysProp(key, value);
41 | }
42 |
43 | public static SysProp withVirtualThreads(final boolean enabled) {
44 | return with("funfix.tasks.virtual-threads", enabled ? "true" : "false");
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskUtils.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jetbrains.annotations.ApiStatus;
4 | import org.jetbrains.annotations.Blocking;
5 | import org.jspecify.annotations.Nullable;
6 |
7 | import java.util.concurrent.ExecutionException;
8 | import java.util.concurrent.Executor;
9 |
10 | @ApiStatus.Internal
11 | final class TaskUtils {
12 | static Task taskUninterruptibleBlockingIO(
13 | final DelayedFun extends T> func
14 | ) {
15 | return Task.fromAsync((ec, callback) -> {
16 | try {
17 | callback.onSuccess(func.invoke());
18 | } catch (final InterruptedException e) {
19 | callback.onCancellation();
20 | } catch (final Exception e) {
21 | callback.onFailure(e);
22 | }
23 | return () -> {};
24 | });
25 | }
26 |
27 | @Blocking
28 | @SuppressWarnings("UnusedReturnValue")
29 | static T runBlockingUninterruptible(
30 | @Nullable final Executor executor,
31 | final Task task
32 | ) throws InterruptedException, ExecutionException {
33 | final var fiber = executor != null
34 | ? task.runFiber(executor)
35 | : task.runFiber();
36 |
37 | fiber.joinBlockingUninterruptible();
38 | try {
39 | return fiber.getResultOrThrow();
40 | } catch (TaskCancellationException | Fiber.NotCompletedException e) {
41 | throw new ExecutionException(e);
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | tags:
7 | - 'v*.*.*'
8 | pull_request:
9 | branches: [ main ]
10 |
11 | jobs:
12 | test-gradle:
13 | runs-on: ubuntu-latest
14 | strategy:
15 | fail-fast: false
16 | matrix:
17 | java-version: ['17', '21']
18 | steps:
19 | - uses: actions/checkout@v4
20 | - name: Set up JDK ${{ matrix.java-version }}
21 | uses: actions/setup-java@v4
22 | with:
23 | cache: 'gradle'
24 | java-version: ${{ matrix.java-version }}
25 | distribution: 'temurin'
26 | # cache-dependency-path: |
27 | # ./**/*.gradle.kts
28 | # ./gradle/wrapper/gradle-wrapper.properties
29 | - name: Setup Gradle
30 | uses: gradle/actions/setup-gradle@v4
31 | - name: Test
32 | run: ./gradlew check --no-daemon
33 | - name: Upload Results
34 | uses: actions/upload-artifact@v4
35 | if: always() # This ensures that test results are uploaded even if the test step fails
36 | with:
37 | name: test-results-${{ matrix.java-version }}
38 | path: |
39 | **/build/reports/
40 | **/build/test-results/
41 |
42 | # test-sbt:
43 | # runs-on: ubuntu-latest
44 | # steps:
45 | # - uses: actions/checkout@v4
46 | # - name: Set up JDK 21
47 | # uses: actions/setup-java@v4
48 | # with:
49 | # cache: 'sbt'
50 | # java-version: 21
51 | # distribution: 'temurin'
52 | # - name: Setup Gradle
53 | # uses: gradle/actions/setup-gradle@v3
54 | # - name: Setup sbt
55 | # uses: sbt/setup-sbt@v1
56 | # - name: Build and test
57 | # run: sbt -v publishLocalGradleDependencies ++test
58 | # working-directory: ./tasks-scala
59 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TimedAwait.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import java.util.concurrent.*;
4 |
5 | import static org.funfix.tasks.jvm.TestSettings.TIMEOUT;
6 | import static org.junit.jupiter.api.Assertions.assertTrue;
7 | import static org.junit.jupiter.api.Assertions.fail;
8 |
9 | public class TimedAwait {
10 | @SuppressWarnings("ResultOfMethodCallIgnored")
11 | static void latchNoExpectations(final CountDownLatch latch) throws InterruptedException {
12 | latch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
13 | }
14 |
15 | static void latchAndExpectCompletion(final CountDownLatch latch) throws InterruptedException {
16 | latchAndExpectCompletion(latch, "latch");
17 | }
18 |
19 | static void latchAndExpectCompletion(final CountDownLatch latch, final String name) throws InterruptedException {
20 | assertTrue(
21 | latch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS),
22 | name + ".await"
23 | );
24 | }
25 |
26 | static void future(final Future> future) throws InterruptedException, TimeoutException {
27 | try {
28 | future.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
29 | } catch (ExecutionException e) {
30 | throw new RuntimeException(e);
31 | }
32 | }
33 |
34 | static void fiberAndExpectCancellation(final Fiber> fiber)
35 | throws InterruptedException {
36 | try {
37 | fiber.awaitBlockingTimed(TIMEOUT);
38 | fail("Fiber should have been cancelled");
39 | } catch (final TaskCancellationException ignored) {
40 | } catch (final TimeoutException e) {
41 | fail("Fiber should have been cancelled", e);
42 | } catch (final ExecutionException e) {
43 | throw new RuntimeException(e);
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/tasks-jvm/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import net.ltgt.gradle.errorprone.CheckSeverity
2 | import net.ltgt.gradle.errorprone.errorprone
3 |
4 | plugins {
5 | id("tasks.java-project")
6 | }
7 |
8 | mavenPublishing {
9 | pom {
10 | name.set("Funfix Tasks (JVM)")
11 | description.set("Task datatype, meant for cross-language interoperability.")
12 | }
13 | }
14 |
15 | dependencies {
16 | api(libs.jspecify)
17 |
18 | errorprone(libs.errorprone.core)
19 | errorprone(libs.errorprone.nullaway)
20 |
21 | compileOnly(libs.jetbrains.annotations)
22 |
23 | testImplementation(platform("org.junit:junit-bom:5.12.1"))
24 | testImplementation("org.junit.jupiter:junit-jupiter")
25 | testRuntimeOnly("org.junit.platform:junit-platform-launcher")
26 | }
27 |
28 | tasks.test {
29 | useJUnitPlatform()
30 | finalizedBy(tasks.jacocoTestReport)
31 | }
32 |
33 | tasks.jacocoTestReport {
34 | dependsOn(tasks.test)
35 | }
36 |
37 | tasks.withType {
38 | sourceCompatibility = JavaVersion.VERSION_17.majorVersion
39 | targetCompatibility = JavaVersion.VERSION_17.majorVersion
40 | options.compilerArgs.addAll(listOf(
41 | "-Xlint:deprecation",
42 | // "-Werror"
43 | ))
44 |
45 | options.errorprone {
46 | disableAllChecks.set(true)
47 | check("NullAway", CheckSeverity.ERROR)
48 | option("NullAway:AnnotatedPackages", "org.funfix")
49 | }
50 | }
51 |
52 | tasks.register("testsOn21") {
53 | useJUnitPlatform()
54 | javaLauncher = javaToolchains.launcherFor {
55 | languageVersion = JavaLanguageVersion.of(JavaVersion.VERSION_21.majorVersion)
56 | }
57 | }
58 |
59 | tasks.register("testsOn17") {
60 | useJUnitPlatform()
61 | javaLauncher = javaToolchains.launcherFor {
62 | languageVersion = JavaLanguageVersion.of(JavaVersion.VERSION_17.majorVersion)
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/ExitCase.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | /**
4 | * For signaling to finalizers how a task exited.
5 | *
6 | * Similar to {@link Outcome}, but without the result. Used by {@link Resource}
7 | * to indicate how a resource was released.
8 | */
9 | public sealed interface ExitCase {
10 | /**
11 | * Signals that the task completed. Used for successful completion, but
12 | * also for cases in which the outcome is unknown.
13 | *
14 | * This is a catch-all result. For example, this is the value passed
15 | * to {@link Resource.Acquired} in {@code releaseTask} when the resource
16 | * is used as an {@link AutoCloseable}, as the {@code AutoCloseable} protocol
17 | * does not distinguish between successful completion, failure, or cancellation.
18 | *
19 | * When receiving a {@code Completed} exit case, the caller shouldn't
20 | * assume that the task was successful.
21 | */
22 | record Completed() implements ExitCase {
23 | private static final Completed INSTANCE = new Completed();
24 | }
25 |
26 | /**
27 | * Signals that the task failed with a known exception.
28 | *
29 | * Used in {@link Resource.Acquired} to indicate that the resource
30 | * is being released due to an exception.
31 | *
32 | * @param error is the exception thrown
33 | */
34 | record Failed(Throwable error) implements ExitCase {}
35 |
36 | /**
37 | * Signals that the task was cancelled.
38 | *
39 | * Used in {@link Resource.Acquired} to indicate that the resource
40 | * is being released due to a cancellation (e.g., the thread was
41 | * interrupted).
42 | */
43 | record Canceled() implements ExitCase {
44 | private static final Canceled INSTANCE = new Canceled();
45 | }
46 |
47 | static ExitCase succeeded() {
48 | return Completed.INSTANCE;
49 | }
50 |
51 | static ExitCase failed(Throwable error) {
52 | return new Failed(error);
53 | }
54 |
55 | static ExitCase canceled() {
56 | return Canceled.INSTANCE;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskExecutor.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jetbrains.annotations.ApiStatus;
4 | import org.jetbrains.annotations.Nullable;
5 |
6 | import java.util.concurrent.Executor;
7 |
8 | @ApiStatus.Internal
9 | interface TaskExecutor extends Executor {
10 | void resumeOnExecutor(Runnable runnable);
11 |
12 | static TaskExecutor from(final Executor executor) {
13 | if (executor instanceof TaskExecutor te) {
14 | return te;
15 | } else {
16 | return new TaskExecutorWithForkedResume(executor);
17 | }
18 | }
19 | }
20 |
21 | @ApiStatus.Internal
22 | final class TaskLocalContext {
23 | static void signalTheStartOfBlockingCall() {
24 | // clears the trampoline first
25 | final var executor = localExecutor.get();
26 | Trampoline.forkAll(
27 | executor != null ? executor : TaskExecutors.sharedBlockingIO()
28 | );
29 | }
30 |
31 | static boolean isCurrentExecutor(final TaskExecutor executor) {
32 | final var currentExecutor = localExecutor.get();
33 | return currentExecutor == executor;
34 | }
35 |
36 | static @Nullable TaskExecutor getAndSetExecutor(@Nullable final TaskExecutor executor) {
37 | final var oldExecutor = localExecutor.get();
38 | localExecutor.set(executor);
39 | return oldExecutor;
40 | }
41 |
42 | private static final ThreadLocal<@Nullable TaskExecutor> localExecutor =
43 | ThreadLocal.withInitial(() -> null);
44 | }
45 |
46 | @ApiStatus.Internal
47 | final class TaskExecutorWithForkedResume implements TaskExecutor {
48 | private final Executor executor;
49 |
50 | TaskExecutorWithForkedResume(final Executor executor) {
51 | this.executor = executor;
52 | }
53 |
54 | @Override
55 | public void execute(final Runnable command) {
56 | executor.execute(() -> {
57 | final var oldExecutor = TaskLocalContext.getAndSetExecutor(this);
58 | try {
59 | command.run();
60 | } finally {
61 | TaskLocalContext.getAndSetExecutor(oldExecutor);
62 | }
63 | });
64 | }
65 |
66 | @Override
67 | public void resumeOnExecutor(final Runnable runnable) {
68 | if (TaskLocalContext.isCurrentExecutor(this)) {
69 | Trampoline.execute(runnable);
70 | } else {
71 | execute(runnable);
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Outcome.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jetbrains.annotations.NonBlocking;
4 | import org.jspecify.annotations.Nullable;
5 |
6 | import java.util.concurrent.ExecutionException;
7 |
8 | /**
9 | * Represents the outcome of a finished task.
10 | *
11 | * @param is the type of the value that the task completed with
12 | */
13 | public sealed interface Outcome
14 | permits Outcome.Success, Outcome.Failure, Outcome.Cancellation {
15 |
16 | /**
17 | * Returns the value of the task if it was successful, or throws an exception.
18 | *
19 | * @return the successful value (in case the outcome is a {@link Success})
20 | * @throws ExecutionException if the task failed with an exception
21 | * @throws TaskCancellationException if the task was cancelled
22 | */
23 | @NonBlocking
24 | T getOrThrow() throws ExecutionException, TaskCancellationException;
25 |
26 | /**
27 | * Signals a successful result of the task.
28 | */
29 | record Success(T value)
30 | implements Outcome {
31 |
32 | @Override
33 | public T getOrThrow() { return value; }
34 |
35 | }
36 |
37 | /**
38 | * Signals that the task failed.
39 | */
40 | record Failure(Throwable exception)
41 | implements Outcome {
42 |
43 | @Override
44 | public T getOrThrow() throws ExecutionException {
45 | throw new ExecutionException(exception);
46 | }
47 |
48 | }
49 |
50 | /**
51 | * Signals that the task was cancelled.
52 | */
53 | record Cancellation()
54 | implements Outcome {
55 |
56 | @Override
57 | public T getOrThrow() throws TaskCancellationException {
58 | throw new TaskCancellationException();
59 | }
60 |
61 | private static final Cancellation> INSTANCE =
62 | new Cancellation<>();
63 | }
64 |
65 | static Outcome success(final T value) {
66 | return new Success<>(value);
67 | }
68 |
69 | static Outcome failure(final Throwable error) {
70 | return new Failure<>(error);
71 | }
72 |
73 | @SuppressWarnings("unchecked")
74 | static Outcome cancellation() {
75 | return (Outcome) Cancellation.INSTANCE;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/AwaitSignalTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.junit.jupiter.api.Test;
4 |
5 | import java.util.concurrent.CountDownLatch;
6 | import java.util.concurrent.TimeUnit;
7 | import java.util.concurrent.TimeoutException;
8 | import java.util.concurrent.atomic.AtomicBoolean;
9 |
10 | import static org.funfix.tasks.jvm.TestSettings.CONCURRENCY_REPEATS;
11 | import static org.funfix.tasks.jvm.TestSettings.TIMEOUT;
12 | import static org.junit.jupiter.api.Assertions.assertFalse;
13 | import static org.junit.jupiter.api.Assertions.assertTrue;
14 |
15 | public class AwaitSignalTest {
16 | @Test
17 | void singleThreaded() throws InterruptedException, TimeoutException {
18 | for (int i = 0; i < CONCURRENCY_REPEATS; i++) {
19 | final var latch = new AwaitSignal();
20 | latch.signal();
21 | latch.await(TimeUnit.SECONDS.toMillis(5));
22 | }
23 | }
24 |
25 | @Test
26 | void multiThreaded() throws InterruptedException {
27 | for (int i = 0; i < CONCURRENCY_REPEATS; i++) {
28 | final var wasStarted = new CountDownLatch(1);
29 | final var latch = new AwaitSignal();
30 | final var hasError = new AtomicBoolean(false);
31 | final var t = new Thread(() -> {
32 | try {
33 | wasStarted.countDown();
34 | latch.await(TimeUnit.SECONDS.toMillis(5));
35 | } catch (InterruptedException | TimeoutException e) {
36 | hasError.set(true);
37 | throw new RuntimeException(e);
38 | }
39 | });
40 | t.start();
41 | wasStarted.await();
42 | latch.signal();
43 | t.join(TIMEOUT.toMillis());
44 | assertFalse(t.isAlive(), "isAlive");
45 | assertFalse(hasError.get());
46 | }
47 | }
48 |
49 | @Test
50 | void canBeInterrupted() throws InterruptedException {
51 | for (int i = 0; i < CONCURRENCY_REPEATS; i++) {
52 | final var latch = new AwaitSignal();
53 | final var wasInterrupted = new AtomicBoolean(false);
54 | final var t = new Thread(() -> {
55 | try {
56 | latch.await();
57 | } catch (InterruptedException e) {
58 | wasInterrupted.set(true);
59 | }
60 | });
61 | t.start();
62 | t.interrupt();
63 | t.join(TIMEOUT.toMillis());
64 | assertFalse(t.isAlive(), "isAlive");
65 | assertTrue(wasInterrupted.get());
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Trampoline.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jetbrains.annotations.ApiStatus;
4 | import org.jspecify.annotations.Nullable;
5 |
6 | import java.util.ArrayDeque;
7 | import java.util.concurrent.Executor;
8 |
9 | /**
10 | * INTERNAL API.
11 | *
12 | * INTERNAL API: Internal apis are subject to change or removal
13 | * without any notice. When code depends on internal APIs, it is subject to
14 | * breakage between minor version updates.
15 | */
16 | @ApiStatus.Internal
17 | final class Trampoline {
18 | private Trampoline() {}
19 |
20 | private static final ThreadLocal<@Nullable ArrayDeque> queue =
21 | new ThreadLocal<>();
22 |
23 | private static void eventLoop() {
24 | while (true) {
25 | final var current = queue.get();
26 | if (current == null) {
27 | return;
28 | }
29 | final var next = current.pollFirst();
30 | if (next == null) {
31 | return;
32 | }
33 | try {
34 | next.run();
35 | } catch (final Throwable e) {
36 | UncaughtExceptionHandler.logOrRethrow(e);
37 | }
38 | }
39 | }
40 |
41 | public static final Executor INSTANCE =
42 | new TaskExecutor() {
43 | @Override
44 | public void resumeOnExecutor(Runnable runnable) {
45 | execute(runnable);
46 | }
47 |
48 | @Override
49 | public void execute(Runnable command) {
50 | var current = queue.get();
51 | if (current == null) {
52 | current = new ArrayDeque<>();
53 | current.add(command);
54 | queue.set(current);
55 | try {
56 | eventLoop();
57 | } finally {
58 | queue.remove();
59 | }
60 | } else {
61 | current.add(command);
62 | }
63 | }
64 | };
65 |
66 | public static void forkAll(final Executor executor) {
67 | final var current = queue.get();
68 | if (current == null) return;
69 |
70 | final var copy = new ArrayDeque<>(current);
71 | executor.execute(() -> Trampoline.execute(() -> {
72 | while (!copy.isEmpty()) {
73 | final var next = copy.pollFirst();
74 | Trampoline.execute(next);
75 | }
76 | }));
77 | }
78 |
79 | public static void execute(final Runnable command) {
80 | INSTANCE.execute(command);
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/OutcomeTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.junit.jupiter.api.Test;
4 |
5 | import java.util.concurrent.ExecutionException;
6 |
7 | import static org.junit.jupiter.api.Assertions.assertEquals;
8 | import static org.junit.jupiter.api.Assertions.fail;
9 |
10 | public class OutcomeTest {
11 | @Test
12 | void outcomeBuildSuccess() {
13 | final var outcome1 = new Outcome.Success<>("value");
14 | final Outcome outcome2 = Outcome.success("value");
15 | assertEquals(outcome1, outcome2);
16 |
17 | if (outcome2 instanceof Outcome.Success success) {
18 | assertEquals("value", success.value());
19 | } else {
20 | fail("Expected Success");
21 | }
22 |
23 | assertEquals("value", outcome1.getOrThrow());
24 | try {
25 | assertEquals("value", outcome2.getOrThrow());
26 | } catch (Exception e) {
27 | throw new RuntimeException(e);
28 | }
29 | }
30 |
31 | @Test
32 | void outcomeBuildCancellation() throws ExecutionException {
33 | final var outcome1 = new Outcome.Cancellation<>();
34 | final Outcome outcome2 = Outcome.cancellation();
35 | assertEquals(outcome1, outcome2);
36 |
37 | if (!(outcome2 instanceof Outcome.Cancellation)) {
38 | fail("Expected Canceled");
39 | }
40 |
41 | try {
42 | outcome1.getOrThrow();
43 | fail("Expected CancellationException");
44 | } catch (TaskCancellationException ignored) {
45 | }
46 | try {
47 | outcome2.getOrThrow();
48 | fail("Expected CancellationException");
49 | } catch (TaskCancellationException ignored) {
50 | }
51 | }
52 |
53 | @Test
54 | void outcomeBuildRuntimeFailure() {
55 | final var e = new RuntimeException("error");
56 | final var outcome1 = new Outcome.Failure<>(e);
57 | final Outcome outcome2 = Outcome.failure(e);
58 | assertEquals(outcome1, outcome2);
59 |
60 | if (outcome2 instanceof Outcome.Failure failure) {
61 | assertEquals("error", failure.exception().getMessage());
62 | } else {
63 | fail("Expected Failure");
64 | }
65 |
66 | try {
67 | outcome1.getOrThrow();
68 | fail("Expected RuntimeException");
69 | } catch (ExecutionException received) {
70 | assertEquals(received.getCause(), e);
71 | }
72 | try {
73 | outcome2.getOrThrow();
74 | fail("Expected RuntimeException");
75 | } catch (ExecutionException received) {
76 | assertEquals(received.getCause(), e);
77 | } catch (TaskCancellationException ex) {
78 | throw new RuntimeException(ex);
79 | }
80 | }
81 | }
82 |
83 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskFromCompletableFutureTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.junit.jupiter.api.Test;
4 |
5 | import java.util.concurrent.CompletableFuture;
6 | import java.util.concurrent.ExecutionException;
7 | import java.util.concurrent.Executors;
8 | import java.util.concurrent.atomic.AtomicBoolean;
9 |
10 | import static org.junit.jupiter.api.Assertions.*;
11 |
12 | public class TaskFromCompletableFutureTest {
13 | @Test
14 | public void happyPath()
15 | throws ExecutionException, InterruptedException {
16 |
17 | final var es = Executors.newCachedThreadPool();
18 | try {
19 | final var isSuspended = new AtomicBoolean(true);
20 | final var task =
21 | Task.fromCompletionStage(() -> {
22 | isSuspended.set(false);
23 | return CompletableFuture.supplyAsync(
24 | () -> "Hello, world!",
25 | es
26 | );
27 | });
28 | // Test that the future is suspended
29 | assertTrue(isSuspended.get(), "Future should be suspended");
30 |
31 | final var result = task.runBlocking();
32 | assertFalse(isSuspended.get(), "Future should have been executed");
33 | assertEquals("Hello, world!", result);
34 | } finally {
35 | es.shutdown();
36 | }
37 | }
38 |
39 | @Test
40 | public void yieldingErrorInsideFuture() throws InterruptedException {
41 | final var es = Executors.newCachedThreadPool();
42 | try {
43 | final Task task =
44 | Task.fromCompletionStage(() ->
45 | CompletableFuture.supplyAsync(
46 | () -> {
47 | throw new SampleException("Error");
48 | },
49 | es
50 | )
51 | );
52 |
53 | try {
54 | task.runBlocking();
55 | fail("Should have thrown an exception");
56 | } catch (final ExecutionException ex) {
57 | assertInstanceOf(SampleException.class, ex.getCause(), "Should have received a SampleException");
58 | }
59 | } finally {
60 | es.shutdown();
61 | }
62 | }
63 |
64 | @Test
65 | public void yieldingErrorInBuilder() throws InterruptedException {
66 | final Task task =
67 | Task.fromCompletionStage(() -> {
68 | throw new SampleException("Error");
69 | });
70 | try {
71 | task.runBlocking();
72 | fail("Should have thrown an exception");
73 | } catch (final ExecutionException ex) {
74 | assertInstanceOf(SampleException.class, ex.getCause(), "Should have received a SampleException");
75 | }
76 | }
77 |
78 | static final class SampleException extends RuntimeException {
79 | public SampleException(final String message) {
80 | super(message);
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/CollectionsTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.junit.jupiter.api.Test;
4 |
5 | import static org.junit.jupiter.api.Assertions.*;
6 |
7 | public class CollectionsTest {
8 | @Test
9 | void listEqualityTest() {
10 | ImmutableStack list1 = ImmutableStack.empty();
11 | ImmutableStack list2 = ImmutableStack.empty();
12 | final var elems = new String[] { "a", "b", "c" };
13 |
14 | for (String elem : elems) {
15 | list1 = list1.prepend(elem);
16 | list2 = list2.prepend(elem);
17 | }
18 |
19 | assertEquals(list1, list2);
20 | assertNotEquals(list1, list2.reverse());
21 | assertEquals(list1.reverse(), list2.reverse());
22 | }
23 |
24 | @Test
25 | void queueToList() {
26 | ImmutableQueue queue = ImmutableQueue.empty();
27 | queue = queue.enqueue("1");
28 | queue = queue.enqueue("2");
29 | queue = queue.enqueue("3");
30 | queue = queue.preOptimize();
31 | queue = queue.enqueue("4");
32 | queue = queue.enqueue("5");
33 | queue = queue.enqueue("6");
34 |
35 | assertEquals(
36 | "ImmutableStack(1, 2, 3, 4, 5, 6)",
37 | queue.toList().toString()
38 | );
39 |
40 | queue = queue.dequeue();
41 | queue = queue.dequeue();
42 | assertEquals(
43 | "ImmutableStack(3, 4, 5, 6)",
44 | queue.toList().toString()
45 | );
46 | }
47 |
48 | @Test
49 | void queueEqualityTest() {
50 | ImmutableQueue list1 = ImmutableQueue.empty();
51 | ImmutableQueue list2 = ImmutableQueue.empty();
52 | ImmutableQueue list3 = ImmutableQueue.empty();
53 | ImmutableQueue list4 = ImmutableQueue.empty();
54 | final var elems = new String[] { "a", "b", "c" };
55 |
56 | list3 = list3.enqueue("0");
57 | for (String elem : elems) {
58 | list1 = list1.enqueue(elem);
59 | list2 = list2.enqueue(elem);
60 | list3 = list3.enqueue(elem);
61 | list4 = list4.enqueue(elem);
62 | }
63 | list4 = list4.enqueue("d");
64 |
65 | assertEquals(list1, list2);
66 | assertEquals(list1.hashCode(), list2.hashCode());
67 | assertNotEquals(list1, list3);
68 | assertNotEquals(list1.hashCode(), list3.hashCode());
69 | assertNotEquals(list1, list4);
70 | assertNotEquals(list1.hashCode(), list4.hashCode());
71 | assertNotEquals(list3, list4);
72 | assertNotEquals(list3.hashCode(), list4.hashCode());
73 |
74 | assertEquals(
75 | "ImmutableQueue(a, b, c)",
76 | list1.toString()
77 | );
78 | assertEquals(
79 | "ImmutableQueue(a, b, c)",
80 | list2.toString()
81 | );
82 | assertEquals(
83 | "ImmutableQueue(0, a, b, c)",
84 | list3.toString()
85 | );
86 | assertEquals(
87 | "ImmutableQueue(a, b, c, d)",
88 | list4.toString()
89 | );
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Continuation.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jetbrains.annotations.ApiStatus;
4 | import org.jspecify.annotations.Nullable;
5 |
6 | import java.util.concurrent.Executor;
7 |
8 | /**
9 | * INTERNAL API.
10 | *
11 | * Continuation objects are used to complete tasks, or for registering
12 | * {@link Cancellable} references that can be used to interrupt running tasks.
13 | *
14 | * {@code Continuation} objects get injected in {@link AsyncFun} functions.
15 | * See {@link Task#fromAsync(AsyncFun)}.
16 | *
17 | * @param is the type of the value that the task will complete with
18 | */
19 | @ApiStatus.Internal
20 | interface Continuation
21 | extends CompletionCallback {
22 |
23 | /**
24 | * Returns the {@link Executor} that the task can use to run its
25 | * asynchronous computation.
26 | */
27 | TaskExecutor getExecutor();
28 |
29 | /**
30 | * Registers a {@link Cancellable} reference that can be used to interrupt
31 | * a running task.
32 | *
33 | * @param cancellable is the reference to the cancellable object that this
34 | * continuation will register.
35 | */
36 | @Nullable Cancellable registerCancellable(Cancellable cancellable);
37 |
38 | CancellableForwardRef registerForwardCancellable();
39 |
40 | Continuation withExecutorOverride(TaskExecutor executor);
41 |
42 | void registerExtraCallback(CompletionCallback extraCallback);
43 | }
44 |
45 | /**
46 | * INTERNAL API.
47 | */
48 | @ApiStatus.Internal
49 | @FunctionalInterface
50 | interface AsyncContinuationFun {
51 | void invoke(Continuation continuation);
52 | }
53 |
54 | /**
55 | * INTERNAL API.
56 | */
57 | @ApiStatus.Internal
58 | final class CancellableContinuation
59 | implements Continuation, Cancellable {
60 |
61 | private final ContinuationCallback callback;
62 | private final MutableCancellable cancellableRef;
63 | private final TaskExecutor executor;
64 |
65 | public CancellableContinuation(
66 | final TaskExecutor executor,
67 | final ContinuationCallback callback
68 | ) {
69 | this(
70 | executor,
71 | callback,
72 | new MutableCancellable()
73 | );
74 | }
75 |
76 | CancellableContinuation(
77 | final TaskExecutor executor,
78 | final ContinuationCallback callback,
79 | final MutableCancellable cancellable
80 | ) {
81 | this.executor = executor;
82 | this.callback = callback;
83 | this.cancellableRef = cancellable;
84 | }
85 |
86 | @Override
87 | public TaskExecutor getExecutor() {
88 | return this.executor;
89 | }
90 |
91 | @Override
92 | public void cancel() {
93 | cancellableRef.cancel();
94 | }
95 |
96 | @Override
97 | public CancellableForwardRef registerForwardCancellable() {
98 | return cancellableRef.newCancellableRef();
99 | }
100 |
101 | @Override
102 | public @Nullable Cancellable registerCancellable(Cancellable cancellable) {
103 | return this.cancellableRef.register(cancellable);
104 | }
105 |
106 | @Override
107 | public void onOutcome(Outcome outcome) {
108 | callback.onOutcome(outcome);
109 | }
110 |
111 | @Override
112 | public void onSuccess(T value) {
113 | callback.onSuccess(value);
114 | }
115 |
116 | @Override
117 | public void onFailure(Throwable e) {
118 | callback.onFailure(e);
119 | }
120 |
121 | @Override
122 | public void onCancellation() {
123 | callback.onCancellation();
124 | }
125 |
126 | @Override
127 | public Continuation withExecutorOverride(TaskExecutor executor) {
128 | return new CancellableContinuation<>(
129 | executor,
130 | callback,
131 | cancellableRef
132 | );
133 | }
134 |
135 | @Override
136 | public void registerExtraCallback(CompletionCallback extraCallback) {
137 | callback.registerExtraCallback(extraCallback);
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskFromBlockingIOTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 | import org.junit.jupiter.api.AfterEach;
5 | import org.junit.jupiter.api.BeforeEach;
6 | import org.junit.jupiter.api.Test;
7 |
8 | import java.util.Objects;
9 | import java.util.concurrent.*;
10 | import java.util.concurrent.atomic.AtomicReference;
11 |
12 | import static org.funfix.tasks.jvm.TestSettings.TIMEOUT;
13 | import static org.junit.jupiter.api.Assertions.*;
14 |
15 | abstract class TaskFromBlockingIOTestBase {
16 | @Nullable protected Executor executor;
17 | @Nullable protected AutoCloseable closeable;
18 |
19 | @AfterEach
20 | void tearDown() throws Exception {
21 | final AutoCloseable closeable = this.closeable;
22 | if (closeable != null) {
23 | this.closeable = null;
24 | closeable.close();
25 | }
26 | }
27 |
28 | abstract void testThreadName(String name);
29 |
30 | @Test
31 | public void runBlockingDoesNotFork() throws ExecutionException, InterruptedException {
32 | Objects.requireNonNull(executor);
33 | final var name = new AtomicReference<>("");
34 | final var thisName = Thread.currentThread().getName();
35 | final var r =
36 | Task.fromBlockingIO(() -> {
37 | name.set(Thread.currentThread().getName());
38 | return "Hello, world!";
39 | }).runBlocking(executor);
40 | assertEquals("Hello, world!", r);
41 | assertEquals(thisName, name.get());
42 | }
43 |
44 | @Test
45 | public void runBlockingTimedForks() throws ExecutionException, InterruptedException, TimeoutException {
46 | Objects.requireNonNull(executor);
47 | final var name = new AtomicReference<>("");
48 | final var r =
49 | Task.fromBlockingIO(() -> {
50 | name.set(Thread.currentThread().getName());
51 | return "Hello, world!";
52 | }).runBlockingTimed(executor, TIMEOUT);
53 | assertEquals("Hello, world!", r);
54 | testThreadName(Objects.requireNonNull(name.get()));
55 | }
56 |
57 | @Test
58 | public void canFail() throws InterruptedException {
59 | Objects.requireNonNull(executor);
60 | try {
61 | Task.fromBlockingIO(() -> { throw new RuntimeException("Error"); })
62 | .runBlocking(executor);
63 | fail("Should have thrown an exception");
64 | } catch (final ExecutionException ex) {
65 | assertEquals("Error", Objects.requireNonNull(ex.getCause()).getMessage());
66 | }
67 | }
68 |
69 | @Test
70 | public void isCancellable() throws InterruptedException, ExecutionException, Fiber.NotCompletedException {
71 | Objects.requireNonNull(executor);
72 | final var latch = new CountDownLatch(1);
73 | @SuppressWarnings("NullAway")
74 | final var task = Task.fromBlockingIO(() -> {
75 | latch.countDown();
76 | Thread.sleep(30000);
77 | return null;
78 | });
79 | final var fiber = task.runFiber(executor);
80 | TimedAwait.latchAndExpectCompletion(latch, "latch");
81 |
82 | fiber.cancel();
83 | fiber.joinBlocking();
84 | try {
85 | fiber.getResultOrThrow();
86 | fail("Should have thrown a CancellationException");
87 | } catch (final TaskCancellationException ignored) {}
88 | }
89 | }
90 |
91 | final class TaskFromBlockingWithExecutorIOTest extends TaskFromBlockingIOTestBase {
92 | @Override
93 | void testThreadName(final String name) {
94 | assertTrue(
95 | name.matches("^es-sample-\\d+$"),
96 | "name.matches(es-sample-)"
97 | );
98 | }
99 |
100 | @BeforeEach
101 | @SuppressWarnings("deprecation")
102 | void setup() {
103 | final ExecutorService es = Executors.newCachedThreadPool(r -> {
104 | final var th = new Thread(r);
105 | th.setName("es-sample-" + th.getId());
106 | return th;
107 | });
108 | this.closeable = es::shutdown;
109 | this.executor = es;
110 | }
111 | }
112 |
113 | final class TaskFromBlockingWithSharedExecutorTest extends TaskFromBlockingIOTestBase {
114 | @Override
115 | void testThreadName(String name) {}
116 |
117 | @BeforeEach
118 | void setup() {
119 | this.executor = TaskExecutors.sharedBlockingIO();
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskExecuteTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 | import org.junit.jupiter.api.Test;
5 |
6 | import java.util.Objects;
7 | import java.util.concurrent.CountDownLatch;
8 | import java.util.concurrent.ExecutionException;
9 | import java.util.concurrent.atomic.AtomicInteger;
10 | import java.util.concurrent.atomic.AtomicReference;
11 |
12 | import static org.junit.jupiter.api.Assertions.*;
13 |
14 | public class TaskExecuteTest {
15 | @Test
16 | void runAsyncWorksForSuccess() throws InterruptedException, TaskCancellationException, ExecutionException {
17 | final var latch =
18 | new CountDownLatch(1);
19 | final var outcomeRef =
20 | new AtomicReference<@Nullable Outcome>(null);
21 |
22 | final var task = Task.fromBlockingIO(() -> "Hello!");
23 | task.runAsync(outcome -> {
24 | outcomeRef.set(outcome);
25 | latch.countDown();
26 | });
27 |
28 | TimedAwait.latchAndExpectCompletion(latch, "latch");
29 | assertInstanceOf(Outcome.Success.class, outcomeRef.get());
30 | assertEquals("Hello!", Objects.requireNonNull(outcomeRef.get()).getOrThrow());
31 | }
32 |
33 | @Test
34 | void runAsyncWorksForFailure() throws InterruptedException, TaskCancellationException {
35 | final var latch =
36 | new CountDownLatch(1);
37 | final var outcomeRef =
38 | new AtomicReference<@Nullable Outcome>(null);
39 | final var expectedError =
40 | new RuntimeException("Error");
41 |
42 | final var task = Task.fromBlockingIO(() -> {
43 | throw expectedError;
44 | });
45 | task.runAsync(outcome -> {
46 | outcomeRef.set(outcome);
47 | latch.countDown();
48 | });
49 |
50 | TimedAwait.latchAndExpectCompletion(latch, "latch");
51 | assertInstanceOf(Outcome.Failure.class, outcomeRef.get());
52 | try {
53 | Objects.requireNonNull(outcomeRef.get()).getOrThrow();
54 | fail("Should have thrown an exception");
55 | } catch (final ExecutionException e) {
56 | assertEquals(expectedError, e.getCause());
57 | }
58 | }
59 |
60 | @Test
61 | void runAsyncWorksForCancellation() throws InterruptedException {
62 | final var nonTermination =
63 | new CountDownLatch(1);
64 | final var latch =
65 | new CountDownLatch(1);
66 | final var outcomeRef =
67 | new AtomicReference<@Nullable Outcome>>(null);
68 |
69 | final var task = Task.fromBlockingIO(() -> {
70 | nonTermination.await();
71 | return "Nooo";
72 | });
73 | final var token = task.runAsync(outcome -> {
74 | outcomeRef.set(outcome);
75 | latch.countDown();
76 | });
77 |
78 | token.cancel();
79 | TimedAwait.latchAndExpectCompletion(latch, "latch");
80 | assertInstanceOf(Outcome.Cancellation.class, outcomeRef.get());
81 | }
82 |
83 | @Test
84 | void runBlockingStackedIsCancellable() throws InterruptedException, ExecutionException, Fiber.NotCompletedException {
85 | final var started = new CountDownLatch(1);
86 | final var latch = new CountDownLatch(1);
87 | final var interruptedHits = new AtomicInteger(0);
88 |
89 | final var task = Task.fromBlockingIO(() -> {
90 | final var innerTask = Task.fromBlockingIO(() -> {
91 | started.countDown();
92 | try {
93 | latch.await();
94 | return "Nooo";
95 | } catch (final InterruptedException e) {
96 | interruptedHits.incrementAndGet();
97 | throw e;
98 | }
99 | });
100 | try {
101 | return innerTask.runBlocking();
102 | } catch (final InterruptedException e) {
103 | interruptedHits.incrementAndGet();
104 | throw e;
105 | }
106 | });
107 |
108 | final var fiber = task.runFiber();
109 | TimedAwait.latchAndExpectCompletion(started, "started");
110 |
111 | fiber.cancel();
112 | fiber.joinBlocking();
113 |
114 | try {
115 | fiber.getResultOrThrow();
116 | fail("Should have thrown a CancellationException");
117 | } catch (TaskCancellationException ignored) {
118 | }
119 | assertEquals(2, interruptedHits.get(), "interruptedHits.get");
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/ResourceTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 | import org.junit.jupiter.api.Test;
5 |
6 | import java.io.*;
7 | import java.nio.charset.StandardCharsets;
8 | import java.util.concurrent.CountDownLatch;
9 | import java.util.concurrent.atomic.AtomicInteger;
10 | import java.util.concurrent.atomic.AtomicReference;
11 |
12 | import static org.junit.jupiter.api.Assertions.assertEquals;
13 |
14 | public class ResourceTest {
15 | @Test
16 | void readAndWriteFromFile() throws Exception {
17 | try (
18 | final var file = createTemporaryFile("test", ".txt").acquireBlocking()
19 | ) {
20 | try (final var writer = openWriter(file.get()).acquireBlocking()) {
21 | writer.get().write("----\n");
22 | writer.get().write("line 1\n");
23 | writer.get().write("line 2\n");
24 | writer.get().write("----\n");
25 | }
26 |
27 | try (final var reader = openReader(file.get()).acquireBlocking()) {
28 | final var builder = new StringBuilder();
29 | String line;
30 | while ((line = reader.get().readLine()) != null) {
31 | builder.append(line).append("\n");
32 | }
33 | final String content = builder.toString();
34 | assertEquals(
35 | "----\nline 1\nline 2\n----\n",
36 | content,
37 | "File content should match the written lines"
38 | );
39 | }
40 | }
41 | }
42 |
43 | @Test
44 | void useIsInterruptible() throws InterruptedException {
45 | for (int i = 0; i < TestSettings.CONCURRENCY_REPEATS; i++) {
46 | final var started = new CountDownLatch(1);
47 | final var latch = new CountDownLatch(1);
48 | final var wasShutdown = new CountDownLatch(1);
49 | final var wasInterrupted = new AtomicInteger(0);
50 | final var wasReleased = new AtomicReference<@Nullable ExitCase>(null);
51 | final var resource = Resource.fromBlockingIO(() ->
52 | Resource.Acquired.fromBlockingIO("my resource", wasReleased::set)
53 | );
54 |
55 | @SuppressWarnings("NullAway")
56 | final var task = Task.fromBlockingFuture(() -> {
57 | try {
58 | resource.useBlocking(res -> {
59 | assertEquals("my resource", res);
60 | started.countDown();
61 | try {
62 | latch.await();
63 | } catch (InterruptedException e) {
64 | wasInterrupted.incrementAndGet();
65 | throw e;
66 | }
67 | return null;
68 | });
69 | } catch (InterruptedException e) {
70 | wasInterrupted.addAndGet(2);
71 | throw e;
72 | } finally {
73 | wasShutdown.countDown();
74 | }
75 | return null;
76 | });
77 |
78 | final var fiber = task.runFiber();
79 | TimedAwait.latchAndExpectCompletion(started, "started");
80 | fiber.cancel();
81 | TimedAwait.latchAndExpectCompletion(wasShutdown, "wasShutdown");
82 | TimedAwait.fiberAndExpectCancellation(fiber);
83 | assertEquals(3, wasInterrupted.get(), "wasInterrupted");
84 | assertEquals(ExitCase.canceled(), wasReleased.get(), "wasReleased");
85 | }
86 | }
87 |
88 | Resource openReader(File file) {
89 | return Resource.fromAutoCloseable(() ->
90 | new BufferedReader(
91 | new InputStreamReader(
92 | new FileInputStream(file),
93 | StandardCharsets.UTF_8
94 | )
95 | ));
96 | }
97 |
98 | Resource openWriter(File file) {
99 | return Resource.fromAutoCloseable(() ->
100 | new BufferedWriter(
101 | new OutputStreamWriter(
102 | new FileOutputStream(file),
103 | StandardCharsets.UTF_8
104 | )
105 | ));
106 | }
107 |
108 | @SuppressWarnings("ResultOfMethodCallIgnored")
109 | Resource createTemporaryFile(String prefix, String suffix) {
110 | return Resource.fromBlockingIO(() -> {
111 | File tempFile = File.createTempFile(prefix, suffix);
112 | tempFile.deleteOnExit(); // Ensure it gets deleted on exit
113 | return Resource.Acquired.fromBlockingIO(
114 | tempFile,
115 | ignored -> tempFile.delete()
116 | );
117 | });
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskWithCancellationTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 | import org.junit.jupiter.api.Test;
5 |
6 | import java.util.concurrent.ConcurrentLinkedQueue;
7 | import java.util.concurrent.CountDownLatch;
8 | import java.util.concurrent.atomic.AtomicReference;
9 |
10 | import static org.junit.jupiter.api.Assertions.assertEquals;
11 | import static org.junit.jupiter.api.Assertions.fail;
12 |
13 | public class TaskWithCancellationTest {
14 | @Test
15 | void testTaskWithCancellation() throws InterruptedException {
16 | for (int r = 0; r < TestSettings.CONCURRENCY_REPEATS; r++) {
17 | final var cancelTokensRef = new ConcurrentLinkedQueue();
18 | final var outcomeRef = new AtomicReference<@Nullable Outcome>(null);
19 |
20 | final var startedLatch = new CountDownLatch(1);
21 | final var taskLatch = new CountDownLatch(1);
22 | final var cancelTokensLatch = new CountDownLatch(2);
23 | final var completedLatch = new CountDownLatch(1);
24 | final var runningTask = Task
25 | .fromBlockingIO(() -> {
26 | try {
27 | startedLatch.countDown();
28 | taskLatch.await();
29 | return "Completed";
30 | } catch (final InterruptedException e) {
31 | TimedAwait.latchNoExpectations(cancelTokensLatch);
32 | cancelTokensRef.add(3);
33 | throw e;
34 | }
35 | })
36 | .ensureRunningOnExecutor()
37 | .withCancellation(() -> {
38 | cancelTokensRef.add(1);
39 | cancelTokensLatch.countDown();
40 | })
41 | .withCancellation(() -> {
42 | cancelTokensRef.add(2);
43 | cancelTokensLatch.countDown();
44 | })
45 | .runAsync(outcome -> {
46 | outcomeRef.set(outcome);
47 | completedLatch.countDown();
48 | });
49 |
50 | TimedAwait.latchAndExpectCompletion(startedLatch, "startedLatch");
51 | runningTask.cancel();
52 | TimedAwait.latchAndExpectCompletion(completedLatch, "completedLatch");
53 | assertEquals(Outcome.cancellation(), outcomeRef.get());
54 |
55 | final var arr = cancelTokensRef.toArray(new Integer[0]);
56 | for (int i = 0; i < arr.length; i++) {
57 | assertEquals(i + 1, arr[i], "cancelTokensRef[" + i + "] should be " + (i + 1));
58 | }
59 | assertEquals(3, arr.length, "cancelTokensRef should have 3 elements");
60 | }
61 | }
62 |
63 | @Test
64 | void testTaskWithCancellationAndFibers() throws Exception {
65 | for (int r = 0; r < TestSettings.CONCURRENCY_REPEATS; r++) {
66 | final var cancelTokensRef = new ConcurrentLinkedQueue();
67 | final var startedLatch = new CountDownLatch(1);
68 | final var taskLatch = new CountDownLatch(1);
69 | final var cancelTokensLatch = new CountDownLatch(2);
70 |
71 | final var fiber = Task
72 | .fromBlockingIO(() -> {
73 | try {
74 | startedLatch.countDown();
75 | taskLatch.await();
76 | return "Completed";
77 | } catch (final InterruptedException e) {
78 | TimedAwait.latchNoExpectations(cancelTokensLatch);
79 | cancelTokensRef.add(3);
80 | throw e;
81 | }
82 | })
83 | .ensureRunningOnExecutor()
84 | .withCancellation(() -> {
85 | cancelTokensRef.add(1);
86 | cancelTokensLatch.countDown();
87 | })
88 | .withCancellation(() -> {
89 | cancelTokensRef.add(2);
90 | cancelTokensLatch.countDown();
91 | })
92 | .runFiber();
93 |
94 | TimedAwait.latchAndExpectCompletion(startedLatch, "startedLatch");
95 | fiber.cancel();
96 | TimedAwait.fiberAndExpectCancellation(fiber);
97 | try {
98 | fiber.getResultOrThrow();
99 | fail("Should have thrown a TaskCancellationException");
100 | } catch (final TaskCancellationException e) {
101 | // Expected
102 | }
103 |
104 | final var arr = cancelTokensRef.toArray(new Integer[0]);
105 | for (int i = 0; i < arr.length; i++) {
106 | assertEquals(i + 1, arr[i], "cancelTokensRef[" + i + "] should be " + (i + 1));
107 | }
108 | assertEquals(3, arr.length, "cancelTokensRef should have 3 elements");
109 | }
110 | }
111 |
112 | }
113 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskCreateTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 | import org.junit.jupiter.api.AfterEach;
5 | import org.junit.jupiter.api.BeforeEach;
6 | import org.junit.jupiter.api.Test;
7 |
8 | import java.util.Objects;
9 | import java.util.concurrent.CountDownLatch;
10 | import java.util.concurrent.ExecutionException;
11 | import java.util.concurrent.Executor;
12 | import java.util.concurrent.TimeoutException;
13 | import java.util.concurrent.atomic.AtomicReference;
14 |
15 | import static org.funfix.tasks.jvm.TestSettings.TIMEOUT;
16 | import static org.junit.jupiter.api.Assertions.*;
17 |
18 | abstract class BaseTaskCreateTest {
19 | @Nullable
20 | protected AutoCloseable closeable;
21 | @Nullable protected Executor executor;
22 |
23 | protected abstract Task fromAsyncTask(final AsyncFun extends T> builder);
24 |
25 | @AfterEach
26 | void tearDown() throws Exception {
27 | if (closeable != null) {
28 | closeable.close();
29 | closeable = null;
30 | }
31 | }
32 |
33 | @Test
34 | void successful() throws ExecutionException, InterruptedException, TimeoutException {
35 | final var noErrors = new CountDownLatch(1);
36 | final var reportedException = new AtomicReference<@Nullable Throwable>(null);
37 |
38 | final Task task = fromAsyncTask((executor, cb) -> {
39 | cb.onSuccess("Hello, world!");
40 | // callback is idempotent
41 | cb.onSuccess("Hello, world! (2)");
42 | noErrors.countDown();
43 | return Cancellable.getEmpty();
44 | });
45 |
46 | final String result = task.runBlockingTimed(TIMEOUT);
47 | assertEquals("Hello, world!", result);
48 | TimedAwait.latchAndExpectCompletion(noErrors, "noErrors");
49 | assertNull(reportedException.get(), "reportedException.get()");
50 | }
51 |
52 | @Test
53 | void failed() throws InterruptedException {
54 | final var noErrors = new CountDownLatch(1);
55 | final var reportedException = new AtomicReference<@Nullable Throwable>(null);
56 | final Task task = fromAsyncTask((executor, cb) -> {
57 | Thread.setDefaultUncaughtExceptionHandler((t, ex) -> reportedException.set(ex));
58 | cb.onFailure(new RuntimeException("Sample exception"));
59 | // callback is idempotent
60 | cb.onFailure(new RuntimeException("Sample exception (2)"));
61 | noErrors.countDown();
62 | return Cancellable.getEmpty();
63 | });
64 | try {
65 | if (executor != null)
66 | task.runBlockingTimed(executor, TIMEOUT);
67 | else
68 | task.runBlockingTimed(TIMEOUT);
69 | } catch (final ExecutionException | TimeoutException ex) {
70 | assertEquals(
71 | "Sample exception",
72 | Objects.requireNonNull(ex.getCause()).getMessage()
73 | );
74 | }
75 | TimedAwait.latchAndExpectCompletion(noErrors, "noErrors");
76 | assertNotNull(reportedException.get(), "reportedException.get()");
77 | assertEquals(
78 | "Sample exception (2)",
79 | Objects.requireNonNull(reportedException.get()).getMessage()
80 | );
81 | }
82 |
83 | @Test
84 | void cancelled() throws InterruptedException, ExecutionException, Fiber.NotCompletedException {
85 | final var noErrors = new CountDownLatch(1);
86 | final var reportedException = new AtomicReference<@Nullable Throwable>(null);
87 |
88 | final Task task = fromAsyncTask((executor, cb) -> () -> {
89 | Thread.setDefaultUncaughtExceptionHandler((t, ex) -> reportedException.set(ex));
90 | cb.onCancellation();
91 | // callback is idempotent
92 | cb.onCancellation();
93 | noErrors.countDown();
94 | });
95 |
96 | final var fiber =
97 | executor != null
98 | ? task.runFiber(executor)
99 | : task.runFiber();
100 | try {
101 | fiber.cancel();
102 | // call is idempotent
103 | fiber.cancel();
104 | fiber.joinBlocking();
105 | fiber.getResultOrThrow();
106 | fail("Should have thrown a TaskCancellationException");
107 | } catch (final TaskCancellationException ex) {
108 | // Expected
109 | }
110 | TimedAwait.latchAndExpectCompletion(noErrors, "noErrors");
111 | assertNull(reportedException.get(), "reportedException.get()");
112 | }
113 | }
114 |
115 | class TaskCreateSimpleDefaultExecutorTest extends BaseTaskCreateTest {
116 | @BeforeEach
117 | void setUp() {
118 | executor = null;
119 | }
120 |
121 | @Override
122 | protected Task fromAsyncTask(final AsyncFun extends T> builder) {
123 | return Task.fromAsync(builder);
124 | }
125 | }
126 |
127 | class TaskCreateSimpleCustomJavaExecutorTest extends BaseTaskCreateTest {
128 | @BeforeEach
129 | void setUp() {
130 | final var javaExecutor = java.util.concurrent.Executors.newCachedThreadPool();
131 | executor = javaExecutor;
132 | closeable = javaExecutor::shutdown;
133 | }
134 |
135 | protected Task fromAsyncTask(final AsyncFun extends T> builder) {
136 | return Task.fromAsync(builder);
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskEnsureExecutorTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 | import org.junit.jupiter.api.Test;
5 |
6 | import java.util.Objects;
7 | import java.util.concurrent.CountDownLatch;
8 | import java.util.concurrent.ExecutionException;
9 | import java.util.concurrent.Executors;
10 | import java.util.concurrent.atomic.AtomicReference;
11 |
12 | import static org.junit.jupiter.api.Assertions.assertEquals;
13 | import static org.junit.jupiter.api.Assertions.assertTrue;
14 |
15 | public class TaskEnsureExecutorTest {
16 | @Test
17 | void testEnsureExecutorOnFiber() throws ExecutionException, InterruptedException {
18 | final var task = Task
19 | .fromBlockingIO(() ->
20 | Thread.currentThread().getName()
21 | );
22 |
23 | final var r1 = task.runBlocking();
24 | final var r2 = task.ensureRunningOnExecutor().runBlocking();
25 |
26 | assertEquals(
27 | Thread.currentThread().getName(),
28 | r1
29 | );
30 | assertTrue(
31 | Objects.requireNonNull(r2).startsWith("tasks-io-"),
32 | "Expected thread name to start with 'tasks-io-', but was: " + r2
33 | );
34 | }
35 |
36 | @Test
37 | @SuppressWarnings("deprecation")
38 | void ensureRunningOnSpecificExecutor() throws ExecutionException, InterruptedException {
39 | final var ec = Executors.newCachedThreadPool(
40 | th -> {
41 | final var t = new Thread(th);
42 | t.setName("my-threads-" + t.getId());
43 | return t;
44 | });
45 | try {
46 | final var task = Task
47 | .fromBlockingIO(() ->
48 | Thread.currentThread().getName()
49 | );
50 |
51 | final var r = task.ensureRunningOnExecutor(ec).runBlocking();
52 | assertTrue(
53 | Objects.requireNonNull(r).startsWith("my-threads-"),
54 | "Expected thread name to start with 'my-threads-', but was: " + r
55 | );
56 |
57 | } finally {
58 | ec.shutdown();
59 | }
60 | }
61 |
62 | @Test
63 | @SuppressWarnings("deprecation")
64 | void switchesBackOnCallbackForRunAsync() throws InterruptedException {
65 | final var ec1 = Executors.newCachedThreadPool(
66 | th -> {
67 | final var t = new Thread(th);
68 | t.setName("executing-thread-" + t.getId());
69 | return t;
70 | });
71 | @SuppressWarnings("deprecation")
72 | final var ec2 = Executors.newCachedThreadPool(
73 | th -> {
74 | final var t = new Thread(th);
75 | t.setName("callback-thread-" + t.getId());
76 | return t;
77 | });
78 | try {
79 | final var threadName1 = new AtomicReference<@Nullable String>(null);
80 | final var threadName2 = new AtomicReference<@Nullable String>(null);
81 | final var isDone = new CountDownLatch(1);
82 | Task.fromBlockingIO(() -> Thread.currentThread().getName())
83 | .ensureRunningOnExecutor(ec1)
84 | .runAsync(ec2, (CompletionCallback) outcome -> {
85 | if (outcome instanceof Outcome.Success value) {
86 | threadName1.set(value.value());
87 | threadName2.set(Thread.currentThread().getName());
88 | } else if (outcome instanceof Outcome.Failure f) {
89 | UncaughtExceptionHandler.logOrRethrow(f.exception());
90 | }
91 | isDone.countDown();
92 | });
93 |
94 | TimedAwait.latchAndExpectCompletion(isDone, "isDone");
95 | assertTrue(
96 | Objects.requireNonNull(threadName1.get()).startsWith("executing-thread-"),
97 | "Expected thread name to start with 'executing-thread-', but was: " + threadName1
98 | );
99 | assertTrue(
100 | Objects.requireNonNull(threadName2.get()).startsWith("callback-thread-"),
101 | "Expected thread name to start with 'callback-thread-', but was: " + threadName2
102 | );
103 | } finally {
104 | ec1.shutdown();
105 | ec2.shutdown();
106 | }
107 | }
108 |
109 | @Test
110 | void testEnsureExecutorOnFiberAfterCompletion() throws ExecutionException, InterruptedException, TaskCancellationException {
111 | final var task = Task.fromBlockingIO(() -> Thread.currentThread().getName());
112 | final var fiber = task.runFiber();
113 |
114 | final var r1 = fiber.awaitBlocking();
115 | assertTrue(
116 | Objects.requireNonNull(r1).startsWith("tasks-io-"),
117 | "Expected thread name to start with 'tasks-io-', but was: " + r1
118 | );
119 |
120 | final var isComplete = new CountDownLatch(1);
121 | final var r2 = new AtomicReference<@Nullable Outcome>(null);
122 | fiber.awaitAsync(outcome -> {
123 | r2.set(outcome);
124 | isComplete.countDown();
125 | });
126 |
127 | TimedAwait.latchAndExpectCompletion(isComplete, "isComplete");
128 | //noinspection DataFlowIssue
129 | final var r2Value = Objects.requireNonNull(r2.get()).getOrThrow();
130 | assertTrue(
131 | r2Value.startsWith("tasks-io-"),
132 | "Expected thread name to start with 'tasks-io-', but was: " + r2Value
133 | );
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/CompletionCallbackTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 | import org.junit.jupiter.api.Test;
5 |
6 | import java.util.concurrent.atomic.AtomicInteger;
7 | import java.util.concurrent.atomic.AtomicReference;
8 |
9 | import static org.junit.jupiter.api.Assertions.assertEquals;
10 |
11 | public class CompletionCallbackTest {
12 | @Test
13 | void emptyLogsRuntimeFailure() throws InterruptedException {
14 | final var cb = CompletionCallback.empty();
15 |
16 | cb.onSuccess("Hello, world!");
17 | cb.onCancellation();
18 |
19 | final var logged = new AtomicReference<@Nullable Throwable>(null);
20 | final var error = new RuntimeException("Sample exception");
21 | final var th = new Thread(() -> cb.onFailure(error));
22 |
23 | th.setUncaughtExceptionHandler((t, e) -> logged.set(e));
24 | th.start();
25 | th.join();
26 |
27 | final var received = logged.get();
28 | assertEquals(error, received);
29 | }
30 |
31 | @Test
32 | void protectedCallbackForSuccess() {
33 | final var called = new AtomicInteger(0);
34 | final var outcomeRef = new AtomicReference<@Nullable Outcome extends String>>(null);
35 | final var cb = AsyncContinuationCallback.protect(
36 | TaskExecutor.from(TaskExecutors.trampoline()),
37 | new CompletionCallback() {
38 | @Override
39 | @SuppressWarnings("NullableProblems")
40 | public void onSuccess(String value) {
41 | onOutcome(Outcome.success(value));
42 | }
43 |
44 | @Override
45 | public void onFailure(Throwable e) {
46 | onOutcome(Outcome.failure(e));
47 | }
48 |
49 | @Override
50 | public void onCancellation() {
51 | onOutcome(Outcome.cancellation());
52 | }
53 |
54 | @Override
55 | public void onOutcome(Outcome outcome) {
56 | outcomeRef.set(outcome);
57 | called.incrementAndGet();
58 | }
59 | }
60 | );
61 |
62 | cb.onSuccess("Hello, world!");
63 | cb.onSuccess("Hello, world! (2)");
64 | cb.onSuccess("Hello, world! (3)");
65 |
66 | assertEquals(1, called.get());
67 | assertEquals(Outcome.success("Hello, world!"), outcomeRef.get());
68 | }
69 |
70 | @Test
71 | void protectedCallbackForRuntimeFailure() throws InterruptedException {
72 | final var called = new AtomicInteger(0);
73 | final var outcomeRef = new AtomicReference<@Nullable Outcome extends String>>(null);
74 | final var cb = AsyncContinuationCallback.protect(
75 | TaskExecutor.from(TaskExecutors.trampoline()),
76 | new CompletionCallback() {
77 | @Override
78 | @SuppressWarnings("NullableProblems")
79 | public void onSuccess(String value) {
80 | onOutcome(Outcome.success(value));
81 | }
82 |
83 | @Override
84 | public void onFailure(Throwable e) {
85 | onOutcome(Outcome.failure(e));
86 | }
87 |
88 | @Override
89 | public void onCancellation() {
90 | onOutcome(Outcome.cancellation());
91 | }
92 |
93 | @Override
94 | public void onOutcome(Outcome outcome) {
95 | outcomeRef.set(outcome);
96 | called.incrementAndGet();
97 | }
98 | }
99 | );
100 |
101 | final var e = new RuntimeException("Boom!");
102 | cb.onFailure(e);
103 |
104 | assertEquals(1, called.get());
105 | assertEquals(Outcome.failure(e), outcomeRef.get());
106 |
107 | final var logged = new AtomicReference<@Nullable Throwable>(null);
108 | final var th = new Thread(() -> cb.onFailure(e));
109 | th.setUncaughtExceptionHandler((t, ex) -> logged.set(ex));
110 | th.start();
111 | th.join();
112 |
113 | assertEquals(1, called.get());
114 | assertEquals(e, logged.get());
115 | }
116 |
117 |
118 | @Test
119 | void protectedCallbackForCancellation() {
120 | final var called = new AtomicInteger(0);
121 | final var outcomeRef = new AtomicReference<@Nullable Outcome extends String>>(null);
122 | final var cb = AsyncContinuationCallback.protect(
123 | TaskExecutor.from(TaskExecutors.trampoline()),
124 | new CompletionCallback() {
125 | @Override
126 | @SuppressWarnings("NullableProblems")
127 | public void onSuccess(String value) {
128 | onOutcome(Outcome.success(value));
129 | }
130 |
131 | @Override
132 | public void onFailure(Throwable e) {
133 | onOutcome(Outcome.failure(e));
134 | }
135 |
136 | @Override
137 | public void onCancellation() {
138 | onOutcome(Outcome.cancellation());
139 | }
140 |
141 | @Override
142 | public void onOutcome(Outcome outcome) {
143 | outcomeRef.set(outcome);
144 | called.incrementAndGet();
145 | }
146 | }
147 | );
148 |
149 | cb.onCancellation();
150 | cb.onCancellation();
151 | cb.onCancellation();
152 |
153 | assertEquals(1, called.get());
154 | assertEquals(Outcome.cancellation(), outcomeRef.get());
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/LoomTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 | import org.junit.jupiter.api.Test;
5 |
6 | import java.util.Objects;
7 | import java.util.concurrent.CountDownLatch;
8 | import java.util.concurrent.atomic.AtomicBoolean;
9 | import java.util.concurrent.atomic.AtomicReference;
10 |
11 | import static org.funfix.tasks.jvm.VirtualThreads.areVirtualThreadsSupported;
12 | import static org.junit.jupiter.api.Assertions.*;
13 | import static org.junit.jupiter.api.Assumptions.assumeTrue;
14 |
15 | public class LoomTest {
16 | @Test
17 | public void commonPoolInJava21() throws InterruptedException {
18 | assumeTrue(areVirtualThreadsSupported(), "Requires Java 21+");
19 |
20 | final var commonPool = TaskExecutors.unlimitedThreadPoolForIO("tasks-io");
21 | try {
22 | final var latch = new CountDownLatch(1);
23 | final var isVirtual = new AtomicBoolean(false);
24 | final var name = new AtomicReference<@Nullable String>();
25 |
26 | commonPool.execute(() -> {
27 | isVirtual.set(VirtualThreads.isVirtualThread(Thread.currentThread()));
28 | name.set(Thread.currentThread().getName());
29 | latch.countDown();
30 | });
31 |
32 | TimedAwait.latchAndExpectCompletion(latch);
33 | assertTrue(isVirtual.get(), "isVirtual");
34 | assertTrue(
35 | Objects.requireNonNull(name.get()).matches("tasks-io-virtual-\\d+"),
36 | "name.matches(\"tasks-io-virtual-\\\\d+\")"
37 | );
38 | } finally {
39 | commonPool.shutdown();
40 | }
41 | }
42 |
43 | @Test
44 | public void canInitializeFactoryInJava21() throws InterruptedException, VirtualThreads.NotSupportedException {
45 | assumeTrue(areVirtualThreadsSupported(), "Requires Java 21+");
46 |
47 | final var f = VirtualThreads.factory("my-vt-");
48 | assertNotNull(f);
49 |
50 | final var latch = new CountDownLatch(1);
51 | final var isVirtual = new AtomicBoolean(false);
52 | final var name = new AtomicReference<@Nullable String>();
53 |
54 | f.newThread(() -> {
55 | isVirtual.set(VirtualThreads.isVirtualThread(Thread.currentThread()));
56 | name.set(Thread.currentThread().getName());
57 | latch.countDown();
58 | }).start();
59 |
60 | TimedAwait.latchAndExpectCompletion(latch);
61 | assertTrue(isVirtual.get(), "isVirtual");
62 | assertTrue(
63 | Objects.requireNonNull(name.get()).matches("my-vt-\\d+"),
64 | "name.matches(\"my-vt-\\\\d+\")"
65 | );
66 | }
67 |
68 | @Test
69 | public void canInitializeExecutorInJava21() throws InterruptedException, VirtualThreads.NotSupportedException {
70 | assumeTrue(areVirtualThreadsSupported(), "Requires Java 21+");
71 |
72 | final var executor = VirtualThreads.executorService("my-vt-");
73 | assertNotNull(executor, "executor");
74 | try {
75 | final var latch = new CountDownLatch(1);
76 | final var isVirtual = new AtomicBoolean(false);
77 | final var name = new AtomicReference<@Nullable String>();
78 | executor.execute(() -> {
79 | isVirtual.set(VirtualThreads.isVirtualThread(Thread.currentThread()));
80 | name.set(Thread.currentThread().getName());
81 | latch.countDown();
82 | });
83 |
84 | TimedAwait.latchAndExpectCompletion(latch);
85 | assertTrue(isVirtual.get(), "isVirtual");
86 | assertTrue(
87 | Objects.requireNonNull(name.get()).matches("my-vt-\\d+"),
88 | "name.matches(\"my-vt-\\\\d+\")"
89 | );
90 | } finally {
91 | executor.shutdown();
92 | }
93 | }
94 |
95 | @Test
96 | public void cannotInitializeLoomUtilsInOlderJava() {
97 | final var r = SysProp.withVirtualThreads(false);
98 | try {
99 | try {
100 | VirtualThreads.factory("tasks-io");
101 | fail("Should throw");
102 | } catch (final VirtualThreads.NotSupportedException ignored) {
103 | }
104 | try {
105 | VirtualThreads.executorService("tasks-io").shutdown();
106 | fail("Should throw");
107 | } catch (final VirtualThreads.NotSupportedException ignored) {
108 | }
109 | } finally {
110 | r.close();
111 | }
112 | }
113 |
114 | @Test
115 | public void commonPoolInOlderJava() throws InterruptedException {
116 | final var r = SysProp.withVirtualThreads(false);
117 | final var commonPool = TaskExecutors.unlimitedThreadPoolForIO("tasks-io");
118 | try {
119 | assertFalse(areVirtualThreadsSupported(), "areVirtualThreadsSupported");
120 | assertNotNull(commonPool, "commonPool");
121 |
122 | final var latch = new CountDownLatch(1);
123 | final var isVirtual = new AtomicBoolean(true);
124 | final var name = new AtomicReference<@Nullable String>();
125 |
126 | commonPool.execute(() -> {
127 | isVirtual.set(VirtualThreads.isVirtualThread(Thread.currentThread()));
128 | name.set(Thread.currentThread().getName());
129 | latch.countDown();
130 | });
131 |
132 | TimedAwait.latchAndExpectCompletion(latch);
133 | assertFalse(isVirtual.get(), "isVirtual");
134 | assertTrue(
135 | Objects.requireNonNull(name.get()).matches("^tasks-io-platform-\\d+$"),
136 | "name.matches(\"^tasks-io-platform-\\\\d+$\")"
137 | );
138 | } finally {
139 | r.close();
140 | commonPool.shutdown();
141 | }
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskFromBlockingFutureTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 | import org.junit.jupiter.api.AfterEach;
5 | import org.junit.jupiter.api.BeforeEach;
6 | import org.junit.jupiter.api.Test;
7 |
8 | import java.util.Objects;
9 | import java.util.concurrent.*;
10 | import java.util.concurrent.atomic.AtomicReference;
11 |
12 | import static org.funfix.tasks.jvm.TestSettings.TIMEOUT;
13 | import static org.junit.jupiter.api.Assertions.*;
14 | import static org.junit.jupiter.api.Assumptions.assumeTrue;
15 |
16 | public class TaskFromBlockingFutureTest {
17 | @Nullable
18 | ExecutorService es;
19 |
20 | @BeforeEach
21 | @SuppressWarnings("deprecation")
22 | void setup() {
23 | es = Executors.newCachedThreadPool(r -> {
24 | final var th = new Thread(r);
25 | th.setName("es-sample-" + th.getId());
26 | th.setDaemon(true);
27 | return th;
28 | });
29 | }
30 |
31 | @AfterEach
32 | void tearDown() {
33 | Objects.requireNonNull(es).shutdown();
34 | }
35 |
36 | @Test
37 | void runBlockingDoesNotFork() throws ExecutionException, InterruptedException {
38 | Objects.requireNonNull(es);
39 |
40 | final var name = new AtomicReference<>("");
41 | final var thisName = Thread.currentThread().getName();
42 | final var task = Task.fromBlockingFuture(() -> {
43 | name.set(Thread.currentThread().getName());
44 | return Objects.requireNonNull(es).submit(() -> "Hello, world!");
45 | });
46 |
47 | final var r = task.runBlocking();
48 | assertEquals("Hello, world!", r);
49 | assertEquals(thisName, name.get());
50 | }
51 |
52 | @Test
53 | void runBlockingTimedForks() throws ExecutionException, InterruptedException, TimeoutException {
54 | Objects.requireNonNull(es);
55 |
56 | final var name = new AtomicReference<>("");
57 | final var task = Task.fromBlockingFuture(() -> {
58 | name.set(Thread.currentThread().getName());
59 | return Objects.requireNonNull(es).submit(() -> "Hello, world!");
60 | });
61 |
62 | final var r = task.runBlockingTimed(es, TIMEOUT);
63 | assertEquals("Hello, world!", r);
64 | assertTrue(
65 | Objects.requireNonNull(name.get()).startsWith("es-sample-"),
66 | "Expected name to start with 'es-sample-', but was: " + name.get()
67 | );
68 | }
69 |
70 | @Test
71 | void runFiberForks() throws ExecutionException, InterruptedException, TimeoutException, TaskCancellationException {
72 | Objects.requireNonNull(es);
73 |
74 | final var name = new AtomicReference<>("");
75 | final var task = Task.fromBlockingFuture(() -> {
76 | name.set(Thread.currentThread().getName());
77 | return Objects.requireNonNull(es).submit(() -> "Hello, world!");
78 | });
79 |
80 | final var r = task.runFiber(es).awaitBlockingTimed(TIMEOUT);
81 | assertEquals("Hello, world!", r);
82 | assertTrue(
83 | Objects.requireNonNull(name.get()).startsWith("es-sample-"),
84 | "Expected name to start with 'es-sample-', but was: " + name.get()
85 | );
86 | }
87 |
88 | @Test
89 | void loomHappyPath() throws ExecutionException, InterruptedException, TimeoutException {
90 | assumeTrue(VirtualThreads.areVirtualThreadsSupported(), "Requires Java 21+");
91 | Objects.requireNonNull(es);
92 |
93 | final var name = new AtomicReference<>("");
94 | final var task = Task.fromBlockingFuture(() -> {
95 | name.set(Thread.currentThread().getName());
96 | return Objects.requireNonNull(es).submit(() -> "Hello, world!");
97 | });
98 |
99 | final var r = task.runBlockingTimed(TIMEOUT);
100 | assertEquals("Hello, world!", r);
101 | assertTrue(Objects.requireNonNull(name.get()).startsWith("tasks-io-virtual-"));
102 | }
103 |
104 | @Test
105 | void throwExceptionInBuilder() throws InterruptedException {
106 | Objects.requireNonNull(es);
107 |
108 | try {
109 | Task.fromBlockingFuture(() -> {
110 | throw new RuntimeException("Error");
111 | }).runBlocking();
112 | } catch (final ExecutionException ex) {
113 | assertEquals("Error", Objects.requireNonNull(ex.getCause()).getMessage());
114 | }
115 | }
116 |
117 | @Test
118 | void throwExceptionInFuture() throws InterruptedException {
119 | Objects.requireNonNull(es);
120 | try {
121 | Task.fromBlockingFuture(() -> Objects.requireNonNull(es).submit(() -> {
122 | throw new RuntimeException("Error");
123 | }))
124 | .runBlocking();
125 | } catch (final ExecutionException ex) {
126 | assertEquals("Error", Objects.requireNonNull(ex.getCause()).getMessage());
127 | }
128 | }
129 |
130 | @SuppressWarnings("ReturnOfNull")
131 | @Test
132 | void builderCanBeCancelled() throws InterruptedException, ExecutionException, TimeoutException, Fiber.NotCompletedException {
133 | Objects.requireNonNull(es);
134 |
135 | final var wasStarted = new CountDownLatch(1);
136 | final var latch = new CountDownLatch(1);
137 |
138 | @SuppressWarnings("NullAway")
139 | final var fiber = Task
140 | .fromBlockingFuture(() -> {
141 | wasStarted.countDown();
142 | try {
143 | Thread.sleep(10000);
144 | return null;
145 | } catch (final InterruptedException e) {
146 | latch.countDown();
147 | throw e;
148 | }
149 | }).runFiber();
150 |
151 | TimedAwait.latchAndExpectCompletion(wasStarted, "wasStarted");
152 | fiber.cancel();
153 | fiber.joinBlockingTimed(TIMEOUT);
154 |
155 | try {
156 | fiber.getResultOrThrow();
157 | fail("Should have thrown a TaskCancellationException");
158 | } catch (final TaskCancellationException ignored) {
159 | }
160 | TimedAwait.latchAndExpectCompletion(latch, "latch");
161 | }
162 |
163 | @Test
164 | void futureCanBeCancelled() throws InterruptedException, ExecutionException, TimeoutException, Fiber.NotCompletedException {
165 | Objects.requireNonNull(es);
166 |
167 | final var latch = new CountDownLatch(1);
168 | final var wasStarted = new CountDownLatch(1);
169 |
170 | final var fiber = Task
171 | .fromBlockingFuture(() -> Objects.requireNonNull(es).submit(() -> {
172 | wasStarted.countDown();
173 | try {
174 | Thread.sleep(10000);
175 | } catch (final InterruptedException e) {
176 | latch.countDown();
177 | }
178 | })).runFiber();
179 |
180 | wasStarted.await();
181 | fiber.cancel();
182 | fiber.joinBlockingTimed(TIMEOUT);
183 |
184 | try {
185 | fiber.getResultOrThrow();
186 | fail("Should have thrown a TaskCancellationException");
187 | } catch (final TaskCancellationException ignored) {
188 | }
189 | TimedAwait.latchAndExpectCompletion(latch, "latch");
190 | }
191 | }
192 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Collections.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jetbrains.annotations.ApiStatus;
4 | import org.jspecify.annotations.Nullable;
5 |
6 | import java.util.Iterator;
7 | import java.util.NoSuchElementException;
8 | import java.util.Objects;
9 | import java.util.function.Predicate;
10 |
11 | @ApiStatus.Internal
12 | sealed abstract class ImmutableStack implements Iterable
13 | permits ImmutableStack.Cons, ImmutableStack.Nil {
14 |
15 | final ImmutableStack prepend(T value) {
16 | return new Cons<>(value, this);
17 | }
18 |
19 | final ImmutableStack prependAll(Iterable extends T> values) {
20 | ImmutableStack result = this;
21 | for (T t : values) {
22 | result = result.prepend(t);
23 | }
24 | return result;
25 | }
26 |
27 | final @Nullable T head() {
28 | if (this instanceof Cons) {
29 | return ((Cons) this).head;
30 | } else {
31 | return null;
32 | }
33 | }
34 |
35 | final ImmutableStack tail() {
36 | if (this instanceof Cons) {
37 | return ((Cons) this).tail;
38 | } else {
39 | return this;
40 | }
41 | }
42 |
43 | final boolean isEmpty() {
44 | return this instanceof Nil;
45 | }
46 |
47 | final ImmutableStack reverse() {
48 | ImmutableStack result = empty();
49 | for (T t : this) {
50 | result = result.prepend(t);
51 | }
52 | return result;
53 | }
54 |
55 | static final class Cons extends ImmutableStack {
56 | final T head;
57 | final ImmutableStack tail;
58 |
59 | Cons(T head, ImmutableStack tail) {
60 | this.head = head;
61 | this.tail = tail;
62 | }
63 |
64 | @Override
65 | public boolean equals(Object o) {
66 | if (!(o instanceof Cons> cons)) return false;
67 | return Objects.equals(head, cons.head) && Objects.equals(tail, cons.tail);
68 | }
69 |
70 | @Override
71 | public int hashCode() {
72 | return Objects.hash(head, tail);
73 | }
74 | }
75 |
76 | static final class Nil extends ImmutableStack {
77 | Nil() {}
78 |
79 | @Override
80 | public int hashCode() {
81 | return -2938;
82 | }
83 |
84 | @Override
85 | public boolean equals(Object obj) {
86 | return obj instanceof Nil>;
87 | }
88 | }
89 |
90 | static ImmutableStack empty() {
91 | return new Nil<>();
92 | }
93 |
94 | @Override
95 | public Iterator iterator() {
96 | final var start = this;
97 | return new Iterator<>() {
98 | private ImmutableStack current = start;
99 |
100 | @Override
101 | public boolean hasNext() {
102 | return current instanceof Cons;
103 | }
104 |
105 | @Override
106 | public T next() {
107 | if (current instanceof Cons cons) {
108 | current = cons.tail;
109 | return cons.head;
110 | } else {
111 | throw new NoSuchElementException();
112 | }
113 | }
114 | };
115 | }
116 |
117 | @Override
118 | public String toString() {
119 | final var sb = new StringBuilder();
120 | sb.append("ImmutableStack(");
121 | var isFirst = true;
122 | for (final var t : this) {
123 | if (isFirst) {
124 | isFirst = false;
125 | } else {
126 | sb.append(", ");
127 | }
128 | sb.append(t);
129 | }
130 | sb.append(")");
131 | return sb.toString();
132 | }
133 | }
134 |
135 | @ApiStatus.Internal
136 | final class ImmutableQueue implements Iterable {
137 | private final ImmutableStack toEnqueue;
138 | private final ImmutableStack toDequeue;
139 |
140 | private ImmutableQueue(ImmutableStack toEnqueue, ImmutableStack toDequeue) {
141 | this.toEnqueue = toEnqueue;
142 | this.toDequeue = toDequeue;
143 | }
144 |
145 | ImmutableQueue enqueue(T value) {
146 | return new ImmutableQueue<>(toEnqueue.prepend(value), toDequeue);
147 | }
148 |
149 | ImmutableStack toList() {
150 | return toEnqueue.reverse().prependAll(toDequeue.reverse());
151 | }
152 |
153 | ImmutableQueue preOptimize() {
154 | if (toDequeue.isEmpty()) {
155 | return new ImmutableQueue<>(ImmutableStack.empty(), toEnqueue.reverse());
156 | } else {
157 | return this;
158 | }
159 | }
160 |
161 | @SuppressWarnings("NullAway")
162 | T peek() throws NoSuchElementException {
163 | if (!toDequeue.isEmpty()) {
164 | return toDequeue.head();
165 | } else if (!toEnqueue.isEmpty()) {
166 | return toEnqueue.reverse().head();
167 | } else {
168 | throw new NoSuchElementException("peek() on empty queue");
169 | }
170 | }
171 |
172 | ImmutableQueue dequeue() {
173 | if (!toDequeue.isEmpty()) {
174 | return new ImmutableQueue<>(toEnqueue, toDequeue.tail());
175 | } else if (!toEnqueue.isEmpty()) {
176 | return new ImmutableQueue<>(ImmutableStack.empty(), toEnqueue.reverse().tail());
177 | } else {
178 | return this;
179 | }
180 | }
181 |
182 | boolean isEmpty() {
183 | return toEnqueue.isEmpty() && toDequeue.isEmpty();
184 | }
185 |
186 | ImmutableQueue filter(Predicate super T> predicate) {
187 | ImmutableQueue result = empty();
188 | for (T t : this) {
189 | if (predicate.test(t)) {
190 | result = result.enqueue(t);
191 | }
192 | }
193 | return result;
194 | }
195 |
196 | @Override
197 | public Iterator iterator() {
198 | return new Iterator<>() {
199 | private ImmutableQueue current = ImmutableQueue.this;
200 |
201 | @Override
202 | public boolean hasNext() {
203 | return !current.isEmpty();
204 | }
205 |
206 | @Override
207 | public T next() {
208 | current = current.preOptimize();
209 | if (current.isEmpty()) {
210 | throw new NoSuchElementException("next() on empty queue");
211 | }
212 | final var value = current.peek();
213 | current = current.dequeue();
214 | return value;
215 | }
216 | };
217 | }
218 |
219 |
220 | @Override
221 | public String toString() {
222 | final var sb = new StringBuilder();
223 | sb.append("ImmutableQueue(");
224 | var isFirst = true;
225 | for (final T t : this) {
226 | if (isFirst) {
227 | isFirst = false;
228 | } else {
229 | sb.append(", ");
230 | }
231 | sb.append(t);
232 | }
233 | sb.append(")");
234 | return sb.toString();
235 | }
236 |
237 | @Override
238 | public boolean equals(Object obj) {
239 | if (obj instanceof ImmutableQueue> that) {
240 | return toList().equals(that.toList());
241 | } else {
242 | return false;
243 | }
244 | }
245 |
246 | @Override
247 | public int hashCode() {
248 | return toList().hashCode();
249 | }
250 |
251 | static ImmutableQueue empty() {
252 | return new ImmutableQueue<>(ImmutableStack.empty(), ImmutableStack.empty());
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Cancellable.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jetbrains.annotations.ApiStatus;
4 | import org.jetbrains.annotations.NonBlocking;
5 | import org.jetbrains.annotations.Nullable;
6 |
7 | import java.util.Objects;
8 | import java.util.concurrent.atomic.AtomicReference;
9 |
10 | /**
11 | * This is a token that can be used for interrupting a scheduled or
12 | * a running task.
13 | *
14 | * The contract for {@code cancel} is:
15 | *
16 | * - Its execution is idempotent, meaning that calling it multiple times
17 | * has the same effect as calling it once.
18 | * - It is safe to call {@code cancel} from any thread.
19 | * - It must not block, or do anything expensive. Blocking for the task's
20 | * interruption should be done by other means, such as by using
21 | * the {@link CompletionCallback} callback.
22 | * - Upon calling {@code cancel}, the {@link CompletionCallback} should
23 | * still be eventually triggered, if it wasn't already. So all paths,
24 | * with cancellation or without, must lead to the {@link CompletionCallback} being called.
25 | *
26 | */
27 | @FunctionalInterface
28 | public interface Cancellable {
29 | /**
30 | * Triggers (idempotent) cancellation.
31 | */
32 | @NonBlocking
33 | void cancel();
34 |
35 | /**
36 | * Returns an empty token that does nothing when cancelled.
37 | */
38 | static Cancellable getEmpty() {
39 | return CancellableUtils.EMPTY;
40 | }
41 | }
42 |
43 | /**
44 | * INTERNAL API.
45 | *
46 | * INTERNAL API: Internal apis are subject to change or removal
47 | * without any notice. When code depends on internal APIs, it is subject to
48 | * breakage between minor version updates.
49 | */
50 | @ApiStatus.Internal
51 | final class CancellableUtils {
52 | static final Cancellable EMPTY = () -> {};
53 | }
54 |
55 | /**
56 | * Represents a forward reference to a {@link Cancellable} that was already
57 | * registered and needs to be filled in later.
58 | *
59 | * INTERNAL API: Internal apis are subject to change or removal
60 | * without any notice. When code depends on internal APIs, it is subject to
61 | * breakage between minor version updates.
62 | */
63 | @ApiStatus.Internal
64 | interface CancellableForwardRef extends Cancellable {
65 | void set(Cancellable cancellable);
66 | }
67 |
68 | /**
69 | * INTERNAL API.
70 | *
71 | * WARN: Internal apis are subject to change or removal without
72 | * any notice. When code depends on internal APIs, it is subject to breakage
73 | * between minor version updates.
74 | */
75 | @ApiStatus.Internal
76 | final class MutableCancellable implements Cancellable {
77 | private final AtomicReference ref;
78 |
79 | MutableCancellable(final Cancellable initialRef) {
80 | ref = new AtomicReference<>(new State.Active(initialRef, 0, null));
81 | }
82 |
83 | MutableCancellable() {
84 | this(CancellableUtils.EMPTY);
85 | }
86 |
87 | @Override
88 | public void cancel() {
89 | @Nullable
90 | var state = ref.getAndSet(State.Closed.INSTANCE);
91 | while (state instanceof State.Active active) {
92 | try {
93 | active.token.cancel();
94 | } catch (Exception e) {
95 | UncaughtExceptionHandler.logOrRethrow(e);
96 | }
97 | state = active.rest;
98 | }
99 | }
100 |
101 | public CancellableForwardRef newCancellableRef() {
102 | final var current = ref.get();
103 | if (current instanceof State.Closed) {
104 | return new CancellableForwardRef() {
105 | @Override
106 | public void set(Cancellable cancellable) {
107 | cancellable.cancel();
108 | }
109 | @Override
110 | public void cancel() {}
111 | };
112 | } else if (current instanceof State.Active active) {
113 | return new CancellableForwardRef() {
114 | @Override
115 | public void set(Cancellable cancellable) {
116 | registerOrdered(
117 | active.order,
118 | cancellable,
119 | active
120 | );
121 | }
122 |
123 | @Override
124 | public void cancel() {
125 | unregister(active.order);
126 | }
127 | };
128 | } else {
129 | throw new IllegalStateException("Invalid state: " + current);
130 | }
131 | }
132 |
133 | public @Nullable Cancellable register(Cancellable token) {
134 | Objects.requireNonNull(token, "token");
135 | while (true) {
136 | final var current = ref.get();
137 | if (current instanceof State.Active active) {
138 | final var newOrder = active.order + 1;
139 | final var update = new State.Active(token, newOrder, active);
140 | if (ref.compareAndSet(current, update)) { return () -> unregister(newOrder); }
141 | } else if (current instanceof State.Closed) {
142 | token.cancel();
143 | return null;
144 | } else {
145 | throw new IllegalStateException("Invalid state: " + current);
146 | }
147 | }
148 | }
149 |
150 | private void unregister(final long order) {
151 | while (true) {
152 | final var current = ref.get();
153 | if (current instanceof State.Active active) {
154 | @Nullable var cursor = active;
155 | @Nullable State.Active acc = null;
156 | while (cursor != null) {
157 | if (cursor.order != order) {
158 | acc = new State.Active(cursor.token, cursor.order, acc);
159 | }
160 | cursor = cursor.rest;
161 | }
162 | // Reversing
163 | @Nullable State.Active update = null;
164 | while (acc != null) {
165 | update = new State.Active(acc.token, acc.order, update);
166 | acc = acc.rest;
167 | }
168 | if (update == null) {
169 | update = new State.Active(Cancellable.getEmpty(), 0, null);
170 | }
171 | if (ref.compareAndSet(current, update)) {
172 | return;
173 | }
174 | } else if (current instanceof State.Closed) {
175 | return;
176 | } else {
177 | throw new IllegalStateException("Invalid state: " + current);
178 | }
179 | }
180 | }
181 |
182 | private void registerOrdered(
183 | final long order,
184 | final Cancellable newToken,
185 | State current
186 | ) {
187 | while (true) {
188 | if (current instanceof State.Active active) {
189 | // Double-check ordering
190 | if (active.order != order) { return; }
191 | // Try to update
192 | final var update = new State.Active(newToken, order + 1, null);
193 | if (ref.compareAndSet(current, update)) { return; }
194 | // Retry
195 | current = ref.get();
196 | } else if (current instanceof State.Closed) {
197 | newToken.cancel();
198 | return;
199 | } else {
200 | throw new IllegalStateException("Invalid state: " + current);
201 | }
202 | }
203 | }
204 |
205 | sealed interface State {
206 | record Active(
207 | Cancellable token,
208 | long order,
209 | @Nullable Active rest
210 | ) implements State {}
211 |
212 | record Closed() implements State {
213 | static final Closed INSTANCE = new Closed();
214 | }
215 | }
216 | }
217 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskExecutorTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 | import org.junit.jupiter.api.Test;
5 |
6 | import java.util.Objects;
7 | import java.util.concurrent.CountDownLatch;
8 | import java.util.concurrent.atomic.AtomicInteger;
9 | import java.util.concurrent.atomic.AtomicReference;
10 |
11 | import static org.junit.jupiter.api.Assertions.*;
12 | import static org.junit.jupiter.api.Assumptions.assumeTrue;
13 |
14 | public class TaskExecutorTest {
15 | @Test
16 | void trampolineIsTaskExecutor() {
17 | final var ec1 = TaskExecutors.trampoline();
18 | final var ec2 = TaskExecutor.from(ec1);
19 | assertEquals(ec1, ec2);
20 | }
21 |
22 | @Test
23 | void trampolineResumeIsExecute() {
24 | final var count = new AtomicInteger(0);
25 | final var ec = TaskExecutor.from(TaskExecutors.trampoline());
26 |
27 | ec.execute(() -> {
28 | count.incrementAndGet();
29 | ec.resumeOnExecutor(count::incrementAndGet);
30 | count.incrementAndGet();
31 | ec.resumeOnExecutor(() -> {
32 | count.incrementAndGet();
33 | ec.resumeOnExecutor(() -> {
34 | count.incrementAndGet();
35 | ec.resumeOnExecutor(count::incrementAndGet);
36 | });
37 | count.incrementAndGet();
38 | });
39 | });
40 |
41 | assertEquals(7, count.get());
42 | }
43 |
44 | @Test
45 | void sharedBlockingIOIsTaskExecutorOnOlderJava() {
46 | final var sysProp = SysProp.withVirtualThreads(false);
47 | assertFalse(VirtualThreads.areVirtualThreadsSupported());
48 | try {
49 | final var ec1 = TaskExecutors.sharedBlockingIO();
50 | final var ec2 = TaskExecutor.from(ec1);
51 | assertEquals(ec1, ec2);
52 | } finally {
53 | sysProp.close();
54 | }
55 | }
56 |
57 | @Test
58 | void sharedBlockingIOIsTaskExecutorOnJava21() {
59 | assumeTrue(VirtualThreads.areVirtualThreadsSupported(), "Requires Java 21+");
60 | final var ec1 = TaskExecutors.sharedBlockingIO();
61 | final var ec2 = TaskExecutor.from(ec1);
62 | assertEquals(ec1, ec2);
63 | }
64 |
65 | private void testThreadName() throws InterruptedException {
66 | final TaskExecutor executor = TaskExecutor.from(TaskExecutors.sharedBlockingIO());
67 | final var threadName = new AtomicReference<@Nullable String>(null);
68 | final var isComplete = new CountDownLatch(1);
69 | executor.execute(() -> {
70 | threadName.set(Thread.currentThread().getName());
71 | isComplete.countDown();
72 | });
73 |
74 | TimedAwait.latchAndExpectCompletion(isComplete, "isComplete");
75 | assertTrue(
76 | Objects.requireNonNull(threadName.get()).startsWith("tasks-io-"),
77 | "\"" + threadName.get() + "\".startsWith(\"tasks-io-\")"
78 | );
79 | }
80 |
81 | @Test
82 | void testThreadNameOlderJava() throws InterruptedException {
83 | final var sysProp = SysProp.withVirtualThreads(false);
84 | assertFalse(VirtualThreads.areVirtualThreadsSupported());
85 | try {
86 | testThreadName();
87 | } finally {
88 | sysProp.close();
89 | }
90 | }
91 |
92 | @Test
93 | void testThreadNameJava21() throws InterruptedException {
94 | assumeTrue(VirtualThreads.areVirtualThreadsSupported(), "Requires Java 21+");
95 | testThreadName();
96 | }
97 |
98 | private void testThreadNameNested() throws InterruptedException {
99 | final TaskExecutor executor = TaskExecutor.from(TaskExecutors.sharedBlockingIO());
100 | final var threadName1 = new AtomicReference<@Nullable String>(null);
101 | final var threadName2 = new AtomicReference<@Nullable String>(null);
102 | final var isComplete = new CountDownLatch(1);
103 |
104 | executor.execute(() -> {
105 | threadName1.set(Thread.currentThread().getName());
106 | executor.execute(() -> {
107 | threadName2.set(Thread.currentThread().getName());
108 | isComplete.countDown();
109 | });
110 | });
111 |
112 | TimedAwait.latchAndExpectCompletion(isComplete, "isComplete");
113 | assertTrue(
114 | Objects.requireNonNull(threadName1.get()).startsWith("tasks-io-"),
115 | "\"" + threadName1.get() + "\".startsWith(\"tasks-io-\")"
116 | );
117 | assertTrue(
118 | Objects.requireNonNull(threadName1.get()).startsWith("tasks-io-"),
119 | "\"" + threadName1.get() + "\".startsWith(\"tasks-io-\")"
120 | );
121 | assertNotSame(
122 | threadName1.get(),
123 | threadName2.get(),
124 | "threadName1.get() != threadName2.get()"
125 | );
126 | }
127 |
128 | @Test
129 | void testThreadNameNestedOlderJava() throws InterruptedException {
130 | final var sysProp = SysProp.withVirtualThreads(false);
131 | assertFalse(VirtualThreads.areVirtualThreadsSupported());
132 | try {
133 | testThreadNameNested();
134 | } finally {
135 | sysProp.close();
136 | }
137 | }
138 |
139 | @Test
140 | void testThreadNameNestedJava21() throws InterruptedException {
141 | assumeTrue(VirtualThreads.areVirtualThreadsSupported(), "Requires Java 21+");
142 | testThreadNameNested();
143 | }
144 |
145 | private void testResumeCanSwitchThread() throws InterruptedException {
146 | final TaskExecutor executor = TaskExecutor.from(TaskExecutors.sharedBlockingIO());
147 | final var threadName1 = new AtomicReference<@Nullable String>(null);
148 | final var threadName2 = new AtomicReference<@Nullable String>(null);
149 | final var isComplete = new CountDownLatch(1);
150 |
151 | executor.resumeOnExecutor(() -> {
152 | threadName1.set(Thread.currentThread().getName());
153 | executor.resumeOnExecutor(() -> {
154 | threadName2.set(Thread.currentThread().getName());
155 | isComplete.countDown();
156 | });
157 | });
158 |
159 | TimedAwait.latchAndExpectCompletion(isComplete, "isComplete");
160 | for (final var name : new String[] {
161 | Objects.requireNonNull(threadName1.get()),
162 | Objects.requireNonNull(threadName2.get())
163 | }) {
164 | assertTrue(
165 | Objects.requireNonNull(name).startsWith("tasks-io-"),
166 | "\"" + name + "\".startsWith(\"tasks-io-\")"
167 | );
168 | }
169 | assertSame(threadName1.get(), threadName2.get());
170 | }
171 |
172 | @Test
173 | void testResumeCanSwitchThreadOlderJava() throws InterruptedException {
174 | final var sysProp = SysProp.withVirtualThreads(false);
175 | assertFalse(VirtualThreads.areVirtualThreadsSupported());
176 | try {
177 | testResumeCanSwitchThread();
178 | } finally {
179 | sysProp.close();
180 | }
181 | }
182 |
183 | @Test
184 | void testResumeCanSwitchThreadJava21() throws InterruptedException {
185 | assumeTrue(VirtualThreads.areVirtualThreadsSupported(), "Requires Java 21+");
186 | testResumeCanSwitchThread();
187 | }
188 |
189 | private void resumeCanStayOnTheSameThread() throws InterruptedException {
190 | final TaskExecutor executor = TaskExecutor.from(TaskExecutors.sharedBlockingIO());
191 | final var threadName1 = new AtomicReference<@Nullable String>(null);
192 | final var threadName2 = new AtomicReference<@Nullable String>(null);
193 | final var isComplete = new CountDownLatch(1);
194 |
195 | executor.execute(() -> {
196 | threadName1.set(Thread.currentThread().getName());
197 | executor.resumeOnExecutor(() -> {
198 | threadName2.set(Thread.currentThread().getName());
199 | isComplete.countDown();
200 | });
201 | });
202 |
203 | TimedAwait.latchAndExpectCompletion(isComplete, "isComplete");
204 | for (final var name : new String[] {
205 | Objects.requireNonNull(threadName1.get()),
206 | Objects.requireNonNull(threadName2.get())
207 | }) {
208 | assertTrue(
209 | Objects.requireNonNull(name).startsWith("tasks-io-"),
210 | "\"" + name + "\".startsWith(\"tasks-io-\")"
211 | );
212 | }
213 | assertSame(threadName1.get(), threadName2.get());
214 | }
215 |
216 | @Test
217 | void resumeCanStayOnTheSameThreadOlderJava() throws InterruptedException {
218 | final var sysProp = SysProp.withVirtualThreads(false);
219 | assertFalse(VirtualThreads.areVirtualThreadsSupported());
220 | try {
221 | resumeCanStayOnTheSameThread();
222 | } finally {
223 | sysProp.close();
224 | }
225 | }
226 |
227 | @Test
228 | void resumeCanStayOnTheSameThreadJava21() throws InterruptedException {
229 | assumeTrue(VirtualThreads.areVirtualThreadsSupported(), "Requires Java 21+");
230 | resumeCanStayOnTheSameThread();
231 | }
232 | }
233 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskExecutors.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jetbrains.annotations.ApiStatus;
4 | import org.jspecify.annotations.Nullable;
5 |
6 | import java.lang.invoke.MethodHandle;
7 | import java.lang.invoke.MethodHandles;
8 | import java.lang.invoke.MethodType;
9 | import java.util.Objects;
10 | import java.util.concurrent.Executor;
11 | import java.util.concurrent.ExecutorService;
12 | import java.util.concurrent.ThreadFactory;
13 |
14 | /**
15 | * Provides utilities for working with [Executor] instances, optimized
16 | * for common use-cases.
17 | */
18 | public final class TaskExecutors {
19 | private static volatile @Nullable Executor sharedVirtualIORef = null;
20 | private static volatile @Nullable Executor sharedPlatformIORef = null;
21 |
22 | /**
23 | * Returns a shared {@link Executor} meant for blocking I/O tasks.
24 | * The reference gets lazily initialized on the first call.
25 | *
26 | * Uses {@link #unlimitedThreadPoolForIO(String)} to create the executor,
27 | * which will use virtual threads on Java 21+, or a plain
28 | * {@code Executors.newCachedThreadPool()} on older JVM versions.
29 | */
30 | public static Executor sharedBlockingIO() {
31 | if (VirtualThreads.areVirtualThreadsSupported()) {
32 | return sharedVirtualIO();
33 | } else {
34 | return sharedPlatformIO();
35 | }
36 | }
37 |
38 | /**
39 | * Returns a shared {@link Executor} that runs tasks on the current thread.
40 | *
41 | * The implementation is thread-safe and uses an internal trampoline
42 | * mechanism to avoid stack overflows when running recursive tasks.
43 | */
44 | public static Executor trampoline() {
45 | return Trampoline.INSTANCE;
46 | }
47 |
48 | private static Executor sharedPlatformIO() {
49 | // Using double-checked locking to avoid synchronization
50 | if (sharedPlatformIORef == null) {
51 | synchronized (TaskExecutors.class) {
52 | if (sharedPlatformIORef == null) {
53 | sharedPlatformIORef = TaskExecutor.from(unlimitedThreadPoolForIO("tasks-io"));
54 | }
55 | }
56 | }
57 | return Objects.requireNonNull(sharedPlatformIORef);
58 | }
59 |
60 | private static Executor sharedVirtualIO() {
61 | // Using double-checked locking to avoid synchronization
62 | if (sharedVirtualIORef == null) {
63 | synchronized (TaskExecutors.class) {
64 | if (sharedVirtualIORef == null) {
65 | sharedVirtualIORef = TaskExecutor.from(unlimitedThreadPoolForIO("tasks-io"));
66 | }
67 | }
68 | }
69 | return Objects.requireNonNull(sharedVirtualIORef);
70 | }
71 |
72 | /**
73 | * Creates an {@code Executor} meant for blocking I/O tasks, with an
74 | * unlimited number of threads.
75 | *
76 | * On Java 21 and above, the created {@code Executor} will run tasks on virtual threads.
77 | * On older JVM versions, it returns a plain {@code Executors.newCachedThreadPool}.
78 | */
79 | @SuppressWarnings({"deprecation", "EmptyCatch"})
80 | public static ExecutorService unlimitedThreadPoolForIO(final String prefix) {
81 | if (VirtualThreads.areVirtualThreadsSupported())
82 | try {
83 | return VirtualThreads.executorService(prefix + "-virtual-");
84 | } catch (final VirtualThreads.NotSupportedException ignored) {}
85 |
86 | return java.util.concurrent.Executors.newCachedThreadPool(r -> {
87 | final var t = new Thread(r);
88 | t.setName(prefix + "-platform-" + t.getId());
89 | return t;
90 | });
91 | }
92 | }
93 |
94 | /**
95 | * Internal utilities — not exposed yet, because lacking Loom support is only
96 | * temporary.
97 | *
98 | * INTERNAL API: Internal apis are subject to change or removal
99 | * without any notice. When code depends on internal APIs, it is subject to
100 | * breakage between minor version updates.
101 | */
102 | @ApiStatus.Internal
103 | @SuppressWarnings("JavaLangInvokeHandleSignature")
104 | final class VirtualThreads {
105 | private static final @Nullable MethodHandle newThreadPerTaskExecutorMethodHandle;
106 |
107 | public static final class NotSupportedException extends Exception {
108 | public NotSupportedException(final String feature) {
109 | super(feature + " is not supported on this JVM");
110 | }
111 | }
112 |
113 | static {
114 | MethodHandle tempHandle;
115 | try {
116 | final var executorsClass = Class.forName("java.util.concurrent.Executors");
117 | final var lookup = MethodHandles.lookup();
118 | tempHandle = lookup.findStatic(
119 | executorsClass,
120 | "newThreadPerTaskExecutor",
121 | MethodType.methodType(ExecutorService.class, ThreadFactory.class));
122 | } catch (final Throwable e) {
123 | tempHandle = null;
124 | }
125 | newThreadPerTaskExecutorMethodHandle = tempHandle;
126 | }
127 |
128 | /**
129 | * Create a virtual thread executor, returns {@code null} if failed.
130 | *
131 | * This function can only return a non-{@code null} value if running on Java 21 or later,
132 | * as it uses reflection to access the {@code Executors.newVirtualThreadPerTaskExecutor}.
133 | *
134 | * @throws NotSupportedException if the current JVM does not support virtual threads.
135 | */
136 | public static ExecutorService executorService(final String prefix)
137 | throws NotSupportedException {
138 |
139 | if (!areVirtualThreadsSupported()) {
140 | throw new NotSupportedException("Executors.newThreadPerTaskExecutor");
141 | }
142 | Throwable thrown = null;
143 | try {
144 | final var factory = factory(prefix);
145 | if (newThreadPerTaskExecutorMethodHandle != null) {
146 | return (ExecutorService) newThreadPerTaskExecutorMethodHandle.invoke(factory);
147 | }
148 | } catch (final NotSupportedException e) {
149 | throw e;
150 | } catch (final Throwable e) {
151 | thrown = e;
152 | }
153 | final var e2 = new NotSupportedException("Executors.newThreadPerTaskExecutor");
154 | if (thrown != null) e2.addSuppressed(thrown);
155 | throw e2;
156 | }
157 |
158 | public static ThreadFactory factory(final String prefix) throws NotSupportedException {
159 | if (!areVirtualThreadsSupported()) {
160 | throw new NotSupportedException("Thread.ofVirtual");
161 | }
162 | try {
163 | final var builderClass = Class.forName("java.lang.Thread$Builder");
164 | final var ofVirtualClass = Class.forName("java.lang.Thread$Builder$OfVirtual");
165 | final var lookup = MethodHandles.lookup();
166 | final var ofVirtualMethod = lookup.findStatic(Thread.class, "ofVirtual", MethodType.methodType(ofVirtualClass));
167 | var builder = ofVirtualMethod.invoke();
168 | final var nameMethod = lookup.findVirtual(ofVirtualClass, "name", MethodType.methodType(ofVirtualClass, String.class, long.class));
169 | final var factoryMethod = lookup.findVirtual(builderClass, "factory", MethodType.methodType(ThreadFactory.class));
170 | builder = nameMethod.invoke(builder, prefix, 0L);
171 | return (ThreadFactory) factoryMethod.invoke(builder);
172 | } catch (final Throwable e) {
173 | final var e2 = new NotSupportedException("Thread.ofVirtual");
174 | e2.addSuppressed(e);
175 | throw e2;
176 | }
177 | }
178 |
179 | private static final @Nullable MethodHandle isVirtualMethodHandle;
180 |
181 | static {
182 | MethodHandle tempHandle;
183 | try {
184 | final var threadClass = Class.forName("java.lang.Thread");
185 | final var lookup = MethodHandles.lookup();
186 | tempHandle = lookup.findVirtual(
187 | threadClass,
188 | "isVirtual",
189 | MethodType.methodType(boolean.class));
190 | } catch (final Throwable e) {
191 | tempHandle = null;
192 | }
193 | isVirtualMethodHandle = tempHandle;
194 | }
195 |
196 | public static boolean isVirtualThread(final Thread th) {
197 | try {
198 | if (isVirtualMethodHandle != null) {
199 | return (boolean) isVirtualMethodHandle.invoke(th);
200 | }
201 | } catch (final Throwable e) {
202 | // Ignored
203 | }
204 | return false;
205 | }
206 |
207 | public static boolean areVirtualThreadsSupported() {
208 | final var sp = System.getProperty("funfix.tasks.virtual-threads");
209 | final var disableFeature = "off".equalsIgnoreCase(sp)
210 | || "false".equalsIgnoreCase(sp)
211 | || "no".equalsIgnoreCase(sp)
212 | || "0".equals(sp)
213 | || "disabled".equalsIgnoreCase(sp);
214 | return !disableFeature && isVirtualMethodHandle != null && newThreadPerTaskExecutorMethodHandle != null;
215 | }
216 | }
217 |
--------------------------------------------------------------------------------
/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskWithOnCompletionTest.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jspecify.annotations.Nullable;
4 | import org.junit.jupiter.api.Test;
5 |
6 | import java.util.concurrent.ConcurrentLinkedQueue;
7 | import java.util.concurrent.CountDownLatch;
8 | import java.util.concurrent.ExecutionException;
9 | import java.util.concurrent.TimeoutException;
10 | import java.util.concurrent.atomic.AtomicReference;
11 |
12 | import static org.funfix.tasks.jvm.TestSettings.CONCURRENCY_REPEATS;
13 | import static org.funfix.tasks.jvm.TestSettings.TIMEOUT;
14 | import static org.junit.jupiter.api.Assertions.assertEquals;
15 | import static org.junit.jupiter.api.Assertions.fail;
16 |
17 | public class TaskWithOnCompletionTest {
18 | @Test
19 | void guaranteeOnSuccess() throws ExecutionException, InterruptedException {
20 | for (int t = 0; t < CONCURRENCY_REPEATS; t++) {
21 | final var ref1 = new AtomicReference<@Nullable Outcome>(null);
22 | final var ref2 = new AtomicReference<@Nullable Outcome>(null);
23 | final var outcome1 = Task
24 | .fromBlockingIO(() -> "Success")
25 | .withOnComplete(ref1::set)
26 | .withOnComplete(ref2::set)
27 | .runBlocking();
28 |
29 | assertEquals("Success", outcome1);
30 | assertEquals(Outcome.success("Success"), ref1.get());
31 | assertEquals(Outcome.success("Success"), ref2.get());
32 | }
33 | }
34 |
35 | @Test
36 | void guaranteeOnSuccessWithFibers() throws ExecutionException, InterruptedException, TaskCancellationException {
37 | for (int t = 0; t < CONCURRENCY_REPEATS; t++) {
38 | final var ref1 = new AtomicReference<@Nullable Outcome>(null);
39 | final var ref2 = new AtomicReference<@Nullable Outcome>(null);
40 | final var fiber = Task
41 | .fromBlockingIO(() -> "Success")
42 | .withOnComplete(ref1::set)
43 | .withOnComplete(ref2::set)
44 | .runFiber();
45 |
46 | assertEquals("Success", fiber.awaitBlocking());
47 | assertEquals(Outcome.success("Success"), ref1.get());
48 | assertEquals(Outcome.success("Success"), ref2.get());
49 | }
50 | }
51 |
52 | @Test
53 | void guaranteeOnSuccessWithBlockingIO() throws ExecutionException, InterruptedException, TimeoutException {
54 | for (int t = 0; t < CONCURRENCY_REPEATS; t++) {
55 | final var ref1 = new AtomicReference<@Nullable Outcome>(null);
56 | final var ref2 = new AtomicReference<@Nullable Outcome>(null);
57 | final var r = Task
58 | .fromBlockingIO(() -> "Success")
59 | .withOnComplete(ref1::set)
60 | .withOnComplete(ref2::set)
61 | .runBlockingTimed(TIMEOUT);
62 |
63 | assertEquals("Success", r);
64 | assertEquals(Outcome.success("Success"), ref1.get());
65 | assertEquals(Outcome.success("Success"), ref2.get());
66 | }
67 | }
68 |
69 |
70 | @Test
71 | void guaranteeOnFailure() throws InterruptedException {
72 | for (int t = 0; t < CONCURRENCY_REPEATS; t++) {
73 | final var ref1 = new AtomicReference<@Nullable Outcome>(null);
74 | final var ref2 = new AtomicReference<@Nullable Outcome>(null);
75 | final var error = new RuntimeException("Failure");
76 |
77 | try {
78 | Task.fromBlockingIO(() -> {
79 | throw error;
80 | })
81 | .withOnComplete(ref1::set)
82 | .withOnComplete(ref2::set)
83 | .runBlocking();
84 | fail("Expected ExecutionException");
85 | } catch (ExecutionException e) {
86 | assertEquals(error, e.getCause());
87 | }
88 |
89 | assertEquals(Outcome.failure(error), ref1.get());
90 | assertEquals(Outcome.failure(error), ref2.get());
91 | }
92 | }
93 |
94 | @Test
95 | void guaranteeOnFailureWithFibers() throws InterruptedException, TaskCancellationException {
96 | for (int t = 0; t < CONCURRENCY_REPEATS; t++) {
97 | final var ref1 = new AtomicReference<@Nullable Outcome>(null);
98 | final var ref2 = new AtomicReference<@Nullable Outcome>(null);
99 | final var error = new RuntimeException("Failure");
100 |
101 | try {
102 | Task.fromBlockingIO(() -> {
103 | throw error;
104 | })
105 | .withOnComplete(ref1::set)
106 | .withOnComplete(ref2::set)
107 | .runFiber()
108 | .awaitBlocking();
109 | fail("Expected ExecutionException");
110 | } catch (ExecutionException e) {
111 | assertEquals(error, e.getCause());
112 | }
113 |
114 | assertEquals(Outcome.failure(error), ref1.get());
115 | assertEquals(Outcome.failure(error), ref2.get());
116 | }
117 | }
118 |
119 | @Test
120 | void guaranteeOnFailureBlockingIO() throws InterruptedException {
121 | for (int t = 0; t < CONCURRENCY_REPEATS; t++) {
122 | final var ref1 = new AtomicReference<@Nullable Outcome>(null);
123 | final var ref2 = new AtomicReference<@Nullable Outcome>(null);
124 | final var error = new RuntimeException("Failure");
125 |
126 | try {
127 | Task.fromBlockingIO(() -> {
128 | throw error;
129 | })
130 | .withOnComplete(ref1::set)
131 | .withOnComplete(ref2::set)
132 | .runBlockingTimed(TIMEOUT);
133 | fail("Expected ExecutionException");
134 | } catch (ExecutionException | TimeoutException e) {
135 | assertEquals(error, e.getCause());
136 | }
137 |
138 | assertEquals(Outcome.failure(error), ref1.get());
139 | assertEquals(Outcome.failure(error), ref2.get());
140 | }
141 | }
142 |
143 | @Test
144 | void guaranteeOnCancellation() throws InterruptedException, ExecutionException, TimeoutException {
145 | for (int t = 0; t < CONCURRENCY_REPEATS; t++) {
146 | final var ref1 = new AtomicReference<@Nullable Outcome>(null);
147 | final var ref2 = new AtomicReference<@Nullable Outcome>(null);
148 | final var latch = new CountDownLatch(1);
149 |
150 | final var task = Task
151 | .fromBlockingIO(() -> {
152 | latch.await();
153 | return "Should not complete";
154 | })
155 | .ensureRunningOnExecutor()
156 | .withOnComplete(ref1::set)
157 | .withOnComplete(ref2::set);
158 |
159 | final var fiber = task.runFiber();
160 | fiber.cancel();
161 |
162 | try {
163 | fiber.awaitBlockingTimed(TIMEOUT);
164 | fail("Expected TaskCancellationException");
165 | } catch (TaskCancellationException e) {
166 | // Expected
167 | } catch (TimeoutException e) {
168 | latch.countDown();
169 | throw e;
170 | }
171 |
172 | assertEquals(Outcome.cancellation(), ref1.get());
173 | assertEquals(Outcome.cancellation(), ref2.get());
174 | }
175 | }
176 |
177 | @Test
178 | void callOrdering() throws InterruptedException {
179 | for (int t = 0; t < CONCURRENCY_REPEATS; t++) {
180 | final var latch = new CountDownLatch(1);
181 | final var ref = new ConcurrentLinkedQueue();
182 |
183 | Task.fromBlockingIO(() -> 0)
184 | .ensureRunningOnExecutor()
185 | .withOnComplete((ignored) -> ref.add(1))
186 | .withOnComplete((ignored) -> ref.add(2))
187 | .runAsync((ignored) -> {
188 | ref.add(3);
189 | latch.countDown();
190 | });
191 |
192 | TimedAwait.latchAndExpectCompletion(latch, "latch");
193 | assertEquals(3, ref.size(), "Expected 3 calls to onCompletion");
194 | final var arr = ref.toArray(new Integer[0]);
195 | for (int i = 0; i < 3; i++) {
196 | assertEquals(i + 1, arr[i]);
197 | }
198 | }
199 | }
200 |
201 | @Test
202 | void callOrderingViaFibers() throws Exception {
203 | for (int t = 0; t < CONCURRENCY_REPEATS; t++) {
204 | final var ref = new ConcurrentLinkedQueue();
205 |
206 | final var fiber = Task.fromBlockingIO(() -> 0)
207 | .ensureRunningOnExecutor()
208 | .withOnComplete((ignored) -> ref.add(1))
209 | .withOnComplete((ignored) -> ref.add(2))
210 | .runFiber();
211 |
212 | assertEquals(0, fiber.awaitBlockingTimed(TIMEOUT));
213 | assertEquals(2, ref.size(), "Expected 2 calls to onCompletion");
214 | final var arr = ref.toArray(new Integer[0]);
215 | for (int i = 0; i < arr.length; i++) {
216 | assertEquals(i + 1, arr[i]);
217 | }
218 | }
219 | }
220 |
221 | }
222 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 | # SPDX-License-Identifier: Apache-2.0
19 | #
20 |
21 | ##############################################################################
22 | #
23 | # Gradle start up script for POSIX generated by Gradle.
24 | #
25 | # Important for running:
26 | #
27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28 | # noncompliant, but you have some other compliant shell such as ksh or
29 | # bash, then to run this script, type that shell name before the whole
30 | # command line, like:
31 | #
32 | # ksh Gradle
33 | #
34 | # Busybox and similar reduced shells will NOT work, because this script
35 | # requires all of these POSIX shell features:
36 | # * functions;
37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39 | # * compound commands having a testable exit status, especially «case»;
40 | # * various built-in commands including «command», «set», and «ulimit».
41 | #
42 | # Important for patching:
43 | #
44 | # (2) This script targets any POSIX shell, so it avoids extensions provided
45 | # by Bash, Ksh, etc; in particular arrays are avoided.
46 | #
47 | # The "traditional" practice of packing multiple parameters into a
48 | # space-separated string is a well documented source of bugs and security
49 | # problems, so this is (mostly) avoided, by progressively accumulating
50 | # options in "$@", and eventually passing that to Java.
51 | #
52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54 | # see the in-line comments for details.
55 | #
56 | # There are tweaks for specific operating systems such as AIX, CygWin,
57 | # Darwin, MinGW, and NonStop.
58 | #
59 | # (3) This script is generated from the Groovy template
60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61 | # within the Gradle project.
62 | #
63 | # You can find Gradle at https://github.com/gradle/gradle/.
64 | #
65 | ##############################################################################
66 |
67 | # Attempt to set APP_HOME
68 |
69 | # Resolve links: $0 may be a link
70 | app_path=$0
71 |
72 | # Need this for daisy-chained symlinks.
73 | while
74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75 | [ -h "$app_path" ]
76 | do
77 | ls=$( ls -ld "$app_path" )
78 | link=${ls#*' -> '}
79 | case $link in #(
80 | /*) app_path=$link ;; #(
81 | *) app_path=$APP_HOME$link ;;
82 | esac
83 | done
84 |
85 | # This is normally unused
86 | # shellcheck disable=SC2034
87 | APP_BASE_NAME=${0##*/}
88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
90 |
91 | # Use the maximum available, or set MAX_FD != -1 to use that value.
92 | MAX_FD=maximum
93 |
94 | warn () {
95 | echo "$*"
96 | } >&2
97 |
98 | die () {
99 | echo
100 | echo "$*"
101 | echo
102 | exit 1
103 | } >&2
104 |
105 | # OS specific support (must be 'true' or 'false').
106 | cygwin=false
107 | msys=false
108 | darwin=false
109 | nonstop=false
110 | case "$( uname )" in #(
111 | CYGWIN* ) cygwin=true ;; #(
112 | Darwin* ) darwin=true ;; #(
113 | MSYS* | MINGW* ) msys=true ;; #(
114 | NONSTOP* ) nonstop=true ;;
115 | esac
116 |
117 | CLASSPATH="\\\"\\\""
118 |
119 |
120 | # Determine the Java command to use to start the JVM.
121 | if [ -n "$JAVA_HOME" ] ; then
122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123 | # IBM's JDK on AIX uses strange locations for the executables
124 | JAVACMD=$JAVA_HOME/jre/sh/java
125 | else
126 | JAVACMD=$JAVA_HOME/bin/java
127 | fi
128 | if [ ! -x "$JAVACMD" ] ; then
129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130 |
131 | Please set the JAVA_HOME variable in your environment to match the
132 | location of your Java installation."
133 | fi
134 | else
135 | JAVACMD=java
136 | if ! command -v java >/dev/null 2>&1
137 | then
138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
139 |
140 | Please set the JAVA_HOME variable in your environment to match the
141 | location of your Java installation."
142 | fi
143 | fi
144 |
145 | # Increase the maximum file descriptors if we can.
146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
147 | case $MAX_FD in #(
148 | max*)
149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
150 | # shellcheck disable=SC2039,SC3045
151 | MAX_FD=$( ulimit -H -n ) ||
152 | warn "Could not query maximum file descriptor limit"
153 | esac
154 | case $MAX_FD in #(
155 | '' | soft) :;; #(
156 | *)
157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
158 | # shellcheck disable=SC2039,SC3045
159 | ulimit -n "$MAX_FD" ||
160 | warn "Could not set maximum file descriptor limit to $MAX_FD"
161 | esac
162 | fi
163 |
164 | # Collect all arguments for the java command, stacking in reverse order:
165 | # * args from the command line
166 | # * the main class name
167 | # * -classpath
168 | # * -D...appname settings
169 | # * --module-path (only if needed)
170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
171 |
172 | # For Cygwin or MSYS, switch paths to Windows format before running java
173 | if "$cygwin" || "$msys" ; then
174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
176 |
177 | JAVACMD=$( cygpath --unix "$JAVACMD" )
178 |
179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
180 | for arg do
181 | if
182 | case $arg in #(
183 | -*) false ;; # don't mess with options #(
184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
185 | [ -e "$t" ] ;; #(
186 | *) false ;;
187 | esac
188 | then
189 | arg=$( cygpath --path --ignore --mixed "$arg" )
190 | fi
191 | # Roll the args list around exactly as many times as the number of
192 | # args, so each arg winds up back in the position where it started, but
193 | # possibly modified.
194 | #
195 | # NB: a `for` loop captures its iteration list before it begins, so
196 | # changing the positional parameters here affects neither the number of
197 | # iterations, nor the values presented in `arg`.
198 | shift # remove old arg
199 | set -- "$@" "$arg" # push replacement arg
200 | done
201 | fi
202 |
203 |
204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
206 |
207 | # Collect all arguments for the java command:
208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
209 | # and any embedded shellness will be escaped.
210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
211 | # treated as '${Hostname}' itself on the command line.
212 |
213 | set -- \
214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
215 | -classpath "$CLASSPATH" \
216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
217 | "$@"
218 |
219 | # Stop when "xargs" is not available.
220 | if ! command -v xargs >/dev/null 2>&1
221 | then
222 | die "xargs is not available"
223 | fi
224 |
225 | # Use "xargs" to parse quoted args.
226 | #
227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
228 | #
229 | # In Bash we could simply go:
230 | #
231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
232 | # set -- "${ARGS[@]}" "$@"
233 | #
234 | # but POSIX shell has neither arrays nor command substitution, so instead we
235 | # post-process each arg (as a line of input to sed) to backslash-escape any
236 | # character that might be a shell metacharacter, then use eval to reverse
237 | # that process (while maintaining the separation between arguments), and wrap
238 | # the whole thing up as a single "set" statement.
239 | #
240 | # This will of course break if any of these variables contains a newline or
241 | # an unmatched quote.
242 | #
243 |
244 | eval "set -- $(
245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
246 | xargs -n1 |
247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
248 | tr '\n' ' '
249 | )" '"$@"'
250 |
251 | exec "$JAVACMD" "$@"
252 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Resource.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jetbrains.annotations.Blocking;
4 | import org.jspecify.annotations.Nullable;
5 |
6 | import java.util.Objects;
7 | import java.util.concurrent.ExecutionException;
8 | import java.util.concurrent.Executor;
9 | import java.util.function.Function;
10 |
11 | /**
12 | * A {@code Resource} represents a resource that can be acquired asynchronously.
13 | *
14 | * This is similar to Java's {@link AutoCloseable}, but it's more portable
15 | * and allows for asynchronous contexts.
16 | *
17 | * {@code Resource} is the equivalent of {@link Task} for resource acquisition.
18 | *
19 | * Sample usage:
20 | *
{@code
21 | * // -------------------------------------------------------
22 | * // SAMPLE METHODS
23 | * // -------------------------------------------------------
24 | *
25 | * // Creates temporary files — note that wrapping this in `Resource` is a
26 | * // win as `File` isn't `AutoCloseable`).
27 | * Resource createTemporaryFile(String prefix, String suffix) {
28 | * return Resource.fromBlockingIO(() -> {
29 | * File tempFile = File.createTempFile(prefix, suffix);
30 | * tempFile.deleteOnExit(); // Ensure it gets deleted on exit
31 | * return Resource.Acquired.fromBlockingIO(
32 | * tempFile,
33 | * ignored -> tempFile.delete()
34 | * );
35 | * });
36 | * }
37 | *
38 | * // Creates `Reader` as a `Resource`
39 | * Resource openReader(File file) {
40 | * return Resource.fromAutoCloseable(() ->
41 | * new BufferedReader(
42 | * new InputStreamReader(
43 | * new FileInputStream(file),
44 | * StandardCharsets.UTF_8
45 | * )
46 | * ));
47 | * }
48 | *
49 | * // Creates `Writer` as a `Resource`
50 | * Resource openWriter(File file) {
51 | * return Resource.fromAutoCloseable(() ->
52 | * new BufferedWriter(
53 | * new OutputStreamWriter(
54 | * new FileOutputStream(file),
55 | * StandardCharsets.UTF_8
56 | * )
57 | * ));
58 | * }
59 | *
60 | * // ...
61 | * // -------------------------------------------------------
62 | * // USAGE EXAMPLE (via try-with-resources)
63 | * // -------------------------------------------------------
64 | * try (
65 | * final var file = createTemporaryFile("test", ".txt").acquireBlocking()
66 | * ) {
67 | * try (final var writer = openWriter(file.get()).acquireBlocking()) {
68 | * writer.get().write("----\n");
69 | * writer.get().write("line 1\n");
70 | * writer.get().write("line 2\n");
71 | * writer.get().write("----\n");
72 | * }
73 | *
74 | * try (final var reader = openReader(file.get()).acquireBlocking()) {
75 | * final var builder = new StringBuilder();
76 | * String line;
77 | * while ((line = reader.get().readLine()) != null) {
78 | * builder.append(line).append("\n");
79 | * }
80 | * final String content = builder.toString();
81 | * assertEquals(
82 | * "----\nline 1\nline 2\n----\n",
83 | * content,
84 | * "File content should match the written lines"
85 | * );
86 | * }
87 | * }
88 | * }
89 | */
90 | public final class Resource {
91 | private final Task> acquireTask;
92 |
93 | private Resource(final Task> acquireTask) {
94 | this.acquireTask = Objects.requireNonNull(acquireTask, "acquireTask");
95 | }
96 |
97 | /**
98 | * Acquires the resource asynchronously via {@link Task}, returning
99 | * it wrapped in {@link Acquired}, which contains the logic for safe release.
100 | */
101 | public Task> acquireTask() {
102 | return acquireTask;
103 | }
104 |
105 | /**
106 | * Acquires the resource via blocking I/O.
107 | *
108 | * This method blocks the current thread until the resource is acquired.
109 | *
110 | * @return an {@link Acquired} object that contains the acquired resource
111 | * and the logic to release it.
112 | * @throws ExecutionException if the resource acquisition failed with an exception
113 | * @throws InterruptedException if the current thread was interrupted while waiting
114 | * for the resource to be acquired
115 | */
116 | @Blocking
117 | public Acquired acquireBlocking()
118 | throws ExecutionException, InterruptedException {
119 | return acquireBlocking(null);
120 | }
121 |
122 | /**
123 | * Acquires the resource via blocking I/O using the specified executor.
124 | *
125 | * This method blocks the current thread until the resource is acquired.
126 | *
127 | * @param executor is the executor to use for executing the acquire task.
128 | * @return an {@link Acquired} object that contains the acquired resource
129 | * and the logic to release it.
130 | * @throws ExecutionException if the resource acquisition failed with an exception
131 | * @throws InterruptedException if the current thread was interrupted while waiting
132 | * for the resource to be acquired
133 | */
134 | @Blocking
135 | public Acquired acquireBlocking(@Nullable final Executor executor)
136 | throws ExecutionException, InterruptedException {
137 |
138 | return Objects.requireNonNull(
139 | executor != null
140 | ? acquireTask().runBlocking(executor)
141 | : acquireTask().runBlocking(),
142 | "Resource acquisition failed, acquireTask returned null"
143 | );
144 | }
145 |
146 | /**
147 | * Safely use the {@code Resource}, a method to use as an alternative to
148 | * Java's try-with-resources.
149 | *
150 | * All the execution happens on the specified {@code executor}, which
151 | * is used for both acquiring the resource, executing the processing
152 | * function and releasing the resource.
153 | *
154 | * Note that an asynchronous (Task-driven) alternative isn't provided,
155 | * as that would require an asynchronous evaluation model that's outside
156 | * the scope. E.g., work with Kotlin's coroutines, or Scala's Cats-Effect
157 | * to achieve that.
158 | *
159 | * @param executor is the executor to use for acquiring the resource and
160 | * for executing the processing function. If {@code null}, the
161 | * default executor for blocking I/O is used.
162 | * @param process is the processing function that can do I/O.
163 | *
164 | * @throws ExecutionException if either the resource acquisition or the
165 | * processing function fails with an exception.
166 | * @throws InterruptedException is thrown if the current thread was interrupted,
167 | * which can also interrupt the resource acquisition or the processing function.
168 | */
169 | @Blocking
170 | @SuppressWarnings("ConstantValue")
171 | public R useBlocking(
172 | @Nullable Executor executor,
173 | final ProcessFun super T, ? extends R> process
174 | ) throws InterruptedException, ExecutionException {
175 | Objects.requireNonNull(process, "use");
176 | final Task task = Task.fromBlockingIO(() -> {
177 | var finalizerCalled = false;
178 | final var acquired = acquireBlocking(executor);
179 | try {
180 | return process.call(acquired.get());
181 | } catch (InterruptedException e) {
182 | if (!finalizerCalled) {
183 | finalizerCalled = true;
184 | acquired.releaseBlocking(ExitCase.canceled());
185 | }
186 | throw e;
187 | } catch (Exception e) {
188 | if (!finalizerCalled) {
189 | finalizerCalled = true;
190 | acquired.releaseBlocking(ExitCase.failed(e));
191 | }
192 | throw e;
193 | } finally {
194 | if (!finalizerCalled) {
195 | acquired.releaseBlocking(ExitCase.succeeded());
196 | }
197 | }
198 | });
199 | return task
200 | .ensureRunningOnExecutor()
201 | .runBlocking(executor);
202 | }
203 |
204 | /**
205 | * Safely use the {@code Resource}, a method to use as an alternative to
206 | * Java's try-with-resources.
207 | *
208 | * This is an overload of {@link #useBlocking(Executor, ProcessFun)}
209 | * that uses the default executor for blocking I/O.
210 | */
211 | @Blocking
212 | public R useBlocking(
213 | final ProcessFun super T, ? extends R> process
214 | ) throws InterruptedException, ExecutionException {
215 | return useBlocking(null, process);
216 | }
217 |
218 | /**
219 | * Creates a {@link Resource} from an asynchronous task that acquires the resource.
220 | *
221 | * The task should return an {@link Acquired} object that contains the
222 | * acquired resource and the logic to release it.
223 | */
224 | public static Resource fromAsync(
225 | final Task> acquire
226 | ) {
227 | Objects.requireNonNull(acquire, "acquire");
228 | return new Resource<>(acquire);
229 | }
230 |
231 | /**
232 | * Creates a {@link Resource} from a builder doing blocking I/O.
233 | *
234 | * This method is useful for resources that are acquired via blocking I/O,
235 | * such as file handles, database connections, etc.
236 | */
237 | public static Resource fromBlockingIO(
238 | final DelayedFun extends Resource.Acquired> acquire
239 | ) {
240 | Objects.requireNonNull(acquire, "acquire");
241 | return Resource.fromAsync(Task.fromBlockingIO(() -> {
242 | final Acquired closeable = acquire.invoke();
243 | Objects.requireNonNull(closeable, "Resource allocation returned null");
244 | return closeable;
245 | }));
246 | }
247 |
248 | /**
249 | * Creates a {@link Resource} from a builder that returns an
250 | * {@link AutoCloseable} resource.
251 | */
252 | public static Resource fromAutoCloseable(
253 | final DelayedFun extends T> acquire
254 | ) {
255 | Objects.requireNonNull(acquire, "acquire");
256 | return Resource.fromBlockingIO(() -> {
257 | final T resource = acquire.invoke();
258 | Objects.requireNonNull(resource, "Resource allocation returned null");
259 | return Acquired.fromBlockingIO(resource, CloseableFun.fromAutoCloseable(resource));
260 | });
261 | }
262 |
263 | /**
264 | * Creates a "pure" {@code Resource}.
265 | *
266 | * A "pure" resource is one that just wraps a known value, with no-op
267 | * release logic.
268 | *
269 | * @see Task#pure(Object)
270 | */
271 | public static Resource pure(T value) {
272 | return Resource.fromAsync(Task.pure(Acquired.pure(value)));
273 | }
274 |
275 | /**
276 | * Tuple that represents an acquired resource and the logic to release it.
277 | *
278 | * @param get is the acquired resource
279 | * @param releaseTask is the (async) function that can release the resource
280 | */
281 | public record Acquired(
282 | T get,
283 | Function> releaseTask
284 | ) implements AutoCloseable {
285 | /**
286 | * Used for asynchronous resource release.
287 | *
288 | * @param exitCase signals the context in which the resource is being released.
289 | * @return a {@link Task} that releases the resource upon invocation.
290 | */
291 | public Task releaseTask(final ExitCase exitCase) {
292 | return releaseTask.apply(exitCase);
293 | }
294 |
295 | /**
296 | * Releases the resource in a blocking manner, using the default executor.
297 | *
298 | * This method can block the current thread until the resource is released.
299 | *
300 | *
301 | * @param exitCase signals the context in which the resource is being released.
302 | */
303 | @Blocking
304 | public void releaseBlocking(final ExitCase exitCase)
305 | throws InterruptedException, ExecutionException {
306 | releaseBlocking(null, exitCase);
307 | }
308 |
309 | /**
310 | * Releases the resource in a blocking manner, using the specified executor.
311 | *
312 | * This method can block the current thread until the resource is released.
313 | *
314 | * @param executor is the executor to use for executing the release task.
315 | * @param exitCase signals the context in which the resource is being released.
316 | */
317 | @Blocking
318 | public void releaseBlocking(
319 | final @Nullable Executor executor,
320 | final ExitCase exitCase
321 | ) throws InterruptedException, ExecutionException {
322 | TaskUtils.runBlockingUninterruptible(executor, releaseTask(exitCase));
323 | }
324 |
325 | /**
326 | * Releases the resource in a blocking manner, using the default executor.
327 | *
328 | * This being part of {@link AutoCloseable} means it can be used via a
329 | * try-with-resources block.
330 | */
331 | @Blocking
332 | @Override
333 | public void close() throws Exception {
334 | releaseBlocking(ExitCase.succeeded());
335 | }
336 |
337 | /**
338 | * Creates a "pure" {@code Acquired} instance with the given value —
339 | * i.e., it just wraps a value with the release function being a no-op.
340 | */
341 | public static Acquired pure(T value) {
342 | return new Acquired<>(value, NOOP);
343 | }
344 |
345 | /**
346 | * Creates an {@link Acquired} instance with a {@link CloseableFun}
347 | * release function that may do blocking I/O.
348 | *
349 | * @see Resource#fromBlockingIO(DelayedFun)
350 | */
351 | public static Acquired fromBlockingIO(
352 | T resource,
353 | CloseableFun release
354 | ) {
355 | Objects.requireNonNull(resource, "resource");
356 | Objects.requireNonNull(release, "release");
357 | @SuppressWarnings("NullAway")
358 | final var acquired = new Acquired<>(resource, release.toAsync());
359 | return acquired;
360 | }
361 |
362 | private static final Function> NOOP =
363 | ignored -> Task.NOOP;
364 | }
365 | }
366 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CompletionCallback.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jetbrains.annotations.ApiStatus;
4 | import org.jspecify.annotations.Nullable;
5 |
6 | import java.io.Serializable;
7 | import java.time.Duration;
8 | import java.util.Objects;
9 | import java.util.concurrent.ExecutionException;
10 | import java.util.concurrent.TimeoutException;
11 | import java.util.concurrent.atomic.AtomicBoolean;
12 | import java.util.concurrent.atomic.AtomicReference;
13 | import java.util.concurrent.locks.AbstractQueuedSynchronizer;
14 |
15 | /**
16 | * Represents a callback that will be invoked when a task completes.
17 | *
18 | * A task can complete either successfully with a value, or with an exception,
19 | * or it can be cancelled.
20 | *
21 | * MUST BE idempotent AND thread-safe.
22 | *
23 | * @param is the type of the value that the task will complete with
24 | */
25 | @FunctionalInterface
26 | public interface CompletionCallback
27 | extends Serializable {
28 |
29 | void onOutcome(Outcome outcome);
30 |
31 | /**
32 | * Must be called when the task completes successfully.
33 | *
34 | * @param value is the successful result of the task, to be signaled
35 | */
36 | default void onSuccess(T value) {
37 | onOutcome(Outcome.success(value));
38 | }
39 |
40 | /**
41 | * Must be called when the task completes with an exception.
42 | *
43 | * @param e is the exception that the task failed with
44 | */
45 | default void onFailure(Throwable e) {
46 | onOutcome(Outcome.failure(e));
47 | }
48 |
49 | /**
50 | * Must be called when the task is cancelled.
51 | */
52 | default void onCancellation() {
53 | onOutcome(Outcome.cancellation());
54 | }
55 |
56 | /**
57 | * Returns a {@code CompletionListener} that does nothing (a no-op).
58 | */
59 | static CompletionCallback empty() {
60 | return outcome -> {
61 | if (outcome instanceof Outcome.Failure f) {
62 | UncaughtExceptionHandler.logOrRethrow(f.exception());
63 | }
64 | };
65 | }
66 | }
67 |
68 | @ApiStatus.Internal
69 | final class ManyCompletionCallback
70 | implements CompletionCallback {
71 |
72 | private final ImmutableStack> listeners;
73 |
74 | @SafeVarargs
75 | ManyCompletionCallback(final CompletionCallback... listeners) {
76 | Objects.requireNonNull(listeners, "listeners");
77 | ImmutableStack> stack = ImmutableStack.empty();
78 | for (final CompletionCallback listener : listeners) {
79 | Objects.requireNonNull(listener, "listener");
80 | stack = stack.prepend(listener);
81 | }
82 | this.listeners = stack;
83 | }
84 |
85 | private ManyCompletionCallback(
86 | final ImmutableStack> listeners
87 | ) {
88 | Objects.requireNonNull(listeners, "listeners");
89 | this.listeners = listeners;
90 | }
91 |
92 | ManyCompletionCallback withExtraListener(CompletionCallback extraListener) {
93 | Objects.requireNonNull(extraListener, "extraListener");
94 | final var newListeners = this.listeners.prepend(extraListener);
95 | return new ManyCompletionCallback<>(newListeners);
96 | }
97 |
98 | @Override
99 | public void onOutcome(Outcome outcome) {
100 | for (final CompletionCallback listener : listeners) {
101 | try {
102 | listener.onOutcome(outcome);
103 | } catch (Throwable e) {
104 | UncaughtExceptionHandler.logOrRethrow(e);
105 | }
106 | }
107 | }
108 |
109 | @Override
110 | public void onSuccess(T value) {
111 | for (final CompletionCallback listener : listeners) {
112 | try {
113 | listener.onSuccess(value);
114 | } catch (Throwable e) {
115 | UncaughtExceptionHandler.logOrRethrow(e);
116 | }
117 | }
118 | }
119 |
120 | @Override
121 | public void onFailure(Throwable e) {
122 | Objects.requireNonNull(e, "e");
123 | for (final CompletionCallback listener : listeners) {
124 | try {
125 | listener.onFailure(e);
126 | } catch (Throwable ex) {
127 | UncaughtExceptionHandler.logOrRethrow(ex);
128 | }
129 | }
130 | }
131 |
132 | @Override
133 | public void onCancellation() {
134 | for (final CompletionCallback listener : listeners) {
135 | try {
136 | listener.onCancellation();
137 | } catch (Throwable e) {
138 | UncaughtExceptionHandler.logOrRethrow(e);
139 | }
140 | }
141 | }
142 | }
143 |
144 | @ApiStatus.Internal
145 | interface ContinuationCallback
146 | extends CompletionCallback, Serializable {
147 |
148 | /**
149 | * Registers an extra callback to be invoked when the task completes.
150 | * This is useful for chaining callbacks or adding additional listeners.
151 | */
152 | void registerExtraCallback(CompletionCallback extraCallback);
153 | }
154 |
155 | @ApiStatus.Internal
156 | final class AsyncContinuationCallback
157 | implements ContinuationCallback, Runnable {
158 |
159 | private final AtomicBoolean isWaiting = new AtomicBoolean(true);
160 | private final AtomicReference> listenerRef;
161 | private final TaskExecutor executor;
162 |
163 | private @Nullable Outcome outcome;
164 | private @Nullable T successValue;
165 | private @Nullable Throwable failureCause;
166 | private boolean isCancelled = false;
167 |
168 | public AsyncContinuationCallback(
169 | final CompletionCallback listener,
170 | final TaskExecutor executor
171 | ) {
172 | this.executor = executor;
173 | this.listenerRef = new AtomicReference<>(
174 | Objects.requireNonNull(listener, "listener")
175 | );
176 | }
177 |
178 | @Override
179 | @SuppressWarnings("NullAway")
180 | public void run() {
181 | if (this.outcome != null) {
182 | listenerRef.get().onOutcome(this.outcome);
183 | } else if (this.failureCause != null) {
184 | listenerRef.get().onFailure(this.failureCause);
185 | } else if (this.isCancelled) {
186 | listenerRef.get().onCancellation();
187 | } else {
188 | listenerRef.get().onSuccess(this.successValue);
189 | }
190 | // For GC purposes; but it doesn't really matter if we nullify these or not
191 | this.outcome = null;
192 | this.successValue = null;
193 | this.failureCause = null;
194 | this.isCancelled = false;
195 | }
196 |
197 | @Override
198 | public void onOutcome(final Outcome outcome) {
199 | Objects.requireNonNull(outcome, "outcome");
200 | if (isWaiting.getAndSet(false)) {
201 | this.outcome = outcome;
202 | executor.resumeOnExecutor(this);
203 | } else if (outcome instanceof Outcome.Failure f) {
204 | UncaughtExceptionHandler.logOrRethrow(f.exception());
205 | }
206 | }
207 |
208 | @Override
209 | public void onSuccess(final T value) {
210 | if (isWaiting.getAndSet(false)) {
211 | this.successValue = value;
212 | executor.resumeOnExecutor(this);
213 | }
214 | }
215 |
216 | @Override
217 | public void onFailure(final Throwable e) {
218 | Objects.requireNonNull(e, "e");
219 | if (isWaiting.getAndSet(false)) {
220 | this.failureCause = e;
221 | executor.resumeOnExecutor(this);
222 | } else {
223 | UncaughtExceptionHandler.logOrRethrow(e);
224 | }
225 | }
226 |
227 | @Override
228 | public void onCancellation() {
229 | if (isWaiting.getAndSet(false)) {
230 | this.isCancelled = true;
231 | executor.resumeOnExecutor(this);
232 | }
233 | }
234 |
235 | public static ContinuationCallback protect(
236 | final TaskExecutor executor,
237 | final CompletionCallback listener
238 | ) {
239 | Objects.requireNonNull(listener, "listener");
240 | return new AsyncContinuationCallback<>(
241 | listener,
242 | executor
243 | );
244 | }
245 |
246 | @SuppressWarnings("NullAway")
247 | public void registerExtraCallback(CompletionCallback extraCallback) {
248 | while (true) {
249 | final var current = listenerRef.get();
250 | if (current instanceof ManyCompletionCallback many) {
251 | final var update = many.withExtraListener(extraCallback);
252 | if (listenerRef.compareAndSet(current, update)) {
253 | return;
254 | }
255 | } else if (listenerRef.compareAndSet(current, new ManyCompletionCallback<>(current, extraCallback))) {
256 | return;
257 | }
258 | }
259 | }
260 | }
261 |
262 | /**
263 | * INTERNAL API.
264 | *
265 | * INTERNAL API: Internal apis are subject to change or removal
266 | * without any notice. When code depends on internal APIs, it is subject to
267 | * breakage between minor version updates.
268 | */
269 | @ApiStatus.Internal
270 | final class BlockingCompletionCallback
271 | extends AbstractQueuedSynchronizer implements ContinuationCallback {
272 |
273 | private final AtomicBoolean isDone =
274 | new AtomicBoolean(false);
275 | private final AtomicReference<@Nullable CompletionCallback> extraCallbackRef =
276 | new AtomicReference<>(null);
277 |
278 | @Nullable
279 | private T result = null;
280 | @Nullable
281 | private Throwable error = null;
282 | @Nullable
283 | private InterruptedException interrupted = null;
284 |
285 | @SuppressWarnings("NullAway")
286 | private void notifyOutcome() {
287 | final var extraCallback = extraCallbackRef.getAndSet(null);
288 | if (extraCallback != null)
289 | try {
290 | if (error != null)
291 | extraCallback.onFailure(error);
292 | else if (interrupted != null)
293 | extraCallback.onCancellation();
294 | else
295 | extraCallback.onSuccess(result);
296 | } catch (Throwable e) {
297 | UncaughtExceptionHandler.logOrRethrow(e);
298 | }
299 | releaseShared(1);
300 | }
301 |
302 | @Override
303 | public void onSuccess(final T value) {
304 | if (!isDone.getAndSet(true)) {
305 | result = value;
306 | notifyOutcome();
307 | }
308 | }
309 |
310 | @Override
311 | public void onFailure(final Throwable e) {
312 | UncaughtExceptionHandler.rethrowIfFatal(e);
313 | if (!isDone.getAndSet(true)) {
314 | error = e;
315 | notifyOutcome();
316 | } else {
317 | UncaughtExceptionHandler.logOrRethrow(e);
318 | }
319 | }
320 |
321 | @Override
322 | public void onCancellation() {
323 | if (!isDone.getAndSet(true)) {
324 | interrupted = new InterruptedException("Task was cancelled");
325 | notifyOutcome();
326 | }
327 | }
328 |
329 | @Override
330 | public void onOutcome(Outcome outcome) {
331 | if (outcome instanceof Outcome.Success success) {
332 | onSuccess(success.value());
333 | } else if (outcome instanceof Outcome.Failure failure) {
334 | onFailure(failure.exception());
335 | } else {
336 | onCancellation();
337 | }
338 | }
339 |
340 | @Override
341 | protected int tryAcquireShared(final int arg) {
342 | return getState() != 0 ? 1 : -1;
343 | }
344 |
345 | @Override
346 | protected boolean tryReleaseShared(final int arg) {
347 | setState(1);
348 | return true;
349 | }
350 |
351 | @FunctionalInterface
352 | interface AwaitFunction {
353 | void apply(boolean isCancelled) throws InterruptedException, TimeoutException;
354 | }
355 |
356 | @SuppressWarnings("NullAway")
357 | private T awaitInline(final Cancellable cancelToken, final AwaitFunction await)
358 | throws InterruptedException, ExecutionException, TimeoutException {
359 |
360 | TaskLocalContext.signalTheStartOfBlockingCall();
361 | var isCancelled = false;
362 | TimeoutException timedOut = null;
363 | while (true) {
364 | try {
365 | await.apply(isCancelled);
366 | break;
367 | } catch (final TimeoutException | InterruptedException e) {
368 | if (!isCancelled) {
369 | isCancelled = true;
370 | if (e instanceof TimeoutException te)
371 | timedOut = te;
372 | cancelToken.cancel();
373 | }
374 | }
375 | // Clearing the interrupted flag may not be necessary,
376 | // but doesn't hurt, and we should have a cleared flag before
377 | // re-throwing the exception
378 | //
379 | // noinspection ResultOfMethodCallIgnored
380 | Thread.interrupted();
381 | }
382 | if (timedOut != null) throw timedOut;
383 | if (interrupted != null) throw interrupted;
384 | if (error != null) throw new ExecutionException(error);
385 | return result;
386 | }
387 |
388 | public T await(final Cancellable cancelToken) throws InterruptedException, ExecutionException {
389 | try {
390 | return awaitInline(cancelToken, isCancelled -> acquireSharedInterruptibly(1));
391 | } catch (final TimeoutException e) {
392 | throw new IllegalStateException("Unexpected timeout", e);
393 | }
394 | }
395 |
396 | public T await(final Cancellable cancelToken, final Duration timeout)
397 | throws ExecutionException, InterruptedException, TimeoutException {
398 |
399 | return awaitInline(cancelToken, isCancelled -> {
400 | if (!isCancelled) {
401 | if (!tryAcquireSharedNanos(1, timeout.toNanos())) {
402 | throw new TimeoutException("Task timed-out after " + timeout);
403 | }
404 | } else {
405 | // Waiting without a timeout, since at this point it's waiting
406 | // on the cancelled task to finish
407 | acquireSharedInterruptibly(1);
408 | }
409 | });
410 | }
411 |
412 | @Override
413 | public void registerExtraCallback(CompletionCallback extraCallback) {
414 | while (true) {
415 | final var current = extraCallbackRef.get();
416 | if (current == null) {
417 | if (extraCallbackRef.compareAndSet(null, extraCallback)) {
418 | return;
419 | }
420 | } else if (current instanceof ManyCompletionCallback many) {
421 | final var update = many.withExtraListener(extraCallback);
422 | if (extraCallbackRef.compareAndSet(current, update)) {
423 | return;
424 | }
425 | } else if (extraCallbackRef.compareAndSet(current, new ManyCompletionCallback<>(current, extraCallback))) {
426 | return;
427 | }
428 | }
429 | }
430 | }
431 |
--------------------------------------------------------------------------------
/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Fiber.java:
--------------------------------------------------------------------------------
1 | package org.funfix.tasks.jvm;
2 |
3 | import org.jetbrains.annotations.ApiStatus;
4 | import org.jetbrains.annotations.Blocking;
5 | import org.jetbrains.annotations.NonBlocking;
6 | import org.jspecify.annotations.Nullable;
7 |
8 | import java.time.Duration;
9 | import java.util.Objects;
10 | import java.util.concurrent.*;
11 | import java.util.concurrent.atomic.AtomicReference;
12 | import java.util.concurrent.locks.AbstractQueuedSynchronizer;
13 |
14 | /**
15 | * A {@code Fiber} is a task that was started concurrently, and that can
16 | * be joined or cancelled.
17 | *
18 | * Fibers are the equivalent of threads, except they are higher-level and
19 | * library-managed, instead of being intrinsic to the JVM.
20 | *
21 | * @param is the result of the fiber, if successful.
22 | */
23 | public interface Fiber extends Cancellable {
24 | /**
25 | * Returns the result of the completed fiber.
26 | *
27 | * This method does not block for the result. In case the fiber is not
28 | * completed, it throws {@link NotCompletedException}. Therefore, by contract,
29 | * it should be called only after the fiber was "joined".
30 | *
31 | * @return the result of the concurrent task, if successful.
32 | * @throws ExecutionException if the task failed with an exception.
33 | * @throws TaskCancellationException if the task was cancelled concurrently,
34 | * thus being completed via cancellation.
35 | * @throws NotCompletedException if the fiber is not completed yet.
36 | */
37 | @NonBlocking
38 | T getResultOrThrow() throws ExecutionException, TaskCancellationException, NotCompletedException;
39 |
40 | /**
41 | * Waits until the fiber completes, and then runs the given callback to
42 | * signal its completion.
43 | *
44 | * Completion includes cancellation. Triggering {@link #cancel()} before
45 | * {@code joinAsync} will cause the fiber to get cancelled, and then the
46 | * "join" back-pressures on cancellation.
47 | *
48 | * @param onComplete is the callback to run when the fiber completes
49 | * (successfully, or with failure, or cancellation)
50 | *
51 | * @return a {@link Cancellable} that can be used to unregister the callback,
52 | * in case the caller is no longer interested in the result. Note this
53 | * does not cancel the fiber itself.
54 | */
55 | @NonBlocking
56 | Cancellable joinAsync(Runnable onComplete);
57 |
58 | /**
59 | * Waits until the fiber completes, and then runs the given callback
60 | * to signal its completion.
61 | *
62 | * This method can be executed as many times as necessary, with the
63 | * result of the {@code Fiber} being memoized. It can also be executed
64 | * after the fiber has completed, in which case the callback will be
65 | * executed immediately.
66 | *
67 | * @param callback will be called with the result when the fiber completes.
68 | *
69 | * @return a {@link Cancellable} that can be used to unregister the callback,
70 | * in case the caller is no longer interested in the result. Note this
71 | * does not cancel the fiber itself.
72 | */
73 | @NonBlocking
74 | default Cancellable awaitAsync(CompletionCallback super T> callback) {
75 | return joinAsync(() -> {
76 | try {
77 | final var result = getResultOrThrow();
78 | callback.onSuccess(result);
79 | } catch (final ExecutionException e) {
80 | callback.onFailure(Objects.requireNonNullElse(e.getCause(), e));
81 | } catch (final TaskCancellationException e) {
82 | callback.onCancellation();
83 | } catch (final Throwable e) {
84 | UncaughtExceptionHandler.rethrowIfFatal(e);
85 | callback.onFailure(e);
86 | }
87 | });
88 | }
89 |
90 | /**
91 | * Blocks the current thread until the fiber completes, or until
92 | * the timeout is reached.
93 | *
94 | * This method does not return the outcome of the fiber. To check
95 | * the outcome, use [outcome].
96 | *
97 | * @throws InterruptedException if the current thread is interrupted, which
98 | * will just stop waiting for the fiber, but will not cancel the running
99 | * task.
100 | * @throws TimeoutException if the timeout is reached before the fiber
101 | * completes.
102 | */
103 | @Blocking
104 | default void joinBlockingTimed(final Duration timeout)
105 | throws InterruptedException, TimeoutException {
106 |
107 | final var latch = new AwaitSignal();
108 | final var token = joinAsync(latch::signal);
109 | try {
110 | latch.await(timeout.toMillis());
111 | } catch (final InterruptedException | TimeoutException e) {
112 | token.cancel();
113 | throw e;
114 | }
115 | }
116 |
117 | /**
118 | * Blocks the current thread until the fiber completes, then returns
119 | * the result of the fiber.
120 | *
121 | * @param timeout is the maximum time to wait for the fiber to complete,
122 | * before throwing a {@link TimeoutException}.
123 | * @return the result of the fiber, if successful.
124 | * @throws InterruptedException if the current thread is interrupted, which
125 | * will just stop waiting for the fiber, but will not
126 | * cancel the running task.
127 | * @throws TimeoutException if the timeout is reached before the fiber completes.
128 | * @throws TaskCancellationException if the fiber was cancelled concurrently.
129 | * @throws ExecutionException if the task failed with an exception.
130 | */
131 | @Blocking
132 | default T awaitBlockingTimed(final Duration timeout)
133 | throws InterruptedException, TimeoutException, TaskCancellationException, ExecutionException {
134 |
135 | joinBlockingTimed(timeout);
136 | try {
137 | return getResultOrThrow();
138 | } catch (NotCompletedException e) {
139 | throw new IllegalStateException(e);
140 | }
141 | }
142 |
143 | /**
144 | * Blocks the current thread until the fiber completes.
145 | *
146 | * This method does not return the outcome of the fiber. To check
147 | * the outcome, use [outcome].
148 | *
149 | * @throws InterruptedException if the current thread is interrupted, which
150 | * will just stop waiting for the fiber, but will not cancel the running
151 | * task.
152 | */
153 | @Blocking
154 | default void joinBlocking() throws InterruptedException {
155 | final var latch = new AwaitSignal();
156 | final var token = joinAsync(latch::signal);
157 | try {
158 | latch.await();
159 | } finally {
160 | token.cancel();
161 | }
162 | }
163 |
164 | /**
165 | * Blocks the current thread until the fiber completes.
166 | *
167 | * Version of {@link #joinBlocking()} that ignores thread interruptions.
168 | * This is most useful after cancelling a fiber, as it ensures that
169 | * processing will back-pressure on the fiber's completion.
170 | *
171 | * WARNING: This method guarantees that upon its return
172 | * the fiber is completed, however, it still throws {@link InterruptedException}
173 | * because it can't swallow interruptions.
174 | *
175 | * Sample:
176 | *
{@code
177 | * final var fiber = Task
178 | * .fromBlockingIO(() -> {
179 | * Thread.sleep(10000);
180 | * })
181 | * .runFiber();
182 | * // ...
183 | * fiber.cancel();
184 | * fiber.joinBlockingUninterruptible();
185 | * }
186 | */
187 | @Blocking
188 | default void joinBlockingUninterruptible() throws InterruptedException {
189 | boolean wasInterrupted = Thread.interrupted();
190 | while (true) {
191 | try {
192 | joinBlocking();
193 | break;
194 | } catch (final InterruptedException e) {
195 | wasInterrupted = true;
196 | }
197 | }
198 | if (wasInterrupted) {
199 | throw new InterruptedException(
200 | "Thread was interrupted in #joinBlockingUninterruptible"
201 | );
202 | }
203 | }
204 |
205 | /**
206 | * Blocks the current thread until the fiber completes, then returns the
207 | * result of the fiber.
208 | *
209 | * @throws InterruptedException if the current thread is interrupted, which
210 | * will just stop waiting for the fiber, but will not
211 | * cancel the running task.
212 | * @throws TaskCancellationException if the fiber was cancelled concurrently.
213 | * @throws ExecutionException if the task failed with an exception.
214 | */
215 | @Blocking
216 | default T awaitBlocking() throws InterruptedException, TaskCancellationException, ExecutionException {
217 | joinBlocking();
218 | try {
219 | return getResultOrThrow();
220 | } catch (NotCompletedException e) {
221 | throw new IllegalStateException(e);
222 | }
223 | }
224 |
225 | /**
226 | * Cancels the fiber, which will eventually stop the running fiber (if
227 | * it's still running), completing it via "cancellation".
228 | *
229 | * This manifests either in a {@link TaskCancellationException} being
230 | * thrown by {@link #getResultOrThrow()}, or in the
231 | * {@link CompletionCallback#onCancellation()} callback being triggered.
232 | */
233 | @NonBlocking
234 | @Override void cancel();
235 |
236 | /**
237 | * Returns a {@link CancellableFuture} that can be used to join the fiber
238 | * asynchronously, or to cancel it.
239 | *
240 | * @return a {@link CancellableFuture} that can be used to asynchronously
241 | * process its result, or to give up on listening for the result. Note that
242 | * cancelling the returned future does not cancel the fiber itself, for
243 | * that you need to call {@link Fiber#cancel()}.
244 | */
245 | @NonBlocking
246 | default CancellableFuture joinAsync() {
247 | final var future = new CompletableFuture();
248 | @SuppressWarnings("DataFlowIssue")
249 | final var token = joinAsync(() -> future.complete(null));
250 | final Cancellable cRef = () -> {
251 | try {
252 | token.cancel();
253 | } finally {
254 | future.cancel(false);
255 | }
256 | };
257 | return new CancellableFuture<>(future, cRef);
258 | }
259 |
260 | /**
261 | * Overload of {@link #awaitAsync(CompletionCallback)}.
262 | *
263 | * @return a {@link CancellableFuture} that can be used to asynchronously
264 | * process its result or to give up on listening for the result. Note that
265 | * cancelling the returned future does not cancel the fiber itself, for
266 | * that you need to call {@link Fiber#cancel()}.
267 | */
268 | @NonBlocking
269 | default CancellableFuture awaitAsync() {
270 | final var f = joinAsync();
271 | return f.transform(it -> it.thenApply(v -> {
272 | try {
273 | return getResultOrThrow();
274 | } catch (final ExecutionException e) {
275 | throw new CompletionException(e.getCause());
276 | } catch (final Throwable e) {
277 | UncaughtExceptionHandler.rethrowIfFatal(e);
278 | throw new CompletionException(e);
279 | }
280 | }));
281 | }
282 |
283 | /**
284 | * Thrown in case {@link #getResultOrThrow()} is called before the fiber
285 | * completes (i.e., before one of the "join" methods return).
286 | */
287 | final class NotCompletedException extends Exception {
288 | public NotCompletedException() {
289 | super("Fiber is not completed");
290 | }
291 | }
292 | }
293 |
294 | /**
295 | * INTERNAL API.
296 | *
297 | * INTERNAL API: Internal apis are subject to change or removal
298 | * without any notice. When code depends on internal APIs, it is subject to
299 | * breakage between minor version updates.
300 | */
301 | @ApiStatus.Internal
302 | final class ExecutedFiber implements Fiber {
303 | private final TaskExecutor executor;
304 | private final Continuation continuation;
305 | private final MutableCancellable cancellableRef;
306 | private final AtomicReference> stateRef;
307 |
308 | private ExecutedFiber(final TaskExecutor executor) {
309 | this.cancellableRef = new MutableCancellable(this::fiberCancel);
310 | this.stateRef = new AtomicReference<>(State.start());
311 | this.executor = executor;
312 | this.continuation = new CancellableContinuation<>(
313 | executor,
314 | new AsyncContinuationCallback<>(
315 | new FiberCallback<>(executor, stateRef),
316 | executor
317 | ),
318 | cancellableRef
319 | );
320 | }
321 |
322 | private void fiberCancel() {
323 | while (true) {
324 | final var current = stateRef.get();
325 | if (current instanceof State.Active active) {
326 | if (stateRef.compareAndSet(current, new State.Cancelled<>(active.listeners))) {
327 | return;
328 | }
329 | } else {
330 | return;
331 | }
332 | }
333 | }
334 |
335 | @Override
336 | public T getResultOrThrow() throws ExecutionException, TaskCancellationException, NotCompletedException {
337 | final var current = stateRef.get();
338 | if (current instanceof State.Completed) {
339 | return ((State.Completed) current).outcome.getOrThrow();
340 | } else {
341 | throw new NotCompletedException();
342 | }
343 | }
344 |
345 | @Override
346 | public Cancellable joinAsync(final Runnable onComplete) {
347 | while (true) {
348 | final var current = stateRef.get();
349 | if (current instanceof State.Active || current instanceof State.Cancelled) {
350 | final var update = current.addListener(onComplete);
351 | if (stateRef.compareAndSet(current, update)) {
352 | return removeListenerCancellable(onComplete);
353 | }
354 | } else {
355 | executor.resumeOnExecutor(onComplete);
356 | return Cancellable.getEmpty();
357 | }
358 | }
359 | }
360 |
361 | @Override
362 | public void cancel() {
363 | this.cancellableRef.cancel();
364 | }
365 |
366 | private Cancellable removeListenerCancellable(final Runnable listener) {
367 | return () -> {
368 | while (true) {
369 | final var current = stateRef.get();
370 | if (current instanceof State.Active || current instanceof State.Cancelled) {
371 | final var update = current.removeListener(listener);
372 | if (stateRef.compareAndSet(current, update)) {
373 | return;
374 | }
375 | } else {
376 | return;
377 | }
378 | }
379 | };
380 | }
381 |
382 | sealed interface State {
383 | record Active