├── 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 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 | [![build](https://github.com/funfix/tasks/actions/workflows/build.yaml/badge.svg)](https://github.com/funfix/tasks/actions/workflows/build.yaml) [![maven](https://img.shields.io/maven-central/v/org.funfix/tasks-jvm.svg)](https://central.sonatype.com/artifact/org.funfix/tasks-jvm) [![javadoc](https://javadoc.io/badge2/org.funfix/tasks-jvm/javadoc.svg)](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 future, 23 | Cancellable cancellable 24 | ) { 25 | public CancellableFuture transform( 26 | Function, ? extends CompletableFuture> 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 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 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 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 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>(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>(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>(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 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 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 | *
  1. Its execution is idempotent, meaning that calling it multiple times 17 | * has the same effect as calling it once.
  2. 18 | *
  3. It is safe to call {@code cancel} from any thread.
  4. 19 | *
  5. 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.
  6. 22 | *
  7. 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.
  8. 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 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 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> 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 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 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( 384 | ImmutableQueue listeners 385 | ) implements State {} 386 | 387 | record Cancelled( 388 | ImmutableQueue listeners 389 | ) implements State {} 390 | 391 | record Completed( 392 | Outcome outcome 393 | ) implements State {} 394 | 395 | default void triggerListeners(TaskExecutor executor) { 396 | if (this instanceof Active ref) { 397 | for (final var listener : ref.listeners) { 398 | executor.resumeOnExecutor(listener); 399 | } 400 | } else if (this instanceof Cancelled ref) { 401 | for (final var listener : ref.listeners) { 402 | executor.resumeOnExecutor(listener); 403 | } 404 | } 405 | } 406 | 407 | default State addListener(final Runnable listener) { 408 | if (this instanceof Active ref) { 409 | final var newQueue = ref.listeners.enqueue(listener); 410 | return new Active<>(newQueue); 411 | } else if (this instanceof Cancelled ref) { 412 | final var newQueue = ref.listeners.enqueue(listener); 413 | return new Cancelled<>(newQueue); 414 | } else { 415 | return this; 416 | } 417 | } 418 | 419 | default State removeListener(final Runnable listener) { 420 | if (this instanceof Active ref) { 421 | final var newQueue = ref.listeners.filter(l -> l != listener); 422 | return new Active<>(newQueue); 423 | } else if (this instanceof Cancelled ref) { 424 | final var newQueue = ref.listeners.filter(l -> l != listener); 425 | return new Cancelled<>(newQueue); 426 | } else { 427 | return this; 428 | } 429 | } 430 | 431 | static State start() { 432 | return new Active<>(ImmutableQueue.empty() ); 433 | } 434 | } 435 | 436 | static Fiber start( 437 | final Executor executor, 438 | final AsyncContinuationFun createFun 439 | ) { 440 | final var taskExecutor = TaskExecutor.from(executor); 441 | final var fiber = new ExecutedFiber(taskExecutor); 442 | taskExecutor.execute(() -> { 443 | try { 444 | createFun.invoke(fiber.continuation); 445 | } catch (final Throwable e) { 446 | UncaughtExceptionHandler.rethrowIfFatal(e); 447 | fiber.continuation.onFailure(e); 448 | } 449 | }); 450 | return fiber; 451 | } 452 | 453 | static final class FiberCallback implements CompletionCallback { 454 | private final TaskExecutor executor; 455 | private final AtomicReference> stateRef; 456 | 457 | FiberCallback( 458 | final TaskExecutor executor, 459 | final AtomicReference> stateRef 460 | ) { 461 | this.executor = executor; 462 | this.stateRef = stateRef; 463 | } 464 | 465 | @Override 466 | public void onSuccess(T value) { 467 | onOutcome(Outcome.success(value)); 468 | } 469 | 470 | @Override 471 | public void onFailure(Throwable e) { 472 | onOutcome(Outcome.failure(e)); 473 | } 474 | 475 | @Override 476 | public void onCancellation() { 477 | onOutcome(Outcome.cancellation()); 478 | } 479 | 480 | @Override 481 | public void onOutcome(Outcome outcome) { 482 | while (true) { 483 | State current = stateRef.get(); 484 | if (current instanceof State.Active) { 485 | if (stateRef.compareAndSet(current, new State.Completed<>(outcome))) { 486 | current.triggerListeners(executor); 487 | return; 488 | } 489 | } else if (current instanceof State.Cancelled) { 490 | State.Completed update = new State.Completed<>(Outcome.cancellation()); 491 | if (stateRef.compareAndSet(current, update)) { 492 | current.triggerListeners(executor); 493 | return; 494 | } 495 | } else if (current instanceof State.Completed) { 496 | if (outcome instanceof Outcome.Failure failure) { 497 | UncaughtExceptionHandler.logOrRethrow(failure.exception()); 498 | } 499 | return; 500 | } else { 501 | throw new IllegalStateException("Invalid state: " + current); 502 | } 503 | } 504 | } 505 | } 506 | } 507 | 508 | /** 509 | * INTERNAL API. 510 | *

511 | * INTERNAL API: Internal apis are subject to change or removal 512 | * without any notice. When code depends on internal APIs, it is subject to 513 | * breakage between minor version updates. 514 | */ 515 | @ApiStatus.Internal 516 | final class AwaitSignal extends AbstractQueuedSynchronizer { 517 | @Override 518 | protected int tryAcquireShared(final int arg) { 519 | return getState() != 0 ? 1 : -1; 520 | } 521 | 522 | @Override 523 | protected boolean tryReleaseShared(final int arg) { 524 | setState(1); 525 | return true; 526 | } 527 | 528 | public void signal() { 529 | releaseShared(1); 530 | } 531 | 532 | public void await() throws InterruptedException { 533 | TaskLocalContext.signalTheStartOfBlockingCall(); 534 | acquireSharedInterruptibly(1); 535 | } 536 | 537 | public void await(final long timeoutMillis) throws InterruptedException, TimeoutException { 538 | TaskLocalContext.signalTheStartOfBlockingCall(); 539 | if (!tryAcquireSharedNanos(1, TimeUnit.MILLISECONDS.toNanos(timeoutMillis))) { 540 | throw new TimeoutException("Timed out after " + timeoutMillis + " millis"); 541 | } 542 | } 543 | } 544 | --------------------------------------------------------------------------------