From 2cea69a5db4f5108acb0631e2efa64672909c21a Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Fri, 28 Mar 2025 08:16:28 +0200 Subject: [PATCH 01/21] Re-add Kotlin and Scala --- .github/workflows/build.yaml | 34 +- build.gradle.kts | 19 + gradle.properties | 3 + settings.gradle.kts | 3 + .../api/tasks-kotlin-coroutines.api | 9 + tasks-kotlin-coroutines/build.gradle.kts | 70 ++ .../org/funfix/tasks/kotlin/coroutines.kt | 40 + .../org/funfix/tasks/kotlin/internals.kt | 15 + .../org/funfix/tasks/kotlin/coroutines.js.kt | 115 +++ .../org/funfix/tasks/kotlin/coroutines.jvm.kt | 114 +++ tasks-kotlin/api/tasks-kotlin.api | 120 +++ tasks-kotlin/build.gradle.kts | 59 ++ .../org/funfix/tasks/kotlin/Cancellable.kt | 22 + .../kotlin/org/funfix/tasks/kotlin/Outcome.kt | 59 ++ .../kotlin/org/funfix/tasks/kotlin/Task.kt | 121 +++ .../tasks/kotlin/UncaughtExceptionHandler.kt | 19 + .../org/funfix/tasks/kotlin/exceptions.kt | 24 + .../org/funfix/tasks/kotlin/executors.kt | 49 ++ .../org/funfix/tasks/kotlin/AsyncTestUtils.kt | 20 + .../org/funfix/tasks/kotlin/AsyncTests.kt | 135 +++ .../org/funfix/tasks/kotlin/Cancellable.js.kt | 46 + .../funfix/tasks/kotlin/CancellablePromise.kt | 17 + .../kotlin/org/funfix/tasks/kotlin/Task.js.kt | 73 ++ .../org/funfix/tasks/kotlin/exceptions.js.kt | 13 + .../org/funfix/tasks/kotlin/executors.js.kt | 129 +++ .../org/funfix/tasks/kotlin/Fiber.jvm.kt | 197 +++++ .../org/funfix/tasks/kotlin/Task.jvm.kt | 220 +++++ .../kotlin/UncaughtExceptionHandler.jvm.kt | 16 + .../kotlin/org/funfix/tasks/kotlin/aliases.kt | 26 + .../org/funfix/tasks/kotlin/executors.jvm.kt | 11 + .../org/funfix/tasks/kotlin/internals.kt | 26 + .../tests/TaskEnsureRunningOnExecutorTest.kt | 34 + .../org/funfix/tests/TaskFromAsyncTest.kt | 56 ++ .../funfix/tests/TaskFromBlockingIOTest.kt | 42 + .../org/funfix/tests/TaskFromFutureTest.kt | 169 ++++ .../org/funfix/tests/TaskRunAsyncTest.kt | 105 +++ .../org/funfix/tests/TaskRunBlockingTest.kt | 106 +++ .../org/funfix/tests/TaskRunFiberTest.kt | 275 ++++++ .../kotlin/org/funfix/tests/TimedAwait.kt | 31 + tasks-scala/build.gradle.kts | 18 + tasks-scala/build.sbt | 68 ++ .../tasks/scala/CompletionCallback.scala | 22 + .../scala-3/org/funfix/tasks/scala/Task.scala | 24 + .../org/funfix/tasks/scala/TaskExecutor.scala | 27 + .../org/funfix/tasks/scala/aliases.scala | 4 + .../org/funfix/tasks/scala/Outcome.scala | 7 + tasks-scala/project/Boilerplate.scala | 27 + tasks-scala/project/build.properties | 1 + tasks-scala/project/plugins.sbt | 4 + tasks-scala/sbt | 818 ++++++++++++++++++ 50 files changed, 3645 insertions(+), 17 deletions(-) create mode 100644 tasks-kotlin-coroutines/api/tasks-kotlin-coroutines.api create mode 100644 tasks-kotlin-coroutines/build.gradle.kts create mode 100644 tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt create mode 100644 tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/internals.kt create mode 100644 tasks-kotlin-coroutines/src/jsMain/kotlin/org/funfix/tasks/kotlin/coroutines.js.kt create mode 100644 tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt create mode 100644 tasks-kotlin/api/tasks-kotlin.api create mode 100644 tasks-kotlin/build.gradle.kts create mode 100644 tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Cancellable.kt create mode 100644 tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Outcome.kt create mode 100644 tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Task.kt create mode 100644 tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.kt create mode 100644 tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/exceptions.kt create mode 100644 tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/executors.kt create mode 100644 tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTestUtils.kt create mode 100644 tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTests.kt create mode 100644 tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Cancellable.js.kt create mode 100644 tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/CancellablePromise.kt create mode 100644 tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Task.js.kt create mode 100644 tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/exceptions.js.kt create mode 100644 tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/executors.js.kt create mode 100644 tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Fiber.jvm.kt create mode 100644 tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Task.jvm.kt create mode 100644 tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.jvm.kt create mode 100644 tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/aliases.kt create mode 100644 tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/executors.jvm.kt create mode 100644 tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/internals.kt create mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskEnsureRunningOnExecutorTest.kt create mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromAsyncTest.kt create mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromBlockingIOTest.kt create mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromFutureTest.kt create mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunAsyncTest.kt create mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunBlockingTest.kt create mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunFiberTest.kt create mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TimedAwait.kt create mode 100644 tasks-scala/build.gradle.kts create mode 100644 tasks-scala/build.sbt create mode 100644 tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/CompletionCallback.scala create mode 100644 tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/Task.scala create mode 100644 tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/TaskExecutor.scala create mode 100644 tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/aliases.scala create mode 100644 tasks-scala/core/shared/src/main/scala-3/org/funfix/tasks/scala/Outcome.scala create mode 100644 tasks-scala/project/Boilerplate.scala create mode 100644 tasks-scala/project/build.properties create mode 100644 tasks-scala/project/plugins.sbt create mode 100755 tasks-scala/sbt diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 779be1f..2f75e59 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -37,20 +37,20 @@ jobs: **/build/reports/ **/build/test-results/ -# test-sbt: -# runs-on: ubuntu-latest -# steps: -# - uses: actions/checkout@v4 -# - name: Set up JDK 21 -# uses: actions/setup-java@v4 -# with: -# cache: 'sbt' -# java-version: 21 -# distribution: 'temurin' -# - name: Setup Gradle -# uses: gradle/actions/setup-gradle@v3 -# - name: Setup sbt -# uses: sbt/setup-sbt@v1 -# - name: Build and test -# run: sbt -v publishLocalGradleDependencies ++test -# working-directory: ./tasks-scala + test-sbt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + cache: 'sbt' + java-version: 21 + distribution: 'temurin' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + - name: Setup sbt + uses: sbt/setup-sbt@v1 + - name: Build and test + run: sbt -v publishLocalGradleDependencies ++test + working-directory: ./tasks-scala diff --git a/build.gradle.kts b/build.gradle.kts index 2d4f0db..40d3151 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,7 @@ import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask val projectVersion = property("project.version").toString() plugins { + id("org.jetbrains.dokka") id("com.github.ben-manes.versions") } @@ -10,6 +11,24 @@ repositories { mavenCentral() } +buildscript { + dependencies { + classpath("org.jetbrains.dokka:dokka-base:2.0.0") + // classpath("org.jetbrains.dokka:kotlin-as-java-plugin:2.0.0") + } +} + +//dokka { +// dokkaPublications.html { +// outputDirectory.set(rootDir.resolve("build/dokka")) +// outputDirectory.set(file("build/dokka")) +// } +//} + +tasks.dokkaHtmlMultiModule { + outputDirectory.set(file("build/dokka")) +} + tasks.named("dependencyUpdates").configure { fun isNonStable(version: String): Boolean { val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } diff --git a/gradle.properties b/gradle.properties index 98f733a..f30a13c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,3 +2,6 @@ kotlin.code.style=official # TO BE modified whenever a new version is released project.version=0.0.3 + +# https://kotlinlang.org/docs/dokka-migration.html#sync-your-project-with-gradle +org.jetbrains.dokka.experimental.gradle.pluginMode=V2EnabledWithHelpers diff --git a/settings.gradle.kts b/settings.gradle.kts index 98f724f..f09c77b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,9 @@ rootProject.name = "tasks" include("tasks-jvm") +include("tasks-kotlin") +include("tasks-kotlin-coroutines") +include("tasks-scala") pluginManagement { repositories { diff --git a/tasks-kotlin-coroutines/api/tasks-kotlin-coroutines.api b/tasks-kotlin-coroutines/api/tasks-kotlin-coroutines.api new file mode 100644 index 0000000..86a457c --- /dev/null +++ b/tasks-kotlin-coroutines/api/tasks-kotlin-coroutines.api @@ -0,0 +1,9 @@ +public final class org/funfix/tasks/kotlin/CoroutinesJvmKt { + public static final fun fromSuspended (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun fromSuspended$default (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun runSuspended (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun runSuspended$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun runSuspended-A-R0woo (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun runSuspended-A-R0woo$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + diff --git a/tasks-kotlin-coroutines/build.gradle.kts b/tasks-kotlin-coroutines/build.gradle.kts new file mode 100644 index 0000000..808d7fb --- /dev/null +++ b/tasks-kotlin-coroutines/build.gradle.kts @@ -0,0 +1,70 @@ +@file:OptIn(ExperimentalKotlinGradlePluginApi::class) + +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode + +plugins { + id("tasks.kmp-project") +} + +mavenPublishing { + pom { + name = "Tasks / Kotlin Coroutines" + description = "Integration with Kotlin's Coroutines" + } +} + +kotlin { + sourceSets { + val commonMain by getting { + compilerOptions { + explicitApi = ExplicitApiMode.Strict + allWarningsAsErrors = true + } + + dependencies { + implementation(project(":tasks-kotlin")) + implementation(libs.kotlinx.coroutines.core) + } + } + + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + + val jvmMain by getting { + compilerOptions { + explicitApi = ExplicitApiMode.Strict + allWarningsAsErrors = true + } + + dependencies { + implementation(project(":tasks-jvm")) + implementation(project(":tasks-kotlin")) + implementation(libs.kotlinx.coroutines.core) + } + } + + val jvmTest by getting { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + + val jsMain by getting { + compilerOptions { + explicitApi = ExplicitApiMode.Strict + allWarningsAsErrors = true + } + + dependencies { + implementation(project(":tasks-kotlin")) + implementation(libs.kotlinx.coroutines.core) + } + } + } +} diff --git a/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt b/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt new file mode 100644 index 0000000..f0c3f19 --- /dev/null +++ b/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt @@ -0,0 +1,40 @@ +package org.funfix.tasks.kotlin + +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * Similar with `runBlocking`, however this is a "suspended" function, + * to be executed in the context of [kotlinx.coroutines]. + * + * NOTES: + * - The [CoroutineDispatcher], made available via the "coroutine context", is + * used to execute the task, being passed to the task's implementation as an + * `Executor`. + * - The coroutine's cancellation protocol cooperates with that of [Task], + * so cancelling the coroutine will also cancel the task (including the + * possibility for back-pressuring on the fiber's completion after + * cancellation). + * + * @param executor is an override of the `Executor` to be used for executing + * the task. If `null`, the `Executor` will be derived from the + * `CoroutineDispatcher` + */ +public expect suspend fun Task.runSuspended( + executor: Executor? = null +): T + +/** + * See documentation for [Task.runSuspended]. + */ +public expect suspend fun PlatformTask.runSuspended( + executor: Executor? = null +): T + +/** + * Creates a [Task] from a suspended block of code. + */ +public expect suspend fun Task.Companion.fromSuspended( + coroutineContext: CoroutineContext = EmptyCoroutineContext, + block: suspend () -> T +): Task diff --git a/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/internals.kt b/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/internals.kt new file mode 100644 index 0000000..b67a24c --- /dev/null +++ b/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/internals.kt @@ -0,0 +1,15 @@ +package org.funfix.tasks.kotlin + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.ContinuationInterceptor +import kotlin.coroutines.coroutineContext + +/** + * Internal API: gets the current [CoroutineDispatcher] from the coroutine context. + */ +internal suspend fun currentDispatcher(): CoroutineDispatcher { + // Access the coroutineContext to get the ContinuationInterceptor + val continuationInterceptor = coroutineContext[ContinuationInterceptor] + return continuationInterceptor as? CoroutineDispatcher ?: Dispatchers.Default +} diff --git a/tasks-kotlin-coroutines/src/jsMain/kotlin/org/funfix/tasks/kotlin/coroutines.js.kt b/tasks-kotlin-coroutines/src/jsMain/kotlin/org/funfix/tasks/kotlin/coroutines.js.kt new file mode 100644 index 0000000..95a6105 --- /dev/null +++ b/tasks-kotlin-coroutines/src/jsMain/kotlin/org/funfix/tasks/kotlin/coroutines.js.kt @@ -0,0 +1,115 @@ +@file:OptIn(DelicateCoroutinesApi::class) + +package org.funfix.tasks.kotlin + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.resumeWithException + +public actual suspend fun PlatformTask.runSuspended( + executor: Executor? +): T = run { + val executorOrDefault = executor ?: buildExecutor(currentDispatcher()) + suspendCancellableCoroutine { cont -> + val contCallback = cont.asCompletionCallback() + try { + val token = this.invoke(executorOrDefault, contCallback) + cont.invokeOnCancellation { + token.cancel() + } + } catch (e: Throwable) { + UncaughtExceptionHandler.rethrowIfFatal(e) + contCallback(Outcome.Failure(e)) + } + } +} + +internal fun buildExecutor(dispatcher: CoroutineDispatcher): Executor = + DispatcherExecutor(dispatcher) + +internal fun buildCoroutineDispatcher( + @Suppress("UNUSED_PARAMETER") executor: Executor +): CoroutineDispatcher = + // Building this CoroutineDispatcher from an Executor is problematic, and there's no + // point in even trying on top of JS engines. + Dispatchers.Default + +private class DispatcherExecutor(val dispatcher: CoroutineDispatcher) : Executor { + override fun execute(command: Runnable) { + if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) { + dispatcher.dispatch( + EmptyCoroutineContext, + kotlinx.coroutines.Runnable { command.run() } + ) + } else { + command.run() + } + } + + override fun toString(): String = + dispatcher.toString() +} + +internal fun CancellableContinuation.asCompletionCallback(): Callback { + var isActive = true + return { outcome -> + if (outcome is Outcome.Failure) { + UncaughtExceptionHandler.rethrowIfFatal(outcome.exception) + } + if (isActive) { + isActive = false + when (outcome) { + is Outcome.Success -> + resume(outcome.value) { _, _, _ -> + // on cancellation? + } + is Outcome.Failure -> + resumeWithException(outcome.exception) + is Outcome.Cancellation -> + resumeWithException(kotlinx.coroutines.CancellationException()) + } + } else if (outcome is Outcome.Failure) { + UncaughtExceptionHandler.logOrRethrow(outcome.exception) + } + } +} + +/** + * Creates a [Task] from a suspended block of code. + */ +public actual suspend fun Task.Companion.fromSuspended( + coroutineContext: CoroutineContext, + block: suspend () -> T +): Task = + Task.fromAsync { executor, callback -> + val job = GlobalScope.launch( + buildCoroutineDispatcher(executor) + coroutineContext + ) { + try { + val r = block() + callback(Outcome.Success(r)) + } catch (e: Throwable) { + UncaughtExceptionHandler.rethrowIfFatal(e) + when (e) { + is CancellationException, is TaskCancellationException -> + callback(Outcome.Cancellation) + else -> + callback(Outcome.Failure(e)) + } + } + } + Cancellable { + job.cancel() + } + } + +public actual suspend fun Task.runSuspended(executor: Executor?): T = + asPlatform.runSuspended(executor) diff --git a/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt b/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt new file mode 100644 index 0000000..aff9371 --- /dev/null +++ b/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt @@ -0,0 +1,114 @@ +@file:JvmName("CoroutinesJvmKt") +@file:OptIn(DelicateCoroutinesApi::class) + +package org.funfix.tasks.kotlin + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import org.funfix.tasks.jvm.CompletionCallback +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.resumeWithException +import org.funfix.tasks.jvm.Outcome + +public actual suspend fun PlatformTask.runSuspended(executor: Executor?): T = + run { + val executorOrDefault = executor ?: currentDispatcher().asExecutor() + suspendCancellableCoroutine { cont -> + val contCallback = CoroutineAsCompletionCallback(cont) + try { + val token = runAsync(executorOrDefault, contCallback) + cont.invokeOnCancellation { + token.cancel() + } + } catch (e: Throwable) { + UncaughtExceptionHandler.rethrowIfFatal(e) + contCallback.onFailure(e) + } + } + } + +/** + * Internal API: wraps a [CancellableContinuation] into a [CompletionCallback]. + */ +internal class CoroutineAsCompletionCallback( + private val cont: CancellableContinuation +) : CompletionCallback { + private val isActive = AtomicBoolean(true) + + private inline fun completeWith(crossinline block: () -> Unit): Boolean = + if (isActive.getAndSet(false)) { + block() + true + } else { + false + } + + override fun onOutcome(outcome: Outcome) { + when (outcome) { + is Outcome.Success -> onSuccess(outcome.value) + is Outcome.Failure -> onFailure(outcome.exception) + is Outcome.Cancellation -> onCancellation() + } + } + + override fun onSuccess(value: T) { + completeWith { + cont.resume(value) { _, _, _ -> + // on cancellation? + } + } + } + + override fun onFailure(e: Throwable) { + if (!completeWith { + cont.resumeWithException(e) + }) { + UncaughtExceptionHandler.logOrRethrow(e) + } + } + + override fun onCancellation() { + completeWith { + cont.resumeWithException(kotlinx.coroutines.CancellationException()) + } + } +} + +public actual suspend fun Task.Companion.fromSuspended( + coroutineContext: CoroutineContext, + block: suspend () -> T +): Task = Task( + PlatformTask.fromAsync { executor, callback -> + val job = GlobalScope.launch( + executor.asCoroutineDispatcher() + coroutineContext + ) { + try { + val r = block() + callback.onSuccess(r) + } catch (e: Throwable) { + UncaughtExceptionHandler.rethrowIfFatal(e) + when (e) { + is CancellationException, + is TaskCancellationException, + is InterruptedException -> + callback.onCancellation() + else -> + callback.onFailure(e) + } + } + } + Cancellable { + job.cancel() + } + } +) + +public actual suspend fun Task.runSuspended(executor: Executor?): T = + asPlatform.runSuspended(executor) diff --git a/tasks-kotlin/api/tasks-kotlin.api b/tasks-kotlin/api/tasks-kotlin.api new file mode 100644 index 0000000..78fee6d --- /dev/null +++ b/tasks-kotlin/api/tasks-kotlin.api @@ -0,0 +1,120 @@ +public final class org/funfix/tasks/kotlin/ExecutorsJvmKt { + public static final fun getSharedIOExecutor ()Ljava/util/concurrent/Executor; + public static final fun getTrampolineExecutor ()Ljava/util/concurrent/Executor; +} + +public final class org/funfix/tasks/kotlin/Fiber : org/funfix/tasks/jvm/Cancellable { + public static final synthetic fun box-impl (Lorg/funfix/tasks/jvm/Fiber;)Lorg/funfix/tasks/kotlin/Fiber; + public fun cancel ()V + public static fun cancel-impl (Lorg/funfix/tasks/jvm/Fiber;)V + public static fun constructor-impl (Lorg/funfix/tasks/jvm/Fiber;)Lorg/funfix/tasks/jvm/Fiber; + public fun equals (Ljava/lang/Object;)Z + public static fun equals-impl (Lorg/funfix/tasks/jvm/Fiber;Ljava/lang/Object;)Z + public static final fun equals-impl0 (Lorg/funfix/tasks/jvm/Fiber;Lorg/funfix/tasks/jvm/Fiber;)Z + public final fun getAsPlatform ()Lorg/funfix/tasks/jvm/Fiber; + public fun hashCode ()I + public static fun hashCode-impl (Lorg/funfix/tasks/jvm/Fiber;)I + public fun toString ()Ljava/lang/String; + public static fun toString-impl (Lorg/funfix/tasks/jvm/Fiber;)Ljava/lang/String; + public final synthetic fun unbox-impl ()Lorg/funfix/tasks/jvm/Fiber; +} + +public final class org/funfix/tasks/kotlin/FiberJvmKt { + public static final fun asKotlin (Lorg/funfix/tasks/jvm/Fiber;)Lorg/funfix/tasks/jvm/Fiber; + public static final fun awaitAsync-bilpdk0 (Lorg/funfix/tasks/jvm/Fiber;Lkotlin/jvm/functions/Function1;)Lorg/funfix/tasks/jvm/Cancellable; + public static final fun awaitBlocking-eJoBjQM (Lorg/funfix/tasks/jvm/Fiber;)Ljava/lang/Object; + public static final fun awaitBlockingTimed-MI-qbaI (Lorg/funfix/tasks/jvm/Fiber;J)Ljava/lang/Object; + public static final fun getOutcomeOrNull-eJoBjQM (Lorg/funfix/tasks/jvm/Fiber;)Lorg/funfix/tasks/kotlin/Outcome; + public static final fun getResultOrThrow-eJoBjQM (Lorg/funfix/tasks/jvm/Fiber;)Ljava/lang/Object; + public static final fun joinAsync-bilpdk0 (Lorg/funfix/tasks/jvm/Fiber;Ljava/lang/Runnable;)Lorg/funfix/tasks/jvm/Cancellable; + public static final fun joinBlocking-eJoBjQM (Lorg/funfix/tasks/jvm/Fiber;)V + public static final fun joinBlockingTimed-MI-qbaI (Lorg/funfix/tasks/jvm/Fiber;J)V +} + +public abstract interface class org/funfix/tasks/kotlin/Outcome { + public static final field Companion Lorg/funfix/tasks/kotlin/Outcome$Companion; + public fun getOrThrow ()Ljava/lang/Object; +} + +public final class org/funfix/tasks/kotlin/Outcome$Cancellation : org/funfix/tasks/kotlin/Outcome { + public static final field INSTANCE Lorg/funfix/tasks/kotlin/Outcome$Cancellation; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/funfix/tasks/kotlin/Outcome$Companion { + public final fun cancellation ()Lorg/funfix/tasks/kotlin/Outcome; + public final fun failure (Ljava/lang/Throwable;)Lorg/funfix/tasks/kotlin/Outcome; + public final fun success (Ljava/lang/Object;)Lorg/funfix/tasks/kotlin/Outcome; +} + +public final class org/funfix/tasks/kotlin/Outcome$Failure : org/funfix/tasks/kotlin/Outcome { + public fun (Ljava/lang/Throwable;)V + public final fun component1 ()Ljava/lang/Throwable; + public final fun copy (Ljava/lang/Throwable;)Lorg/funfix/tasks/kotlin/Outcome$Failure; + public static synthetic fun copy$default (Lorg/funfix/tasks/kotlin/Outcome$Failure;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/funfix/tasks/kotlin/Outcome$Failure; + public fun equals (Ljava/lang/Object;)Z + public final fun getException ()Ljava/lang/Throwable; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/funfix/tasks/kotlin/Outcome$Success : org/funfix/tasks/kotlin/Outcome { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lorg/funfix/tasks/kotlin/Outcome$Success; + public static synthetic fun copy$default (Lorg/funfix/tasks/kotlin/Outcome$Success;Ljava/lang/Object;ILjava/lang/Object;)Lorg/funfix/tasks/kotlin/Outcome$Success; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/funfix/tasks/kotlin/Task { + public static final field Companion Lorg/funfix/tasks/kotlin/Task$Companion; + public static final synthetic fun box-impl (Lorg/funfix/tasks/jvm/Task;)Lorg/funfix/tasks/kotlin/Task; + public static fun constructor-impl (Lorg/funfix/tasks/jvm/Task;)Lorg/funfix/tasks/jvm/Task; + public fun equals (Ljava/lang/Object;)Z + public static fun equals-impl (Lorg/funfix/tasks/jvm/Task;Ljava/lang/Object;)Z + public static final fun equals-impl0 (Lorg/funfix/tasks/jvm/Task;Lorg/funfix/tasks/jvm/Task;)Z + public static final fun getAsJava-impl (Lorg/funfix/tasks/jvm/Task;)Lorg/funfix/tasks/jvm/Task; + public final fun getAsPlatform ()Lorg/funfix/tasks/jvm/Task; + public fun hashCode ()I + public static fun hashCode-impl (Lorg/funfix/tasks/jvm/Task;)I + public fun toString ()Ljava/lang/String; + public static fun toString-impl (Lorg/funfix/tasks/jvm/Task;)Ljava/lang/String; + public final synthetic fun unbox-impl ()Lorg/funfix/tasks/jvm/Task; +} + +public final class org/funfix/tasks/kotlin/Task$Companion { +} + +public final class org/funfix/tasks/kotlin/TaskJvmKt { + public static final fun ensureRunningOnExecutor-EZXAkWY (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;)Lorg/funfix/tasks/jvm/Task; + public static synthetic fun ensureRunningOnExecutor-EZXAkWY$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;ILjava/lang/Object;)Lorg/funfix/tasks/jvm/Task; + public static final fun fromAsync (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/jvm/functions/Function2;)Lorg/funfix/tasks/jvm/Task; + public static final fun fromBlockingFuture (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/jvm/functions/Function0;)Lorg/funfix/tasks/jvm/Task; + public static final fun fromBlockingIO (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/jvm/functions/Function0;)Lorg/funfix/tasks/jvm/Task; + public static final fun fromCancellableFuture (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/jvm/functions/Function0;)Lorg/funfix/tasks/jvm/Task; + public static final fun fromCompletionStage (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/jvm/functions/Function0;)Lorg/funfix/tasks/jvm/Task; + public static final fun runAsync-A-R0woo (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/jvm/functions/Function1;)Lorg/funfix/tasks/jvm/Cancellable; + public static synthetic fun runAsync-A-R0woo$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/funfix/tasks/jvm/Cancellable; + public static final fun runBlocking-EZXAkWY (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;)Ljava/lang/Object; + public static synthetic fun runBlocking-EZXAkWY$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun runBlockingTimed-4GGJJa0 (Lorg/funfix/tasks/jvm/Task;JLjava/util/concurrent/Executor;)Ljava/lang/Object; + public static synthetic fun runBlockingTimed-4GGJJa0$default (Lorg/funfix/tasks/jvm/Task;JLjava/util/concurrent/Executor;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun runFiber-EZXAkWY (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;)Lorg/funfix/tasks/jvm/Fiber; + public static synthetic fun runFiber-EZXAkWY$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;ILjava/lang/Object;)Lorg/funfix/tasks/jvm/Fiber; +} + +public final class org/funfix/tasks/kotlin/TaskKt { + public static final fun asKotlin (Lorg/funfix/tasks/jvm/Task;)Lorg/funfix/tasks/jvm/Task; +} + +public final class org/funfix/tasks/kotlin/UncaughtExceptionHandler { + public static final field INSTANCE Lorg/funfix/tasks/kotlin/UncaughtExceptionHandler; + public final fun logOrRethrow (Ljava/lang/Throwable;)V + public final fun rethrowIfFatal (Ljava/lang/Throwable;)V +} + diff --git a/tasks-kotlin/build.gradle.kts b/tasks-kotlin/build.gradle.kts new file mode 100644 index 0000000..40a4518 --- /dev/null +++ b/tasks-kotlin/build.gradle.kts @@ -0,0 +1,59 @@ +@file:OptIn(ExperimentalKotlinGradlePluginApi::class) + +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode + +plugins { + id("tasks.kmp-project") +} + +mavenPublishing { + pom { + name = "Tasks / Kotlin" + description = "Integration with Kotlin Multiplatform" + } +} + +kotlin { + sourceSets { + val commonMain by getting { + compilerOptions { + explicitApi = ExplicitApiMode.Strict + allWarningsAsErrors = true + } + } + + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + + val jvmMain by getting { + compilerOptions { + explicitApi = ExplicitApiMode.Strict + allWarningsAsErrors = true + } + + dependencies { + implementation(project(":tasks-jvm")) + compileOnly(libs.jetbrains.annotations) + } + } + + val jvmTest by getting { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + + val jsMain by getting { + compilerOptions { + explicitApi = ExplicitApiMode.Strict + allWarningsAsErrors = true + } + } + } +} diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Cancellable.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Cancellable.kt new file mode 100644 index 0000000..d7b550c --- /dev/null +++ b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Cancellable.kt @@ -0,0 +1,22 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package org.funfix.tasks.kotlin + +/** + * Represents a non-blocking piece of logic that triggers the cancellation + * procedure of an asynchronous computation. + * + * MUST NOT block the calling thread. Interruption of the computation + * isn't guaranteed to have happened after this call returns. + * + * MUST BE idempotent, i.e. calling it multiple times should have the same + * effect as calling it once. + * + * MUST BE thread-safe. + */ +public expect fun interface Cancellable { + /** + * Triggers the cancellation of the computation. + */ + public fun cancel() +} diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Outcome.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Outcome.kt new file mode 100644 index 0000000..6f4dd4d --- /dev/null +++ b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Outcome.kt @@ -0,0 +1,59 @@ +package org.funfix.tasks.kotlin + +/** + * Represents the result of a computation. + * + * This is a union type that can signal: + * - a successful result, via [Outcome.Success] + * - a failure (with an exception), via [Outcome.Failure] + * - a cancelled computation, via [Outcome.Cancellation] + */ +public sealed interface Outcome { + public val orThrow: T + /** + * Returns the successful result of a computation, or throws an exception + * if the computation failed or was cancelled. + * + * @throws TaskCancellationException in case this is an [Outcome.Cancellation] + * @throws Throwable in case this is an [Outcome.Failure] + */ + @Throws(TaskCancellationException::class) + get() = + when (this) { + is Success -> value + is Failure -> throw exception + is Cancellation -> throw TaskCancellationException("Task was cancelled") + } + + /** + * Returned in case the task was successful. + */ + public data class Success(val value: T): Outcome + + /** + * Returned in case the task failed with an exception. + */ + public data class Failure(val exception: Throwable): Outcome + + /** + * Returned in case the task was cancelled. + */ + public data object Cancellation: Outcome + + public companion object { + /** + * Constructs a successful [Outcome] with the given value. + */ + public fun success(value: T): Outcome = Success(value) + + /** + * Constructs a failed [Outcome] with the given exception. + */ + public fun failure(e: Throwable): Outcome = Failure(e) + + /** + * Constructs a cancelled [Outcome]. + */ + public fun cancellation(): Outcome = Cancellation + } +} diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Task.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Task.kt new file mode 100644 index 0000000..7e3b021 --- /dev/null +++ b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Task.kt @@ -0,0 +1,121 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package org.funfix.tasks.kotlin + +/** + * An alias for a platform-specific implementation that powers [Task]. + */ +public expect class PlatformTask + +/** + * Kotlin-specific callback type used for signaling the completion of running + * tasks. + */ +public typealias Callback = (Outcome) -> Unit + +/** + * A task is a computation that can be executed asynchronously. + * + * In the vocabulary of "reactive streams", this is a "cold data source", + * meaning that the computation hasn't executed yet, and when it will execute, + * the result won't get cached (memoized). In the vocabulary of + * "functional programming", this is a pure value, being somewhat equivalent + * to `IO`. + * + * This is designed to be a compile-time type that's going to be erased at + * runtime. Therefore, for the JVM at least, when using it in your APIs, it + * won't pollute it with Kotlin-specific wrappers. + */ +public expect value class Task public constructor( + public val asPlatform: PlatformTask +) { + // Companion object currently doesn't do anything, but we + // need to define one to make the class open for extensions. + public companion object +} + +/** + * Converts a platform task to a Kotlin task. + * + * E.g., can convert a `jvm.Task` to a `kotlin.Task`. + */ +public fun PlatformTask.asKotlin(): Task = + Task(this) + +/** + * Ensures that the task starts asynchronously and runs on the given executor, + * regardless of the `run` method that is used, or the injected executor in + * any of those methods. + * + * One example where this is useful is for blocking I/O operations, for + * ensuring that the task runs on the thread-pool meant for blocking I/O, + * regardless of what executor is passed to [runAsync]. + * + * Example: + * ```kotlin + * Task.fromBlockingIO { + * // Reads a file from disk + * Files.readString(Paths.get("file.txt")) + * }.ensureRunningOnExecutor( + * BlockingIOExecutor + * ) + * ``` + * + * Another use-case is for ensuring that the task runs asynchronously, on + * another thread. Otherwise, tasks may be able to execute on the current thread: + * + * ```kotlin + * val task = Task.fromBlockingIO { + * // Reads a file from disk + * Files.readString(Paths.get("file.txt")) + * } + * + * task + * // Ensuring the task runs on a different thread + * .ensureRunningOnExecutor() + * // Blocking the current thread for the result (JVM API) + * .runBlocking() + * ``` + * + * @param executor is the [Executor] used as an override. If `null`, then + * the executor injected (e.g., in [runAsync]) will be used. + */ +public expect fun Task.ensureRunningOnExecutor(executor: Executor? = null): Task + +/** + * Executes the task asynchronously. + * + * @param executor is the [Executor] to use for running the task + * @param callback is the callback given for signaling completion + * @return a [Cancellable] that can be used to cancel the running task + */ +public expect fun Task.runAsync( + executor: Executor? = null, + callback: Callback +): Cancellable + +/** + * Creates a task from an asynchronous computation, initiated on the current thread. + * + * This method ensures: + * 1. Idempotent cancellation + * 2. Trampolined execution to avoid stack-overflows + * + * The created task will execute the given function on the current + * thread, by using a "trampoline" to avoid stack overflows. This may + * be useful if the computation for initiating the async process is + * expected to be fast. However, if the computation can block the + * current thread, it is recommended to use [fromForkedAsync] instead, + * which will initiate the computation by yielding first (i.e., on the + * JVM this means the execution will start on a different thread). + * + * @param start is the function that will trigger the async computation, + * injecting a callback that will be used to signal the result, and an + * executor that can be used for creating additional threads. + * + * @return a new task that will execute the given builder function upon execution + * @see fromForkedAsync + */ +public expect fun Task.Companion.fromAsync( + start: (Executor, Callback) -> Cancellable +): Task diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.kt new file mode 100644 index 0000000..eda1499 --- /dev/null +++ b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.kt @@ -0,0 +1,19 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package org.funfix.tasks.kotlin + +/** + * Utilities for handling uncaught exceptions. + */ +public expect object UncaughtExceptionHandler { + /** + * Used for filtering the fatal exceptions that should + * crash the process (e.g., `OutOfMemoryError`). + */ + public fun rethrowIfFatal(e: Throwable) + + /** + * Logs a caught exception, or rethrows it if it's fatal. + */ + public fun logOrRethrow(e: Throwable) +} diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/exceptions.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/exceptions.kt new file mode 100644 index 0000000..d29fa54 --- /dev/null +++ b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/exceptions.kt @@ -0,0 +1,24 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package org.funfix.tasks.kotlin + +/** + * An exception that is thrown when waiting for the result of a task + * that has been cancelled. + * + * Note, this is unlike the JVM's `InterruptedException` or Kotlin's + * `CancellationException`, which are thrown when the current thread or fiber is + * interrupted. This exception is thrown when waiting for the result of a task + * that has been cancelled concurrently, but this doesn't mean that the current + * thread or fiber was interrupted. + */ +public expect open class TaskCancellationException(message: String?): Exception { + public constructor() +} + +/** + * Exception thrown when trying to get the result of a fiber that + * hasn't completed yet. + */ +public expect class FiberNotCompletedException public constructor() : + Exception diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/executors.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/executors.kt new file mode 100644 index 0000000..db83c28 --- /dev/null +++ b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/executors.kt @@ -0,0 +1,49 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package org.funfix.tasks.kotlin + +/** + * An [Executor] is an abstraction for a thread-pool or a single-threaded + * event-loop, used for running tasks. + * + * On the JVM, this is an alias for the `java.util.concurrent.Executor` + * interface. On top of JavaScript, one way to implement this is via + * `setTimeout`. + */ +public expect fun interface Executor { + public fun execute(command: Runnable) +} + +/** + * A simple interface for a task that can be executed asynchronously. + * + * On the JVM, this is an alias for the `java.lang.Runnable` interface. + */ +public expect fun interface Runnable { + public fun run() +} + +/** + * The global executor, used for running tasks that don't specify an + * explicit executor. + * + * On top of the JVM, this is powered by "virtual threads" (project loom), if + * the runtime supports it (Java 21+). Otherwise, it's an unlimited "cached" + * thread-pool. On top of JavaScript, blocking I/O operations are not possible + * in the browser, and discouraged in Node.js. JS runtimes don't have + * multi-threading with shared-memory concurrency, so this will be just a plain + * executor. + */ +public expect val SharedIOExecutor: Executor + +/** + * An [Executor] that runs tasks on the current thread. + * + * Uses a [trampoline](https://en.wikipedia.org/wiki/Trampoline_(computing)) + * to ensure that recursive calls don't blow the stack. + * + * Using this executor is useful for making asynchronous callbacks stack-safe. + * Note, however, that the tasks get executed on the current thread, immediately, + * even if the implementation guards against stack overflows. + */ +public expect val TrampolineExecutor: Executor diff --git a/tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTestUtils.kt b/tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTestUtils.kt new file mode 100644 index 0000000..aa186ec --- /dev/null +++ b/tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTestUtils.kt @@ -0,0 +1,20 @@ +package org.funfix.tasks.kotlin + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +interface AsyncTestUtils { + fun runTest( + context: CoroutineContext = EmptyCoroutineContext, + testBody: suspend TestScope.() -> Unit + ) { + kotlinx.coroutines.test.runTest(context) { + withContext(Dispatchers.Unconfined) { + testBody() + } + } + } +} diff --git a/tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTests.kt b/tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTests.kt new file mode 100644 index 0000000..4e02a99 --- /dev/null +++ b/tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTests.kt @@ -0,0 +1,135 @@ +//package org.funfix.tasks.kotlin +// +//import kotlinx.coroutines.yield +//import kotlin.test.Test +//import kotlin.test.assertEquals +//import kotlin.test.fail +// +//class AsyncTests: AsyncTestUtils { +// @Test +// fun createAsync() = runTest { +// val task = taskFromAsync { executor, callback -> +// executor.execute { +// callback(Outcome.Success(1 + 1)) +// } +// EmptyCancellable +// } +// +// val r = task.executeSuspended() +// assertEquals(2, r) +// } +// +// @Test +// fun fromSuspendedHappy() = runTest { +// val task = taskFromSuspended { +// yield() +// 1 + 1 +// } +// +// val r = task.executeSuspended() +// assertEquals(2, r) +// } +// +// @Test +// fun fromSuspendedFailure() = runTest { +// val e = RuntimeException("Boom") +// val task = taskFromSuspended { +// yield() +// throw e +// } +// +// try { +// task.executeSuspended() +// fail("Should have thrown") +// } catch (e: RuntimeException) { +// assertEquals("Boom", e.message) +// } +// } +// +// @Test +// fun simpleSuspendedChaining() = runTest { +// val task = taskFromSuspended { +// yield() +// 1 + 1 +// } +// +// val task2 = taskFromSuspended { +// yield() +// task.executeSuspended() + 1 +// } +// +// val r = task2.executeSuspended() +// assertEquals(3, r) +// } +// +// @Test +// fun fiberChaining() = runTest { +// val task = taskFromSuspended { +// yield() +// 1 + 1 +// } +// +// val task2 = taskFromSuspended { +// yield() +// task.executeFiber().awaitSuspended() + 1 +// } +// +// val r = task2.executeSuspended() +// assertEquals(3, r) +// } +// +// @Test +// fun complexChaining() = runTest { +// val task = taskFromSuspended { +// yield() +// 1 + 1 +// } +// +// val task2 = taskFromSuspended { +// yield() +// task.executeSuspended() + 1 +// } +// +// val task3 = taskFromSuspended { +// yield() +// task2.executeFiber().awaitSuspended() + 1 +// } +// +// val task4 = taskFromSuspended { +// yield() +// val deferred = async { task3.executeSuspended() } +// deferred.await() + 1 +// } +// +// val r = task4.executeSuspended() +// assertEquals(5, r) +// } +// +// @Test +// fun cancellation() = runTest { +// val lock = Mutex() +// val latch = CompletableDeferred() +// val wasCancelled = CompletableDeferred() +// lock.lock() +// +// val job = async { +// taskFromSuspended { +// yield() +// latch.complete(Unit) +// try { +// lock.lock() +// } finally { +// wasCancelled.complete(Unit) +// lock.unlock() +// } +// }.executeSuspended() +// } +// +// withTimeout(5000) { latch.await() } +// job.cancel() +// +// withTimeout(5000) { +// wasCancelled.await() +// } +// } +//} diff --git a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Cancellable.js.kt b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Cancellable.js.kt new file mode 100644 index 0000000..a90c1e0 --- /dev/null +++ b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Cancellable.js.kt @@ -0,0 +1,46 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package org.funfix.tasks.kotlin + +public actual fun interface Cancellable { + public actual fun cancel() + + public companion object { + public val empty: Cancellable = + Cancellable {} + } +} + +internal class MutableCancellable : Cancellable { + private var ref: State = State.Active(Cancellable.empty, 0) + + override fun cancel() { + when (val current = ref) { + is State.Active -> { + ref = State.Cancelled + current.token.cancel() + } + State.Cancelled -> return + } + } + + fun set(token: Cancellable) { + while (true) { + when (val current = ref) { + is State.Active -> { + ref = State.Active(token, current.order + 1) + return + } + is State.Cancelled -> { + token.cancel() + return + } + } + } + } + + private sealed interface State { + data class Active(val token: Cancellable, val order: Int) : State + data object Cancelled : State + } +} diff --git a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/CancellablePromise.kt b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/CancellablePromise.kt new file mode 100644 index 0000000..eccde07 --- /dev/null +++ b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/CancellablePromise.kt @@ -0,0 +1,17 @@ +package org.funfix.tasks.kotlin + +import kotlin.js.Promise + +/** + * This is a wrapper around a JavaScript [Promise] with a + * [Cancellable] reference attached. + * + * A standard JavaScript [Promise] is not connected to its + * asynchronous task and cannot be cancelled. Thus, if we want to cancel + * a task, we need to keep a reference to a [Cancellable] object that + * can do the job. + */ +public data class CancellablePromise( + val promise: Promise, + val cancellable: Cancellable +) diff --git a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Task.js.kt b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Task.js.kt new file mode 100644 index 0000000..3457bfc --- /dev/null +++ b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Task.js.kt @@ -0,0 +1,73 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package org.funfix.tasks.kotlin + +public actual class PlatformTask( + private val f: (Executor, (Outcome) -> Unit) -> Cancellable +) { + public operator fun invoke( + executor: Executor, + callback: (Outcome) -> Unit + ): Cancellable = + f(executor, callback) +} + +public actual value class Task public actual constructor( + public actual val asPlatform: PlatformTask +) { + public actual companion object +} + +public actual fun Task.runAsync( + executor: Executor?, + callback: (Outcome) -> Unit +): Cancellable { + val protected = callback.protect() + try { + return asPlatform.invoke( + executor ?: SharedIOExecutor, + protected + ) + } catch (e: Throwable) { + UncaughtExceptionHandler.rethrowIfFatal(e) + protected(Outcome.failure(e)) + return Cancellable.empty + } +} + +public actual fun Task.Companion.fromAsync( + start: (Executor, Callback) -> Cancellable +): Task = + Task(PlatformTask { executor, cb -> + val cRef = MutableCancellable() + TrampolineExecutor.execute { + cRef.set(start(executor, cb)) + } + cRef + }) + +internal fun Callback.protect(): Callback { + var isWaiting = true + return { o -> + if (o is Outcome.Failure) { + UncaughtExceptionHandler.logOrRethrow(o.exception) + } + if (isWaiting) { + isWaiting = false + TrampolineExecutor.execute { + this@protect.invoke(o) + } + } + } +} + +public actual fun Task.ensureRunningOnExecutor(executor: Executor?): Task = + Task(PlatformTask { injectedExecutor, callback -> + val ec = executor ?: injectedExecutor + val cRef = MutableCancellable() + ec.execute { + val c = this@ensureRunningOnExecutor.asPlatform.invoke(ec, callback) + cRef.set(c) + } + cRef + }) diff --git a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/exceptions.js.kt b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/exceptions.js.kt new file mode 100644 index 0000000..bb51e11 --- /dev/null +++ b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/exceptions.js.kt @@ -0,0 +1,13 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package org.funfix.tasks.kotlin + +public actual open class TaskCancellationException public actual constructor( + message: String? +): Exception(message) { + public actual constructor() : this(null) +} + +public actual class FiberNotCompletedException + public actual constructor(): Exception("Fiber not completed yet") + diff --git a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/executors.js.kt b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/executors.js.kt new file mode 100644 index 0000000..ca602ae --- /dev/null +++ b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/executors.js.kt @@ -0,0 +1,129 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package org.funfix.tasks.kotlin + +import org.w3c.dom.WindowOrWorkerGlobalScope +import kotlin.js.Promise + +public actual fun interface Runnable { + public actual fun run() +} + +public actual fun interface Executor { + public actual fun execute(command: Runnable) +} + +public actual val SharedIOExecutor: Executor + get() = JSExecutor + +public actual val TrampolineExecutor: Executor + get() = Trampoline + +private external val self: dynamic +private external val global: dynamic + +private val globalOrSelfDynamic = + (self ?: global)!! +private val globalOrSelf = + globalOrSelfDynamic.unsafeCast() + +private object JSExecutor: Executor { + class NotSupported(execType: ExecType): Exception( + "Executor type $execType is not supported on this runtime" + ) + + private var execType = + if (globalOrSelfDynamic.setInterval != null) ExecType.ViaSetInterval + else if (globalOrSelfDynamic.setTimeout != null) ExecType.ViaSetTimeout + else ExecType.Trampolined + + inline fun withExecType(execType: ExecType, block: () -> T): T { + when (execType) { + ExecType.ViaSetInterval -> + if (globalOrSelfDynamic.setInterval == null) throw NotSupported(execType) + ExecType.ViaSetTimeout -> + if (globalOrSelfDynamic.setTimeout == null) throw NotSupported(execType) + ExecType.Trampolined -> + Unit + } + val oldRef = this.execType + this.execType = execType + try { + return block() + } finally { + this.execType = oldRef + } + } + + fun yield(): Promise { + return Promise { resolve, _ -> + execute { + resolve(Unit) + } + } + } + + override fun execute(command: Runnable) { + val handler: () -> Unit = { + try { + command.run() + } catch (e: Exception) { + UncaughtExceptionHandler.logOrRethrow(e) + } + } + when (execType) { + ExecType.ViaSetInterval -> + globalOrSelf.setInterval(handler) + ExecType.ViaSetTimeout -> + globalOrSelf.setTimeout(handler, -1) + ExecType.Trampolined -> + Trampoline.execute(command) + } + } + + sealed interface ExecType { + data object ViaSetInterval: ExecType + data object ViaSetTimeout: ExecType + data object Trampolined: ExecType + } +} + +private object Trampoline: Executor { + private var queue: MutableList? = null + + private fun eventLoop() { + while (true) { + val current = queue + if (current.isNullOrEmpty()) { + return + } + val next = current.removeFirstOrNull() + try { + next?.run() + } catch (e: Exception) { + UncaughtExceptionHandler.logOrRethrow(e) + } + } + } + + override fun execute(command: Runnable) { + val current = queue ?: mutableListOf() + current.add(command) + queue = current + try { + eventLoop() + } finally { + queue = null + } + } +} + +public actual object UncaughtExceptionHandler { + public actual fun rethrowIfFatal(e: Throwable) { + // Can we do something here? + } + + public actual fun logOrRethrow(e: Throwable) { + console.error(e) + } +} diff --git a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Fiber.jvm.kt b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Fiber.jvm.kt new file mode 100644 index 0000000..1d5d358 --- /dev/null +++ b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Fiber.jvm.kt @@ -0,0 +1,197 @@ +@file:JvmName("FiberJvmKt") + +package org.funfix.tasks.kotlin + +import org.jetbrains.annotations.Blocking +import org.jetbrains.annotations.NonBlocking +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeoutException +import kotlin.jvm.Throws +import kotlin.time.Duration +import kotlin.time.toJavaDuration + +public typealias PlatformFiber = org.funfix.tasks.jvm.Fiber + +/** + * A fiber is a running task being executed concurrently, and that can be + * joined/awaited or cancelled. + * + * This is the equivalent of Kotlin's `Deferred` type. + * + * This is designed to be a compile-time type that's going to be erased at + * runtime. Therefore, for the JVM at least, when using it in your APIs, it + * won't pollute it with Kotlin-specific wrappers. + */ +@JvmInline +public value class Fiber public constructor( + public val asPlatform: PlatformFiber +): Cancellable { + /** + * Cancels the fiber, which will eventually stop the running fiber (if + * it's still running), completing it via "cancellation". + * + * This manifests either in a [TaskCancellationException] being thrown by + * [resultOrThrow], or in the completion callback being triggered. + */ + @NonBlocking + override fun cancel(): Unit = asPlatform.cancel() +} + +/** + * Converts the source to a [Kotlin Fiber][Fiber]. + * + * E.g., can convert from a `jvm.Fiber` to a `kotlin.Fiber`. + */ +public fun PlatformFiber.asKotlin(): Fiber = + Fiber(this) + +/** + * Returns the result of the completed fiber. + * + * This method does not block for the result. In case the fiber is not + * completed, it throws [FiberNotCompletedException]. Therefore, by contract, + * it should be called only after the fiber was "joined". + * + * @return the result of the concurrent task, if successful. + * @throws TaskCancellationException if the task was cancelled concurrently, + * thus being completed via cancellation. + * @throws FiberNotCompletedException if the fiber is not completed yet. + * @throws Throwable if the task finished with an exception. + */ +public val Fiber.resultOrThrow: T + @NonBlocking + @Throws(TaskCancellationException::class, FiberNotCompletedException::class) + get() = asPlatform.resultOrThrow + +/** + * Returns the [Outcome] of the completed fiber, or `null` in case the + * fiber is not completed yet. + * + * This method does not block for the result. In case the fiber is not + * completed, it returns `null`. Therefore, it should be called after + * the fiber was "joined". + */ +public val Fiber.outcomeOrNull: Outcome? get() = + try { + Outcome.Success(asPlatform.resultOrThrow) + } catch (e: TaskCancellationException) { + Outcome.Cancellation + } catch (e: ExecutionException) { + Outcome.Failure(e.cause ?: e) + } catch (e: Throwable) { + UncaughtExceptionHandler.rethrowIfFatal(e) + Outcome.Failure(e) + } + +/** + * Waits until the fiber completes, and then runs the given callback to + * signal its completion. + * + * Completion includes cancellation. Triggering [Fiber.cancel] before + * [joinAsync] will cause the fiber to get cancelled, and then the + * "join" back-pressures on cancellation. + * + * @param onComplete is the callback to run when the fiber completes + * (successfully, or with failure, or cancellation) + */ +@NonBlocking +public fun Fiber.joinAsync(onComplete: Runnable): Cancellable = + asPlatform.joinAsync(onComplete) + +/** + * Waits until the fiber completes, and then runs the given callback + * to signal its completion. + * + * This method can be executed as many times as necessary, with the + * result of the `Fiber` being memoized. It can also be executed + * after the fiber has completed, in which case the callback will be + * executed immediately. + * + * @param callback will be called with the result when the fiber completes. + * + * @return a [Cancellable] that can be used to unregister the callback, + * in case the caller is no longer interested in the result. Note this + * does not cancel the fiber itself. + */ +@NonBlocking +public fun Fiber.awaitAsync(callback: Callback): Cancellable = + asPlatform.awaitAsync(callback.asJava()) + +/** + * Blocks the current thread until the fiber completes. + * + * This method does not return the outcome of the fiber. To check + * the outcome, use [resultOrThrow]. + * + * @throws InterruptedException if the current thread is interrupted, which + * will just stop waiting for the fiber, but will not cancel the running + * task. + */ +@Blocking +@Throws(InterruptedException::class) +public fun Fiber.joinBlocking(): Unit = + asPlatform.joinBlocking() + +/** + * Blocks the current thread until the fiber completes, then returns the + * result of the fiber. + * + * @throws InterruptedException if the current thread is interrupted, which + * will just stop waiting for the fiber, but will not cancel the running task. + * + * @throws TaskCancellationException if the fiber was cancelled concurrently. + * + * @throws Throwable if the task failed with an exception. + */ +@Blocking +@Throws(InterruptedException::class, TaskCancellationException::class) +public fun Fiber.awaitBlocking(): T = + try { + asPlatform.awaitBlocking() + } catch (e: ExecutionException) { + throw e.cause ?: e + } + +/** + * Blocks the current thread until the fiber completes, or until the + * timeout is reached. + * + * This method does not return the outcome of the fiber. To check the + * outcome, use [resultOrThrow]. + * + * @throws InterruptedException if the current thread is interrupted, which + * will just stop waiting for the fiber, but will not cancel the running + * task. + * + * @throws TimeoutException if the timeout is reached before the fiber + * completes. + */ +@Blocking +@Throws(InterruptedException::class, TimeoutException::class) +public fun Fiber.joinBlockingTimed(timeout: Duration): Unit = + asPlatform.joinBlockingTimed(timeout.toJavaDuration()) + +/** + * Blocks the current thread until the fiber completes, then returns the result of the fiber. + * + * @param timeout the maximum time to wait for the fiber to complete, before + * throwing a [TimeoutException]. + * + * @return the result of the fiber, if successful. + * + * @throws InterruptedException if the current thread is interrupted, which + * will just stop waiting for the fiber, but will not cancel the running + * task. + * @throws TimeoutException if the timeout is reached before the fiber completes. + * @throws TaskCancellationException if the fiber was cancelled concurrently. + * @throws Throwable if the task failed with an exception. + */ +@Blocking +@Throws(InterruptedException::class, TaskCancellationException::class, TimeoutException::class) +public fun Fiber.awaitBlockingTimed(timeout: Duration): T = + try { + asPlatform.awaitBlockingTimed(timeout.toJavaDuration()) + } catch (e: ExecutionException) { + throw e.cause ?: e + } + diff --git a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Task.jvm.kt b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Task.jvm.kt new file mode 100644 index 0000000..0c8f59c --- /dev/null +++ b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Task.jvm.kt @@ -0,0 +1,220 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@file:JvmName("TaskJvmKt") + +package org.funfix.tasks.kotlin + +import org.jetbrains.annotations.Blocking +import org.jetbrains.annotations.NonBlocking +import java.util.concurrent.CompletionStage +import java.util.concurrent.ExecutionException +import java.util.concurrent.Future +import java.util.concurrent.TimeoutException +import kotlin.jvm.Throws +import kotlin.time.Duration +import kotlin.time.toJavaDuration + +public actual typealias PlatformTask = org.funfix.tasks.jvm.Task + +@JvmInline +public actual value class Task public actual constructor( + public actual val asPlatform: PlatformTask +) { + /** + * Converts this task to a `jvm.Task`. + */ + public val asJava: PlatformTask get() = asPlatform + + public actual companion object +} + +@NonBlocking +public actual fun Task.ensureRunningOnExecutor(executor: Executor?): Task = + Task(when (executor) { + null -> asPlatform.ensureRunningOnExecutor() + else -> asPlatform.ensureRunningOnExecutor(executor) + }) + +@NonBlocking +public actual fun Task.runAsync( + executor: Executor?, + callback: Callback +): Cancellable = + when (executor) { + null -> asPlatform.runAsync(callback.asJava()) + else -> asPlatform.runAsync(executor, callback.asJava()) + } + +/** + * Executes the task concurrently and returns a [Fiber] that can be + * used to wait for the result or cancel the task. + * + * Similar to [runAsync], this method starts the execution on a different thread. + * + * @param executor is the [Executor] that may be used to run the task. + * + * @return a [Fiber] that can be used to wait for the outcome, + * or to cancel the running fiber. + */ +@NonBlocking +public fun Task.runFiber(executor: Executor? = null): Fiber = + Fiber(when (executor) { + null -> asPlatform.runFiber() + else -> asPlatform.runFiber(executor) + }) + +/** + * Executes the task and blocks until it completes, or the current thread gets + * interrupted (in which case the task is also cancelled). + * + * Given that the intention is to block the current thread for the result, the + * task starts execution on the current thread. + * + * @param executor the [Executor] that may be used to run the task. + * @return the successful result of the task. + * + * @throws InterruptedException if the current thread is interrupted, which also + * cancels the running task. Note that on interruption, the running concurrent + * task must also be interrupted, as this method always blocks for its + * interruption or completion. + * + * @throws Throwable if the task fails with an exception + */ +@Blocking +@Throws(InterruptedException::class) +public fun Task.runBlocking(executor: Executor? = null): T = + try { + when (executor) { + null -> asPlatform.runBlocking() + else -> asPlatform.runBlocking(executor) + } + } catch (e: ExecutionException) { + throw e.cause ?: e + } + +/** + * Executes the task and blocks until it completes, the timeout is reached, or + * the current thread is interrupted. + * + * **EXECUTION MODEL:** Execution starts on a different thread, by necessity, + * otherwise the execution could block the current thread indefinitely, without + * the possibility of interrupting the task after the timeout occurs. + * + * @param timeout the maximum time to wait for the task to complete before + * throwing a [TimeoutException]. + * + * @param executor the [Executor] that may be used to run the task. If one isn't + * provided, the execution will use [SharedIOExecutor] as the default. + * + * @return the successful result of the task. + * + * @throws InterruptedException if the current thread is interrupted. The + * running task is also cancelled, and this method does not return until + * `onCancel` is signaled. + * + * @throws TimeoutException if the task doesn't complete within the specified + * timeout. The running task is also cancelled on timeout, and this method does + * not return until `onCancel` is signaled. + * + * @throws Throwable if the task fails with an exception. + */ +@Blocking +@Throws(InterruptedException::class, TimeoutException::class) +public fun Task.runBlockingTimed( + timeout: Duration, + executor: Executor? = null +): T = + try { + when (executor) { + null -> asPlatform.runBlockingTimed(timeout.toJavaDuration()) + else -> asPlatform.runBlockingTimed(executor, timeout.toJavaDuration()) + } + } catch (e: ExecutionException) { + throw e.cause ?: e + } + +// Builders + +@NonBlocking +public actual fun Task.Companion.fromAsync( + start: (Executor, Callback) -> Cancellable +): Task = + Task(PlatformTask.fromAsync { executor, cb -> + start(executor, cb.asKotlin()) + }) + +/** + * Creates a task from a function executing blocking I/O. + * + * This uses Java's interruption protocol (i.e., [Thread.interrupt]) for + * cancelling the task. + */ +@NonBlocking +public fun Task.Companion.fromBlockingIO(block: () -> T): Task = + Task(PlatformTask.fromBlockingIO(block)) + +/** + * Creates a task from a [Future] builder. + * + * This is compatible with Java's interruption protocol and [Future.cancel], + * with the resulting task being cancellable. + * + * **NOTE:** Use [fromCompletionStage] for directly converting + * [java.util.concurrent.CompletableFuture] builders, because it is not possible + * to cancel such values, and the logic needs to reflect it. Better yet, use + * [fromCancellableFuture] for working with [CompletionStage] values that can be + * cancelled. + * + * @param builder is the function that will create the [Future] upon this task's + * execution. + * + * @return a new task that will complete with the result of the created `Future` + * upon execution + * + * @see fromCompletionStage + * @see fromCancellableFuture + */ +@NonBlocking +public fun Task.Companion.fromBlockingFuture(builder: () -> Future): Task = + Task(PlatformTask.fromBlockingFuture(builder)) + +/** + * Creates tasks from a builder of [CompletionStage]. + * + * **NOTE:** `CompletionStage` isn't cancellable, and the resulting task should + * reflect this (i.e., on cancellation, the listener should not receive an + * `onCancel` signal until the `CompletionStage` actually completes). + * + * Prefer using [fromCancellableFuture] for working with [CompletionStage] + * values that can be cancelled. + * + * @param builder is the function that will create the [CompletionStage] + * value. It's a builder because `Task` values are cold values + * (lazy, not executed yet). + * + * @return a new task that upon execution will complete with the result of + * the created `CancellableCompletionStage` + * + * @see fromCancellableFuture + */ +@NonBlocking +public fun Task.Companion.fromCompletionStage(builder: () -> CompletionStage): Task = + Task(PlatformTask.fromCompletionStage(builder)) + +/** + * Creates tasks from a builder of [CancellableFuture]. + * + * This is the recommended way to work with [CompletionStage] builders, because + * cancelling such values (e.g., [java.util.concurrent.CompletableFuture]) + * doesn't work for cancelling the connecting computation. As such, the user + * should provide an explicit [Cancellable] token that can be used. + * + * @param builder the function that will create the [CancellableFuture] value. + * It's a builder because [Task] values are cold values (lazy, not executed + * yet). + * + * @return a new task that upon execution will complete with the result of the + * created [CancellableFuture] + */ +@NonBlocking +public fun Task.Companion.fromCancellableFuture(builder: () -> CancellableFuture): Task = + Task(PlatformTask.fromCancellableFuture(builder)) diff --git a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.jvm.kt b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.jvm.kt new file mode 100644 index 0000000..9d7679f --- /dev/null +++ b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.jvm.kt @@ -0,0 +1,16 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package org.funfix.tasks.kotlin + +/** + * Utilities for handling uncaught exceptions. + */ +public actual object UncaughtExceptionHandler { + public actual fun rethrowIfFatal(e: Throwable) { + org.funfix.tasks.jvm.UncaughtExceptionHandler.rethrowIfFatal(e) + } + + public actual fun logOrRethrow(e: Throwable) { + org.funfix.tasks.jvm.UncaughtExceptionHandler.logOrRethrow(e) + } +} diff --git a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/aliases.kt b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/aliases.kt new file mode 100644 index 0000000..2ef84a3 --- /dev/null +++ b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/aliases.kt @@ -0,0 +1,26 @@ +@file:JvmName("AliasesKt") +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package org.funfix.tasks.kotlin + +public actual typealias Cancellable = org.funfix.tasks.jvm.Cancellable + +/** + * A `CancellableFuture` is a tuple of a `CompletableFuture` and a `Cancellable` + * reference. + * + * It's used to model the result of asynchronous computations that can be + * cancelled. Needed because `CompletableFuture` doesn't actually support + * cancellation. It's similar to [Fiber], which should be preferred, because + * it's more principled. `CancellableFuture` is useful for interop with + * Java libraries that use `CompletableFuture`. + */ +public typealias CancellableFuture = org.funfix.tasks.jvm.CancellableFuture + +public actual typealias TaskCancellationException = org.funfix.tasks.jvm.TaskCancellationException + +public actual typealias FiberNotCompletedException = org.funfix.tasks.jvm.Fiber.NotCompletedException + +public actual typealias Runnable = java.lang.Runnable + +public actual typealias Executor = java.util.concurrent.Executor diff --git a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/executors.jvm.kt b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/executors.jvm.kt new file mode 100644 index 0000000..1480b94 --- /dev/null +++ b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/executors.jvm.kt @@ -0,0 +1,11 @@ +@file:JvmName("ExecutorsJvmKt") + +package org.funfix.tasks.kotlin + +import org.funfix.tasks.jvm.TaskExecutors + +public actual val TrampolineExecutor: Executor + get() = TaskExecutors.trampoline() + +public actual val SharedIOExecutor: Executor + get() = TaskExecutors.sharedBlockingIO() diff --git a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/internals.kt b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/internals.kt new file mode 100644 index 0000000..5d15ab3 --- /dev/null +++ b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/internals.kt @@ -0,0 +1,26 @@ +@file:JvmName("InternalsJvmKt") + +package org.funfix.tasks.kotlin + +import org.funfix.tasks.jvm.CompletionCallback + + +internal typealias KotlinCallback = (Outcome) -> Unit + +internal fun CompletionCallback.asKotlin(): KotlinCallback = + { outcome -> + when (outcome) { + is Outcome.Success -> this.onSuccess(outcome.value) + is Outcome.Failure -> this.onFailure(outcome.exception) + is Outcome.Cancellation -> this.onCancellation() + } + } + +internal fun KotlinCallback.asJava(): CompletionCallback = + CompletionCallback { outcome -> + when (outcome) { + is org.funfix.tasks.jvm.Outcome.Success -> this@asJava(Outcome.Success(outcome.value)) + is org.funfix.tasks.jvm.Outcome.Failure -> this@asJava(Outcome.Failure(outcome.exception)) + is org.funfix.tasks.jvm.Outcome.Cancellation -> this@asJava(Outcome.Cancellation) + } + } diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskEnsureRunningOnExecutorTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskEnsureRunningOnExecutorTest.kt new file mode 100644 index 0000000..2d68f79 --- /dev/null +++ b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskEnsureRunningOnExecutorTest.kt @@ -0,0 +1,34 @@ +package org.funfix.tests + +import org.funfix.tasks.kotlin.Task +import org.funfix.tasks.kotlin.ensureRunningOnExecutor +import org.funfix.tasks.kotlin.fromBlockingIO +import org.funfix.tasks.kotlin.runBlocking +import org.junit.jupiter.api.Assertions.assertTrue +import java.util.concurrent.Executors +import kotlin.test.Test +import kotlin.test.assertEquals + +class TaskEnsureRunningOnExecutorTest { + @Test + @Suppress("DEPRECATION") + fun ensureRunningOnExecutorWorks() { + val ex = Executors.newCachedThreadPool { r -> + val th = Thread(r) + th.name = "my-thread-" + th.id + th + } + try { + val t = Task.fromBlockingIO { Thread.currentThread().name } + val n1 = t.runBlocking() + val n2 = t.ensureRunningOnExecutor().runBlocking() + val n3 = t.ensureRunningOnExecutor(ex).runBlocking() + + assertEquals(Thread.currentThread().name, n1) + assertTrue(n2.startsWith("tasks-io-"), "tasks-io") + assertTrue(n3.startsWith("my-thread-"), "my-thread") + } finally { + ex.shutdown() + } + } +} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromAsyncTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromAsyncTest.kt new file mode 100644 index 0000000..79a1d37 --- /dev/null +++ b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromAsyncTest.kt @@ -0,0 +1,56 @@ +package org.funfix.tests + +import org.funfix.tasks.kotlin.Cancellable +import org.funfix.tasks.kotlin.Outcome +import org.funfix.tasks.kotlin.Task +import org.funfix.tasks.kotlin.fromAsync +import org.funfix.tasks.kotlin.joinBlocking +import org.funfix.tasks.kotlin.outcomeOrNull +import org.funfix.tasks.kotlin.runBlocking +import org.funfix.tasks.kotlin.runFiber +import java.util.concurrent.CountDownLatch +import kotlin.test.Test +import kotlin.test.assertEquals + +class TaskFromAsyncTest { + @Test + fun `fromAsync happy path`() { + val task = Task.fromAsync { _, cb -> + cb(Outcome.success(1)) + Cancellable.getEmpty() + } + assertEquals(1, task.runBlocking()) + } + + @Test + fun `fromAsync with error`() { + val ex = RuntimeException("Boom!") + val task = Task.fromAsync { _, cb -> + cb(Outcome.failure(ex)) + Cancellable.getEmpty() + } + try { + task.runBlocking() + } catch (e: RuntimeException) { + assertEquals(ex, e) + } + } + + @Test + fun `fromAsync can be cancelled`() { + val latch = CountDownLatch(1) + val task = Task.fromAsync { executor, cb -> + executor.execute { + latch.await() + cb(Outcome.Cancellation) + } + Cancellable { + latch.countDown() + } + } + val fiber = task.runFiber() + fiber.cancel() + fiber.joinBlocking() + assertEquals(Outcome.Cancellation, fiber.outcomeOrNull) + } +} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromBlockingIOTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromBlockingIOTest.kt new file mode 100644 index 0000000..3493719 --- /dev/null +++ b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromBlockingIOTest.kt @@ -0,0 +1,42 @@ +package org.funfix.tests + +import org.funfix.tasks.kotlin.Outcome +import org.funfix.tasks.kotlin.Task +import org.funfix.tasks.kotlin.fromBlockingIO +import org.funfix.tasks.kotlin.joinBlocking +import org.funfix.tasks.kotlin.outcomeOrNull +import org.funfix.tasks.kotlin.runBlocking +import org.funfix.tasks.kotlin.runFiber +import kotlin.test.Test +import kotlin.test.assertEquals + +class TaskFromBlockingIOTest { + @Test + fun `fromBlockingIO (success)`() { + val task = Task.fromBlockingIO { 1 } + assertEquals(1, task.runBlocking()) + } + + @Test + fun `fromBlockingIO (failure)`() { + val ex = RuntimeException("Boom!") + val task = Task.fromBlockingIO { throw ex } + try { + task.runBlocking() + } catch (e: RuntimeException) { + assertEquals(ex, e) + } + } + + @Test + fun `fromBlockingIO (cancellation)`() { + val task: Task = Task.fromBlockingIO { + Thread.sleep(10000) + 1 + } + val fiber = task.runFiber() + fiber.cancel() + fiber.joinBlocking() + assertEquals(Outcome.Cancellation, fiber.outcomeOrNull) + } +} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromFutureTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromFutureTest.kt new file mode 100644 index 0000000..e6726a3 --- /dev/null +++ b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromFutureTest.kt @@ -0,0 +1,169 @@ +package org.funfix.tests + +import org.funfix.tasks.jvm.Cancellable +import org.funfix.tasks.jvm.CancellableFuture +import org.funfix.tasks.kotlin.Outcome +import org.funfix.tasks.kotlin.Task +import org.funfix.tasks.kotlin.fromBlockingFuture +import org.funfix.tasks.kotlin.fromCancellableFuture +import org.funfix.tasks.kotlin.fromCompletionStage +import org.funfix.tasks.kotlin.joinBlocking +import org.funfix.tasks.kotlin.outcomeOrNull +import org.funfix.tasks.kotlin.runBlocking +import org.funfix.tasks.kotlin.runFiber +import java.util.concurrent.Callable +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail + +class TaskFromFutureTest { + @Test + fun `fromBlockingFuture (success)`() { + val ec = Executors.newCachedThreadPool() + try { + val task = Task.fromBlockingFuture { + ec.submit(Callable { 1 }) + } + assertEquals(1, task.runBlocking()) + } finally { + ec.shutdown() + } + } + + @Test + fun `fromBlockingFuture (failure)`() { + val ex = RuntimeException("Boom!") + val ec = Executors.newCachedThreadPool() + try { + val task = Task.fromBlockingFuture { + ec.submit { throw ex } + } + try { + task.runBlocking() + fail("Expected exception") + } catch (e: RuntimeException) { + assertEquals(ex, e) + } + } finally { + ec.shutdown() + } + } + + @Test + fun `fromBlockingFuture (cancellation)`() { + val ec = Executors.newCachedThreadPool() + val wasStarted = CountDownLatch(1) + val wasCancelled = CountDownLatch(1) + try { + val task = Task.fromBlockingFuture { + ec.submit(Callable { + wasStarted.countDown() + try { + Thread.sleep(10000) + 1 + } catch (e: InterruptedException) { + wasCancelled.countDown() + throw e + } + }) + } + val fiber = task.runFiber() + TimedAwait.latchAndExpectCompletion(wasStarted, "wasStarted") + fiber.cancel() + fiber.joinBlocking() + assertEquals(Outcome.Cancellation, fiber.outcomeOrNull) + TimedAwait.latchAndExpectCompletion(wasCancelled, "wasCancelled") + } finally { + ec.shutdown() + } + } + + @Test + fun `fromCompletionStage (success)`() { + val task = Task.fromCompletionStage { + CompletableFuture.supplyAsync { 1 } + } + assertEquals(1, task.runBlocking()) + } + + @Test + fun `fromCompletionStage (failure)`() { + val ex = RuntimeException("Boom!") + val task = Task.fromCompletionStage { + CompletableFuture.supplyAsync { + throw ex + } + } + try { + task.runBlocking() + fail("Expected exception") + } catch (e: RuntimeException) { + assertEquals(ex, e) + } + } + + @Test + fun `fromCancellableFuture (success)`() { + val ec = Executors.newCachedThreadPool() + try { + val task = Task.fromCancellableFuture { + CancellableFuture( + CompletableFuture.supplyAsync { 1 }, + Cancellable.getEmpty() + ) + } + assertEquals(1, task.runBlocking()) + } finally { + ec.shutdown() + } + } + + @Test + fun `fromCancellableFuture (failure)`() { + val ex = RuntimeException("Boom!") + val ec = Executors.newCachedThreadPool() + try { + val task = Task.fromCancellableFuture { + CancellableFuture( + CompletableFuture.supplyAsync { + throw ex + }, + Cancellable.getEmpty() + ) + } + try { + task.runBlocking() + fail("Expected exception") + } catch (e: RuntimeException) { + assertEquals(ex, e) + } + } finally { + ec.shutdown() + } + } + + @Test + fun `fromCancellableFuture (cancellation)`() { + val wasStarted = CountDownLatch(1) + val wasCancelled = CountDownLatch(1) + val task = Task.fromCancellableFuture { + CancellableFuture( + CompletableFuture.supplyAsync { + wasStarted.countDown() + TimedAwait.latchNoExpectations(wasCancelled) + } + ) { + wasCancelled.countDown() + } + } + val fiber = task.runFiber() + TimedAwait.latchAndExpectCompletion(wasStarted, "wasStarted") + fiber.cancel() + fiber.joinBlocking() + assertEquals(Outcome.Cancellation, fiber.outcomeOrNull) + TimedAwait.latchAndExpectCompletion(wasCancelled, "wasCancelled") + } +} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunAsyncTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunAsyncTest.kt new file mode 100644 index 0000000..be81a16 --- /dev/null +++ b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunAsyncTest.kt @@ -0,0 +1,105 @@ +package org.funfix.tests + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail +import org.funfix.tasks.jvm.TaskCancellationException +import org.funfix.tasks.kotlin.Outcome +import org.funfix.tasks.kotlin.Task +import org.funfix.tasks.kotlin.fromBlockingIO +import org.funfix.tasks.kotlin.runAsync +import org.junit.jupiter.api.Assertions.assertTrue + +class TaskRunAsyncTest { + @Test + fun `runAsync (success)`() { + val latch = CountDownLatch(1) + val outcomeRef = AtomicReference?>(null) + val task = Task.fromBlockingIO { 1 } + task.runAsync { outcome -> + outcomeRef.set(outcome) + latch.countDown() + } + + TimedAwait.latchAndExpectCompletion(latch) + assertEquals(Outcome.Success(1), outcomeRef.get()) + assertEquals(1, outcomeRef.get()!!.orThrow) + } + + @Test + fun `runAsync (failure)`() { + val latch = CountDownLatch(1) + val outcomeRef = AtomicReference?>(null) + val ex = RuntimeException("Boom!") + val task = Task.fromBlockingIO { throw ex } + task.runAsync { outcome -> + outcomeRef.set(outcome) + latch.countDown() + } + + TimedAwait.latchAndExpectCompletion(latch) + assertEquals(Outcome.Failure(ex), outcomeRef.get()) + try { + outcomeRef.get()!!.orThrow + fail("Expected exception") + } catch (e: RuntimeException) { + assertEquals(ex, e) + } + } + + @Test + fun `runAsync (cancellation)`() { + val latch = CountDownLatch(1) + val wasStarted = CountDownLatch(1) + val outcomeRef = AtomicReference?>(null) + val task = Task.fromBlockingIO { + wasStarted.countDown() + Thread.sleep(10000) + 1 + } + val cancel = task.runAsync { outcome -> + outcomeRef.set(outcome) + latch.countDown() + } + + TimedAwait.latchAndExpectCompletion(wasStarted, "wasStarted") + cancel.cancel() + TimedAwait.latchAndExpectCompletion(latch) + assertEquals(Outcome.Cancellation, outcomeRef.get()) + try { + outcomeRef.get()!!.orThrow + fail("Expected exception") + } catch (e: TaskCancellationException) { + // expected + } + } + + @Test + @Suppress("DEPRECATION") + fun `runAsync runs with given executor`() { + val ec = Executors.newCachedThreadPool { r -> + val t = Thread(r) + t.isDaemon = true + t.name = "my-thread-${t.id}" + t + } + try { + val latch = CountDownLatch(1) + val outcomeRef = AtomicReference?>(null) + val task = Task.fromBlockingIO { + Thread.currentThread().name + } + task.runAsync(ec) { outcome -> + outcomeRef.set(outcome) + latch.countDown() + } + TimedAwait.latchAndExpectCompletion(latch) + assertTrue(outcomeRef.get()!!.orThrow.startsWith("my-thread-")) + } finally { + ec.shutdown() + } + } +} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunBlockingTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunBlockingTest.kt new file mode 100644 index 0000000..5d8ae69 --- /dev/null +++ b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunBlockingTest.kt @@ -0,0 +1,106 @@ +package org.funfix.tests + +import org.funfix.tasks.kotlin.SharedIOExecutor +import org.funfix.tasks.kotlin.Task +import org.funfix.tasks.kotlin.fromBlockingIO +import org.funfix.tasks.kotlin.runBlocking +import org.funfix.tasks.kotlin.runBlockingTimed +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail +import kotlin.time.toKotlinDuration + +class TaskRunBlockingTest { + @Test + fun `runBlocking (success)`() { + val task = Task.fromBlockingIO { 1 } + + assertEquals(1, task.runBlocking()) + assertEquals(1, task.runBlocking(SharedIOExecutor)) + } + + @Test + fun `runBlocking (failure)`() { + val ex = RuntimeException("Boom!") + val task = Task.fromBlockingIO { throw ex } + + try { + task.runBlocking() + } catch (e: RuntimeException) { + assertEquals(ex, e) + } + + try { + task.runBlocking(SharedIOExecutor) + } catch (e: RuntimeException) { + assertEquals(ex, e) + } + } + + @Test + fun `runBlocking (cancellation)`() { + val task: Task = Task.fromBlockingIO { + throw InterruptedException() + } + try { + task.runBlocking() + } catch (e: InterruptedException) { + // expected + } + try { + task.runBlocking(SharedIOExecutor) + } catch (e: InterruptedException) { + // expected + } + } + + @Test + fun `runBlockingTimed (success)`() { + val task = Task.fromBlockingIO { 1 } + + assertEquals(1, task.runBlockingTimed( + TimedAwait.TIMEOUT.toKotlinDuration() + )) + assertEquals(1, task.runBlockingTimed( + TimedAwait.TIMEOUT.toKotlinDuration(), + SharedIOExecutor + )) + } + + @Test + fun `runBlockingTimed (failure)`() { + val ex = RuntimeException("Boom!") + val task = Task.fromBlockingIO { throw ex } + + try { + task.runBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) + } catch (e: RuntimeException) { + assertEquals(ex, e) + } + + try { + task.runBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration(), SharedIOExecutor) + } catch (e: RuntimeException) { + assertEquals(ex, e) + } + } + + @Test + fun `runBlockingTimed (cancellation)`() { + val task: Task = Task.fromBlockingIO { + throw InterruptedException() + } + try { + task.runBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) + fail("Expected exception") + } catch (e: InterruptedException) { + // expected + } + try { + task.runBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration(), SharedIOExecutor) + fail("Expected exception") + } catch (e: InterruptedException) { + // expected + } + } +} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunFiberTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunFiberTest.kt new file mode 100644 index 0000000..d4ecd20 --- /dev/null +++ b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunFiberTest.kt @@ -0,0 +1,275 @@ +package org.funfix.tests + +import org.funfix.tasks.jvm.TaskCancellationException +import org.funfix.tasks.kotlin.Outcome +import org.funfix.tasks.kotlin.Task +import org.funfix.tasks.kotlin.awaitAsync +import org.funfix.tasks.kotlin.awaitBlocking +import org.funfix.tasks.kotlin.awaitBlockingTimed +import org.funfix.tasks.kotlin.fromBlockingIO +import org.funfix.tasks.kotlin.joinAsync +import org.funfix.tasks.kotlin.joinBlocking +import org.funfix.tasks.kotlin.joinBlockingTimed +import org.funfix.tasks.kotlin.outcomeOrNull +import org.funfix.tasks.kotlin.resultOrThrow +import org.funfix.tasks.kotlin.runFiber +import org.junit.jupiter.api.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.assertEquals +import kotlin.test.fail +import kotlin.time.toKotlinDuration + +class TaskRunFiberTest { + @Test + fun `runFiber + joinAsync (success)`() { + val fiber = Task + .fromBlockingIO { 1 } + .runFiber() + + val latch = CountDownLatch(1) + val outcomeRef = AtomicReference?>(null) + fiber.joinAsync { + outcomeRef.set(fiber.outcomeOrNull!!) + latch.countDown() + } + + TimedAwait.latchAndExpectCompletion(latch) + assertEquals(Outcome.Success(1), outcomeRef.get()) + } + + @Test + fun `runFiber + joinAsync (failure)`() { + val ex = RuntimeException("Boom!") + val fiber = Task + .fromBlockingIO { throw ex } + .runFiber() + + val latch = CountDownLatch(1) + val outcomeRef = AtomicReference?>(null) + fiber.joinAsync { + outcomeRef.set(fiber.outcomeOrNull!!) + latch.countDown() + } + + TimedAwait.latchAndExpectCompletion(latch) + assertEquals(Outcome.Failure(ex), outcomeRef.get()) + } + + @Test + fun `runFiber + joinAsync (cancellation)`() { + val fiber = Task + .fromBlockingIO { Thread.sleep(10000) } + .runFiber() + + val latch = CountDownLatch(1) + val outcomeRef = AtomicReference?>(null) + fiber.joinAsync { + outcomeRef.set(fiber.outcomeOrNull!!) + latch.countDown() + } + + fiber.cancel() + TimedAwait.latchAndExpectCompletion(latch) + assertEquals(Outcome.Cancellation, outcomeRef.get()) + } + + @Test + fun `runFiber + awaitAsync (success)`() { + val fiber = Task + .fromBlockingIO { 1 } + .runFiber() + + val latch = CountDownLatch(1) + val outcomeRef = AtomicReference?>(null) + fiber.awaitAsync { outcome -> + outcomeRef.set(outcome) + latch.countDown() + } + + TimedAwait.latchAndExpectCompletion(latch) + assertEquals(Outcome.Success(1), outcomeRef.get()) + } + + @Test + fun `runFiber + awaitAsync (failure)`() { + val ex = RuntimeException("Boom!") + val fiber = Task + .fromBlockingIO { throw ex } + .runFiber() + + val latch = CountDownLatch(1) + val outcomeRef = AtomicReference?>(null) + fiber.awaitAsync { outcome -> + outcomeRef.set(outcome) + latch.countDown() + } + + TimedAwait.latchAndExpectCompletion(latch) + assertEquals(Outcome.Failure(ex), outcomeRef.get()) + } + + @Test + fun `runFiber + awaitAsync (cancellation)`() { + val fiber = Task + .fromBlockingIO { Thread.sleep(10000) } + .runFiber() + + val latch = CountDownLatch(1) + val outcomeRef = AtomicReference?>(null) + fiber.awaitAsync { outcome -> + outcomeRef.set(outcome) + latch.countDown() + } + + fiber.cancel() + TimedAwait.latchAndExpectCompletion(latch) + assertEquals(Outcome.Cancellation, outcomeRef.get()) + } + + @Test + fun `runFiber + joinBlocking (success)`() { + val fiber = Task + .fromBlockingIO { 1 } + .runFiber() + + fiber.joinBlocking() + assertEquals(1, fiber.resultOrThrow) + assertEquals(Outcome.Success(1), fiber.outcomeOrNull) + } + + @Test + fun `runFiber + joinBlocking (failure)`() { + val ex = RuntimeException("Boom!") + val fiber = Task + .fromBlockingIO { throw ex } + .runFiber() + + fiber.joinBlocking() + assertEquals(Outcome.Failure(ex), fiber.outcomeOrNull) + } + + @Test + fun `runFiber + joinBlocking (cancellation)`() { + val fiber = Task + .fromBlockingIO { Thread.sleep(10000) } + .runFiber() + + fiber.cancel() + fiber.joinBlocking() + assertEquals(Outcome.Cancellation, fiber.outcomeOrNull) + } + + @Test + fun `runFiber + awaitBlocking (success)`() { + val fiber = Task + .fromBlockingIO { 1 } + .runFiber() + + val result = fiber.awaitBlocking() + assertEquals(1, result) + } + + @Test + fun `runFiber + awaitBlocking (failure)`() { + val ex = RuntimeException("Boom!") + val fiber = Task + .fromBlockingIO { throw ex } + .runFiber() + + try { + fiber.awaitBlocking() + fail("Expected exception") + } catch (e: RuntimeException) { + assertEquals(ex, e) + } + } + + @Test + fun `runFiber + awaitBlocking (cancellation)`() { + val fiber = Task + .fromBlockingIO { Thread.sleep(10000) } + .runFiber() + + fiber.cancel() + try { + fiber.awaitBlocking() + fail("Expected exception") + } catch (e: TaskCancellationException) { + // expected + } + } + + @Test + fun `runFiber + joinBlockingTimed (success)`() { + val fiber = Task + .fromBlockingIO { 1 } + .runFiber() + + fiber.joinBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) + assertEquals(1, fiber.resultOrThrow) + assertEquals(Outcome.Success(1), fiber.outcomeOrNull) + } + + @Test + fun `runFiber + joinBlockingTimed (failure)`() { + val ex = RuntimeException("Boom!") + val fiber = Task + .fromBlockingIO { throw ex } + .runFiber() + + fiber.joinBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) + assertEquals(Outcome.Failure(ex), fiber.outcomeOrNull) + } + + @Test + fun `runFiber + joinBlockingTimed (cancellation)`() { + val fiber = Task + .fromBlockingIO { Thread.sleep(10000) } + .runFiber() + + fiber.cancel() + fiber.joinBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) + assertEquals(Outcome.Cancellation, fiber.outcomeOrNull) + } + + @Test + fun `runFiber + awaitBlockingTimed (success)`() { + val fiber = Task + .fromBlockingIO { 1 } + .runFiber() + + val result = fiber.awaitBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) + assertEquals(1, result) + } + + @Test + fun `runFiber + awaitBlockingTimed (failure)`() { + val ex = RuntimeException("Boom!") + val fiber = Task + .fromBlockingIO { throw ex } + .runFiber() + + try { + fiber.awaitBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) + fail("Expected exception") + } catch (e: RuntimeException) { + assertEquals(ex, e) + } + } + + @Test + fun `runFiber + awaitBlockingTimed (cancellation)`() { + val fiber = Task + .fromBlockingIO { Thread.sleep(10000) } + .runFiber() + + fiber.cancel() + try { + fiber.awaitBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) + fail("Expected exception") + } catch (e: TaskCancellationException) { + // expected + } + } +} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TimedAwait.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TimedAwait.kt new file mode 100644 index 0000000..3b89538 --- /dev/null +++ b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TimedAwait.kt @@ -0,0 +1,31 @@ +package org.funfix.tests + +import java.time.Duration +import java.util.concurrent.* + +object TimedAwait { + val TIMEOUT: Duration = + if (System.getenv("CI") != null) Duration.ofSeconds(20) + else Duration.ofSeconds(10) + + @Throws(InterruptedException::class) + fun latchNoExpectations(latch: CountDownLatch): Boolean = + latch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) + + @Throws(InterruptedException::class) + fun latchAndExpectCompletion(latch: CountDownLatch) = latchAndExpectCompletion(latch, "latch") + + @Throws(InterruptedException::class) + fun latchAndExpectCompletion(latch: CountDownLatch, name: String) { + assert(latch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) { "$name.await" } + } + + @Throws(InterruptedException::class, TimeoutException::class) + fun future(future: Future<*>) { + try { + future.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) + } catch (e: ExecutionException) { + throw RuntimeException(e) + } + } +} diff --git a/tasks-scala/build.gradle.kts b/tasks-scala/build.gradle.kts new file mode 100644 index 0000000..1886ad0 --- /dev/null +++ b/tasks-scala/build.gradle.kts @@ -0,0 +1,18 @@ + +tasks.register("assemble", Exec::class) { + workingDir(project.projectDir) + + commandLine("./sbt", "package") +} + +tasks.register("executeShellCommand", Exec::class) { + // Set the command to execute. Example: "echo", "Hello, World!" + commandLine("echo", "Hello, World!") + commandLine("echo", project.projectDir) + + // Optionally, set the working directory + workingDir(project.rootDir) + + // Logging the output + standardOutput = System.out +} diff --git a/tasks-scala/build.sbt b/tasks-scala/build.sbt new file mode 100644 index 0000000..bd2ca3f --- /dev/null +++ b/tasks-scala/build.sbt @@ -0,0 +1,68 @@ +import Boilerplate.crossVersionSharedSources +import java.io.FileInputStream +import java.util.Properties + +ThisBuild / scalaVersion := "3.3.1" +ThisBuild / crossScalaVersions := Seq("2.13.14", scalaVersion.value) + +ThisBuild / resolvers ++= Seq(Resolver.mavenLocal) + +val publishLocalGradleDependencies = taskKey[Unit]("Builds and publishes gradle dependencies") +val props = settingKey[Properties]("Main project properties") + +ThisBuild / props := { + val projectProperties = new Properties() + val rootDir = (ThisBuild / baseDirectory).value + val fis = new FileInputStream(s"$rootDir/../gradle.properties") + projectProperties.load(fis) + projectProperties +} + +ThisBuild / version := { + val base = props.value.getProperty("project.version") + val isRelease = + sys.env.get("BUILD_RELEASE").filter(_.nonEmpty) + .orElse(Option(System.getProperty("buildRelease"))) + .exists(it => it == "true" || it == "1" || it == "yes" || it == "on") + if (isRelease) base else s"$base-SNAPSHOT" +} + +Global / onChangedBuildSource := ReloadOnSourceChanges + +lazy val root = project + .in(file(".")) + .settings( + publish := {}, + publishLocal := {}, + publishLocalGradleDependencies := { + import scala.sys.process.* + val rootDir = (ThisBuild / baseDirectory).value + val command = Process( + "./gradlew" :: "publishToMavenLocal" :: Nil, + new File(rootDir, ".."), + ) + val log = streams.value.log + val exitCode = command ! log + if (exitCode != 0) { + sys.error(s"Command failed with exit code $exitCode") + } + } + ) + .aggregate(coreJVM, coreJS) + +lazy val core = crossProject(JVMPlatform, JSPlatform) + .crossType(CrossType.Full) + .in(file("core")) + .settings(crossVersionSharedSources) + .settings( + name := "tasks-scala", + ) + .jvmSettings( + libraryDependencies ++= Seq( + "org.funfix" % "tasks-jvm" % version.value + ) + ) + +lazy val coreJVM = core.jvm +lazy val coreJS = core.js + diff --git a/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/CompletionCallback.scala b/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/CompletionCallback.scala new file mode 100644 index 0000000..f4677d1 --- /dev/null +++ b/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/CompletionCallback.scala @@ -0,0 +1,22 @@ +package org.funfix.tasks.scala + +import org.funfix.tasks.jvm.CompletionCallback as JavaCompletionCallback + +type CompletionCallback[-A] = Outcome[A] => Unit + +extension [A](cb: JavaCompletionCallback[_ >: A]) { + def asScala: CompletionCallback[A] = { + case Outcome.Success(value) => cb.onSuccess(value) + case Outcome.Failure(ex) => cb.onFailure(ex) + case Outcome.Cancellation => cb.onCancellation() + } +} + +extension [A](cb: CompletionCallback[A]) { + def asJava: JavaCompletionCallback[_ >: A] = + new JavaCompletionCallback[A] { + def onSuccess(value: A): Unit = cb(Outcome.Success(value)) + def onFailure(ex: Throwable): Unit = cb(Outcome.Failure(ex)) + def onCancellation(): Unit = cb(Outcome.Cancellation) + } +} diff --git a/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/Task.scala b/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/Task.scala new file mode 100644 index 0000000..e4dce4a --- /dev/null +++ b/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/Task.scala @@ -0,0 +1,24 @@ +package org.funfix.tasks.scala + +import org.funfix.tasks.jvm.Task as JavaTask + +opaque type Task[+A] = JavaTask[_ <: A] + +object Task { + def apply[A](underlying: JavaTask[_ <: A]): Task[A] = underlying + + def fromAsync[A](f: (Executor, CompletionCallback[A]) => Cancellable): Task[A] = + Task[A](JavaTask.fromAsync { (ec, cb) => + f(ec, cb.asScala) + }) + + extension [A](task: Task[A]) { + def asPlatform: JavaTask[_ <: A] = task + + def unsafeRunAsync(ec: Executor)(cb: CompletionCallback[A]): Cancellable = + unsafeRunAsync(cb)(using TaskExecutor(ec)) + + def unsafeRunAsync(cb: CompletionCallback[A])(using ec: TaskExecutor): Cancellable = + task.asPlatform.runAsync(ec, cb.asJava) + } +} diff --git a/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/TaskExecutor.scala b/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/TaskExecutor.scala new file mode 100644 index 0000000..3c795eb --- /dev/null +++ b/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/TaskExecutor.scala @@ -0,0 +1,27 @@ +package org.funfix.tasks.scala + +import org.funfix.tasks.jvm.TaskExecutors + +import scala.concurrent.ExecutionContext + +opaque type TaskExecutor <: Executor = Executor + +object TaskExecutor { + def apply(executor: Executor): TaskExecutor = executor + + lazy val compute: TaskExecutor = + TaskExecutor(ExecutionContext.global) + + lazy val blockingIO: TaskExecutor = + TaskExecutor(TaskExecutors.sharedBlockingIO()) + + lazy val trampoline: TaskExecutor = + TaskExecutor(TaskExecutors.trampoline()) + + object Givens { + given compute: TaskExecutor = + TaskExecutor.compute + given blockingIO: TaskExecutor = + TaskExecutor.blockingIO + } +} diff --git a/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/aliases.scala b/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/aliases.scala new file mode 100644 index 0000000..acc7448 --- /dev/null +++ b/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/aliases.scala @@ -0,0 +1,4 @@ +package org.funfix.tasks.scala + +type Cancellable = org.funfix.tasks.jvm.Cancellable +type Executor = java.util.concurrent.Executor \ No newline at end of file diff --git a/tasks-scala/core/shared/src/main/scala-3/org/funfix/tasks/scala/Outcome.scala b/tasks-scala/core/shared/src/main/scala-3/org/funfix/tasks/scala/Outcome.scala new file mode 100644 index 0000000..bfa2390 --- /dev/null +++ b/tasks-scala/core/shared/src/main/scala-3/org/funfix/tasks/scala/Outcome.scala @@ -0,0 +1,7 @@ +package org.funfix.tasks.scala + +enum Outcome[+A] { + case Success(value: A) + case Failure(exception: Throwable) + case Cancellation +} diff --git a/tasks-scala/project/Boilerplate.scala b/tasks-scala/project/Boilerplate.scala new file mode 100644 index 0000000..b768a4c --- /dev/null +++ b/tasks-scala/project/Boilerplate.scala @@ -0,0 +1,27 @@ +import sbt.* +import sbt.Keys.* + +object Boilerplate { + /** + * For working with Scala version-specific source files, allowing us to + * use 2.13 or 3.x specific APIs. + */ + lazy val crossVersionSharedSources: Seq[Setting[?]] = { + def scalaPartV = Def setting (CrossVersion partialVersion scalaVersion.value) + Seq(Compile, Test).map { sc => + (sc / unmanagedSourceDirectories) ++= { + (sc / unmanagedSourceDirectories).value + .filterNot(_.getPath.matches("^.*\\d+$")) + .flatMap { dir => + Seq( + scalaPartV.value match { + case Some((2, _)) => Seq(new File(dir.getPath + "-2")) + case Some((3, _)) => Seq(new File(dir.getPath + "-3")) + case _ => Nil + }, + ).flatten + } + } + } + } +} diff --git a/tasks-scala/project/build.properties b/tasks-scala/project/build.properties new file mode 100644 index 0000000..081fdbb --- /dev/null +++ b/tasks-scala/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.10.0 diff --git a/tasks-scala/project/plugins.sbt b/tasks-scala/project/plugins.sbt new file mode 100644 index 0000000..8a47437 --- /dev/null +++ b/tasks-scala/project/plugins.sbt @@ -0,0 +1,4 @@ +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2") +addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.4") diff --git a/tasks-scala/sbt b/tasks-scala/sbt new file mode 100755 index 0000000..7d3f707 --- /dev/null +++ b/tasks-scala/sbt @@ -0,0 +1,818 @@ +#!/usr/bin/env bash + +set +e +declare builtin_sbt_version="1.10.0" +declare -a residual_args +declare -a java_args +declare -a scalac_args +declare -a sbt_commands +declare -a sbt_options +declare -a print_version +declare -a print_sbt_version +declare -a print_sbt_script_version +declare -a shutdownall +declare -a original_args +declare java_cmd=java +declare java_version +declare init_sbt_version=_to_be_replaced +declare sbt_default_mem=1024 +declare -r default_sbt_opts="" +declare -r default_java_opts="-Dfile.encoding=UTF-8" +declare sbt_verbose= +declare sbt_debug= +declare build_props_sbt_version= +declare use_sbtn= +declare no_server= +declare sbtn_command="$SBTN_CMD" +declare sbtn_version="1.10.0" + +### ------------------------------- ### +### Helper methods for BASH scripts ### +### ------------------------------- ### + +# Bash reimplementation of realpath to return the absolute path +realpathish () { +( + TARGET_FILE="$1" + FIX_CYGPATH="$2" + + cd "$(dirname "$TARGET_FILE")" + TARGET_FILE=$(basename "$TARGET_FILE") + + COUNT=0 + while [ -L "$TARGET_FILE" -a $COUNT -lt 100 ] + do + TARGET_FILE=$(readlink "$TARGET_FILE") + cd "$(dirname "$TARGET_FILE")" + TARGET_FILE=$(basename "$TARGET_FILE") + COUNT=$(($COUNT + 1)) + done + + TARGET_DIR="$(pwd -P)" + if [ "$TARGET_DIR" == "/" ]; then + TARGET_FILE="/$TARGET_FILE" + else + TARGET_FILE="$TARGET_DIR/$TARGET_FILE" + fi + + # make sure we grab the actual windows path, instead of cygwin's path. + if [[ "x$FIX_CYGPATH" != "x" ]]; then + echo "$(cygwinpath "$TARGET_FILE")" + else + echo "$TARGET_FILE" + fi +) +} + +# Uses uname to detect if we're in the odd cygwin environment. +is_cygwin() { + local os=$(uname -s) + case "$os" in + CYGWIN*) return 0 ;; + MINGW*) return 0 ;; + MSYS*) return 0 ;; + *) return 1 ;; + esac +} + +# TODO - Use nicer bash-isms here. +CYGWIN_FLAG=$(if is_cygwin; then echo true; else echo false; fi) + +# This can fix cygwin style /cygdrive paths so we get the +# windows style paths. +cygwinpath() { + local file="$1" + if [[ "$CYGWIN_FLAG" == "true" ]]; then #" + echo $(cygpath -w $file) + else + echo $file + fi +} + + +declare -r sbt_bin_dir="$(dirname "$(realpathish "$0")")" +declare -r sbt_home="$(dirname "$sbt_bin_dir")" + +echoerr () { + echo 1>&2 "$@" +} +vlog () { + [[ $sbt_verbose || $sbt_debug ]] && echoerr "$@" +} +dlog () { + [[ $sbt_debug ]] && echoerr "$@" +} + +jar_file () { + echo "$(cygwinpath "${sbt_home}/bin/sbt-launch.jar")" +} + +jar_url () { + local repo_base="$SBT_LAUNCH_REPO" + if [[ $repo_base == "" ]]; then + repo_base="https://repo1.maven.org/maven2" + fi + echo "$repo_base/org/scala-sbt/sbt-launch/$1/sbt-launch-$1.jar" +} + +download_url () { + local url="$1" + local jar="$2" + mkdir -p $(dirname "$jar") && { + if command -v curl > /dev/null; then + curl --silent -L "$url" --output "$jar" + elif command -v wget > /dev/null; then + wget --quiet -O "$jar" "$url" + fi + } && [[ -f "$jar" ]] +} + +acquire_sbt_jar () { + local launcher_sv="$1" + if [[ "$launcher_sv" == "" ]]; then + if [[ "$init_sbt_version" != "_to_be_replaced" ]]; then + launcher_sv="$init_sbt_version" + else + launcher_sv="$builtin_sbt_version" + fi + fi + local user_home && user_home=$(findProperty user.home) + download_jar="${user_home:-$HOME}/.cache/sbt/boot/sbt-launch/$launcher_sv/sbt-launch-$launcher_sv.jar" + if [[ -f "$download_jar" ]]; then + sbt_jar="$download_jar" + else + sbt_url=$(jar_url "$launcher_sv") + echoerr "downloading sbt launcher $launcher_sv" + download_url "$sbt_url" "${download_jar}.temp" + download_url "${sbt_url}.sha1" "${download_jar}.sha1" + if command -v shasum > /dev/null; then + if echo "$(cat "${download_jar}.sha1") ${download_jar}.temp" | shasum -c - > /dev/null; then + mv "${download_jar}.temp" "${download_jar}" + else + echoerr "failed to download launcher jar: $sbt_url (shasum mismatch)" + exit 2 + fi + else + mv "${download_jar}.temp" "${download_jar}" + fi + if [[ -f "$download_jar" ]]; then + sbt_jar="$download_jar" + else + echoerr "failed to download launcher jar: $sbt_url" + exit 2 + fi + fi +} + +acquire_sbtn () { + local sbtn_v="$1" + local user_home && user_home=$(findProperty user.home) + local p="${user_home:-$HOME}/.cache/sbt/boot/sbtn/$sbtn_v" + local target="$p/sbtn" + local archive_target= + local url= + local arch="x86_64" + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + arch=$(uname -m) + if [[ "$arch" == "aarch64" ]] || [[ "$arch" == "x86_64" ]]; then + archive_target="$p/sbtn-${arch}-pc-linux-${sbtn_v}.tar.gz" + url="https://github.com/sbt/sbtn-dist/releases/download/v${sbtn_v}/sbtn-${arch}-pc-linux-${sbtn_v}.tar.gz" + else + echoerr "sbtn is not supported on $arch" + exit 2 + fi + elif [[ "$OSTYPE" == "darwin"* ]]; then + archive_target="$p/sbtn-universal-apple-darwin-${sbtn_v}.tar.gz" + url="https://github.com/sbt/sbtn-dist/releases/download/v${sbtn_v}/sbtn-universal-apple-darwin-${sbtn_v}.tar.gz" + elif [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then + target="$p/sbtn.exe" + archive_target="$p/sbtn-x86_64-pc-win32-${sbtn_v}.zip" + url="https://github.com/sbt/sbtn-dist/releases/download/v${sbtn_v}/sbtn-x86_64-pc-win32-${sbtn_v}.zip" + else + echoerr "sbtn is not supported on $OSTYPE" + exit 2 + fi + + if [[ -f "$target" ]]; then + sbtn_command="$target" + else + echoerr "downloading sbtn ${sbtn_v} for ${arch}" + download_url "$url" "$archive_target" + if [[ "$OSTYPE" == "linux-gnu"* ]] || [[ "$OSTYPE" == "darwin"* ]]; then + tar zxf "$archive_target" --directory "$p" + else + unzip "$archive_target" -d "$p" + fi + sbtn_command="$target" + fi +} + +# execRunner should be called only once to give up control to java +execRunner () { + # print the arguments one to a line, quoting any containing spaces + [[ $sbt_verbose || $sbt_debug ]] && echo "# Executing command line:" && { + for arg; do + if printf "%s\n" "$arg" | grep -q ' '; then + printf "\"%s\"\n" "$arg" + else + printf "%s\n" "$arg" + fi + done + echo "" + } + + if [[ "$CYGWIN_FLAG" == "true" ]]; then + # In cygwin we loose the ability to re-hook stty if exec is used + # https://github.com/sbt/sbt-launcher-package/issues/53 + "$@" + else + exec "$@" + fi +} + +addJava () { + dlog "[addJava] arg = '$1'" + java_args=( "${java_args[@]}" "$1" ) +} +addSbt () { + dlog "[addSbt] arg = '$1'" + sbt_commands=( "${sbt_commands[@]}" "$1" ) +} +addResidual () { + dlog "[residual] arg = '$1'" + residual_args=( "${residual_args[@]}" "$1" ) +} +addDebugger () { + addJava "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=$1" +} + +addMemory () { + dlog "[addMemory] arg = '$1'" + # evict memory related options + local xs=("${java_args[@]}") + java_args=() + for i in "${xs[@]}"; do + if ! [[ "${i}" == *-Xmx* ]] && ! [[ "${i}" == *-Xms* ]] && ! [[ "${i}" == *-Xss* ]] && ! [[ "${i}" == *-XX:MaxPermSize* ]] && ! [[ "${i}" == *-XX:MaxMetaspaceSize* ]] && ! [[ "${i}" == *-XX:ReservedCodeCacheSize* ]]; then + java_args+=("${i}") + fi + done + local ys=("${sbt_options[@]}") + sbt_options=() + for i in "${ys[@]}"; do + if ! [[ "${i}" == *-Xmx* ]] && ! [[ "${i}" == *-Xms* ]] && ! [[ "${i}" == *-Xss* ]] && ! [[ "${i}" == *-XX:MaxPermSize* ]] && ! [[ "${i}" == *-XX:MaxMetaspaceSize* ]] && ! [[ "${i}" == *-XX:ReservedCodeCacheSize* ]]; then + sbt_options+=("${i}") + fi + done + # a ham-fisted attempt to move some memory settings in concert + local mem=$1 + local codecache=$(( $mem / 8 )) + (( $codecache > 128 )) || codecache=128 + (( $codecache < 512 )) || codecache=512 + local class_metadata_size=$(( $codecache * 2 )) + if [[ -z $java_version ]]; then + java_version=$(jdk_version) + fi + + addJava "-Xms${mem}m" + addJava "-Xmx${mem}m" + addJava "-Xss4M" + addJava "-XX:ReservedCodeCacheSize=${codecache}m" + (( $java_version >= 8 )) || addJava "-XX:MaxPermSize=${class_metadata_size}m" +} + +addDefaultMemory() { + # if we detect any of these settings in ${JAVA_OPTS} or ${JAVA_TOOL_OPTIONS} we need to NOT output our settings. + # The reason is the Xms/Xmx, if they don't line up, cause errors. + if [[ "${java_args[@]}" == *-Xmx* ]] || \ + [[ "${java_args[@]}" == *-Xms* ]] || \ + [[ "${java_args[@]}" == *-Xss* ]] || \ + [[ "${java_args[@]}" == *-XX:+UseCGroupMemoryLimitForHeap* ]] || \ + [[ "${java_args[@]}" == *-XX:MaxRAM* ]] || \ + [[ "${java_args[@]}" == *-XX:InitialRAMPercentage* ]] || \ + [[ "${java_args[@]}" == *-XX:MaxRAMPercentage* ]] || \ + [[ "${java_args[@]}" == *-XX:MinRAMPercentage* ]]; then + : + elif [[ "${JAVA_TOOL_OPTIONS}" == *-Xmx* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-Xms* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-Xss* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-XX:+UseCGroupMemoryLimitForHeap* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-XX:MaxRAM* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-XX:InitialRAMPercentage* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-XX:MaxRAMPercentage* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-XX:MinRAMPercentage* ]] ; then + : + elif [[ "${sbt_options[@]}" == *-Xmx* ]] || \ + [[ "${sbt_options[@]}" == *-Xms* ]] || \ + [[ "${sbt_options[@]}" == *-Xss* ]] || \ + [[ "${sbt_options[@]}" == *-XX:+UseCGroupMemoryLimitForHeap* ]] || \ + [[ "${sbt_options[@]}" == *-XX:MaxRAM* ]] || \ + [[ "${sbt_options[@]}" == *-XX:InitialRAMPercentage* ]] || \ + [[ "${sbt_options[@]}" == *-XX:MaxRAMPercentage* ]] || \ + [[ "${sbt_options[@]}" == *-XX:MinRAMPercentage* ]] ; then + : + else + addMemory $sbt_default_mem + fi +} + +addSbtScriptProperty () { + if [[ "${java_args[@]}" == *-Dsbt.script=* ]]; then + : + else + sbt_script=$0 + # Use // to replace all spaces with %20. + sbt_script=${sbt_script// /%20} + addJava "-Dsbt.script=$sbt_script" + fi +} + +require_arg () { + local type="$1" + local opt="$2" + local arg="$3" + if [[ -z "$arg" ]] || [[ "${arg:0:1}" == "-" ]]; then + echo "$opt requires <$type> argument" + exit 1 + fi +} + +is_function_defined() { + declare -f "$1" > /dev/null +} + +# parses JDK version from the -version output line. +# 8 for 1.8.0_nn, 9 for 9-ea etc, and "no_java" for undetected +jdk_version() { + local result + local lines=$("$java_cmd" -Xms32M -Xmx32M -version 2>&1 | tr '\r' '\n') + local IFS=$'\n' + for line in $lines; do + if [[ (-z $result) && ($line = *"version \""*) ]] + then + local ver=$(echo $line | sed -e 's/.*version "\(.*\)"\(.*\)/\1/; 1q') + # on macOS sed doesn't support '?' + if [[ $ver = "1."* ]] + then + result=$(echo $ver | sed -e 's/1\.\([0-9]*\)\(.*\)/\1/; 1q') + else + result=$(echo $ver | sed -e 's/\([0-9]*\)\(.*\)/\1/; 1q') + fi + fi + done + if [[ -z $result ]] + then + result=no_java + fi + echo "$result" +} + +# Find the first occurrence of the given property name and returns its value by looking at: +# - properties set by command-line options, +# - JAVA_OPTS environment variable, +# - SBT_OPTS environment variable, +# - _JAVA_OPTIONS environment variable and +# - JAVA_TOOL_OPTIONS environment variable +# in that order. +findProperty() { + local -a java_opts_array + local -a sbt_opts_array + local -a _java_options_array + local -a java_tool_options_array + read -a java_opts_array <<< "$JAVA_OPTS" + read -a sbt_opts_array <<< "$SBT_OPTS" + read -a _java_options_array <<< "$_JAVA_OPTIONS" + read -a java_tool_options_array <<< "$JAVA_TOOL_OPTIONS" + + local args_to_check=( + "${java_args[@]}" + "${java_opts_array[@]}" + "${sbt_opts_array[@]}" + "${_java_options_array[@]}" + "${java_tool_options_array[@]}") + + for opt in "${args_to_check[@]}"; do + if [[ "$opt" == -D$1=* ]]; then + echo "${opt#-D$1=}" + return + fi + done +} + +# Extracts the preloaded directory from either -Dsbt.preloaded, -Dsbt.global.base or -Duser.home +# in that order. +getPreloaded() { + local preloaded && preloaded=$(findProperty sbt.preloaded) + [ "$preloaded" ] && echo "$preloaded" && return + + local global_base && global_base=$(findProperty sbt.global.base) + [ "$global_base" ] && echo "$global_base/preloaded" && return + + local user_home && user_home=$(findProperty user.home) + echo "${user_home:-$HOME}/.sbt/preloaded" +} + +syncPreloaded() { + local source_preloaded="$sbt_home/lib/local-preloaded/" + local target_preloaded="$(getPreloaded)" + if [[ "$init_sbt_version" == "" ]]; then + # FIXME: better $init_sbt_version detection + init_sbt_version="$(ls -1 "$source_preloaded/org/scala-sbt/sbt/")" + fi + [[ -f "$target_preloaded/org/scala-sbt/sbt/$init_sbt_version/" ]] || { + # lib/local-preloaded exists (This is optional) + [[ -d "$source_preloaded" ]] && { + command -v rsync >/dev/null 2>&1 && { + mkdir -p "$target_preloaded" + rsync --recursive --links --perms --times --ignore-existing "$source_preloaded" "$target_preloaded" || true + } + } + } +} + +# Detect that we have java installed. +checkJava() { + local required_version="$1" + # Now check to see if it's a good enough version + local good_enough="$(expr $java_version ">=" $required_version)" + if [[ "$java_version" == "" ]]; then + echo + echo "No Java Development Kit (JDK) installation was detected." + echo Please go to http://www.oracle.com/technetwork/java/javase/downloads/ and download. + echo + exit 1 + elif [[ "$good_enough" != "1" ]]; then + echo + echo "The Java Development Kit (JDK) installation you have is not up to date." + echo $script_name requires at least version $required_version+, you have + echo version $java_version + echo + echo Please go to http://www.oracle.com/technetwork/java/javase/downloads/ and download + echo a valid JDK and install before running $script_name. + echo + exit 1 + fi +} + +copyRt() { + local at_least_9="$(expr $java_version ">=" 9)" + if [[ "$at_least_9" == "1" ]]; then + # The grep for java9-rt-ext- matches the filename prefix printed in Export.java + java9_ext=$("$java_cmd" "${sbt_options[@]}" "${java_args[@]}" \ + -jar "$sbt_jar" --rt-ext-dir | grep java9-rt-ext- | tr -d '\r') + java9_rt=$(echo "$java9_ext/rt.jar") + vlog "[copyRt] java9_rt = '$java9_rt'" + if [[ ! -f "$java9_rt" ]]; then + echo copying runtime jar... + mkdir -p "$java9_ext" + "$java_cmd" \ + "${sbt_options[@]}" \ + "${java_args[@]}" \ + -jar "$sbt_jar" \ + --export-rt \ + "${java9_rt}" + fi + addJava "-Dscala.ext.dirs=${java9_ext}" + fi +} + +run() { + # Copy preloaded repo to user's preloaded directory + syncPreloaded + + # no jar? download it. + [[ -f "$sbt_jar" ]] || acquire_sbt_jar "$sbt_version" || { + exit 1 + } + + # TODO - java check should be configurable... + checkJava "6" + + # Java 9 support + copyRt + + # If we're in cygwin, we should use the windows config, and terminal hacks + if [[ "$CYGWIN_FLAG" == "true" ]]; then #" + stty -icanon min 1 -echo > /dev/null 2>&1 + addJava "-Djline.terminal=jline.UnixTerminal" + addJava "-Dsbt.cygwin=true" + fi + + if [[ $print_sbt_version ]]; then + execRunner "$java_cmd" -jar "$sbt_jar" "sbtVersion" | tail -1 | sed -e 's/\[info\]//g' + elif [[ $print_sbt_script_version ]]; then + echo "$init_sbt_version" + elif [[ $print_version ]]; then + execRunner "$java_cmd" -jar "$sbt_jar" "sbtVersion" | tail -1 | sed -e 's/\[info\]/sbt version in this project:/g' + echo "sbt script version: $init_sbt_version" + elif [[ $shutdownall ]]; then + local sbt_processes=( $(jps -v | grep sbt-launch | cut -f1 -d ' ') ) + for procId in "${sbt_processes[@]}"; do + kill -9 $procId + done + echo "shutdown ${#sbt_processes[@]} sbt processes" + else + # run sbt + execRunner "$java_cmd" \ + "${java_args[@]}" \ + "${sbt_options[@]}" \ + "${java_tool_options[@]}" \ + -jar "$sbt_jar" \ + "${sbt_commands[@]}" \ + "${residual_args[@]}" + fi + + exit_code=$? + + # Clean up the terminal from cygwin hacks. + if [[ "$CYGWIN_FLAG" == "true" ]]; then #" + stty icanon echo > /dev/null 2>&1 + fi + exit $exit_code +} + +declare -ra noshare_opts=(-Dsbt.global.base=project/.sbtboot -Dsbt.boot.directory=project/.boot -Dsbt.ivy.home=project/.ivy) +declare -r sbt_opts_file=".sbtopts" +declare -r build_props_file="$(pwd)/project/build.properties" +declare -r etc_sbt_opts_file="/etc/sbt/sbtopts" +# this allows /etc/sbt/sbtopts location to be changed +declare -r etc_file="${SBT_ETC_FILE:-$etc_sbt_opts_file}" +declare -r dist_sbt_opts_file="${sbt_home}/conf/sbtopts" +declare -r win_sbt_opts_file="${sbt_home}/conf/sbtconfig.txt" +declare sbt_jar="$(jar_file)" + +usage() { + cat < path to global settings/plugins directory (default: ~/.sbt) + --sbt-boot path to shared boot directory (default: ~/.sbt/boot in 0.11 series) + --sbt-cache path to global cache directory (default: operating system specific) + --ivy path to local Ivy repository (default: ~/.ivy2) + --mem set memory options (default: $sbt_default_mem) + --no-share use all local caches; no sharing + --no-global uses global caches, but does not use global ~/.sbt directory. + --jvm-debug Turn on JVM debugging, open at the given port. + --batch disable interactive mode + + # sbt version (default: from project/build.properties if present, else latest release) + --sbt-version use the specified version of sbt + --sbt-jar use the specified jar as the sbt launcher + + --java-home alternate JAVA_HOME + + # jvm options and output control + JAVA_OPTS environment variable, if unset uses "$default_java_opts" + .jvmopts if this file exists in the current directory, its contents + are appended to JAVA_OPTS + SBT_OPTS environment variable, if unset uses "$default_sbt_opts" + .sbtopts if this file exists in the current directory, its contents + are prepended to the runner args + /etc/sbt/sbtopts if this file exists, it is prepended to the runner args + -Dkey=val pass -Dkey=val directly to the java runtime + -J-X pass option -X directly to the java runtime + (-J is stripped) + +In the case of duplicated or conflicting options, the order above +shows precedence: JAVA_OPTS lowest, command line options highest. +EOM +} + +process_my_args () { + while [[ $# -gt 0 ]]; do + case "$1" in + -batch|--batch) exec + + -sbt-create|--sbt-create) sbt_create=true && shift ;; + + new) sbt_new=true && addResidual "$1" && shift ;; + + *) addResidual "$1" && shift ;; + esac + done + + # Now, ensure sbt version is used. + [[ "${sbt_version}XXX" != "XXX" ]] && addJava "-Dsbt.version=$sbt_version" + + # Confirm a user's intent if the current directory does not look like an sbt + # top-level directory and neither the -sbt-create option nor the "new" + # command was given. + [[ -f ./build.sbt || -d ./project || -n "$sbt_create" || -n "$sbt_new" ]] || { + echo "[warn] Neither build.sbt nor a 'project' directory in the current directory: $(pwd)" + while true; do + echo 'c) continue' + echo 'q) quit' + + read -p '? ' || exit 1 + case "$REPLY" in + c|C) break ;; + q|Q) exit 1 ;; + esac + done + } +} + +## map over argument array. this is used to process both command line arguments and SBT_OPTS +map_args () { + local options=() + local commands=() + while [[ $# -gt 0 ]]; do + case "$1" in + -no-colors|--no-colors) options=( "${options[@]}" "-Dsbt.log.noformat=true" ) && shift ;; + -timings|--timings) options=( "${options[@]}" "-Dsbt.task.timings=true" "-Dsbt.task.timings.on.shutdown=true" ) && shift ;; + -traces|--traces) options=( "${options[@]}" "-Dsbt.traces=true" ) && shift ;; + --supershell=*) options=( "${options[@]}" "-Dsbt.supershell=${1:13}" ) && shift ;; + -supershell=*) options=( "${options[@]}" "-Dsbt.supershell=${1:12}" ) && shift ;; + -no-server|--no-server) options=( "${options[@]}" "-Dsbt.io.virtual=false" "-Dsbt.server.autostart=false" ) && shift ;; + --color=*) options=( "${options[@]}" "-Dsbt.color=${1:8}" ) && shift ;; + -color=*) options=( "${options[@]}" "-Dsbt.color=${1:7}" ) && shift ;; + -no-share|--no-share) options=( "${options[@]}" "${noshare_opts[@]}" ) && shift ;; + -no-global|--no-global) options=( "${options[@]}" "-Dsbt.global.base=$(pwd)/project/.sbtboot" ) && shift ;; + -ivy|--ivy) require_arg path "$1" "$2" && options=( "${options[@]}" "-Dsbt.ivy.home=$2" ) && shift 2 ;; + -sbt-boot|--sbt-boot) require_arg path "$1" "$2" && options=( "${options[@]}" "-Dsbt.boot.directory=$2" ) && shift 2 ;; + -sbt-dir|--sbt-dir) require_arg path "$1" "$2" && options=( "${options[@]}" "-Dsbt.global.base=$2" ) && shift 2 ;; + -debug|--debug) commands=( "${commands[@]}" "-debug" ) && shift ;; + -debug-inc|--debug-inc) options=( "${options[@]}" "-Dxsbt.inc.debug=true" ) && shift ;; + *) options=( "${options[@]}" "$1" ) && shift ;; + esac + done + declare -p options + declare -p commands +} + +process_args () { + while [[ $# -gt 0 ]]; do + case "$1" in + -h|-help|--help) usage; exit 1 ;; + -v|-verbose|--verbose) sbt_verbose=1 && shift ;; + -V|-version|--version) print_version=1 && shift ;; + --numeric-version) print_sbt_version=1 && shift ;; + --script-version) print_sbt_script_version=1 && shift ;; + shutdownall) shutdownall=1 && shift ;; + -d|-debug|--debug) sbt_debug=1 && addSbt "-debug" && shift ;; + -client|--client) use_sbtn=1 && shift ;; + --server) use_sbtn=0 && shift ;; + + -mem|--mem) require_arg integer "$1" "$2" && addMemory "$2" && shift 2 ;; + -jvm-debug|--jvm-debug) require_arg port "$1" "$2" && addDebugger $2 && shift 2 ;; + -batch|--batch) exec = 2 )) || ( (( $sbtBinaryV_1 >= 1 )) && (( $sbtBinaryV_2 >= 4 )) ); then + if [[ "$use_sbtn" == "1" ]]; then + echo "true" + else + echo "false" + fi + else + echo "false" + fi +} + +runNativeClient() { + vlog "[debug] running native client" + detectNativeClient + [[ -f "$sbtn_command" ]] || acquire_sbtn "$sbtn_version" || { + exit 1 + } + for i in "${!original_args[@]}"; do + if [[ "${original_args[i]}" = "--client" ]]; then + unset 'original_args[i]' + fi + done + sbt_script=$0 + sbt_script=${sbt_script/ /%20} + execRunner "$sbtn_command" "--sbt-script=$sbt_script" "${original_args[@]}" +} + +original_args=("$@") + +# Here we pull in the default settings configuration. +[[ -f "$dist_sbt_opts_file" ]] && set -- $(loadConfigFile "$dist_sbt_opts_file") "$@" + +# Here we pull in the global settings configuration. +[[ -f "$etc_file" ]] && set -- $(loadConfigFile "$etc_file") "$@" + +# Pull in the project-level config file, if it exists. +[[ -f "$sbt_opts_file" ]] && set -- $(loadConfigFile "$sbt_opts_file") "$@" + +# Pull in the project-level java config, if it exists. +[[ -f ".jvmopts" ]] && export JAVA_OPTS="$JAVA_OPTS $(loadConfigFile .jvmopts)" + +# Pull in default JAVA_OPTS +[[ -z "${JAVA_OPTS// }" ]] && export JAVA_OPTS="$default_java_opts" + +[[ -f "$build_props_file" ]] && loadPropFile "$build_props_file" + +java_args=($JAVA_OPTS) +sbt_options0=(${SBT_OPTS:-$default_sbt_opts}) +java_tool_options=($JAVA_TOOL_OPTIONS) +if [[ "$SBT_NATIVE_CLIENT" == "true" ]]; then + use_sbtn=1 +fi + +# Split SBT_OPTS into options/commands +miniscript=$(map_args "${sbt_options0[@]}") && eval "${miniscript/options/sbt_options}" && \ +eval "${miniscript/commands/sbt_additional_commands}" + +# Combine command line options/commands and commands from SBT_OPTS +miniscript=$(map_args "$@") && eval "${miniscript/options/cli_options}" && eval "${miniscript/commands/cli_commands}" +args1=( "${cli_options[@]}" "${cli_commands[@]}" "${sbt_additional_commands[@]}" ) + +# process the combined args, then reset "$@" to the residuals +process_args "${args1[@]}" +vlog "[sbt_options] $(declare -p sbt_options)" + +if [[ "$(isRunNativeClient)" == "true" ]]; then + set -- "${residual_args[@]}" + argumentCount=$# + runNativeClient +else + java_version="$(jdk_version)" + vlog "[process_args] java_version = '$java_version'" + addDefaultMemory + addSbtScriptProperty + set -- "${residual_args[@]}" + argumentCount=$# + run +fi From 5ed61eaa05213631d0f54ed868fa2a0b98ed0fe7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 15:32:29 +0200 Subject: [PATCH 02/21] Merge origin/main and restore Kotlin build configuration (#15) * Update README * Add Resource (#12) * v0.1.0 * @Nullable Executor * v0.1.1 * Add errorprone plugin with NullAway to build (#13) * v0.1.2 * Fix concurrency bug * Trampoline#forkAll optimisation * v0.1.3 * Remove Resource.Closeable * v0.2.0 * Update Javadoc link * Fix config * v0.2.1 * Add more @Blocking annotations, improve tests * Task#withOnComplete, Task#withCancellation (#14) * v0.3.0 * Trigger build on tags * Add Fiber#joinBlockingUninterruptible * v0.3.1 * Initial plan * Merge origin/main and restore build configuration - Merged origin/main with Kotlin build configuration preserved - Removed Scala module and updated settings.gradle.kts - Updated build configuration to include errorprone plugin - Added errorprone dependencies to buildSrc and libs.versions.toml - Integrated latest tasks-jvm code from origin/main Co-authored-by: alexandru <11753+alexandru@users.noreply.github.com> * Fix cancellation handling in fromCancellableFuture - Added CancellationException to the list of exceptions that trigger onCancellation - Fixed order of cancellation: cancel CompletableFuture before calling custom cancellable to avoid race condition where future completes before being cancelled - All tests now pass including gradle check Co-authored-by: alexandru <11753+alexandru@users.noreply.github.com> --------- Co-authored-by: Alexandru Nedelcu Co-authored-by: Alexandru Nedelcu Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexandru <11753+alexandru@users.noreply.github.com> --- buildSrc/build.gradle.kts | 1 + .../main/kotlin/tasks.java-project.gradle.kts | 1 + gradle/libs.versions.toml | 6 + settings.gradle.kts | 1 - tasks-jvm/build.gradle.kts | 23 +- .../java/org/funfix/tasks/jvm/AsyncFun.java | 2 - .../org/funfix/tasks/jvm/Cancellable.java | 140 ++- .../funfix/tasks/jvm/CancellableFuture.java | 2 - .../org/funfix/tasks/jvm/CloseableFun.java | 41 + .../org/funfix/tasks/jvm/Collections.java | 39 +- .../funfix/tasks/jvm/CompletionCallback.java | 307 ++++++- .../org/funfix/tasks/jvm/Continuation.java | 41 +- .../java/org/funfix/tasks/jvm/DelayedFun.java | 2 - .../java/org/funfix/tasks/jvm/ExitCase.java | 58 ++ .../main/java/org/funfix/tasks/jvm/Fiber.java | 197 ++--- .../java/org/funfix/tasks/jvm/Outcome.java | 5 +- .../java/org/funfix/tasks/jvm/ProcessFun.java | 14 + .../java/org/funfix/tasks/jvm/Resource.java | 365 ++++++++ .../main/java/org/funfix/tasks/jvm/Task.java | 251 ++---- .../tasks/jvm/TaskCancellationException.java | 2 - .../org/funfix/tasks/jvm/TaskExecutor.java | 54 +- .../org/funfix/tasks/jvm/TaskExecutors.java | 9 +- .../java/org/funfix/tasks/jvm/TaskUtils.java | 44 + .../java/org/funfix/tasks/jvm/Trampoline.java | 21 +- .../tasks/jvm/UncaughtExceptionHandler.java | 7 +- .../org/funfix/tasks/jvm/package-info.java | 4 + .../org/funfix/tasks/jvm/AwaitSignalTest.java | 39 +- .../tasks/jvm/CompletionCallbackTest.java | 9 +- .../java/org/funfix/tasks/jvm/FiberTests.java | 77 +- .../java/org/funfix/tasks/jvm/LoomTest.java | 20 +- .../org/funfix/tasks/jvm/OutcomeTest.java | 8 +- .../java/org/funfix/tasks/jvm/PureTest.java | 28 + .../org/funfix/tasks/jvm/ResourceTest.java | 119 +++ .../java/org/funfix/tasks/jvm/SysProp.java | 2 - .../org/funfix/tasks/jvm/TaskCreateTest.java | 16 +- .../tasks/jvm/TaskEnsureExecutorTest.java | 7 +- .../org/funfix/tasks/jvm/TaskExecuteTest.java | 2 - .../funfix/tasks/jvm/TaskExecutorTest.java | 2 - .../tasks/jvm/TaskFromBlockingFutureTest.java | 45 +- .../tasks/jvm/TaskFromBlockingIOTest.java | 17 +- .../tasks/jvm/TaskWithCancellationTest.java | 112 +++ .../tasks/jvm/TaskWithOnCompletionTest.java | 221 +++++ .../org/funfix/tasks/jvm/TestSettings.java | 21 + .../java/org/funfix/tasks/jvm/TimedAwait.java | 28 +- .../org/funfix/tasks/jvm/TrampolineTest.java | 11 + tasks-scala/build.gradle.kts | 18 - tasks-scala/build.sbt | 68 -- .../tasks/scala/CompletionCallback.scala | 22 - .../scala-3/org/funfix/tasks/scala/Task.scala | 24 - .../org/funfix/tasks/scala/TaskExecutor.scala | 27 - .../org/funfix/tasks/scala/aliases.scala | 4 - .../org/funfix/tasks/scala/Outcome.scala | 7 - tasks-scala/project/Boilerplate.scala | 27 - tasks-scala/project/build.properties | 1 - tasks-scala/project/plugins.sbt | 4 - tasks-scala/sbt | 818 ------------------ 56 files changed, 1881 insertions(+), 1560 deletions(-) create mode 100644 tasks-jvm/src/main/java/org/funfix/tasks/jvm/CloseableFun.java create mode 100644 tasks-jvm/src/main/java/org/funfix/tasks/jvm/ExitCase.java create mode 100644 tasks-jvm/src/main/java/org/funfix/tasks/jvm/ProcessFun.java create mode 100644 tasks-jvm/src/main/java/org/funfix/tasks/jvm/Resource.java create mode 100644 tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskUtils.java create mode 100644 tasks-jvm/src/main/java/org/funfix/tasks/jvm/package-info.java create mode 100644 tasks-jvm/src/test/java/org/funfix/tasks/jvm/PureTest.java create mode 100644 tasks-jvm/src/test/java/org/funfix/tasks/jvm/ResourceTest.java create mode 100644 tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskWithCancellationTest.java create mode 100644 tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskWithOnCompletionTest.java create mode 100644 tasks-jvm/src/test/java/org/funfix/tasks/jvm/TestSettings.java delete mode 100644 tasks-scala/build.gradle.kts delete mode 100644 tasks-scala/build.sbt delete mode 100644 tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/CompletionCallback.scala delete mode 100644 tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/Task.scala delete mode 100644 tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/TaskExecutor.scala delete mode 100644 tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/aliases.scala delete mode 100644 tasks-scala/core/shared/src/main/scala-3/org/funfix/tasks/scala/Outcome.scala delete mode 100644 tasks-scala/project/Boilerplate.scala delete mode 100644 tasks-scala/project/build.properties delete mode 100644 tasks-scala/project/plugins.sbt delete mode 100755 tasks-scala/sbt diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 439473a..dcc3bdf 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -27,4 +27,5 @@ dependencies { implementation(libs.gradle.versions.plugin) implementation(libs.binary.compatibility.validator.plugin) implementation(libs.vanniktech.publish.plugin) + implementation(libs.errorprone.gradle.plugin) } diff --git a/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts b/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts index 64565b2..7bdb8a4 100644 --- a/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts +++ b/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts @@ -1,5 +1,6 @@ plugins { `java-library` jacoco + id("net.ltgt.errorprone") id("tasks.base") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5016e3..cbe8756 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,9 @@ publish = "0.29.0" jspecify = "1.0.0" lombok = "1.18.36" binary-compatibility-validator = "0.16.2" +errorprone-plugin = "4.3.0" +errorprone = "2.41.0" +errorprone-nullaway = "0.12.8" [libraries] # Plugins specified in buildSrc/build.gradle.kts @@ -20,6 +23,7 @@ gradle-versions-plugin = { module = "com.github.ben-manes:gradle-versions-plugin vanniktech-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "publish" } # org.jetbrains.kotlinx.binary-compatibility-validator binary-compatibility-validator-plugin = { module = "org.jetbrains.kotlinx.binary-compatibility-validator:org.jetbrains.kotlinx.binary-compatibility-validator.gradle.plugin", version.ref = "binary-compatibility-validator" } +errorprone-gradle-plugin = { module = "net.ltgt.gradle:gradle-errorprone-plugin", version.ref = "errorprone-plugin" } # Actual libraries jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" } @@ -29,3 +33,5 @@ kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotl kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } +errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone"} +errorprone-nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "errorprone-nullaway" } diff --git a/settings.gradle.kts b/settings.gradle.kts index f09c77b..c425a36 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,7 +3,6 @@ rootProject.name = "tasks" include("tasks-jvm") include("tasks-kotlin") include("tasks-kotlin-coroutines") -include("tasks-scala") pluginManagement { repositories { diff --git a/tasks-jvm/build.gradle.kts b/tasks-jvm/build.gradle.kts index ef8d450..0076d9e 100644 --- a/tasks-jvm/build.gradle.kts +++ b/tasks-jvm/build.gradle.kts @@ -1,3 +1,6 @@ +import net.ltgt.gradle.errorprone.CheckSeverity +import net.ltgt.gradle.errorprone.errorprone + plugins { id("tasks.java-project") } @@ -11,9 +14,11 @@ mavenPublishing { dependencies { api(libs.jspecify) + + errorprone(libs.errorprone.core) + errorprone(libs.errorprone.nullaway) + compileOnly(libs.jetbrains.annotations) - compileOnly(libs.lombok) - annotationProcessor(libs.lombok) testImplementation(platform("org.junit:junit-bom:5.12.1")) testImplementation("org.junit.jupiter:junit-jupiter") @@ -22,16 +27,26 @@ dependencies { tasks.test { useJUnitPlatform() - finalizedBy(tasks.jacocoTestReport) // report is always generated after tests run + finalizedBy(tasks.jacocoTestReport) } tasks.jacocoTestReport { - dependsOn(tasks.test) // tests are required to run before generating the report + dependsOn(tasks.test) } tasks.withType { sourceCompatibility = JavaVersion.VERSION_17.majorVersion targetCompatibility = JavaVersion.VERSION_17.majorVersion + options.compilerArgs.addAll(listOf( + "-Xlint:deprecation", +// "-Werror" + )) + + options.errorprone { + disableAllChecks.set(true) + check("NullAway", CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "org.funfix") + } } tasks.register("testsOn21") { diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/AsyncFun.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/AsyncFun.java index 693dd61..4e9ed31 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/AsyncFun.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/AsyncFun.java @@ -1,7 +1,6 @@ package org.funfix.tasks.jvm; import org.jetbrains.annotations.NonBlocking; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import java.io.Serializable; @@ -12,7 +11,6 @@ *

* This function type is what's needed to describe {@link Task} instances. */ -@NullMarked @FunctionalInterface @NonBlocking public interface AsyncFun extends Serializable { diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Cancellable.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Cancellable.java index b1e68a4..b072603 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Cancellable.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Cancellable.java @@ -1,10 +1,8 @@ package org.funfix.tasks.jvm; -import lombok.Data; -import lombok.EqualsAndHashCode; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NonBlocking; -import org.jspecify.annotations.NullMarked; +import org.jetbrains.annotations.Nullable; import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; @@ -27,7 +25,6 @@ * */ @FunctionalInterface -@NullMarked public interface Cancellable { /** * Triggers (idempotent) cancellation. @@ -51,9 +48,8 @@ static Cancellable getEmpty() { * breakage between minor version updates. */ @ApiStatus.Internal -@NullMarked final class CancellableUtils { - static Cancellable EMPTY = () -> {}; + static final Cancellable EMPTY = () -> {}; } /** @@ -65,9 +61,7 @@ final class CancellableUtils { * breakage between minor version updates. */ @ApiStatus.Internal -@NullMarked -@FunctionalInterface -interface CancellableForwardRef { +interface CancellableForwardRef extends Cancellable { void set(Cancellable cancellable); } @@ -79,45 +73,105 @@ interface CancellableForwardRef { * between minor version updates. */ @ApiStatus.Internal -@NullMarked final class MutableCancellable implements Cancellable { - private final AtomicReference ref = - new AtomicReference<>(new State.Active(Cancellable.getEmpty(), 0)); + private final AtomicReference ref; + + MutableCancellable(final Cancellable initialRef) { + ref = new AtomicReference<>(new State.Active(initialRef, 0, null)); + } + + MutableCancellable() { + this(CancellableUtils.EMPTY); + } @Override public void cancel() { - final var prev = ref.getAndSet(State.Cancelled.INSTANCE); - if (prev instanceof State.Active) { - ((State.Active) prev).token.cancel(); + @Nullable + var state = ref.getAndSet(State.Closed.INSTANCE); + while (state instanceof State.Active active) { + try { + active.token.cancel(); + } catch (Exception e) { + UncaughtExceptionHandler.logOrRethrow(e); + } + state = active.rest; } } public CancellableForwardRef newCancellableRef() { final var current = ref.get(); - if (current instanceof State.Cancelled) { - return Cancellable::cancel; - } else if (current instanceof State.Active) { - final var active = (State.Active) current; - return cancellable -> registerOrdered( - active.order, - cancellable, - active - ); + if (current instanceof State.Closed) { + return new CancellableForwardRef() { + @Override + public void set(Cancellable cancellable) { + cancellable.cancel(); + } + @Override + public void cancel() {} + }; + } else if (current instanceof State.Active active) { + return new CancellableForwardRef() { + @Override + public void set(Cancellable cancellable) { + registerOrdered( + active.order, + cancellable, + active + ); + } + + @Override + public void cancel() { + unregister(active.order); + } + }; } else { throw new IllegalStateException("Invalid state: " + current); } } - public void register(Cancellable token) { + public @Nullable Cancellable register(Cancellable token) { Objects.requireNonNull(token, "token"); while (true) { final var current = ref.get(); - if (current instanceof State.Active) { - final var active = (State.Active) current; - final var update = new State.Active(token, active.order + 1); - if (ref.compareAndSet(current, update)) { return; } - } else if (current instanceof State.Cancelled) { + if (current instanceof State.Active active) { + final var newOrder = active.order + 1; + final var update = new State.Active(token, newOrder, active); + if (ref.compareAndSet(current, update)) { return () -> unregister(newOrder); } + } else if (current instanceof State.Closed) { token.cancel(); + return null; + } else { + throw new IllegalStateException("Invalid state: " + current); + } + } + } + + private void unregister(final long order) { + while (true) { + final var current = ref.get(); + if (current instanceof State.Active active) { + @Nullable var cursor = active; + @Nullable State.Active acc = null; + while (cursor != null) { + if (cursor.order != order) { + acc = new State.Active(cursor.token, cursor.order, acc); + } + cursor = cursor.rest; + } + // Reversing + @Nullable State.Active update = null; + while (acc != null) { + update = new State.Active(acc.token, acc.order, update); + acc = acc.rest; + } + if (update == null) { + update = new State.Active(Cancellable.getEmpty(), 0, null); + } + if (ref.compareAndSet(current, update)) { + return; + } + } else if (current instanceof State.Closed) { return; } else { throw new IllegalStateException("Invalid state: " + current); @@ -131,16 +185,15 @@ private void registerOrdered( State current ) { while (true) { - if (current instanceof State.Active) { + if (current instanceof State.Active active) { // Double-check ordering - final var active = (State.Active) current; if (active.order != order) { return; } // Try to update - final var update = new State.Active(newToken, order + 1); + final var update = new State.Active(newToken, order + 1, null); if (ref.compareAndSet(current, update)) { return; } // Retry current = ref.get(); - } else if (current instanceof State.Cancelled) { + } else if (current instanceof State.Closed) { newToken.cancel(); return; } else { @@ -149,18 +202,15 @@ private void registerOrdered( } } - static sealed abstract class State { - @Data - @EqualsAndHashCode(callSuper = false) - static final class Active extends State { - private final Cancellable token; - private final long order; - } + sealed interface State { + record Active( + Cancellable token, + long order, + @Nullable Active rest + ) implements State {} - @Data - @EqualsAndHashCode(callSuper = false) - static final class Cancelled extends State { - static Cancelled INSTANCE = new Cancelled(); + record Closed() implements State { + static final Closed INSTANCE = new Closed(); } } } diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CancellableFuture.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CancellableFuture.java index bbd0316..6abcafa 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CancellableFuture.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CancellableFuture.java @@ -1,6 +1,5 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import java.util.concurrent.CompletableFuture; @@ -19,7 +18,6 @@ * be preferred because it's more principled. {@code CancellableFuture} * is useful more for interoperability with Java code. */ -@NullMarked public record CancellableFuture( CompletableFuture future, Cancellable cancellable diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CloseableFun.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CloseableFun.java new file mode 100644 index 0000000..25f338b --- /dev/null +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CloseableFun.java @@ -0,0 +1,41 @@ +package org.funfix.tasks.jvm; + +import java.util.function.Function; + +/** + * Blocking/synchronous finalizer of {@link Resource}. + * + * @see Resource#fromBlockingIO(DelayedFun) + */ +@FunctionalInterface +public interface CloseableFun extends AutoCloseable { + void close(ExitCase exitCase) throws Exception; + + /** + * Converts this blocking finalizer into an asynchronous one + * that can be used for initializing {@link Resource.Acquired}. + */ + default Function> toAsync() { + @SuppressWarnings("NullAway") + final Function> r = + exitCase -> TaskUtils.taskUninterruptibleBlockingIO(() -> { + close(exitCase); + return null; + }); + return r; + } + + @Override + default void close() throws Exception { + close(ExitCase.succeeded()); + } + + static CloseableFun fromAutoCloseable(AutoCloseable resource) { + return ignored -> resource.close(); + } + + /** + * Reusable reference for a no-op {@code CloseableFun}. + */ + CloseableFun NOOP = ignored -> {}; +} diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Collections.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Collections.java index 480abdd..608e9c6 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Collections.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Collections.java @@ -1,17 +1,15 @@ package org.funfix.tasks.jvm; -import lombok.EqualsAndHashCode; import org.jetbrains.annotations.ApiStatus; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import java.util.Iterator; import java.util.NoSuchElementException; +import java.util.Objects; import java.util.function.Predicate; @ApiStatus.Internal -@NullMarked -sealed abstract class ImmutableStack implements Iterable +sealed abstract class ImmutableStack implements Iterable permits ImmutableStack.Cons, ImmutableStack.Nil { final ImmutableStack prepend(T value) { @@ -26,8 +24,7 @@ final ImmutableStack prependAll(Iterable values) { return result; } - @Nullable - final T head() { + final @Nullable T head() { if (this instanceof Cons) { return ((Cons) this).head; } else { @@ -55,8 +52,7 @@ final ImmutableStack reverse() { return result; } - @EqualsAndHashCode(callSuper = false) - static final class Cons extends ImmutableStack { + static final class Cons extends ImmutableStack { final T head; final ImmutableStack tail; @@ -64,11 +60,31 @@ static final class Cons extends ImmutableStack { this.head = head; this.tail = tail; } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Cons cons)) return false; + return Objects.equals(head, cons.head) && Objects.equals(tail, cons.tail); + } + + @Override + public int hashCode() { + return Objects.hash(head, tail); + } } - @EqualsAndHashCode(callSuper = false) static final class Nil extends ImmutableStack { Nil() {} + + @Override + public int hashCode() { + return -2938; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof Nil; + } } static ImmutableStack empty() { @@ -117,7 +133,6 @@ public String toString() { } @ApiStatus.Internal -@NullMarked final class ImmutableQueue implements Iterable { private final ImmutableStack toEnqueue; private final ImmutableStack toDequeue; @@ -128,7 +143,6 @@ private ImmutableQueue(ImmutableStack toEnqueue, ImmutableStack toDequeue) } ImmutableQueue enqueue(T value) { - //noinspection DataFlowIssue return new ImmutableQueue<>(toEnqueue.prepend(value), toDequeue); } @@ -140,11 +154,11 @@ ImmutableQueue preOptimize() { if (toDequeue.isEmpty()) { return new ImmutableQueue<>(ImmutableStack.empty(), toEnqueue.reverse()); } else { - //noinspection NullableProblems return this; } } + @SuppressWarnings("NullAway") T peek() throws NoSuchElementException { if (!toDequeue.isEmpty()) { return toDequeue.head(); @@ -161,7 +175,6 @@ ImmutableQueue dequeue() { } else if (!toEnqueue.isEmpty()) { return new ImmutableQueue<>(ImmutableStack.empty(), toEnqueue.reverse().tail()); } else { - //noinspection NullableProblems return this; } } diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CompletionCallback.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CompletionCallback.java index 03bee59..5040be3 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CompletionCallback.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/CompletionCallback.java @@ -1,12 +1,16 @@ package org.funfix.tasks.jvm; import org.jetbrains.annotations.ApiStatus; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import java.io.Serializable; +import java.time.Duration; import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.AbstractQueuedSynchronizer; /** * Represents a callback that will be invoked when a task completes. @@ -18,7 +22,6 @@ * * @param is the type of the value that the task will complete with */ -@NullMarked @FunctionalInterface public interface CompletionCallback extends Serializable { @@ -51,7 +54,7 @@ default void onCancellation() { } /** - * @return a {@code CompletionListener} that does nothing. + * Returns a {@code CompletionListener} that does nothing (a no-op). */ static CompletionCallback empty() { return outcome -> { @@ -63,12 +66,98 @@ default void onCancellation() { } @ApiStatus.Internal -@NullMarked -final class ProtectedCompletionCallback - implements CompletionCallback, Runnable { +final class ManyCompletionCallback + implements CompletionCallback { + + private final ImmutableStack> listeners; + + @SafeVarargs + ManyCompletionCallback(final CompletionCallback... listeners) { + Objects.requireNonNull(listeners, "listeners"); + ImmutableStack> stack = ImmutableStack.empty(); + for (final CompletionCallback listener : listeners) { + Objects.requireNonNull(listener, "listener"); + stack = stack.prepend(listener); + } + this.listeners = stack; + } + + private ManyCompletionCallback( + final ImmutableStack> listeners + ) { + Objects.requireNonNull(listeners, "listeners"); + this.listeners = listeners; + } + + ManyCompletionCallback withExtraListener(CompletionCallback extraListener) { + Objects.requireNonNull(extraListener, "extraListener"); + final var newListeners = this.listeners.prepend(extraListener); + return new ManyCompletionCallback<>(newListeners); + } + + @Override + public void onOutcome(Outcome outcome) { + for (final CompletionCallback listener : listeners) { + try { + listener.onOutcome(outcome); + } catch (Throwable e) { + UncaughtExceptionHandler.logOrRethrow(e); + } + } + } + + @Override + public void onSuccess(T value) { + for (final CompletionCallback listener : listeners) { + try { + listener.onSuccess(value); + } catch (Throwable e) { + UncaughtExceptionHandler.logOrRethrow(e); + } + } + } + + @Override + public void onFailure(Throwable e) { + Objects.requireNonNull(e, "e"); + for (final CompletionCallback listener : listeners) { + try { + listener.onFailure(e); + } catch (Throwable ex) { + UncaughtExceptionHandler.logOrRethrow(ex); + } + } + } + + @Override + public void onCancellation() { + for (final CompletionCallback listener : listeners) { + try { + listener.onCancellation(); + } catch (Throwable e) { + UncaughtExceptionHandler.logOrRethrow(e); + } + } + } +} + +@ApiStatus.Internal +interface ContinuationCallback + extends CompletionCallback, Serializable { + + /** + * Registers an extra callback to be invoked when the task completes. + * This is useful for chaining callbacks or adding additional listeners. + */ + void registerExtraCallback(CompletionCallback extraCallback); +} + +@ApiStatus.Internal +final class AsyncContinuationCallback + implements ContinuationCallback, Runnable { private final AtomicBoolean isWaiting = new AtomicBoolean(true); - private final CompletionCallback listener; + private final AtomicReference> listenerRef; private final TaskExecutor executor; private @Nullable Outcome outcome; @@ -76,24 +165,27 @@ final class ProtectedCompletionCallback private @Nullable Throwable failureCause; private boolean isCancelled = false; - private ProtectedCompletionCallback( + public AsyncContinuationCallback( final CompletionCallback listener, final TaskExecutor executor ) { - this.listener = listener; this.executor = executor; + this.listenerRef = new AtomicReference<>( + Objects.requireNonNull(listener, "listener") + ); } @Override + @SuppressWarnings("NullAway") public void run() { if (this.outcome != null) { - listener.onOutcome(this.outcome); + listenerRef.get().onOutcome(this.outcome); } else if (this.failureCause != null) { - listener.onFailure(this.failureCause); + listenerRef.get().onFailure(this.failureCause); } else if (this.isCancelled) { - listener.onCancellation(); + listenerRef.get().onCancellation(); } else { - listener.onSuccess(this.successValue); + listenerRef.get().onSuccess(this.successValue); } // For GC purposes; but it doesn't really matter if we nullify these or not this.outcome = null; @@ -140,14 +232,199 @@ public void onCancellation() { } } - public static CompletionCallback protect( + public static ContinuationCallback protect( final TaskExecutor executor, final CompletionCallback listener ) { Objects.requireNonNull(listener, "listener"); - return new ProtectedCompletionCallback<>( + return new AsyncContinuationCallback<>( listener, executor ); } + + @SuppressWarnings("NullAway") + public void registerExtraCallback(CompletionCallback extraCallback) { + while (true) { + final var current = listenerRef.get(); + if (current instanceof ManyCompletionCallback many) { + final var update = many.withExtraListener(extraCallback); + if (listenerRef.compareAndSet(current, update)) { + return; + } + } else if (listenerRef.compareAndSet(current, new ManyCompletionCallback<>(current, extraCallback))) { + return; + } + } + } +} + +/** + * INTERNAL API. + *

+ * INTERNAL API: Internal apis are subject to change or removal + * without any notice. When code depends on internal APIs, it is subject to + * breakage between minor version updates. + */ +@ApiStatus.Internal +final class BlockingCompletionCallback + extends AbstractQueuedSynchronizer implements ContinuationCallback { + + private final AtomicBoolean isDone = + new AtomicBoolean(false); + private final AtomicReference<@Nullable CompletionCallback> extraCallbackRef = + new AtomicReference<>(null); + + @Nullable + private T result = null; + @Nullable + private Throwable error = null; + @Nullable + private InterruptedException interrupted = null; + + @SuppressWarnings("NullAway") + private void notifyOutcome() { + final var extraCallback = extraCallbackRef.getAndSet(null); + if (extraCallback != null) + try { + if (error != null) + extraCallback.onFailure(error); + else if (interrupted != null) + extraCallback.onCancellation(); + else + extraCallback.onSuccess(result); + } catch (Throwable e) { + UncaughtExceptionHandler.logOrRethrow(e); + } + releaseShared(1); + } + + @Override + public void onSuccess(final T value) { + if (!isDone.getAndSet(true)) { + result = value; + notifyOutcome(); + } + } + + @Override + public void onFailure(final Throwable e) { + UncaughtExceptionHandler.rethrowIfFatal(e); + if (!isDone.getAndSet(true)) { + error = e; + notifyOutcome(); + } else { + UncaughtExceptionHandler.logOrRethrow(e); + } + } + + @Override + public void onCancellation() { + if (!isDone.getAndSet(true)) { + interrupted = new InterruptedException("Task was cancelled"); + notifyOutcome(); + } + } + + @Override + public void onOutcome(Outcome outcome) { + if (outcome instanceof Outcome.Success success) { + onSuccess(success.value()); + } else if (outcome instanceof Outcome.Failure failure) { + onFailure(failure.exception()); + } else { + onCancellation(); + } + } + + @Override + protected int tryAcquireShared(final int arg) { + return getState() != 0 ? 1 : -1; + } + + @Override + protected boolean tryReleaseShared(final int arg) { + setState(1); + return true; + } + + @FunctionalInterface + interface AwaitFunction { + void apply(boolean isCancelled) throws InterruptedException, TimeoutException; + } + + @SuppressWarnings("NullAway") + private T awaitInline(final Cancellable cancelToken, final AwaitFunction await) + throws InterruptedException, ExecutionException, TimeoutException { + + TaskLocalContext.signalTheStartOfBlockingCall(); + var isCancelled = false; + TimeoutException timedOut = null; + while (true) { + try { + await.apply(isCancelled); + break; + } catch (final TimeoutException | InterruptedException e) { + if (!isCancelled) { + isCancelled = true; + if (e instanceof TimeoutException te) + timedOut = te; + cancelToken.cancel(); + } + } + // Clearing the interrupted flag may not be necessary, + // but doesn't hurt, and we should have a cleared flag before + // re-throwing the exception + // + // noinspection ResultOfMethodCallIgnored + Thread.interrupted(); + } + if (timedOut != null) throw timedOut; + if (interrupted != null) throw interrupted; + if (error != null) throw new ExecutionException(error); + return result; + } + + public T await(final Cancellable cancelToken) throws InterruptedException, ExecutionException { + try { + return awaitInline(cancelToken, isCancelled -> acquireSharedInterruptibly(1)); + } catch (final TimeoutException e) { + throw new IllegalStateException("Unexpected timeout", e); + } + } + + public T await(final Cancellable cancelToken, final Duration timeout) + throws ExecutionException, InterruptedException, TimeoutException { + + return awaitInline(cancelToken, isCancelled -> { + if (!isCancelled) { + if (!tryAcquireSharedNanos(1, timeout.toNanos())) { + throw new TimeoutException("Task timed-out after " + timeout); + } + } else { + // Waiting without a timeout, since at this point it's waiting + // on the cancelled task to finish + acquireSharedInterruptibly(1); + } + }); + } + + @Override + public void registerExtraCallback(CompletionCallback extraCallback) { + while (true) { + final var current = extraCallbackRef.get(); + if (current == null) { + if (extraCallbackRef.compareAndSet(null, extraCallback)) { + return; + } + } else if (current instanceof ManyCompletionCallback many) { + final var update = many.withExtraListener(extraCallback); + if (extraCallbackRef.compareAndSet(current, update)) { + return; + } + } else if (extraCallbackRef.compareAndSet(current, new ManyCompletionCallback<>(current, extraCallback))) { + return; + } + } + } } diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Continuation.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Continuation.java index ade0eb7..268a9a2 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Continuation.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Continuation.java @@ -1,7 +1,6 @@ package org.funfix.tasks.jvm; import org.jetbrains.annotations.ApiStatus; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import java.util.concurrent.Executor; @@ -18,7 +17,6 @@ * @param is the type of the value that the task will complete with */ @ApiStatus.Internal -@NullMarked interface Continuation extends CompletionCallback { @@ -35,38 +33,38 @@ interface Continuation * @param cancellable is the reference to the cancellable object that this * continuation will register. */ - void registerCancellable(Cancellable cancellable); + @Nullable Cancellable registerCancellable(Cancellable cancellable); CancellableForwardRef registerForwardCancellable(); - CancellableContinuation withExecutorOverride(TaskExecutor executor); + Continuation withExecutorOverride(TaskExecutor executor); + + void registerExtraCallback(CompletionCallback extraCallback); } /** * INTERNAL API. */ @ApiStatus.Internal -@NullMarked @FunctionalInterface interface AsyncContinuationFun { - void invoke(Continuation continuation); + void invoke(Continuation continuation); } /** * INTERNAL API. */ @ApiStatus.Internal -@NullMarked final class CancellableContinuation implements Continuation, Cancellable { - private final CompletionCallback callback; - private final MutableCancellable cancellable; + private final ContinuationCallback callback; + private final MutableCancellable cancellableRef; private final TaskExecutor executor; public CancellableContinuation( final TaskExecutor executor, - final CompletionCallback callback + final ContinuationCallback callback ) { this( executor, @@ -75,14 +73,14 @@ public CancellableContinuation( ); } - private CancellableContinuation( + CancellableContinuation( final TaskExecutor executor, - final CompletionCallback callback, + final ContinuationCallback callback, final MutableCancellable cancellable ) { this.executor = executor; this.callback = callback; - this.cancellable = cancellable; + this.cancellableRef = cancellable; } @Override @@ -92,17 +90,17 @@ public TaskExecutor getExecutor() { @Override public void cancel() { - cancellable.cancel(); + cancellableRef.cancel(); } @Override public CancellableForwardRef registerForwardCancellable() { - return cancellable.newCancellableRef(); + return cancellableRef.newCancellableRef(); } @Override - public void registerCancellable(Cancellable cancellable) { - this.cancellable.register(cancellable); + public @Nullable Cancellable registerCancellable(Cancellable cancellable) { + return this.cancellableRef.register(cancellable); } @Override @@ -126,11 +124,16 @@ public void onCancellation() { } @Override - public CancellableContinuation withExecutorOverride(TaskExecutor executor) { + public Continuation withExecutorOverride(TaskExecutor executor) { return new CancellableContinuation<>( executor, callback, - cancellable + cancellableRef ); } + + @Override + public void registerExtraCallback(CompletionCallback extraCallback) { + callback.registerExtraCallback(extraCallback); + } } diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/DelayedFun.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/DelayedFun.java index 26e7626..6832c68 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/DelayedFun.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/DelayedFun.java @@ -1,6 +1,5 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; /** @@ -9,7 +8,6 @@ * These functions are allowed to trigger side effects, with * blocking I/O and to throw exceptions. */ -@NullMarked @FunctionalInterface public interface DelayedFun { T invoke() throws Exception; diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/ExitCase.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/ExitCase.java new file mode 100644 index 0000000..ef1012c --- /dev/null +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/ExitCase.java @@ -0,0 +1,58 @@ +package org.funfix.tasks.jvm; + +/** + * For signaling to finalizers how a task exited. + *

+ * Similar to {@link Outcome}, but without the result. Used by {@link Resource} + * to indicate how a resource was released. + */ +public sealed interface ExitCase { + /** + * Signals that the task completed. Used for successful completion, but + * also for cases in which the outcome is unknown. + *
+ * This is a catch-all result. For example, this is the value passed + * to {@link Resource.Acquired} in {@code releaseTask} when the resource + * is used as an {@link AutoCloseable}, as the {@code AutoCloseable} protocol + * does not distinguish between successful completion, failure, or cancellation. + *
+ * When receiving a {@code Completed} exit case, the caller shouldn't + * assume that the task was successful. + */ + record Completed() implements ExitCase { + private static final Completed INSTANCE = new Completed(); + } + + /** + * Signals that the task failed with a known exception. + *
+ * Used in {@link Resource.Acquired} to indicate that the resource + * is being released due to an exception. + * + * @param error is the exception thrown + */ + record Failed(Throwable error) implements ExitCase {} + + /** + * Signals that the task was cancelled. + *
+ * Used in {@link Resource.Acquired} to indicate that the resource + * is being released due to a cancellation (e.g., the thread was + * interrupted). + */ + record Canceled() implements ExitCase { + private static final Canceled INSTANCE = new Canceled(); + } + + static ExitCase succeeded() { + return Completed.INSTANCE; + } + + static ExitCase failed(Throwable error) { + return new Failed(error); + } + + static ExitCase canceled() { + return Canceled.INSTANCE; + } +} diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Fiber.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Fiber.java index 865a415..42445a5 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Fiber.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Fiber.java @@ -1,15 +1,12 @@ package org.funfix.tasks.jvm; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.ToString; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NonBlocking; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import java.time.Duration; +import java.util.Objects; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.AbstractQueuedSynchronizer; @@ -23,7 +20,6 @@ * * @param is the result of the fiber, if successful. */ -@NullMarked public interface Fiber extends Cancellable { /** * Returns the result of the completed fiber. @@ -81,7 +77,7 @@ default Cancellable awaitAsync(CompletionCallback callback) { final var result = getResultOrThrow(); callback.onSuccess(result); } catch (final ExecutionException e) { - callback.onFailure(e.getCause()); + callback.onFailure(Objects.requireNonNullElse(e.getCause(), e)); } catch (final TaskCancellationException e) { callback.onCancellation(); } catch (final Throwable e) { @@ -165,6 +161,47 @@ default void joinBlocking() throws InterruptedException { } } + /** + * Blocks the current thread until the fiber completes. + *

+ * Version of {@link #joinBlocking()} that ignores thread interruptions. + * This is most useful after cancelling a fiber, as it ensures that + * processing will back-pressure on the fiber's completion. + *

+ * WARNING: This method guarantees that upon its return + * the fiber is completed, however, it still throws {@link InterruptedException} + * because it can't swallow interruptions. + *

+ * Sample: + *

{@code
+     *   final var fiber = Task
+     *     .fromBlockingIO(() -> {
+     *       Thread.sleep(10000);
+     *     })
+     *     .runFiber();
+     *   // ...
+     *   fiber.cancel();
+     *   fiber.joinBlockingUninterruptible();
+     * }
+ */ + @Blocking + default void joinBlockingUninterruptible() throws InterruptedException { + boolean wasInterrupted = Thread.interrupted(); + while (true) { + try { + joinBlocking(); + break; + } catch (final InterruptedException e) { + wasInterrupted = true; + } + } + if (wasInterrupted) { + throw new InterruptedException( + "Thread was interrupted in #joinBlockingUninterruptible" + ); + } + } + /** * Blocks the current thread until the fiber completes, then returns the * result of the fiber. @@ -206,8 +243,9 @@ default T awaitBlocking() throws InterruptedException, TaskCancellationException * that you need to call {@link Fiber#cancel()}. */ @NonBlocking - default CancellableFuture<@Nullable Void> joinAsync() { - final var future = new CompletableFuture<@Nullable Void>(); + default CancellableFuture joinAsync() { + final var future = new CompletableFuture(); + @SuppressWarnings("DataFlowIssue") final var token = joinAsync(() -> future.complete(null)); final Cancellable cRef = () -> { try { @@ -261,16 +299,37 @@ public NotCompletedException() { * breakage between minor version updates. */ @ApiStatus.Internal -@NullMarked final class ExecutedFiber implements Fiber { private final TaskExecutor executor; - private final Continuation continuation; - private final AtomicReference> stateRef = - new AtomicReference<>(State.start()); + private final Continuation continuation; + private final MutableCancellable cancellableRef; + private final AtomicReference> stateRef; private ExecutedFiber(final TaskExecutor executor) { + this.cancellableRef = new MutableCancellable(this::fiberCancel); + this.stateRef = new AtomicReference<>(State.start()); this.executor = executor; - this.continuation = new FiberContinuation<>(executor, stateRef); + this.continuation = new CancellableContinuation<>( + executor, + new AsyncContinuationCallback<>( + new FiberCallback<>(executor, stateRef), + executor + ), + cancellableRef + ); + } + + private void fiberCancel() { + while (true) { + final var current = stateRef.get(); + if (current instanceof State.Active active) { + if (stateRef.compareAndSet(current, new State.Cancelled<>(active.listeners))) { + return; + } + } else { + return; + } + } } @Override @@ -301,17 +360,7 @@ public Cancellable joinAsync(final Runnable onComplete) { @Override public void cancel() { - while (true) { - final var current = stateRef.get(); - if (current instanceof State.Active active) { - if (stateRef.compareAndSet(current, new State.Cancelled<>(active.listeners))) { - active.cancellable.cancel(); - return; - } - } else { - return; - } - } + this.cancellableRef.cancel(); } private Cancellable removeListenerCancellable(final Runnable listener) { @@ -330,46 +379,20 @@ private Cancellable removeListenerCancellable(final Runnable listener) { }; } - @NullMarked - static abstract class State { - @ToString - @EqualsAndHashCode(callSuper = false) - @Getter - static final class Active - extends State { - private final ImmutableQueue listeners; - private final MutableCancellable cancellable; - - public Active(ImmutableQueue listeners, MutableCancellable cancellable) { - this.listeners = listeners; - this.cancellable = cancellable; - } - } + sealed interface State { + record Active( + ImmutableQueue listeners + ) implements State {} - @ToString - @EqualsAndHashCode(callSuper = false) - @Getter - static final class Cancelled - extends State { - private final ImmutableQueue listeners; + record Cancelled( + ImmutableQueue listeners + ) implements State {} - public Cancelled(ImmutableQueue listeners) { - this.listeners = listeners; - } - } - - @ToString - @EqualsAndHashCode(callSuper = false) - @Getter - static final class Completed - extends State { - private final Outcome outcome; - public Completed(Outcome outcome) { - this.outcome = outcome; - } - } + record Completed( + Outcome outcome + ) implements State {} - final void triggerListeners(TaskExecutor executor) { + default void triggerListeners(TaskExecutor executor) { if (this instanceof Active ref) { for (final var listener : ref.listeners) { executor.resumeOnExecutor(listener); @@ -381,10 +404,10 @@ final void triggerListeners(TaskExecutor executor) { } } - final State addListener(final Runnable listener) { + default State addListener(final Runnable listener) { if (this instanceof Active ref) { final var newQueue = ref.listeners.enqueue(listener); - return new Active<>(newQueue, ref.cancellable); + return new Active<>(newQueue); } else if (this instanceof Cancelled ref) { final var newQueue = ref.listeners.enqueue(listener); return new Cancelled<>(newQueue); @@ -393,10 +416,10 @@ final State addListener(final Runnable listener) { } } - final State removeListener(final Runnable listener) { + default State removeListener(final Runnable listener) { if (this instanceof Active ref) { final var newQueue = ref.listeners.filter(l -> l != listener); - return new Active<>(newQueue, ref.cancellable); + return new Active<>(newQueue); } else if (this instanceof Cancelled ref) { final var newQueue = ref.listeners.filter(l -> l != listener); return new Cancelled<>(newQueue); @@ -406,7 +429,7 @@ final State removeListener(final Runnable listener) { } static State start() { - return new Active<>(ImmutableQueue.empty(), new MutableCancellable()); + return new Active<>(ImmutableQueue.empty() ); } } @@ -427,12 +450,11 @@ final State removeListener(final Runnable listener) { return fiber; } - @NullMarked - static final class FiberContinuation implements Continuation { + static final class FiberCallback implements CompletionCallback { private final TaskExecutor executor; private final AtomicReference> stateRef; - FiberContinuation( + FiberCallback( final TaskExecutor executor, final AtomicReference> stateRef ) { @@ -440,35 +462,6 @@ static final class FiberContinuation implements Cont this.stateRef = stateRef; } - @Override - public TaskExecutor getExecutor() { - return executor; - } - - @Override - public CancellableForwardRef registerForwardCancellable() { - final var current = stateRef.get(); - if (current instanceof State.Completed) { - return (cancellable) -> {}; - } else if (current instanceof State.Cancelled) { - return Cancellable::cancel; - } else if (current instanceof State.Active active) { - return active.cancellable.newCancellableRef(); - } else { - throw new IllegalStateException("Invalid state: " + current); - } - } - - @Override - public void registerCancellable(Cancellable cancellable) { - final var current = stateRef.get(); - if (current instanceof State.Active active) { - active.cancellable.register(cancellable); - } else if (current instanceof State.Cancelled) { - cancellable.cancel(); - } - } - @Override public void onSuccess(T value) { onOutcome(Outcome.success(value)); @@ -509,11 +502,6 @@ public void onOutcome(Outcome outcome) { } } } - - @Override - public CancellableContinuation withExecutorOverride(TaskExecutor executor) { - return new CancellableContinuation<>(executor, this); - } } } @@ -525,7 +513,6 @@ public CancellableContinuation withExecutorOverride(TaskExecutor executor) { * breakage between minor version updates. */ @ApiStatus.Internal -@NullMarked final class AwaitSignal extends AbstractQueuedSynchronizer { @Override protected int tryAcquireShared(final int arg) { @@ -543,10 +530,12 @@ public void signal() { } public void await() throws InterruptedException { + TaskLocalContext.signalTheStartOfBlockingCall(); acquireSharedInterruptibly(1); } public void await(final long timeoutMillis) throws InterruptedException, TimeoutException { + TaskLocalContext.signalTheStartOfBlockingCall(); if (!tryAcquireSharedNanos(1, TimeUnit.MILLISECONDS.toNanos(timeoutMillis))) { throw new TimeoutException("Timed out after " + timeoutMillis + " millis"); } diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Outcome.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Outcome.java index e6fddef..36d8832 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Outcome.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Outcome.java @@ -1,7 +1,6 @@ package org.funfix.tasks.jvm; import org.jetbrains.annotations.NonBlocking; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import java.util.concurrent.ExecutionException; @@ -11,7 +10,6 @@ * * @param is the type of the value that the task completed with */ -@NullMarked public sealed interface Outcome permits Outcome.Success, Outcome.Failure, Outcome.Cancellation { @@ -33,6 +31,7 @@ record Success(T value) @Override public T getOrThrow() { return value; } + } /** @@ -41,9 +40,11 @@ record Success(T value) record Failure(Throwable exception) implements Outcome { + @Override public T getOrThrow() throws ExecutionException { throw new ExecutionException(exception); } + } /** diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/ProcessFun.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/ProcessFun.java new file mode 100644 index 0000000..b6f63b2 --- /dev/null +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/ProcessFun.java @@ -0,0 +1,14 @@ +package org.funfix.tasks.jvm; + +import org.jspecify.annotations.Nullable; + +/** + * The equivalent of {@code Function} for processing I/O that can + * throw exceptions. + * + * @see DelayedFun for the variant with no parameters + */ +@FunctionalInterface +public interface ProcessFun { + Out call(In input) throws Exception; +} diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Resource.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Resource.java new file mode 100644 index 0000000..868004c --- /dev/null +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Resource.java @@ -0,0 +1,365 @@ +package org.funfix.tasks.jvm; + +import org.jetbrains.annotations.Blocking; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.function.Function; + +/** + * A {@code Resource} represents a resource that can be acquired asynchronously. + *

+ * This is similar to Java's {@link AutoCloseable}, but it's more portable + * and allows for asynchronous contexts. + *

+ * {@code Resource} is the equivalent of {@link Task} for resource acquisition. + *

+ * Sample usage: + *

{@code
+ *     // -------------------------------------------------------
+ *     // SAMPLE METHODS
+ *     // -------------------------------------------------------
+ *
+ *     // Creates temporary files — note that wrapping this in `Resource` is a
+ *     // win as `File` isn't `AutoCloseable`).
+ *     Resource createTemporaryFile(String prefix, String suffix) {
+ *         return Resource.fromBlockingIO(() -> {
+ *             File tempFile = File.createTempFile(prefix, suffix);
+ *             tempFile.deleteOnExit(); // Ensure it gets deleted on exit
+ *             return Resource.Acquired.fromBlockingIO(
+ *                 tempFile,
+ *                 ignored -> tempFile.delete()
+ *             );
+ *         });
+ *     }
+ *
+ *     // Creates `Reader` as a `Resource`
+ *     Resource openReader(File file) {
+ *         return Resource.fromAutoCloseable(() ->
+ *             new BufferedReader(
+ *                 new InputStreamReader(
+ *                     new FileInputStream(file),
+ *                     StandardCharsets.UTF_8
+ *                 )
+ *             ));
+ *     }
+ *
+ *     // Creates `Writer` as a `Resource`
+ *     Resource openWriter(File file) {
+ *         return Resource.fromAutoCloseable(() ->
+ *             new BufferedWriter(
+ *                 new OutputStreamWriter(
+ *                     new FileOutputStream(file),
+ *                     StandardCharsets.UTF_8
+ *                 )
+ *             ));
+ *     }
+ *
+ *     // ...
+ *     // -------------------------------------------------------
+ *     // USAGE EXAMPLE (via try-with-resources)
+ *     // -------------------------------------------------------
+ *     try (
+ *         final var file = createTemporaryFile("test", ".txt").acquireBlocking()
+ *     ) {
+ *         try (final var writer = openWriter(file.get()).acquireBlocking()) {
+ *             writer.get().write("----\n");
+ *             writer.get().write("line 1\n");
+ *             writer.get().write("line 2\n");
+ *             writer.get().write("----\n");
+ *         }
+ *
+ *         try (final var reader = openReader(file.get()).acquireBlocking()) {
+ *             final var builder = new StringBuilder();
+ *             String line;
+ *             while ((line = reader.get().readLine()) != null) {
+ *                 builder.append(line).append("\n");
+ *             }
+ *             final String content = builder.toString();
+ *             assertEquals(
+ *                 "----\nline 1\nline 2\n----\n",
+ *                 content,
+ *                 "File content should match the written lines"
+ *             );
+ *         }
+ *     }
+ * }
+ */ +public final class Resource { + private final Task> acquireTask; + + private Resource(final Task> acquireTask) { + this.acquireTask = Objects.requireNonNull(acquireTask, "acquireTask"); + } + + /** + * Acquires the resource asynchronously via {@link Task}, returning + * it wrapped in {@link Acquired}, which contains the logic for safe release. + */ + public Task> acquireTask() { + return acquireTask; + } + + /** + * Acquires the resource via blocking I/O. + *

+ * This method blocks the current thread until the resource is acquired. + *

+ * @return an {@link Acquired} object that contains the acquired resource + * and the logic to release it. + * @throws ExecutionException if the resource acquisition failed with an exception + * @throws InterruptedException if the current thread was interrupted while waiting + * for the resource to be acquired + */ + @Blocking + public Acquired acquireBlocking() + throws ExecutionException, InterruptedException { + return acquireBlocking(null); + } + + /** + * Acquires the resource via blocking I/O using the specified executor. + *

+ * This method blocks the current thread until the resource is acquired. + *

+ * @param executor is the executor to use for executing the acquire task. + * @return an {@link Acquired} object that contains the acquired resource + * and the logic to release it. + * @throws ExecutionException if the resource acquisition failed with an exception + * @throws InterruptedException if the current thread was interrupted while waiting + * for the resource to be acquired + */ + @Blocking + public Acquired acquireBlocking(@Nullable final Executor executor) + throws ExecutionException, InterruptedException { + + return Objects.requireNonNull( + executor != null + ? acquireTask().runBlocking(executor) + : acquireTask().runBlocking(), + "Resource acquisition failed, acquireTask returned null" + ); + } + + /** + * Safely use the {@code Resource}, a method to use as an alternative to + * Java's try-with-resources. + *

+ * All the execution happens on the specified {@code executor}, which + * is used for both acquiring the resource, executing the processing + * function and releasing the resource. + *

+ * Note that an asynchronous (Task-driven) alternative isn't provided, + * as that would require an asynchronous evaluation model that's outside + * the scope. E.g., work with Kotlin's coroutines, or Scala's Cats-Effect + * to achieve that. + * + * @param executor is the executor to use for acquiring the resource and + * for executing the processing function. If {@code null}, the + * default executor for blocking I/O is used. + * @param process is the processing function that can do I/O. + * + * @throws ExecutionException if either the resource acquisition or the + * processing function fails with an exception. + * @throws InterruptedException is thrown if the current thread was interrupted, + * which can also interrupt the resource acquisition or the processing function. + */ + @Blocking + @SuppressWarnings("ConstantValue") + public R useBlocking( + @Nullable Executor executor, + final ProcessFun process + ) throws InterruptedException, ExecutionException { + Objects.requireNonNull(process, "use"); + final Task task = Task.fromBlockingIO(() -> { + var finalizerCalled = false; + final var acquired = acquireBlocking(executor); + try { + return process.call(acquired.get()); + } catch (InterruptedException e) { + if (!finalizerCalled) { + finalizerCalled = true; + acquired.releaseBlocking(ExitCase.canceled()); + } + throw e; + } catch (Exception e) { + if (!finalizerCalled) { + finalizerCalled = true; + acquired.releaseBlocking(ExitCase.failed(e)); + } + throw e; + } finally { + if (!finalizerCalled) { + acquired.releaseBlocking(ExitCase.succeeded()); + } + } + }); + return task + .ensureRunningOnExecutor() + .runBlocking(executor); + } + + /** + * Safely use the {@code Resource}, a method to use as an alternative to + * Java's try-with-resources. + *

+ * This is an overload of {@link #useBlocking(Executor, ProcessFun)} + * that uses the default executor for blocking I/O. + */ + @Blocking + public R useBlocking( + final ProcessFun process + ) throws InterruptedException, ExecutionException { + return useBlocking(null, process); + } + + /** + * Creates a {@link Resource} from an asynchronous task that acquires the resource. + *

+ * The task should return an {@link Acquired} object that contains the + * acquired resource and the logic to release it. + */ + public static Resource fromAsync( + final Task> acquire + ) { + Objects.requireNonNull(acquire, "acquire"); + return new Resource<>(acquire); + } + + /** + * Creates a {@link Resource} from a builder doing blocking I/O. + *

+ * This method is useful for resources that are acquired via blocking I/O, + * such as file handles, database connections, etc. + */ + public static Resource fromBlockingIO( + final DelayedFun> acquire + ) { + Objects.requireNonNull(acquire, "acquire"); + return Resource.fromAsync(Task.fromBlockingIO(() -> { + final Acquired closeable = acquire.invoke(); + Objects.requireNonNull(closeable, "Resource allocation returned null"); + return closeable; + })); + } + + /** + * Creates a {@link Resource} from a builder that returns an + * {@link AutoCloseable} resource. + */ + public static Resource fromAutoCloseable( + final DelayedFun acquire + ) { + Objects.requireNonNull(acquire, "acquire"); + return Resource.fromBlockingIO(() -> { + final T resource = acquire.invoke(); + Objects.requireNonNull(resource, "Resource allocation returned null"); + return Acquired.fromBlockingIO(resource, CloseableFun.fromAutoCloseable(resource)); + }); + } + + /** + * Creates a "pure" {@code Resource}. + *

+ * A "pure" resource is one that just wraps a known value, with no-op + * release logic. + *

+ * @see Task#pure(Object) + */ + public static Resource pure(T value) { + return Resource.fromAsync(Task.pure(Acquired.pure(value))); + } + + /** + * Tuple that represents an acquired resource and the logic to release it. + * + * @param get is the acquired resource + * @param releaseTask is the (async) function that can release the resource + */ + public record Acquired( + T get, + Function> releaseTask + ) implements AutoCloseable { + /** + * Used for asynchronous resource release. + *

+ * @param exitCase signals the context in which the resource is being released. + * @return a {@link Task} that releases the resource upon invocation. + */ + public Task releaseTask(final ExitCase exitCase) { + return releaseTask.apply(exitCase); + } + + /** + * Releases the resource in a blocking manner, using the default executor. + *

+ * This method can block the current thread until the resource is released. + *

+ *

+ * @param exitCase signals the context in which the resource is being released. + */ + @Blocking + public void releaseBlocking(final ExitCase exitCase) + throws InterruptedException, ExecutionException { + releaseBlocking(null, exitCase); + } + + /** + * Releases the resource in a blocking manner, using the specified executor. + *

+ * This method can block the current thread until the resource is released. + *

+ * @param executor is the executor to use for executing the release task. + * @param exitCase signals the context in which the resource is being released. + */ + @Blocking + public void releaseBlocking( + final @Nullable Executor executor, + final ExitCase exitCase + ) throws InterruptedException, ExecutionException { + TaskUtils.runBlockingUninterruptible(executor, releaseTask(exitCase)); + } + + /** + * Releases the resource in a blocking manner, using the default executor. + *

+ * This being part of {@link AutoCloseable} means it can be used via a + * try-with-resources block. + */ + @Blocking + @Override + public void close() throws Exception { + releaseBlocking(ExitCase.succeeded()); + } + + /** + * Creates a "pure" {@code Acquired} instance with the given value — + * i.e., it just wraps a value with the release function being a no-op. + */ + public static Acquired pure(T value) { + return new Acquired<>(value, NOOP); + } + + /** + * Creates an {@link Acquired} instance with a {@link CloseableFun} + * release function that may do blocking I/O. + * + * @see Resource#fromBlockingIO(DelayedFun) + */ + public static Acquired fromBlockingIO( + T resource, + CloseableFun release + ) { + Objects.requireNonNull(resource, "resource"); + Objects.requireNonNull(release, "release"); + @SuppressWarnings("NullAway") + final var acquired = new Acquired<>(resource, release.toAsync()); + return acquired; + } + + private static final Function> NOOP = + ignored -> Task.NOOP; + } +} diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Task.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Task.java index 4558518..ff0acc7 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Task.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Task.java @@ -1,20 +1,17 @@ package org.funfix.tasks.jvm; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NonBlocking; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import java.time.Duration; import java.util.Objects; import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.AbstractQueuedSynchronizer; /** * Represents a function that can be executed asynchronously. */ -@NullMarked public final class Task { private final AsyncContinuationFun createFun; @@ -39,11 +36,12 @@ private Task(final AsyncContinuationFun createFun) { * ); * } */ - public Task ensureRunningOnExecutor(final Executor executor) { + public Task ensureRunningOnExecutor(final @Nullable Executor executor) { return new Task<>((cont) -> { - final var taskExecutor = TaskExecutor.from(executor); - final var cont2 = cont.withExecutorOverride(taskExecutor); - taskExecutor.resumeOnExecutor(() -> createFun.invoke(cont2)); + final Continuation cont2 = executor != null + ? cont.withExecutorOverride(TaskExecutor.from(executor)) + : cont; + cont2.getExecutor().resumeOnExecutor(() -> createFun.invoke(cont2)); }); } @@ -70,9 +68,43 @@ public Task ensureRunningOnExecutor(final Executor executor) { * */ public Task ensureRunningOnExecutor() { - return new Task<>((cont) -> - cont.getExecutor().resumeOnExecutor(() -> createFun.invoke(cont)) - ); + return ensureRunningOnExecutor(null); + } + + /** + * Guarantees that the task will complete with the given callback. + *

+ * This method is useful for releasing resources or performing + * cleanup operations when the task completes. + *

+ * This callback will be invoked in addition to whatever the client + * provides as a callback to {@link #runAsync(CompletionCallback)} + * or similar methods. + *

+ * WARNING: The invocation of this method is concurrent + * with the task's completion, meaning that ordering isn't guaranteed + * (i.e., a callback installed with this method may be called before or + * after the callback provided to {@link #runAsync(CompletionCallback)}). + */ + public Task withOnComplete(final CompletionCallback callback) { + return new Task<>((cont) -> { + @SuppressWarnings("unchecked") + final var extraCallback = (CompletionCallback) Objects.requireNonNull(callback); + cont.registerExtraCallback(extraCallback); + cont.getExecutor().resumeOnExecutor(() -> createFun.invoke(cont)); + }); + } + + /** + * Registers a {@link Cancellable} that can be used to cancel the running task. + */ + public Task withCancellation( + final Cancellable cancellable + ) { + return new Task<>((cont) -> { + cont.registerCancellable(cancellable); + cont.getExecutor().resumeOnExecutor(() -> createFun.invoke(cont)); + }); } /** @@ -86,13 +118,19 @@ public Task ensureRunningOnExecutor() { * @return a {@link Cancellable} that can be used to cancel a running task */ public Cancellable runAsync( - final Executor executor, + final @Nullable Executor executor, final CompletionCallback callback ) { - final var taskExecutor = TaskExecutor.from(executor); + final var taskExecutor = TaskExecutor.from( + executor != null ? executor : TaskExecutors.sharedBlockingIO() + ); + @SuppressWarnings("unchecked") final var cont = new CancellableContinuation<>( taskExecutor, - ProtectedCompletionCallback.protect(taskExecutor, callback) + new AsyncContinuationCallback<>( + (CompletionCallback) callback, + taskExecutor + ) ); taskExecutor.execute(() -> { try { @@ -115,7 +153,7 @@ public Cancellable runAsync( * @return a {@link Cancellable} that can be used to cancel a running task */ public Cancellable runAsync(final CompletionCallback callback) { - return runAsync(TaskExecutors.sharedBlockingIO(), callback); + return runAsync(null, callback); } /** @@ -130,8 +168,11 @@ public Cancellable runAsync(final CompletionCallback callback) { * or to cancel the running fiber. */ @NonBlocking - public Fiber runFiber(final Executor executor) { - return ExecutedFiber.start(executor, createFun); + public Fiber runFiber(final @Nullable Executor executor) { + return ExecutedFiber.start( + executor != null ? executor : TaskExecutors.sharedBlockingIO(), + createFun + ); } /** @@ -146,7 +187,7 @@ public Fiber runFiber(final Executor executor) { */ @NonBlocking public Fiber runFiber() { - return runFiber(TaskExecutors.sharedBlockingIO()); + return runFiber(null); } /** @@ -166,13 +207,17 @@ public Fiber runFiber() { * concurrent task must also be interrupted, as this method always blocks * for its interruption or completion. */ - public T runBlocking(final Executor executor) + @Blocking + public T runBlocking(final @Nullable Executor executor) throws ExecutionException, InterruptedException { final var blockingCallback = new BlockingCompletionCallback(); - final var taskExecutor = TaskExecutor.from(executor); + final var taskExecutor = TaskExecutor.from( + executor != null ? executor : TaskExecutors.sharedBlockingIO() + ); final var cont = new CancellableContinuation<>(taskExecutor, blockingCallback); createFun.invoke(cont); + TaskLocalContext.signalTheStartOfBlockingCall(); return blockingCallback.await(cont); } @@ -192,8 +237,9 @@ public T runBlocking(final Executor executor) * concurrent task must also be interrupted, as this method always blocks * for its interruption or completion. */ + @Blocking public T runBlocking() throws ExecutionException, InterruptedException { - return runBlocking(TaskExecutors.sharedBlockingIO()); + return runBlocking(null); } /** @@ -219,11 +265,13 @@ public T runBlocking() throws ExecutionException, InterruptedException { * and this method does not returning until `onCancel` is signaled. */ public T runBlockingTimed( - final Executor executor, + final @Nullable Executor executor, final Duration timeout ) throws ExecutionException, InterruptedException, TimeoutException { final var blockingCallback = new BlockingCompletionCallback(); - final var taskExecutor = TaskExecutor.from(executor); + final var taskExecutor = TaskExecutor.from( + executor != null ? executor : TaskExecutors.sharedBlockingIO() + ); final var cont = new CancellableContinuation<>(taskExecutor, blockingCallback); taskExecutor.execute(() -> createFun.invoke(cont)); return blockingCallback.await(cont, timeout); @@ -250,7 +298,7 @@ public T runBlockingTimed( */ public T runBlockingTimed(final Duration timeout) throws ExecutionException, InterruptedException, TimeoutException { - return runBlockingTimed(TaskExecutors.sharedBlockingIO(), timeout); + return runBlockingTimed(null, timeout); } /** @@ -292,17 +340,23 @@ public T runBlockingTimed(final Duration timeout) public static Task fromBlockingIO(final DelayedFun run) { return new Task<>((cont) -> { Thread th = Thread.currentThread(); - cont.registerCancellable(th::interrupt); + final var registration = cont.registerCancellable(th::interrupt); + if (registration == null) { + cont.onCancellation(); + return; + } try { T result; try { + TaskLocalContext.signalTheStartOfBlockingCall(); result = run.invoke(); } finally { - cont.registerCancellable(Cancellable.getEmpty()); + registration.cancel(); } if (th.isInterrupted()) { throw new InterruptedException(); } + //noinspection DataFlowIssue cont.onSuccess(result); } catch (final InterruptedException | TaskCancellationException e) { cont.onCancellation(); @@ -339,6 +393,7 @@ public T runBlockingTimed(final Duration timeout) return fromBlockingIO(() -> { final var f = Objects.requireNonNull(builder.invoke()); try { + TaskLocalContext.signalTheStartOfBlockingCall(); return f.get(); } catch (final ExecutionException e) { if (e.getCause() instanceof RuntimeException) @@ -409,135 +464,18 @@ public T runBlockingTimed(final Duration timeout) ) { return new Task<>(new TaskFromCancellableFuture<>(builder)); } -} - -/** - * INTERNAL API. - *

- * INTERNAL API: Internal apis are subject to change or removal - * without any notice. When code depends on internal APIs, it is subject to - * breakage between minor version updates. - */ -@ApiStatus.Internal -@NullMarked -final class BlockingCompletionCallback - extends AbstractQueuedSynchronizer implements CompletionCallback { - - private final AtomicBoolean isDone = new AtomicBoolean(false); - @Nullable - private T result = null; - @Nullable - private Throwable error = null; - @Nullable - private InterruptedException interrupted = null; - - @Override - public void onSuccess(final T value) { - if (!isDone.getAndSet(true)) { - result = value; - releaseShared(1); - } - } - - @Override - public void onFailure(final Throwable e) { - UncaughtExceptionHandler.rethrowIfFatal(e); - if (!isDone.getAndSet(true)) { - error = e; - releaseShared(1); - } else { - UncaughtExceptionHandler.logOrRethrow(e); - } - } - - @Override - public void onCancellation() { - if (!isDone.getAndSet(true)) { - interrupted = new InterruptedException("Task was cancelled"); - releaseShared(1); - } - } - - @Override - public void onOutcome(Outcome outcome) { - if (outcome instanceof Outcome.Success success) { - onSuccess(success.value()); - } else if (outcome instanceof Outcome.Failure failure) { - onFailure(failure.exception()); - } else { - onCancellation(); - } - } - @Override - protected int tryAcquireShared(final int arg) { - return getState() != 0 ? 1 : -1; - } - - @Override - protected boolean tryReleaseShared(final int arg) { - setState(1); - return true; - } - - @FunctionalInterface - interface AwaitFunction { - void apply(boolean isCancelled) throws InterruptedException, TimeoutException; - } - - private T awaitInline(final Cancellable cancelToken, final AwaitFunction await) - throws InterruptedException, ExecutionException, TimeoutException { - - var isCancelled = false; - TimeoutException timedOut = null; - while (true) { - try { - await.apply(isCancelled); - break; - } catch (final TimeoutException | InterruptedException e) { - if (!isCancelled) { - isCancelled = true; - if (e instanceof TimeoutException) - timedOut = (TimeoutException) e; - cancelToken.cancel(); - } - } - // Clearing the interrupted flag may not be necessary, - // but doesn't hurt, and we should have a cleared flag before - // re-throwing the exception - // - // noinspection ResultOfMethodCallIgnored - Thread.interrupted(); - } - if (timedOut != null) throw timedOut; - if (interrupted != null) throw interrupted; - if (error != null) throw new ExecutionException(error); - return result; - } - - public T await(final Cancellable cancelToken) throws InterruptedException, ExecutionException { - try { - return awaitInline(cancelToken, isCancelled -> acquireSharedInterruptibly(1)); - } catch (final TimeoutException e) { - throw new IllegalStateException("Unexpected timeout", e); - } + /** + * Creates a task that completes with the given static/pure value. + */ + public static Task pure(final T value) { + //noinspection DataFlowIssue + return new Task<>((cont) -> cont.onSuccess(value)); } - public T await(final Cancellable cancelToken, final Duration timeout) - throws ExecutionException, InterruptedException, TimeoutException { - - return awaitInline(cancelToken, isCancelled -> { - if (!isCancelled) { - if (!tryAcquireSharedNanos(1, timeout.toNanos())) { - throw new TimeoutException("Task timed-out after " + timeout); - } - } else { - // Waiting without a timeout, since at this point it's waiting - // on the cancelled task to finish - acquireSharedInterruptibly(1); - } - }); - } + /** Reusable "void" task that does nothing, completing immediately. */ + @SuppressWarnings({"NullAway", "DataFlowIssue"}) + public static final Task NOOP = Task.pure(null); } /** @@ -548,7 +486,6 @@ public T await(final Cancellable cancelToken, final Duration timeout) * breakage between minor version updates. */ @ApiStatus.Internal -@NullMarked final class TaskFromCancellableFuture implements AsyncContinuationFun { @@ -559,7 +496,7 @@ public TaskFromCancellableFuture(DelayedFun> buil } @Override - public void invoke(Continuation continuation) { + public void invoke(Continuation continuation) { try { final var cancellableRef = continuation.registerForwardCancellable(); @@ -572,9 +509,9 @@ public void invoke(Continuation continuation) { cancellableRef.set(() -> { try { - cancellable.cancel(); - } finally { future.cancel(true); + } finally { + cancellable.cancel(); } }); } catch (Throwable e) { @@ -589,7 +526,7 @@ private static CompletableFuture getCompletableFuture( ) { CompletableFuture future = cancellableFuture.future(); future.whenComplete((value, error) -> { - if (error instanceof InterruptedException || error instanceof TaskCancellationException) { + if (error instanceof InterruptedException || error instanceof TaskCancellationException || error instanceof CancellationException) { callback.onCancellation(); } else if (error instanceof ExecutionException) { callback.onFailure(error.getCause() != null ? error.getCause() : error); diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskCancellationException.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskCancellationException.java index 6024101..d5024bd 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskCancellationException.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskCancellationException.java @@ -1,6 +1,5 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; /** @@ -17,7 +16,6 @@ * the current thread being interrupted, and also, the current thread * might be interrupted without the concurrent job being cancelled.

*/ -@NullMarked public class TaskCancellationException extends Exception { public TaskCancellationException() { super(); diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskExecutor.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskExecutor.java index 338e659..946c133 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskExecutor.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskExecutor.java @@ -1,54 +1,74 @@ package org.funfix.tasks.jvm; import org.jetbrains.annotations.ApiStatus; -import org.jspecify.annotations.NullMarked; +import org.jetbrains.annotations.Nullable; + import java.util.concurrent.Executor; -@NullMarked @ApiStatus.Internal interface TaskExecutor extends Executor { void resumeOnExecutor(Runnable runnable); static TaskExecutor from(final Executor executor) { - if (executor instanceof TaskExecutor) { - return (TaskExecutor) executor; + if (executor instanceof TaskExecutor te) { + return te; } else { return new TaskExecutorWithForkedResume(executor); } } } -@NullMarked +@ApiStatus.Internal +final class TaskLocalContext { + static void signalTheStartOfBlockingCall() { + // clears the trampoline first + final var executor = localExecutor.get(); + Trampoline.forkAll( + executor != null ? executor : TaskExecutors.sharedBlockingIO() + ); + } + + static boolean isCurrentExecutor(final TaskExecutor executor) { + final var currentExecutor = localExecutor.get(); + return currentExecutor == executor; + } + + static @Nullable TaskExecutor getAndSetExecutor(@Nullable final TaskExecutor executor) { + final var oldExecutor = localExecutor.get(); + localExecutor.set(executor); + return oldExecutor; + } + + private static final ThreadLocal<@Nullable TaskExecutor> localExecutor = + ThreadLocal.withInitial(() -> null); +} + +@ApiStatus.Internal final class TaskExecutorWithForkedResume implements TaskExecutor { private final Executor executor; - TaskExecutorWithForkedResume(Executor executor) { + TaskExecutorWithForkedResume(final Executor executor) { this.executor = executor; } @Override - public void execute(Runnable command) { + public void execute(final Runnable command) { executor.execute(() -> { - final int oldLocalHash = localExecutor.get(); - localExecutor.set(System.identityHashCode(executor)); + final var oldExecutor = TaskLocalContext.getAndSetExecutor(this); try { command.run(); } finally { - localExecutor.set(oldLocalHash); + TaskLocalContext.getAndSetExecutor(oldExecutor); } }); } @Override - public void resumeOnExecutor(Runnable runnable) { - final int localHash = localExecutor.get(); - if (localHash == System.identityHashCode(executor)) { - Trampoline.INSTANCE.execute(runnable); + public void resumeOnExecutor(final Runnable runnable) { + if (TaskLocalContext.isCurrentExecutor(this)) { + Trampoline.execute(runnable); } else { execute(runnable); } } - - private static final ThreadLocal localExecutor = - ThreadLocal.withInitial(() -> 0); } diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskExecutors.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskExecutors.java index 2eec00f..4d63c74 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskExecutors.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskExecutors.java @@ -1,20 +1,20 @@ package org.funfix.tasks.jvm; import org.jetbrains.annotations.ApiStatus; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.util.Objects; -import java.util.concurrent.*; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadFactory; /** * Provides utilities for working with [Executor] instances, optimized * for common use-cases. */ -@NullMarked public final class TaskExecutors { private static volatile @Nullable Executor sharedVirtualIORef = null; private static volatile @Nullable Executor sharedPlatformIORef = null; @@ -76,6 +76,7 @@ private static Executor sharedVirtualIO() { * On Java 21 and above, the created {@code Executor} will run tasks on virtual threads. * On older JVM versions, it returns a plain {@code Executors.newCachedThreadPool}. */ + @SuppressWarnings({"deprecation", "EmptyCatch"}) public static ExecutorService unlimitedThreadPoolForIO(final String prefix) { if (VirtualThreads.areVirtualThreadsSupported()) try { @@ -99,7 +100,7 @@ public static ExecutorService unlimitedThreadPoolForIO(final String prefix) { * breakage between minor version updates. */ @ApiStatus.Internal -@NullMarked +@SuppressWarnings("JavaLangInvokeHandleSignature") final class VirtualThreads { private static final @Nullable MethodHandle newThreadPerTaskExecutorMethodHandle; diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskUtils.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskUtils.java new file mode 100644 index 0000000..a7a0ec5 --- /dev/null +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/TaskUtils.java @@ -0,0 +1,44 @@ +package org.funfix.tasks.jvm; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Blocking; +import org.jspecify.annotations.Nullable; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; + +@ApiStatus.Internal +final class TaskUtils { + static Task taskUninterruptibleBlockingIO( + final DelayedFun func + ) { + return Task.fromAsync((ec, callback) -> { + try { + callback.onSuccess(func.invoke()); + } catch (final InterruptedException e) { + callback.onCancellation(); + } catch (final Exception e) { + callback.onFailure(e); + } + return () -> {}; + }); + } + + @Blocking + @SuppressWarnings("UnusedReturnValue") + static T runBlockingUninterruptible( + @Nullable final Executor executor, + final Task task + ) throws InterruptedException, ExecutionException { + final var fiber = executor != null + ? task.runFiber(executor) + : task.runFiber(); + + fiber.joinBlockingUninterruptible(); + try { + return fiber.getResultOrThrow(); + } catch (TaskCancellationException | Fiber.NotCompletedException e) { + throw new ExecutionException(e); + } + } +} diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Trampoline.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Trampoline.java index f5218a5..335b53c 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Trampoline.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Trampoline.java @@ -1,10 +1,9 @@ package org.funfix.tasks.jvm; import org.jetbrains.annotations.ApiStatus; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; -import java.util.LinkedList; +import java.util.ArrayDeque; import java.util.concurrent.Executor; /** @@ -15,11 +14,10 @@ * breakage between minor version updates. */ @ApiStatus.Internal -@NullMarked final class Trampoline { private Trampoline() {} - private static final ThreadLocal<@Nullable LinkedList> queue = + private static final ThreadLocal<@Nullable ArrayDeque> queue = new ThreadLocal<>(); private static void eventLoop() { @@ -51,7 +49,7 @@ public void resumeOnExecutor(Runnable runnable) { public void execute(Runnable command) { var current = queue.get(); if (current == null) { - current = new LinkedList<>(); + current = new ArrayDeque<>(); current.add(command); queue.set(current); try { @@ -65,6 +63,19 @@ public void execute(Runnable command) { } }; + public static void forkAll(final Executor executor) { + final var current = queue.get(); + if (current == null) return; + + final var copy = new ArrayDeque<>(current); + executor.execute(() -> Trampoline.execute(() -> { + while (!copy.isEmpty()) { + final var next = copy.pollFirst(); + Trampoline.execute(next); + } + })); + } + public static void execute(final Runnable command) { INSTANCE.execute(command); } diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/UncaughtExceptionHandler.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/UncaughtExceptionHandler.java index c016011..7a52850 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/UncaughtExceptionHandler.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/UncaughtExceptionHandler.java @@ -1,11 +1,8 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; - /** * Utilities for handling uncaught exceptions. */ -@NullMarked public final class UncaughtExceptionHandler { public static void rethrowIfFatal(final Throwable e) { if (e instanceof StackOverflowError) { @@ -13,8 +10,8 @@ public static void rethrowIfFatal(final Throwable e) { // the process return; } - if (e instanceof Error) { - throw (Error) e; + if (e instanceof Error error) { + throw error; } } diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/package-info.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/package-info.java new file mode 100644 index 0000000..67047ca --- /dev/null +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.funfix.tasks.jvm; + +import org.jspecify.annotations.NullMarked; diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/AwaitSignalTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/AwaitSignalTest.java index b504d31..68bda2a 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/AwaitSignalTest.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/AwaitSignalTest.java @@ -7,14 +7,15 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import static org.funfix.tasks.jvm.TestSettings.CONCURRENCY_REPEATS; +import static org.funfix.tasks.jvm.TestSettings.TIMEOUT; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; public class AwaitSignalTest { - final int repeat = 1000; @Test void singleThreaded() throws InterruptedException, TimeoutException { - for (int i = 0; i < repeat; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var latch = new AwaitSignal(); latch.signal(); latch.await(TimeUnit.SECONDS.toMillis(5)); @@ -23,7 +24,7 @@ void singleThreaded() throws InterruptedException, TimeoutException { @Test void multiThreaded() throws InterruptedException { - for (int i = 0; i < repeat; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var wasStarted = new CountDownLatch(1); final var latch = new AwaitSignal(); final var hasError = new AtomicBoolean(false); @@ -39,7 +40,7 @@ void multiThreaded() throws InterruptedException { t.start(); wasStarted.await(); latch.signal(); - t.join(TimedAwait.TIMEOUT.toMillis()); + t.join(TIMEOUT.toMillis()); assertFalse(t.isAlive(), "isAlive"); assertFalse(hasError.get()); } @@ -47,19 +48,21 @@ void multiThreaded() throws InterruptedException { @Test void canBeInterrupted() throws InterruptedException { - final var latch = new AwaitSignal(); - final var wasInterrupted = new AtomicBoolean(false); - final var t = new Thread(() -> { - try { - latch.await(); - } catch (InterruptedException e) { - wasInterrupted.set(true); - } - }); - t.start(); - t.interrupt(); - t.join(TimedAwait.TIMEOUT.toMillis()); - assertFalse(t.isAlive(), "isAlive"); - assertTrue(wasInterrupted.get()); + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { + final var latch = new AwaitSignal(); + final var wasInterrupted = new AtomicBoolean(false); + final var t = new Thread(() -> { + try { + latch.await(); + } catch (InterruptedException e) { + wasInterrupted.set(true); + } + }); + t.start(); + t.interrupt(); + t.join(TIMEOUT.toMillis()); + assertFalse(t.isAlive(), "isAlive"); + assertTrue(wasInterrupted.get()); + } } } diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/CompletionCallbackTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/CompletionCallbackTest.java index 1a6206e..e92e534 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/CompletionCallbackTest.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/CompletionCallbackTest.java @@ -1,14 +1,13 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; + import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; -@NullMarked public class CompletionCallbackTest { @Test void emptyLogsRuntimeFailure() throws InterruptedException { @@ -33,7 +32,7 @@ void emptyLogsRuntimeFailure() throws InterruptedException { void protectedCallbackForSuccess() { final var called = new AtomicInteger(0); final var outcomeRef = new AtomicReference<@Nullable Outcome>(null); - final var cb = ProtectedCompletionCallback.protect( + final var cb = AsyncContinuationCallback.protect( TaskExecutor.from(TaskExecutors.trampoline()), new CompletionCallback() { @Override @@ -72,7 +71,7 @@ public void onOutcome(Outcome outcome) { void protectedCallbackForRuntimeFailure() throws InterruptedException { final var called = new AtomicInteger(0); final var outcomeRef = new AtomicReference<@Nullable Outcome>(null); - final var cb = ProtectedCompletionCallback.protect( + final var cb = AsyncContinuationCallback.protect( TaskExecutor.from(TaskExecutors.trampoline()), new CompletionCallback() { @Override @@ -120,7 +119,7 @@ public void onOutcome(Outcome outcome) { void protectedCallbackForCancellation() { final var called = new AtomicInteger(0); final var outcomeRef = new AtomicReference<@Nullable Outcome>(null); - final var cb = ProtectedCompletionCallback.protect( + final var cb = AsyncContinuationCallback.protect( TaskExecutor.from(TaskExecutors.trampoline()), new CompletionCallback() { @Override diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/FiberTests.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/FiberTests.java index c8f5fd3..d50ebda 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/FiberTests.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/FiberTests.java @@ -1,7 +1,5 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -13,14 +11,13 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import static org.funfix.tasks.jvm.TestSettings.CONCURRENCY_REPEATS; +import static org.funfix.tasks.jvm.TestSettings.TIMEOUT; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; -@NullMarked abstract class BaseFiberTest { - final int repeatCount = 1000; - protected @Nullable Executor executor = null; protected @Nullable AutoCloseable closeable = null; @@ -107,21 +104,21 @@ public void canFail() throws InterruptedException, TaskCancellationException, Fi fiber.getResultOrThrow(); fail("Should have thrown an exception"); } catch (final ExecutionException ex) { - assertEquals("My Error", ex.getCause().getMessage()); + assertEquals("My Error", Objects.requireNonNull(ex.getCause()).getMessage()); } } @Test public void resultIsMemoized() throws InterruptedException, TaskCancellationException, ExecutionException, Fiber.NotCompletedException { - final Fiber<@NonNull Integer> fiber = startFiber( + final Fiber fiber = startFiber( Task.fromBlockingIO(() -> ThreadLocalRandom.current().nextInt()) ); fiber.joinBlocking(); - final int result = fiber.getResultOrThrow(); + final Integer result = fiber.getResultOrThrow(); fiber.joinBlocking(); - final int result2 = fiber.getResultOrThrow(); + final Integer result2 = fiber.getResultOrThrow(); assertEquals(result, result2); } @@ -130,7 +127,7 @@ public void resultIsMemoized() throws InterruptedException, TaskCancellationExce public void joinCanBeInterrupted() throws InterruptedException, ExecutionException, TaskCancellationException, Fiber.NotCompletedException { final var latch = new CountDownLatch(1); final var started = new CountDownLatch(1); - final Fiber<@NonNull Boolean> fiber = startFiber( + final Fiber fiber = startFiber( Task.fromBlockingIO(() -> { TimedAwait.latchNoExpectations(latch); return true; @@ -155,12 +152,13 @@ public void joinCanBeInterrupted() throws InterruptedException, ExecutionExcepti latch.countDown(); fiber.joinBlocking(); + //noinspection DataFlowIssue assertTrue(fiber.getResultOrThrow()); } @Test void concurrencyHappyPathExecuteThenJoinAsync() throws InterruptedException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var latch = new CountDownLatch(1); final boolean[] wasExecuted = {false}; final var fiber = startFiber( @@ -174,17 +172,17 @@ void concurrencyHappyPathExecuteThenJoinAsync() throws InterruptedException { @Test void happyPathExecuteThenJoinBlockingTimed() throws InterruptedException, TimeoutException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final boolean[] wasExecuted = {false}; final var fiber = startFiber(Task.fromBlockingIO(() -> wasExecuted[0] = true)); - fiber.joinBlockingTimed(TimedAwait.TIMEOUT); + fiber.joinBlockingTimed(TIMEOUT); assertTrue(wasExecuted[0], "wasExecuted"); } } @Test void concurrencyHappyPathExecuteThenJoin() throws InterruptedException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final boolean[] wasExecuted = {false}; final var fiber = startFiber(() -> wasExecuted[0] = true); fiber.joinBlocking(); @@ -194,7 +192,7 @@ void concurrencyHappyPathExecuteThenJoin() throws InterruptedException { @Test void happyPathExecuteThenJoinFuture() throws InterruptedException, TimeoutException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final boolean[] wasExecuted = {false}; final var fiber = startFiber(() -> wasExecuted[0] = true); TimedAwait.future(fiber.joinAsync().future()); @@ -204,7 +202,7 @@ void happyPathExecuteThenJoinFuture() throws InterruptedException, TimeoutExcept @Test void concurrencyInterruptedThenJoinAsync() throws InterruptedException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var awaitCancellation = new CountDownLatch(1); final var onCompletion = new CountDownLatch(1); final var hits = new AtomicInteger(0); @@ -226,7 +224,7 @@ void concurrencyInterruptedThenJoinAsync() throws InterruptedException { @Test void joinAsyncIsInterruptible() throws InterruptedException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var onComplete = new CountDownLatch(2); final var awaitCancellation = new CountDownLatch(1); final var fiber = startFiber(() -> { @@ -251,7 +249,7 @@ void joinAsyncIsInterruptible() throws InterruptedException { @Test void joinFutureIsInterruptible() throws InterruptedException, TimeoutException, ExecutionException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var onComplete = new CountDownLatch(2); final var awaitCancellation = new CountDownLatch(1); final var fiber = startFiber(() -> { @@ -269,7 +267,7 @@ void joinFutureIsInterruptible() throws InterruptedException, TimeoutException, token.cancellable().cancel(); try { - token.future().get(TimedAwait.TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + token.future().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); fail("Should have been interrupted"); } catch (java.util.concurrent.CancellationException ignored) { } @@ -282,7 +280,7 @@ void joinFutureIsInterruptible() throws InterruptedException, TimeoutException, @Test void cancelAfterExecution() throws InterruptedException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var latch = new CountDownLatch(1); final var fiber = startFiber(() -> 0); fiber.joinAsync(latch::countDown); @@ -293,7 +291,7 @@ void cancelAfterExecution() throws InterruptedException { @Test void awaitAsyncHappyPath() throws InterruptedException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var latch = new CountDownLatch(1); final var fiber = startFiber(() -> 1); final var result = new AtomicInteger(0); @@ -314,7 +312,7 @@ void awaitAsyncHappyPath() throws InterruptedException { @Test void awaitAsyncCanFail() throws InterruptedException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var latch = new CountDownLatch(1); final var ex = new RuntimeException("My Error"); final Fiber fiber = startFiber(() -> { @@ -337,7 +335,7 @@ void awaitAsyncCanFail() throws InterruptedException { @Test void awaitAsyncSignalsCancellation() throws InterruptedException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var fiberLatch = new CountDownLatch(1); final var wasInterrupted = new AtomicInteger(0); final var wasStarted = new CountDownLatch(1); @@ -372,7 +370,7 @@ void awaitAsyncSignalsCancellation() throws InterruptedException { @Test void awaitBlockingHappyPath() throws TaskCancellationException, ExecutionException, InterruptedException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var fiber = startFiber(() -> 1); final var r = fiber.awaitBlocking(); assertEquals(1, r); @@ -381,7 +379,7 @@ void awaitBlockingHappyPath() throws TaskCancellationException, ExecutionExcepti @Test void awaitBlockingFailure() throws TaskCancellationException, InterruptedException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var ex = new RuntimeException("My Error"); final Fiber fiber = startFiber(() -> { throw ex; }); try { @@ -395,7 +393,7 @@ void awaitBlockingFailure() throws TaskCancellationException, InterruptedExcepti @Test void awaitBlockingSignalsCancellation() throws InterruptedException, ExecutionException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var latch = new CountDownLatch(1); final Fiber fiber = startFiber(() -> { TimedAwait.latchNoExpectations(latch); @@ -415,20 +413,20 @@ void awaitBlockingSignalsCancellation() throws InterruptedException, ExecutionEx void awaitBlockingTimedHappyPath() throws TaskCancellationException, ExecutionException, InterruptedException, TimeoutException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var fiber = startFiber(() -> 1); - final var r = fiber.awaitBlockingTimed(TimedAwait.TIMEOUT); + final var r = fiber.awaitBlockingTimed(TIMEOUT); assertEquals(1, r); } } @Test void awaitBlockingTimedFailure() throws TaskCancellationException, InterruptedException, TimeoutException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var ex = new RuntimeException("My Error"); final Fiber fiber = startFiber(() -> { throw ex; }); try { - fiber.awaitBlockingTimed(TimedAwait.TIMEOUT); + fiber.awaitBlockingTimed(TIMEOUT); fail("Should have failed"); } catch (ExecutionException e) { assertEquals(ex, e.getCause()); @@ -439,7 +437,7 @@ void awaitBlockingTimedFailure() throws TaskCancellationException, InterruptedEx @Test void awaitBlockingTimedSignalsCancellation() throws InterruptedException, ExecutionException, TimeoutException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var latch = new CountDownLatch(1); final Fiber fiber = startFiber(() -> { TimedAwait.latchNoExpectations(latch); @@ -448,7 +446,7 @@ void awaitBlockingTimedSignalsCancellation() throws InterruptedException, Execut fiber.cancel(); try { - fiber.awaitBlockingTimed(TimedAwait.TIMEOUT); + fiber.awaitBlockingTimed(TIMEOUT); fail("Should have failed"); } catch (TaskCancellationException ignored) { } @@ -469,7 +467,7 @@ void awaitBlockingTimesOut() throws TaskCancellationException, ExecutionExceptio } finally { fiber.cancel(); try { - fiber.awaitBlockingTimed(TimedAwait.TIMEOUT); + fiber.awaitBlockingTimed(TIMEOUT); fail("Should have been cancelled"); } catch (TimeoutException e) { //noinspection ThrowFromFinallyBlock @@ -481,7 +479,7 @@ void awaitBlockingTimesOut() throws TaskCancellationException, ExecutionExceptio @Test void awaitAsyncFutureHappyPath() { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var fiber = startFiber(() -> 1); final var future = fiber.awaitAsync().future(); try { @@ -494,7 +492,7 @@ void awaitAsyncFutureHappyPath() { @Test void awaitAsyncFutureFailure() throws InterruptedException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var ex = new RuntimeException("My Error"); final var fiber = startFiber(() -> { throw ex; @@ -511,7 +509,7 @@ void awaitAsyncFutureFailure() throws InterruptedException { @Test void awaitAsyncFutureCanSignalCancellation() throws InterruptedException { - for (int i = 0; i < repeatCount; i++) { + for (int i = 0; i < CONCURRENCY_REPEATS; i++) { final var latch = new CountDownLatch(1); final var fiber = startFiber(() -> { TimedAwait.latchNoExpectations(latch); @@ -530,11 +528,9 @@ void awaitAsyncFutureCanSignalCancellation() throws InterruptedException { } } -@NullMarked class FiberWithDefaultExecutorTest extends BaseFiberTest { } -@NullMarked class FiberWithVirtualThreadsTest extends BaseFiberTest { @BeforeEach void setUp() { @@ -548,14 +544,13 @@ void testVirtualThreads() throws ExecutionException, InterruptedException, Timeo final var task = Task .fromBlockingIO(() -> Thread.currentThread().getName()); assertTrue( - Objects.requireNonNull(task.runBlockingTimed(TimedAwait.TIMEOUT)) + Objects.requireNonNull(task.runBlockingTimed(TIMEOUT)) .matches("tasks-io-virtual-\\d+"), "currentThread.name.matches(\"tasks-io-virtual-\\\\d+\")" ); } } -@NullMarked class FiberWithPlatformThreadsTest extends BaseFiberTest { @BeforeEach void setUp() { @@ -569,7 +564,7 @@ void testPlatformThreads() throws ExecutionException, InterruptedException, Time final var task = Task.fromBlockingIO(() -> Thread.currentThread().getName()); assertTrue( - Objects.requireNonNull(task.runBlockingTimed(TimedAwait.TIMEOUT)) + Objects.requireNonNull(task.runBlockingTimed(TIMEOUT)) .matches("tasks-io-platform-\\d+"), "currentThread.name.matches(\"tasks-io-platform-\\\\d+\")" ); diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/LoomTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/LoomTest.java index fc434ac..344af0f 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/LoomTest.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/LoomTest.java @@ -1,7 +1,9 @@ package org.funfix.tasks.jvm; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; +import java.util.Objects; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -19,7 +21,7 @@ public void commonPoolInJava21() throws InterruptedException { try { final var latch = new CountDownLatch(1); final var isVirtual = new AtomicBoolean(false); - final var name = new AtomicReference(); + final var name = new AtomicReference<@Nullable String>(); commonPool.execute(() -> { isVirtual.set(VirtualThreads.isVirtualThread(Thread.currentThread())); @@ -30,8 +32,8 @@ public void commonPoolInJava21() throws InterruptedException { TimedAwait.latchAndExpectCompletion(latch); assertTrue(isVirtual.get(), "isVirtual"); assertTrue( - name.get().matches("tasks-io-virtual-\\d+"), - "name.matches(\"tasks-io-virtual-\\\\d+\")" + Objects.requireNonNull(name.get()).matches("tasks-io-virtual-\\d+"), + "name.matches(\"tasks-io-virtual-\\\\d+\")" ); } finally { commonPool.shutdown(); @@ -47,7 +49,7 @@ public void canInitializeFactoryInJava21() throws InterruptedException, VirtualT final var latch = new CountDownLatch(1); final var isVirtual = new AtomicBoolean(false); - final var name = new AtomicReference(); + final var name = new AtomicReference<@Nullable String>(); f.newThread(() -> { isVirtual.set(VirtualThreads.isVirtualThread(Thread.currentThread())); @@ -58,7 +60,7 @@ public void canInitializeFactoryInJava21() throws InterruptedException, VirtualT TimedAwait.latchAndExpectCompletion(latch); assertTrue(isVirtual.get(), "isVirtual"); assertTrue( - name.get().matches("my-vt-\\d+"), + Objects.requireNonNull(name.get()).matches("my-vt-\\d+"), "name.matches(\"my-vt-\\\\d+\")" ); } @@ -72,7 +74,7 @@ public void canInitializeExecutorInJava21() throws InterruptedException, Virtual try { final var latch = new CountDownLatch(1); final var isVirtual = new AtomicBoolean(false); - final var name = new AtomicReference(); + final var name = new AtomicReference<@Nullable String>(); executor.execute(() -> { isVirtual.set(VirtualThreads.isVirtualThread(Thread.currentThread())); name.set(Thread.currentThread().getName()); @@ -82,7 +84,7 @@ public void canInitializeExecutorInJava21() throws InterruptedException, Virtual TimedAwait.latchAndExpectCompletion(latch); assertTrue(isVirtual.get(), "isVirtual"); assertTrue( - name.get().matches("my-vt-\\d+"), + Objects.requireNonNull(name.get()).matches("my-vt-\\d+"), "name.matches(\"my-vt-\\\\d+\")" ); } finally { @@ -119,7 +121,7 @@ public void commonPoolInOlderJava() throws InterruptedException { final var latch = new CountDownLatch(1); final var isVirtual = new AtomicBoolean(true); - final var name = new AtomicReference(); + final var name = new AtomicReference<@Nullable String>(); commonPool.execute(() -> { isVirtual.set(VirtualThreads.isVirtualThread(Thread.currentThread())); @@ -130,7 +132,7 @@ public void commonPoolInOlderJava() throws InterruptedException { TimedAwait.latchAndExpectCompletion(latch); assertFalse(isVirtual.get(), "isVirtual"); assertTrue( - name.get().matches("^tasks-io-platform-\\d+$"), + Objects.requireNonNull(name.get()).matches("^tasks-io-platform-\\d+$"), "name.matches(\"^tasks-io-platform-\\\\d+$\")" ); } finally { diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/OutcomeTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/OutcomeTest.java index 3b1bd14..3a3c8b3 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/OutcomeTest.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/OutcomeTest.java @@ -1,6 +1,5 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; import java.util.concurrent.ExecutionException; @@ -8,7 +7,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.fail; -@NullMarked public class OutcomeTest { @Test void outcomeBuildSuccess() { @@ -16,8 +14,7 @@ void outcomeBuildSuccess() { final Outcome outcome2 = Outcome.success("value"); assertEquals(outcome1, outcome2); - if (outcome2 instanceof Outcome.Success) { - final var success = (Outcome.Success) outcome2; + if (outcome2 instanceof Outcome.Success success) { assertEquals("value", success.value()); } else { fail("Expected Success"); @@ -60,8 +57,7 @@ void outcomeBuildRuntimeFailure() { final Outcome outcome2 = Outcome.failure(e); assertEquals(outcome1, outcome2); - if (outcome2 instanceof Outcome.Failure) { - final var failure = (Outcome.Failure) outcome2; + if (outcome2 instanceof Outcome.Failure failure) { assertEquals("error", failure.exception().getMessage()); } else { fail("Expected Failure"); diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/PureTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/PureTest.java new file mode 100644 index 0000000..931d87e --- /dev/null +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/PureTest.java @@ -0,0 +1,28 @@ +package org.funfix.tasks.jvm; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PureTest { + @Test + void pureTask() throws ExecutionException, InterruptedException { + final var task = Task.pure(42); + for (int i = 0; i < 100; i++) { + final var outcome = task.runBlocking(); + assertEquals(42, outcome); + } + } + + @Test + void pureResource() throws ExecutionException, InterruptedException { + final var resource = Resource.pure(42); + for (int i = 0; i < 100; i++) { + final var outcome = resource.useBlocking(value -> value); + assertEquals(42, outcome); + } + } +} diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/ResourceTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/ResourceTest.java new file mode 100644 index 0000000..81993ac --- /dev/null +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/ResourceTest.java @@ -0,0 +1,119 @@ +package org.funfix.tasks.jvm; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ResourceTest { + @Test + void readAndWriteFromFile() throws Exception { + try ( + final var file = createTemporaryFile("test", ".txt").acquireBlocking() + ) { + try (final var writer = openWriter(file.get()).acquireBlocking()) { + writer.get().write("----\n"); + writer.get().write("line 1\n"); + writer.get().write("line 2\n"); + writer.get().write("----\n"); + } + + try (final var reader = openReader(file.get()).acquireBlocking()) { + final var builder = new StringBuilder(); + String line; + while ((line = reader.get().readLine()) != null) { + builder.append(line).append("\n"); + } + final String content = builder.toString(); + assertEquals( + "----\nline 1\nline 2\n----\n", + content, + "File content should match the written lines" + ); + } + } + } + + @Test + void useIsInterruptible() throws InterruptedException { + for (int i = 0; i < TestSettings.CONCURRENCY_REPEATS; i++) { + final var started = new CountDownLatch(1); + final var latch = new CountDownLatch(1); + final var wasShutdown = new CountDownLatch(1); + final var wasInterrupted = new AtomicInteger(0); + final var wasReleased = new AtomicReference<@Nullable ExitCase>(null); + final var resource = Resource.fromBlockingIO(() -> + Resource.Acquired.fromBlockingIO("my resource", wasReleased::set) + ); + + @SuppressWarnings("NullAway") + final var task = Task.fromBlockingFuture(() -> { + try { + resource.useBlocking(res -> { + assertEquals("my resource", res); + started.countDown(); + try { + latch.await(); + } catch (InterruptedException e) { + wasInterrupted.incrementAndGet(); + throw e; + } + return null; + }); + } catch (InterruptedException e) { + wasInterrupted.addAndGet(2); + throw e; + } finally { + wasShutdown.countDown(); + } + return null; + }); + + final var fiber = task.runFiber(); + TimedAwait.latchAndExpectCompletion(started, "started"); + fiber.cancel(); + TimedAwait.latchAndExpectCompletion(wasShutdown, "wasShutdown"); + TimedAwait.fiberAndExpectCancellation(fiber); + assertEquals(3, wasInterrupted.get(), "wasInterrupted"); + assertEquals(ExitCase.canceled(), wasReleased.get(), "wasReleased"); + } + } + + Resource openReader(File file) { + return Resource.fromAutoCloseable(() -> + new BufferedReader( + new InputStreamReader( + new FileInputStream(file), + StandardCharsets.UTF_8 + ) + )); + } + + Resource openWriter(File file) { + return Resource.fromAutoCloseable(() -> + new BufferedWriter( + new OutputStreamWriter( + new FileOutputStream(file), + StandardCharsets.UTF_8 + ) + )); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + Resource createTemporaryFile(String prefix, String suffix) { + return Resource.fromBlockingIO(() -> { + File tempFile = File.createTempFile(prefix, suffix); + tempFile.deleteOnExit(); // Ensure it gets deleted on exit + return Resource.Acquired.fromBlockingIO( + tempFile, + ignored -> tempFile.delete() + ); + }); + } +} diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/SysProp.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/SysProp.java index 3c6a710..294330b 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/SysProp.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/SysProp.java @@ -1,12 +1,10 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.ReentrantLock; -@NullMarked public class SysProp implements AutoCloseable { private static final ConcurrentMap locks = new java.util.concurrent.ConcurrentHashMap<>(); diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskCreateTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskCreateTest.java index e323745..ee70253 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskCreateTest.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskCreateTest.java @@ -1,6 +1,5 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -13,9 +12,9 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; +import static org.funfix.tasks.jvm.TestSettings.TIMEOUT; import static org.junit.jupiter.api.Assertions.*; -@NullMarked abstract class BaseTaskCreateTest { @Nullable protected AutoCloseable closeable; @@ -44,7 +43,7 @@ void successful() throws ExecutionException, InterruptedException, TimeoutExcept return Cancellable.getEmpty(); }); - final String result = task.runBlockingTimed(TimedAwait.TIMEOUT); + final String result = task.runBlockingTimed(TIMEOUT); assertEquals("Hello, world!", result); TimedAwait.latchAndExpectCompletion(noErrors, "noErrors"); assertNull(reportedException.get(), "reportedException.get()"); @@ -64,11 +63,14 @@ void failed() throws InterruptedException { }); try { if (executor != null) - task.runBlockingTimed(executor, TimedAwait.TIMEOUT); + task.runBlockingTimed(executor, TIMEOUT); else - task.runBlockingTimed(TimedAwait.TIMEOUT); + task.runBlockingTimed(TIMEOUT); } catch (final ExecutionException | TimeoutException ex) { - assertEquals("Sample exception", ex.getCause().getMessage()); + assertEquals( + "Sample exception", + Objects.requireNonNull(ex.getCause()).getMessage() + ); } TimedAwait.latchAndExpectCompletion(noErrors, "noErrors"); assertNotNull(reportedException.get(), "reportedException.get()"); @@ -110,7 +112,6 @@ void cancelled() throws InterruptedException, ExecutionException, Fiber.NotCompl } } -@NullMarked class TaskCreateSimpleDefaultExecutorTest extends BaseTaskCreateTest { @BeforeEach void setUp() { @@ -123,7 +124,6 @@ protected Task fromAsyncTask(final AsyncFun builder) { } } -@NullMarked class TaskCreateSimpleCustomJavaExecutorTest extends BaseTaskCreateTest { @BeforeEach void setUp() { diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskEnsureExecutorTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskEnsureExecutorTest.java index fa9d82d..917d68c 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskEnsureExecutorTest.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskEnsureExecutorTest.java @@ -1,6 +1,5 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; @@ -13,7 +12,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -@NullMarked public class TaskEnsureExecutorTest { @Test void testEnsureExecutorOnFiber() throws ExecutionException, InterruptedException { @@ -36,6 +34,7 @@ void testEnsureExecutorOnFiber() throws ExecutionException, InterruptedException } @Test + @SuppressWarnings("deprecation") void ensureRunningOnSpecificExecutor() throws ExecutionException, InterruptedException { final var ec = Executors.newCachedThreadPool( th -> { @@ -61,6 +60,7 @@ void ensureRunningOnSpecificExecutor() throws ExecutionException, InterruptedExc } @Test + @SuppressWarnings("deprecation") void switchesBackOnCallbackForRunAsync() throws InterruptedException { final var ec1 = Executors.newCachedThreadPool( th -> { @@ -68,6 +68,7 @@ void switchesBackOnCallbackForRunAsync() throws InterruptedException { t.setName("executing-thread-" + t.getId()); return t; }); + @SuppressWarnings("deprecation") final var ec2 = Executors.newCachedThreadPool( th -> { final var t = new Thread(th); @@ -125,7 +126,7 @@ void testEnsureExecutorOnFiberAfterCompletion() throws ExecutionException, Inter TimedAwait.latchAndExpectCompletion(isComplete, "isComplete"); //noinspection DataFlowIssue - final var r2Value = Objects.requireNonNull(r2.get().getOrThrow()); + final var r2Value = Objects.requireNonNull(r2.get()).getOrThrow(); assertTrue( r2Value.startsWith("tasks-io-"), "Expected thread name to start with 'tasks-io-', but was: " + r2Value diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskExecuteTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskExecuteTest.java index 78eb4f0..126188a 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskExecuteTest.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskExecuteTest.java @@ -1,6 +1,5 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; @@ -12,7 +11,6 @@ import static org.junit.jupiter.api.Assertions.*; -@NullMarked public class TaskExecuteTest { @Test void runAsyncWorksForSuccess() throws InterruptedException, TaskCancellationException, ExecutionException { diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskExecutorTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskExecutorTest.java index e3148d9..201b8b7 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskExecutorTest.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskExecutorTest.java @@ -1,6 +1,5 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; @@ -12,7 +11,6 @@ import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assumptions.assumeTrue; -@NullMarked public class TaskExecutorTest { @Test void trampolineIsTaskExecutor() { diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskFromBlockingFutureTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskFromBlockingFutureTest.java index 0c8906c..235c194 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskFromBlockingFutureTest.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskFromBlockingFutureTest.java @@ -1,6 +1,5 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -10,15 +9,16 @@ import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicReference; +import static org.funfix.tasks.jvm.TestSettings.TIMEOUT; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assumptions.assumeTrue; -@NullMarked public class TaskFromBlockingFutureTest { @Nullable ExecutorService es; @BeforeEach + @SuppressWarnings("deprecation") void setup() { es = Executors.newCachedThreadPool(r -> { final var th = new Thread(r); @@ -41,7 +41,7 @@ void runBlockingDoesNotFork() throws ExecutionException, InterruptedException { final var thisName = Thread.currentThread().getName(); final var task = Task.fromBlockingFuture(() -> { name.set(Thread.currentThread().getName()); - return es.submit(() -> "Hello, world!"); + return Objects.requireNonNull(es).submit(() -> "Hello, world!"); }); final var r = task.runBlocking(); @@ -54,16 +54,15 @@ void runBlockingTimedForks() throws ExecutionException, InterruptedException, Ti Objects.requireNonNull(es); final var name = new AtomicReference<>(""); - final var thisName = Thread.currentThread().getName(); final var task = Task.fromBlockingFuture(() -> { name.set(Thread.currentThread().getName()); - return es.submit(() -> "Hello, world!"); + return Objects.requireNonNull(es).submit(() -> "Hello, world!"); }); - final var r = task.runBlockingTimed(es, TimedAwait.TIMEOUT); + final var r = task.runBlockingTimed(es, TIMEOUT); assertEquals("Hello, world!", r); assertTrue( - name.get().startsWith("es-sample-"), + Objects.requireNonNull(name.get()).startsWith("es-sample-"), "Expected name to start with 'es-sample-', but was: " + name.get() ); } @@ -73,16 +72,15 @@ void runFiberForks() throws ExecutionException, InterruptedException, TimeoutExc Objects.requireNonNull(es); final var name = new AtomicReference<>(""); - final var thisName = Thread.currentThread().getName(); final var task = Task.fromBlockingFuture(() -> { name.set(Thread.currentThread().getName()); - return es.submit(() -> "Hello, world!"); + return Objects.requireNonNull(es).submit(() -> "Hello, world!"); }); - final var r = task.runFiber(es).awaitBlockingTimed(TimedAwait.TIMEOUT); + final var r = task.runFiber(es).awaitBlockingTimed(TIMEOUT); assertEquals("Hello, world!", r); assertTrue( - name.get().startsWith("es-sample-"), + Objects.requireNonNull(name.get()).startsWith("es-sample-"), "Expected name to start with 'es-sample-', but was: " + name.get() ); } @@ -95,12 +93,12 @@ void loomHappyPath() throws ExecutionException, InterruptedException, TimeoutExc final var name = new AtomicReference<>(""); final var task = Task.fromBlockingFuture(() -> { name.set(Thread.currentThread().getName()); - return es.submit(() -> "Hello, world!"); + return Objects.requireNonNull(es).submit(() -> "Hello, world!"); }); - final var r = task.runBlockingTimed(TimedAwait.TIMEOUT); + final var r = task.runBlockingTimed(TIMEOUT); assertEquals("Hello, world!", r); - assertTrue(name.get().startsWith("tasks-io-virtual-")); + assertTrue(Objects.requireNonNull(name.get()).startsWith("tasks-io-virtual-")); } @Test @@ -112,7 +110,7 @@ void throwExceptionInBuilder() throws InterruptedException { throw new RuntimeException("Error"); }).runBlocking(); } catch (final ExecutionException ex) { - assertEquals("Error", ex.getCause().getMessage()); + assertEquals("Error", Objects.requireNonNull(ex.getCause()).getMessage()); } } @@ -120,12 +118,12 @@ void throwExceptionInBuilder() throws InterruptedException { void throwExceptionInFuture() throws InterruptedException { Objects.requireNonNull(es); try { - Task.fromBlockingFuture(() -> es.submit(() -> { - throw new RuntimeException("Error"); - })) - .runBlocking(); + Task.fromBlockingFuture(() -> Objects.requireNonNull(es).submit(() -> { + throw new RuntimeException("Error"); + })) + .runBlocking(); } catch (final ExecutionException ex) { - assertEquals("Error", ex.getCause().getMessage()); + assertEquals("Error", Objects.requireNonNull(ex.getCause()).getMessage()); } } @@ -137,6 +135,7 @@ void builderCanBeCancelled() throws InterruptedException, ExecutionException, Ti final var wasStarted = new CountDownLatch(1); final var latch = new CountDownLatch(1); + @SuppressWarnings("NullAway") final var fiber = Task .fromBlockingFuture(() -> { wasStarted.countDown(); @@ -151,7 +150,7 @@ void builderCanBeCancelled() throws InterruptedException, ExecutionException, Ti TimedAwait.latchAndExpectCompletion(wasStarted, "wasStarted"); fiber.cancel(); - fiber.joinBlockingTimed(TimedAwait.TIMEOUT); + fiber.joinBlockingTimed(TIMEOUT); try { fiber.getResultOrThrow(); @@ -169,7 +168,7 @@ void futureCanBeCancelled() throws InterruptedException, ExecutionException, Tim final var wasStarted = new CountDownLatch(1); final var fiber = Task - .fromBlockingFuture(() -> es.submit(() -> { + .fromBlockingFuture(() -> Objects.requireNonNull(es).submit(() -> { wasStarted.countDown(); try { Thread.sleep(10000); @@ -180,7 +179,7 @@ void futureCanBeCancelled() throws InterruptedException, ExecutionException, Tim wasStarted.await(); fiber.cancel(); - fiber.joinBlockingTimed(TimedAwait.TIMEOUT); + fiber.joinBlockingTimed(TIMEOUT); try { fiber.getResultOrThrow(); diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskFromBlockingIOTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskFromBlockingIOTest.java index 30c1d0a..bc7dd8a 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskFromBlockingIOTest.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskFromBlockingIOTest.java @@ -1,18 +1,17 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.opentest4j.AssertionFailedError; import java.util.Objects; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicReference; + +import static org.funfix.tasks.jvm.TestSettings.TIMEOUT; import static org.junit.jupiter.api.Assertions.*; -@NullMarked abstract class TaskFromBlockingIOTestBase { @Nullable protected Executor executor; @Nullable protected AutoCloseable closeable; @@ -50,9 +49,9 @@ public void runBlockingTimedForks() throws ExecutionException, InterruptedExcept Task.fromBlockingIO(() -> { name.set(Thread.currentThread().getName()); return "Hello, world!"; - }).runBlockingTimed(executor, TimedAwait.TIMEOUT); + }).runBlockingTimed(executor, TIMEOUT); assertEquals("Hello, world!", r); - testThreadName(name.get()); + testThreadName(Objects.requireNonNull(name.get())); } @Test @@ -60,10 +59,10 @@ public void canFail() throws InterruptedException { Objects.requireNonNull(executor); try { Task.fromBlockingIO(() -> { throw new RuntimeException("Error"); }) - .runBlocking(executor); + .runBlocking(executor); fail("Should have thrown an exception"); } catch (final ExecutionException ex) { - assertEquals("Error", ex.getCause().getMessage()); + assertEquals("Error", Objects.requireNonNull(ex.getCause()).getMessage()); } } @@ -71,6 +70,7 @@ public void canFail() throws InterruptedException { public void isCancellable() throws InterruptedException, ExecutionException, Fiber.NotCompletedException { Objects.requireNonNull(executor); final var latch = new CountDownLatch(1); + @SuppressWarnings("NullAway") final var task = Task.fromBlockingIO(() -> { latch.countDown(); Thread.sleep(30000); @@ -88,7 +88,6 @@ public void isCancellable() throws InterruptedException, ExecutionException, Fib } } -@NullMarked final class TaskFromBlockingWithExecutorIOTest extends TaskFromBlockingIOTestBase { @Override void testThreadName(final String name) { @@ -99,6 +98,7 @@ void testThreadName(final String name) { } @BeforeEach + @SuppressWarnings("deprecation") void setup() { final ExecutorService es = Executors.newCachedThreadPool(r -> { final var th = new Thread(r); @@ -110,7 +110,6 @@ void setup() { } } -@NullMarked final class TaskFromBlockingWithSharedExecutorTest extends TaskFromBlockingIOTestBase { @Override void testThreadName(String name) {} diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskWithCancellationTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskWithCancellationTest.java new file mode 100644 index 0000000..f31e13e --- /dev/null +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskWithCancellationTest.java @@ -0,0 +1,112 @@ +package org.funfix.tasks.jvm; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +public class TaskWithCancellationTest { + @Test + void testTaskWithCancellation() throws InterruptedException { + for (int r = 0; r < TestSettings.CONCURRENCY_REPEATS; r++) { + final var cancelTokensRef = new ConcurrentLinkedQueue(); + final var outcomeRef = new AtomicReference<@Nullable Outcome>(null); + + final var startedLatch = new CountDownLatch(1); + final var taskLatch = new CountDownLatch(1); + final var cancelTokensLatch = new CountDownLatch(2); + final var completedLatch = new CountDownLatch(1); + final var runningTask = Task + .fromBlockingIO(() -> { + try { + startedLatch.countDown(); + taskLatch.await(); + return "Completed"; + } catch (final InterruptedException e) { + TimedAwait.latchNoExpectations(cancelTokensLatch); + cancelTokensRef.add(3); + throw e; + } + }) + .ensureRunningOnExecutor() + .withCancellation(() -> { + cancelTokensRef.add(1); + cancelTokensLatch.countDown(); + }) + .withCancellation(() -> { + cancelTokensRef.add(2); + cancelTokensLatch.countDown(); + }) + .runAsync(outcome -> { + outcomeRef.set(outcome); + completedLatch.countDown(); + }); + + TimedAwait.latchAndExpectCompletion(startedLatch, "startedLatch"); + runningTask.cancel(); + TimedAwait.latchAndExpectCompletion(completedLatch, "completedLatch"); + assertEquals(Outcome.cancellation(), outcomeRef.get()); + + final var arr = cancelTokensRef.toArray(new Integer[0]); + for (int i = 0; i < arr.length; i++) { + assertEquals(i + 1, arr[i], "cancelTokensRef[" + i + "] should be " + (i + 1)); + } + assertEquals(3, arr.length, "cancelTokensRef should have 3 elements"); + } + } + + @Test + void testTaskWithCancellationAndFibers() throws Exception { + for (int r = 0; r < TestSettings.CONCURRENCY_REPEATS; r++) { + final var cancelTokensRef = new ConcurrentLinkedQueue(); + final var startedLatch = new CountDownLatch(1); + final var taskLatch = new CountDownLatch(1); + final var cancelTokensLatch = new CountDownLatch(2); + + final var fiber = Task + .fromBlockingIO(() -> { + try { + startedLatch.countDown(); + taskLatch.await(); + return "Completed"; + } catch (final InterruptedException e) { + TimedAwait.latchNoExpectations(cancelTokensLatch); + cancelTokensRef.add(3); + throw e; + } + }) + .ensureRunningOnExecutor() + .withCancellation(() -> { + cancelTokensRef.add(1); + cancelTokensLatch.countDown(); + }) + .withCancellation(() -> { + cancelTokensRef.add(2); + cancelTokensLatch.countDown(); + }) + .runFiber(); + + TimedAwait.latchAndExpectCompletion(startedLatch, "startedLatch"); + fiber.cancel(); + TimedAwait.fiberAndExpectCancellation(fiber); + try { + fiber.getResultOrThrow(); + fail("Should have thrown a TaskCancellationException"); + } catch (final TaskCancellationException e) { + // Expected + } + + final var arr = cancelTokensRef.toArray(new Integer[0]); + for (int i = 0; i < arr.length; i++) { + assertEquals(i + 1, arr[i], "cancelTokensRef[" + i + "] should be " + (i + 1)); + } + assertEquals(3, arr.length, "cancelTokensRef should have 3 elements"); + } + } + +} diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskWithOnCompletionTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskWithOnCompletionTest.java new file mode 100644 index 0000000..2cc57ab --- /dev/null +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TaskWithOnCompletionTest.java @@ -0,0 +1,221 @@ +package org.funfix.tasks.jvm; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + +import static org.funfix.tasks.jvm.TestSettings.CONCURRENCY_REPEATS; +import static org.funfix.tasks.jvm.TestSettings.TIMEOUT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +public class TaskWithOnCompletionTest { + @Test + void guaranteeOnSuccess() throws ExecutionException, InterruptedException { + for (int t = 0; t < CONCURRENCY_REPEATS; t++) { + final var ref1 = new AtomicReference<@Nullable Outcome>(null); + final var ref2 = new AtomicReference<@Nullable Outcome>(null); + final var outcome1 = Task + .fromBlockingIO(() -> "Success") + .withOnComplete(ref1::set) + .withOnComplete(ref2::set) + .runBlocking(); + + assertEquals("Success", outcome1); + assertEquals(Outcome.success("Success"), ref1.get()); + assertEquals(Outcome.success("Success"), ref2.get()); + } + } + + @Test + void guaranteeOnSuccessWithFibers() throws ExecutionException, InterruptedException, TaskCancellationException { + for (int t = 0; t < CONCURRENCY_REPEATS; t++) { + final var ref1 = new AtomicReference<@Nullable Outcome>(null); + final var ref2 = new AtomicReference<@Nullable Outcome>(null); + final var fiber = Task + .fromBlockingIO(() -> "Success") + .withOnComplete(ref1::set) + .withOnComplete(ref2::set) + .runFiber(); + + assertEquals("Success", fiber.awaitBlocking()); + assertEquals(Outcome.success("Success"), ref1.get()); + assertEquals(Outcome.success("Success"), ref2.get()); + } + } + + @Test + void guaranteeOnSuccessWithBlockingIO() throws ExecutionException, InterruptedException, TimeoutException { + for (int t = 0; t < CONCURRENCY_REPEATS; t++) { + final var ref1 = new AtomicReference<@Nullable Outcome>(null); + final var ref2 = new AtomicReference<@Nullable Outcome>(null); + final var r = Task + .fromBlockingIO(() -> "Success") + .withOnComplete(ref1::set) + .withOnComplete(ref2::set) + .runBlockingTimed(TIMEOUT); + + assertEquals("Success", r); + assertEquals(Outcome.success("Success"), ref1.get()); + assertEquals(Outcome.success("Success"), ref2.get()); + } + } + + + @Test + void guaranteeOnFailure() throws InterruptedException { + for (int t = 0; t < CONCURRENCY_REPEATS; t++) { + final var ref1 = new AtomicReference<@Nullable Outcome>(null); + final var ref2 = new AtomicReference<@Nullable Outcome>(null); + final var error = new RuntimeException("Failure"); + + try { + Task.fromBlockingIO(() -> { + throw error; + }) + .withOnComplete(ref1::set) + .withOnComplete(ref2::set) + .runBlocking(); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + assertEquals(error, e.getCause()); + } + + assertEquals(Outcome.failure(error), ref1.get()); + assertEquals(Outcome.failure(error), ref2.get()); + } + } + + @Test + void guaranteeOnFailureWithFibers() throws InterruptedException, TaskCancellationException { + for (int t = 0; t < CONCURRENCY_REPEATS; t++) { + final var ref1 = new AtomicReference<@Nullable Outcome>(null); + final var ref2 = new AtomicReference<@Nullable Outcome>(null); + final var error = new RuntimeException("Failure"); + + try { + Task.fromBlockingIO(() -> { + throw error; + }) + .withOnComplete(ref1::set) + .withOnComplete(ref2::set) + .runFiber() + .awaitBlocking(); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + assertEquals(error, e.getCause()); + } + + assertEquals(Outcome.failure(error), ref1.get()); + assertEquals(Outcome.failure(error), ref2.get()); + } + } + + @Test + void guaranteeOnFailureBlockingIO() throws InterruptedException { + for (int t = 0; t < CONCURRENCY_REPEATS; t++) { + final var ref1 = new AtomicReference<@Nullable Outcome>(null); + final var ref2 = new AtomicReference<@Nullable Outcome>(null); + final var error = new RuntimeException("Failure"); + + try { + Task.fromBlockingIO(() -> { + throw error; + }) + .withOnComplete(ref1::set) + .withOnComplete(ref2::set) + .runBlockingTimed(TIMEOUT); + fail("Expected ExecutionException"); + } catch (ExecutionException | TimeoutException e) { + assertEquals(error, e.getCause()); + } + + assertEquals(Outcome.failure(error), ref1.get()); + assertEquals(Outcome.failure(error), ref2.get()); + } + } + + @Test + void guaranteeOnCancellation() throws InterruptedException, ExecutionException, TimeoutException { + for (int t = 0; t < CONCURRENCY_REPEATS; t++) { + final var ref1 = new AtomicReference<@Nullable Outcome>(null); + final var ref2 = new AtomicReference<@Nullable Outcome>(null); + final var latch = new CountDownLatch(1); + + final var task = Task + .fromBlockingIO(() -> { + latch.await(); + return "Should not complete"; + }) + .ensureRunningOnExecutor() + .withOnComplete(ref1::set) + .withOnComplete(ref2::set); + + final var fiber = task.runFiber(); + fiber.cancel(); + + try { + fiber.awaitBlockingTimed(TIMEOUT); + fail("Expected TaskCancellationException"); + } catch (TaskCancellationException e) { + // Expected + } catch (TimeoutException e) { + latch.countDown(); + throw e; + } + + assertEquals(Outcome.cancellation(), ref1.get()); + assertEquals(Outcome.cancellation(), ref2.get()); + } + } + + @Test + void callOrdering() throws InterruptedException { + for (int t = 0; t < CONCURRENCY_REPEATS; t++) { + final var latch = new CountDownLatch(1); + final var ref = new ConcurrentLinkedQueue(); + + Task.fromBlockingIO(() -> 0) + .ensureRunningOnExecutor() + .withOnComplete((ignored) -> ref.add(1)) + .withOnComplete((ignored) -> ref.add(2)) + .runAsync((ignored) -> { + ref.add(3); + latch.countDown(); + }); + + TimedAwait.latchAndExpectCompletion(latch, "latch"); + assertEquals(3, ref.size(), "Expected 3 calls to onCompletion"); + final var arr = ref.toArray(new Integer[0]); + for (int i = 0; i < 3; i++) { + assertEquals(i + 1, arr[i]); + } + } + } + + @Test + void callOrderingViaFibers() throws Exception { + for (int t = 0; t < CONCURRENCY_REPEATS; t++) { + final var ref = new ConcurrentLinkedQueue(); + + final var fiber = Task.fromBlockingIO(() -> 0) + .ensureRunningOnExecutor() + .withOnComplete((ignored) -> ref.add(1)) + .withOnComplete((ignored) -> ref.add(2)) + .runFiber(); + + assertEquals(0, fiber.awaitBlockingTimed(TIMEOUT)); + assertEquals(2, ref.size(), "Expected 2 calls to onCompletion"); + final var arr = ref.toArray(new Integer[0]); + for (int i = 0; i < arr.length; i++) { + assertEquals(i + 1, arr[i]); + } + } + } + +} diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TestSettings.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TestSettings.java new file mode 100644 index 0000000..096418d --- /dev/null +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TestSettings.java @@ -0,0 +1,21 @@ +package org.funfix.tasks.jvm; + +import java.time.Duration; + +public class TestSettings { + public static final Duration TIMEOUT; + public static final int CONCURRENCY_REPEATS; + + static { + if (System.getenv("CI") != null) + TIMEOUT = Duration.ofSeconds(20); + else + TIMEOUT = Duration.ofSeconds(10); + + if (System.getenv("CI") != null) { + CONCURRENCY_REPEATS = 1000; + } else { + CONCURRENCY_REPEATS = 10000; + } + } +} diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TimedAwait.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TimedAwait.java index d9cf37e..8085c86 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TimedAwait.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TimedAwait.java @@ -1,23 +1,12 @@ package org.funfix.tasks.jvm; -import org.jspecify.annotations.NullMarked; - -import java.time.Duration; import java.util.concurrent.*; +import static org.funfix.tasks.jvm.TestSettings.TIMEOUT; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; -@NullMarked public class TimedAwait { - public static Duration TIMEOUT; - - static { - if (System.getenv("CI") != null) - TIMEOUT = Duration.ofSeconds(20); - else - TIMEOUT = Duration.ofSeconds(10); - } - @SuppressWarnings("ResultOfMethodCallIgnored") static void latchNoExpectations(final CountDownLatch latch) throws InterruptedException { latch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); @@ -41,4 +30,17 @@ static void future(final Future future) throws InterruptedException, TimeoutE throw new RuntimeException(e); } } + + static void fiberAndExpectCancellation(final Fiber fiber) + throws InterruptedException { + try { + fiber.awaitBlockingTimed(TIMEOUT); + fail("Fiber should have been cancelled"); + } catch (final TaskCancellationException ignored) { + } catch (final TimeoutException e) { + fail("Fiber should have been cancelled", e); + } catch (final ExecutionException e) { + throw new RuntimeException(e); + } + } } diff --git a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TrampolineTest.java b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TrampolineTest.java index 12d01e1..0ccab35 100644 --- a/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TrampolineTest.java +++ b/tasks-jvm/src/test/java/org/funfix/tasks/jvm/TrampolineTest.java @@ -15,6 +15,17 @@ Runnable recursiveRunnable(int level, int maxLevel, Runnable onComplete) { }; } + @Test + void tasksAreExecutedInFIFOOrder() { + final int[] calls = { 0 }; + Trampoline.execute(() -> { + for (int i = 0; i < 100; i++) { + Trampoline.execute(() -> calls[0]++); + } + }); + assertEquals(100, calls[0]); + } + @Test void testDepth() { final boolean[] wasExecuted = { false }; diff --git a/tasks-scala/build.gradle.kts b/tasks-scala/build.gradle.kts deleted file mode 100644 index 1886ad0..0000000 --- a/tasks-scala/build.gradle.kts +++ /dev/null @@ -1,18 +0,0 @@ - -tasks.register("assemble", Exec::class) { - workingDir(project.projectDir) - - commandLine("./sbt", "package") -} - -tasks.register("executeShellCommand", Exec::class) { - // Set the command to execute. Example: "echo", "Hello, World!" - commandLine("echo", "Hello, World!") - commandLine("echo", project.projectDir) - - // Optionally, set the working directory - workingDir(project.rootDir) - - // Logging the output - standardOutput = System.out -} diff --git a/tasks-scala/build.sbt b/tasks-scala/build.sbt deleted file mode 100644 index bd2ca3f..0000000 --- a/tasks-scala/build.sbt +++ /dev/null @@ -1,68 +0,0 @@ -import Boilerplate.crossVersionSharedSources -import java.io.FileInputStream -import java.util.Properties - -ThisBuild / scalaVersion := "3.3.1" -ThisBuild / crossScalaVersions := Seq("2.13.14", scalaVersion.value) - -ThisBuild / resolvers ++= Seq(Resolver.mavenLocal) - -val publishLocalGradleDependencies = taskKey[Unit]("Builds and publishes gradle dependencies") -val props = settingKey[Properties]("Main project properties") - -ThisBuild / props := { - val projectProperties = new Properties() - val rootDir = (ThisBuild / baseDirectory).value - val fis = new FileInputStream(s"$rootDir/../gradle.properties") - projectProperties.load(fis) - projectProperties -} - -ThisBuild / version := { - val base = props.value.getProperty("project.version") - val isRelease = - sys.env.get("BUILD_RELEASE").filter(_.nonEmpty) - .orElse(Option(System.getProperty("buildRelease"))) - .exists(it => it == "true" || it == "1" || it == "yes" || it == "on") - if (isRelease) base else s"$base-SNAPSHOT" -} - -Global / onChangedBuildSource := ReloadOnSourceChanges - -lazy val root = project - .in(file(".")) - .settings( - publish := {}, - publishLocal := {}, - publishLocalGradleDependencies := { - import scala.sys.process.* - val rootDir = (ThisBuild / baseDirectory).value - val command = Process( - "./gradlew" :: "publishToMavenLocal" :: Nil, - new File(rootDir, ".."), - ) - val log = streams.value.log - val exitCode = command ! log - if (exitCode != 0) { - sys.error(s"Command failed with exit code $exitCode") - } - } - ) - .aggregate(coreJVM, coreJS) - -lazy val core = crossProject(JVMPlatform, JSPlatform) - .crossType(CrossType.Full) - .in(file("core")) - .settings(crossVersionSharedSources) - .settings( - name := "tasks-scala", - ) - .jvmSettings( - libraryDependencies ++= Seq( - "org.funfix" % "tasks-jvm" % version.value - ) - ) - -lazy val coreJVM = core.jvm -lazy val coreJS = core.js - diff --git a/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/CompletionCallback.scala b/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/CompletionCallback.scala deleted file mode 100644 index f4677d1..0000000 --- a/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/CompletionCallback.scala +++ /dev/null @@ -1,22 +0,0 @@ -package org.funfix.tasks.scala - -import org.funfix.tasks.jvm.CompletionCallback as JavaCompletionCallback - -type CompletionCallback[-A] = Outcome[A] => Unit - -extension [A](cb: JavaCompletionCallback[_ >: A]) { - def asScala: CompletionCallback[A] = { - case Outcome.Success(value) => cb.onSuccess(value) - case Outcome.Failure(ex) => cb.onFailure(ex) - case Outcome.Cancellation => cb.onCancellation() - } -} - -extension [A](cb: CompletionCallback[A]) { - def asJava: JavaCompletionCallback[_ >: A] = - new JavaCompletionCallback[A] { - def onSuccess(value: A): Unit = cb(Outcome.Success(value)) - def onFailure(ex: Throwable): Unit = cb(Outcome.Failure(ex)) - def onCancellation(): Unit = cb(Outcome.Cancellation) - } -} diff --git a/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/Task.scala b/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/Task.scala deleted file mode 100644 index e4dce4a..0000000 --- a/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/Task.scala +++ /dev/null @@ -1,24 +0,0 @@ -package org.funfix.tasks.scala - -import org.funfix.tasks.jvm.Task as JavaTask - -opaque type Task[+A] = JavaTask[_ <: A] - -object Task { - def apply[A](underlying: JavaTask[_ <: A]): Task[A] = underlying - - def fromAsync[A](f: (Executor, CompletionCallback[A]) => Cancellable): Task[A] = - Task[A](JavaTask.fromAsync { (ec, cb) => - f(ec, cb.asScala) - }) - - extension [A](task: Task[A]) { - def asPlatform: JavaTask[_ <: A] = task - - def unsafeRunAsync(ec: Executor)(cb: CompletionCallback[A]): Cancellable = - unsafeRunAsync(cb)(using TaskExecutor(ec)) - - def unsafeRunAsync(cb: CompletionCallback[A])(using ec: TaskExecutor): Cancellable = - task.asPlatform.runAsync(ec, cb.asJava) - } -} diff --git a/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/TaskExecutor.scala b/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/TaskExecutor.scala deleted file mode 100644 index 3c795eb..0000000 --- a/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/TaskExecutor.scala +++ /dev/null @@ -1,27 +0,0 @@ -package org.funfix.tasks.scala - -import org.funfix.tasks.jvm.TaskExecutors - -import scala.concurrent.ExecutionContext - -opaque type TaskExecutor <: Executor = Executor - -object TaskExecutor { - def apply(executor: Executor): TaskExecutor = executor - - lazy val compute: TaskExecutor = - TaskExecutor(ExecutionContext.global) - - lazy val blockingIO: TaskExecutor = - TaskExecutor(TaskExecutors.sharedBlockingIO()) - - lazy val trampoline: TaskExecutor = - TaskExecutor(TaskExecutors.trampoline()) - - object Givens { - given compute: TaskExecutor = - TaskExecutor.compute - given blockingIO: TaskExecutor = - TaskExecutor.blockingIO - } -} diff --git a/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/aliases.scala b/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/aliases.scala deleted file mode 100644 index acc7448..0000000 --- a/tasks-scala/core/jvm/src/main/scala-3/org/funfix/tasks/scala/aliases.scala +++ /dev/null @@ -1,4 +0,0 @@ -package org.funfix.tasks.scala - -type Cancellable = org.funfix.tasks.jvm.Cancellable -type Executor = java.util.concurrent.Executor \ No newline at end of file diff --git a/tasks-scala/core/shared/src/main/scala-3/org/funfix/tasks/scala/Outcome.scala b/tasks-scala/core/shared/src/main/scala-3/org/funfix/tasks/scala/Outcome.scala deleted file mode 100644 index bfa2390..0000000 --- a/tasks-scala/core/shared/src/main/scala-3/org/funfix/tasks/scala/Outcome.scala +++ /dev/null @@ -1,7 +0,0 @@ -package org.funfix.tasks.scala - -enum Outcome[+A] { - case Success(value: A) - case Failure(exception: Throwable) - case Cancellation -} diff --git a/tasks-scala/project/Boilerplate.scala b/tasks-scala/project/Boilerplate.scala deleted file mode 100644 index b768a4c..0000000 --- a/tasks-scala/project/Boilerplate.scala +++ /dev/null @@ -1,27 +0,0 @@ -import sbt.* -import sbt.Keys.* - -object Boilerplate { - /** - * For working with Scala version-specific source files, allowing us to - * use 2.13 or 3.x specific APIs. - */ - lazy val crossVersionSharedSources: Seq[Setting[?]] = { - def scalaPartV = Def setting (CrossVersion partialVersion scalaVersion.value) - Seq(Compile, Test).map { sc => - (sc / unmanagedSourceDirectories) ++= { - (sc / unmanagedSourceDirectories).value - .filterNot(_.getPath.matches("^.*\\d+$")) - .flatMap { dir => - Seq( - scalaPartV.value match { - case Some((2, _)) => Seq(new File(dir.getPath + "-2")) - case Some((3, _)) => Seq(new File(dir.getPath + "-3")) - case _ => Nil - }, - ).flatten - } - } - } - } -} diff --git a/tasks-scala/project/build.properties b/tasks-scala/project/build.properties deleted file mode 100644 index 081fdbb..0000000 --- a/tasks-scala/project/build.properties +++ /dev/null @@ -1 +0,0 @@ -sbt.version=1.10.0 diff --git a/tasks-scala/project/plugins.sbt b/tasks-scala/project/plugins.sbt deleted file mode 100644 index 8a47437..0000000 --- a/tasks-scala/project/plugins.sbt +++ /dev/null @@ -1,4 +0,0 @@ -addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2") -addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") -addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.4") diff --git a/tasks-scala/sbt b/tasks-scala/sbt deleted file mode 100755 index 7d3f707..0000000 --- a/tasks-scala/sbt +++ /dev/null @@ -1,818 +0,0 @@ -#!/usr/bin/env bash - -set +e -declare builtin_sbt_version="1.10.0" -declare -a residual_args -declare -a java_args -declare -a scalac_args -declare -a sbt_commands -declare -a sbt_options -declare -a print_version -declare -a print_sbt_version -declare -a print_sbt_script_version -declare -a shutdownall -declare -a original_args -declare java_cmd=java -declare java_version -declare init_sbt_version=_to_be_replaced -declare sbt_default_mem=1024 -declare -r default_sbt_opts="" -declare -r default_java_opts="-Dfile.encoding=UTF-8" -declare sbt_verbose= -declare sbt_debug= -declare build_props_sbt_version= -declare use_sbtn= -declare no_server= -declare sbtn_command="$SBTN_CMD" -declare sbtn_version="1.10.0" - -### ------------------------------- ### -### Helper methods for BASH scripts ### -### ------------------------------- ### - -# Bash reimplementation of realpath to return the absolute path -realpathish () { -( - TARGET_FILE="$1" - FIX_CYGPATH="$2" - - cd "$(dirname "$TARGET_FILE")" - TARGET_FILE=$(basename "$TARGET_FILE") - - COUNT=0 - while [ -L "$TARGET_FILE" -a $COUNT -lt 100 ] - do - TARGET_FILE=$(readlink "$TARGET_FILE") - cd "$(dirname "$TARGET_FILE")" - TARGET_FILE=$(basename "$TARGET_FILE") - COUNT=$(($COUNT + 1)) - done - - TARGET_DIR="$(pwd -P)" - if [ "$TARGET_DIR" == "/" ]; then - TARGET_FILE="/$TARGET_FILE" - else - TARGET_FILE="$TARGET_DIR/$TARGET_FILE" - fi - - # make sure we grab the actual windows path, instead of cygwin's path. - if [[ "x$FIX_CYGPATH" != "x" ]]; then - echo "$(cygwinpath "$TARGET_FILE")" - else - echo "$TARGET_FILE" - fi -) -} - -# Uses uname to detect if we're in the odd cygwin environment. -is_cygwin() { - local os=$(uname -s) - case "$os" in - CYGWIN*) return 0 ;; - MINGW*) return 0 ;; - MSYS*) return 0 ;; - *) return 1 ;; - esac -} - -# TODO - Use nicer bash-isms here. -CYGWIN_FLAG=$(if is_cygwin; then echo true; else echo false; fi) - -# This can fix cygwin style /cygdrive paths so we get the -# windows style paths. -cygwinpath() { - local file="$1" - if [[ "$CYGWIN_FLAG" == "true" ]]; then #" - echo $(cygpath -w $file) - else - echo $file - fi -} - - -declare -r sbt_bin_dir="$(dirname "$(realpathish "$0")")" -declare -r sbt_home="$(dirname "$sbt_bin_dir")" - -echoerr () { - echo 1>&2 "$@" -} -vlog () { - [[ $sbt_verbose || $sbt_debug ]] && echoerr "$@" -} -dlog () { - [[ $sbt_debug ]] && echoerr "$@" -} - -jar_file () { - echo "$(cygwinpath "${sbt_home}/bin/sbt-launch.jar")" -} - -jar_url () { - local repo_base="$SBT_LAUNCH_REPO" - if [[ $repo_base == "" ]]; then - repo_base="https://repo1.maven.org/maven2" - fi - echo "$repo_base/org/scala-sbt/sbt-launch/$1/sbt-launch-$1.jar" -} - -download_url () { - local url="$1" - local jar="$2" - mkdir -p $(dirname "$jar") && { - if command -v curl > /dev/null; then - curl --silent -L "$url" --output "$jar" - elif command -v wget > /dev/null; then - wget --quiet -O "$jar" "$url" - fi - } && [[ -f "$jar" ]] -} - -acquire_sbt_jar () { - local launcher_sv="$1" - if [[ "$launcher_sv" == "" ]]; then - if [[ "$init_sbt_version" != "_to_be_replaced" ]]; then - launcher_sv="$init_sbt_version" - else - launcher_sv="$builtin_sbt_version" - fi - fi - local user_home && user_home=$(findProperty user.home) - download_jar="${user_home:-$HOME}/.cache/sbt/boot/sbt-launch/$launcher_sv/sbt-launch-$launcher_sv.jar" - if [[ -f "$download_jar" ]]; then - sbt_jar="$download_jar" - else - sbt_url=$(jar_url "$launcher_sv") - echoerr "downloading sbt launcher $launcher_sv" - download_url "$sbt_url" "${download_jar}.temp" - download_url "${sbt_url}.sha1" "${download_jar}.sha1" - if command -v shasum > /dev/null; then - if echo "$(cat "${download_jar}.sha1") ${download_jar}.temp" | shasum -c - > /dev/null; then - mv "${download_jar}.temp" "${download_jar}" - else - echoerr "failed to download launcher jar: $sbt_url (shasum mismatch)" - exit 2 - fi - else - mv "${download_jar}.temp" "${download_jar}" - fi - if [[ -f "$download_jar" ]]; then - sbt_jar="$download_jar" - else - echoerr "failed to download launcher jar: $sbt_url" - exit 2 - fi - fi -} - -acquire_sbtn () { - local sbtn_v="$1" - local user_home && user_home=$(findProperty user.home) - local p="${user_home:-$HOME}/.cache/sbt/boot/sbtn/$sbtn_v" - local target="$p/sbtn" - local archive_target= - local url= - local arch="x86_64" - if [[ "$OSTYPE" == "linux-gnu"* ]]; then - arch=$(uname -m) - if [[ "$arch" == "aarch64" ]] || [[ "$arch" == "x86_64" ]]; then - archive_target="$p/sbtn-${arch}-pc-linux-${sbtn_v}.tar.gz" - url="https://github.com/sbt/sbtn-dist/releases/download/v${sbtn_v}/sbtn-${arch}-pc-linux-${sbtn_v}.tar.gz" - else - echoerr "sbtn is not supported on $arch" - exit 2 - fi - elif [[ "$OSTYPE" == "darwin"* ]]; then - archive_target="$p/sbtn-universal-apple-darwin-${sbtn_v}.tar.gz" - url="https://github.com/sbt/sbtn-dist/releases/download/v${sbtn_v}/sbtn-universal-apple-darwin-${sbtn_v}.tar.gz" - elif [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then - target="$p/sbtn.exe" - archive_target="$p/sbtn-x86_64-pc-win32-${sbtn_v}.zip" - url="https://github.com/sbt/sbtn-dist/releases/download/v${sbtn_v}/sbtn-x86_64-pc-win32-${sbtn_v}.zip" - else - echoerr "sbtn is not supported on $OSTYPE" - exit 2 - fi - - if [[ -f "$target" ]]; then - sbtn_command="$target" - else - echoerr "downloading sbtn ${sbtn_v} for ${arch}" - download_url "$url" "$archive_target" - if [[ "$OSTYPE" == "linux-gnu"* ]] || [[ "$OSTYPE" == "darwin"* ]]; then - tar zxf "$archive_target" --directory "$p" - else - unzip "$archive_target" -d "$p" - fi - sbtn_command="$target" - fi -} - -# execRunner should be called only once to give up control to java -execRunner () { - # print the arguments one to a line, quoting any containing spaces - [[ $sbt_verbose || $sbt_debug ]] && echo "# Executing command line:" && { - for arg; do - if printf "%s\n" "$arg" | grep -q ' '; then - printf "\"%s\"\n" "$arg" - else - printf "%s\n" "$arg" - fi - done - echo "" - } - - if [[ "$CYGWIN_FLAG" == "true" ]]; then - # In cygwin we loose the ability to re-hook stty if exec is used - # https://github.com/sbt/sbt-launcher-package/issues/53 - "$@" - else - exec "$@" - fi -} - -addJava () { - dlog "[addJava] arg = '$1'" - java_args=( "${java_args[@]}" "$1" ) -} -addSbt () { - dlog "[addSbt] arg = '$1'" - sbt_commands=( "${sbt_commands[@]}" "$1" ) -} -addResidual () { - dlog "[residual] arg = '$1'" - residual_args=( "${residual_args[@]}" "$1" ) -} -addDebugger () { - addJava "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=$1" -} - -addMemory () { - dlog "[addMemory] arg = '$1'" - # evict memory related options - local xs=("${java_args[@]}") - java_args=() - for i in "${xs[@]}"; do - if ! [[ "${i}" == *-Xmx* ]] && ! [[ "${i}" == *-Xms* ]] && ! [[ "${i}" == *-Xss* ]] && ! [[ "${i}" == *-XX:MaxPermSize* ]] && ! [[ "${i}" == *-XX:MaxMetaspaceSize* ]] && ! [[ "${i}" == *-XX:ReservedCodeCacheSize* ]]; then - java_args+=("${i}") - fi - done - local ys=("${sbt_options[@]}") - sbt_options=() - for i in "${ys[@]}"; do - if ! [[ "${i}" == *-Xmx* ]] && ! [[ "${i}" == *-Xms* ]] && ! [[ "${i}" == *-Xss* ]] && ! [[ "${i}" == *-XX:MaxPermSize* ]] && ! [[ "${i}" == *-XX:MaxMetaspaceSize* ]] && ! [[ "${i}" == *-XX:ReservedCodeCacheSize* ]]; then - sbt_options+=("${i}") - fi - done - # a ham-fisted attempt to move some memory settings in concert - local mem=$1 - local codecache=$(( $mem / 8 )) - (( $codecache > 128 )) || codecache=128 - (( $codecache < 512 )) || codecache=512 - local class_metadata_size=$(( $codecache * 2 )) - if [[ -z $java_version ]]; then - java_version=$(jdk_version) - fi - - addJava "-Xms${mem}m" - addJava "-Xmx${mem}m" - addJava "-Xss4M" - addJava "-XX:ReservedCodeCacheSize=${codecache}m" - (( $java_version >= 8 )) || addJava "-XX:MaxPermSize=${class_metadata_size}m" -} - -addDefaultMemory() { - # if we detect any of these settings in ${JAVA_OPTS} or ${JAVA_TOOL_OPTIONS} we need to NOT output our settings. - # The reason is the Xms/Xmx, if they don't line up, cause errors. - if [[ "${java_args[@]}" == *-Xmx* ]] || \ - [[ "${java_args[@]}" == *-Xms* ]] || \ - [[ "${java_args[@]}" == *-Xss* ]] || \ - [[ "${java_args[@]}" == *-XX:+UseCGroupMemoryLimitForHeap* ]] || \ - [[ "${java_args[@]}" == *-XX:MaxRAM* ]] || \ - [[ "${java_args[@]}" == *-XX:InitialRAMPercentage* ]] || \ - [[ "${java_args[@]}" == *-XX:MaxRAMPercentage* ]] || \ - [[ "${java_args[@]}" == *-XX:MinRAMPercentage* ]]; then - : - elif [[ "${JAVA_TOOL_OPTIONS}" == *-Xmx* ]] || \ - [[ "${JAVA_TOOL_OPTIONS}" == *-Xms* ]] || \ - [[ "${JAVA_TOOL_OPTIONS}" == *-Xss* ]] || \ - [[ "${JAVA_TOOL_OPTIONS}" == *-XX:+UseCGroupMemoryLimitForHeap* ]] || \ - [[ "${JAVA_TOOL_OPTIONS}" == *-XX:MaxRAM* ]] || \ - [[ "${JAVA_TOOL_OPTIONS}" == *-XX:InitialRAMPercentage* ]] || \ - [[ "${JAVA_TOOL_OPTIONS}" == *-XX:MaxRAMPercentage* ]] || \ - [[ "${JAVA_TOOL_OPTIONS}" == *-XX:MinRAMPercentage* ]] ; then - : - elif [[ "${sbt_options[@]}" == *-Xmx* ]] || \ - [[ "${sbt_options[@]}" == *-Xms* ]] || \ - [[ "${sbt_options[@]}" == *-Xss* ]] || \ - [[ "${sbt_options[@]}" == *-XX:+UseCGroupMemoryLimitForHeap* ]] || \ - [[ "${sbt_options[@]}" == *-XX:MaxRAM* ]] || \ - [[ "${sbt_options[@]}" == *-XX:InitialRAMPercentage* ]] || \ - [[ "${sbt_options[@]}" == *-XX:MaxRAMPercentage* ]] || \ - [[ "${sbt_options[@]}" == *-XX:MinRAMPercentage* ]] ; then - : - else - addMemory $sbt_default_mem - fi -} - -addSbtScriptProperty () { - if [[ "${java_args[@]}" == *-Dsbt.script=* ]]; then - : - else - sbt_script=$0 - # Use // to replace all spaces with %20. - sbt_script=${sbt_script// /%20} - addJava "-Dsbt.script=$sbt_script" - fi -} - -require_arg () { - local type="$1" - local opt="$2" - local arg="$3" - if [[ -z "$arg" ]] || [[ "${arg:0:1}" == "-" ]]; then - echo "$opt requires <$type> argument" - exit 1 - fi -} - -is_function_defined() { - declare -f "$1" > /dev/null -} - -# parses JDK version from the -version output line. -# 8 for 1.8.0_nn, 9 for 9-ea etc, and "no_java" for undetected -jdk_version() { - local result - local lines=$("$java_cmd" -Xms32M -Xmx32M -version 2>&1 | tr '\r' '\n') - local IFS=$'\n' - for line in $lines; do - if [[ (-z $result) && ($line = *"version \""*) ]] - then - local ver=$(echo $line | sed -e 's/.*version "\(.*\)"\(.*\)/\1/; 1q') - # on macOS sed doesn't support '?' - if [[ $ver = "1."* ]] - then - result=$(echo $ver | sed -e 's/1\.\([0-9]*\)\(.*\)/\1/; 1q') - else - result=$(echo $ver | sed -e 's/\([0-9]*\)\(.*\)/\1/; 1q') - fi - fi - done - if [[ -z $result ]] - then - result=no_java - fi - echo "$result" -} - -# Find the first occurrence of the given property name and returns its value by looking at: -# - properties set by command-line options, -# - JAVA_OPTS environment variable, -# - SBT_OPTS environment variable, -# - _JAVA_OPTIONS environment variable and -# - JAVA_TOOL_OPTIONS environment variable -# in that order. -findProperty() { - local -a java_opts_array - local -a sbt_opts_array - local -a _java_options_array - local -a java_tool_options_array - read -a java_opts_array <<< "$JAVA_OPTS" - read -a sbt_opts_array <<< "$SBT_OPTS" - read -a _java_options_array <<< "$_JAVA_OPTIONS" - read -a java_tool_options_array <<< "$JAVA_TOOL_OPTIONS" - - local args_to_check=( - "${java_args[@]}" - "${java_opts_array[@]}" - "${sbt_opts_array[@]}" - "${_java_options_array[@]}" - "${java_tool_options_array[@]}") - - for opt in "${args_to_check[@]}"; do - if [[ "$opt" == -D$1=* ]]; then - echo "${opt#-D$1=}" - return - fi - done -} - -# Extracts the preloaded directory from either -Dsbt.preloaded, -Dsbt.global.base or -Duser.home -# in that order. -getPreloaded() { - local preloaded && preloaded=$(findProperty sbt.preloaded) - [ "$preloaded" ] && echo "$preloaded" && return - - local global_base && global_base=$(findProperty sbt.global.base) - [ "$global_base" ] && echo "$global_base/preloaded" && return - - local user_home && user_home=$(findProperty user.home) - echo "${user_home:-$HOME}/.sbt/preloaded" -} - -syncPreloaded() { - local source_preloaded="$sbt_home/lib/local-preloaded/" - local target_preloaded="$(getPreloaded)" - if [[ "$init_sbt_version" == "" ]]; then - # FIXME: better $init_sbt_version detection - init_sbt_version="$(ls -1 "$source_preloaded/org/scala-sbt/sbt/")" - fi - [[ -f "$target_preloaded/org/scala-sbt/sbt/$init_sbt_version/" ]] || { - # lib/local-preloaded exists (This is optional) - [[ -d "$source_preloaded" ]] && { - command -v rsync >/dev/null 2>&1 && { - mkdir -p "$target_preloaded" - rsync --recursive --links --perms --times --ignore-existing "$source_preloaded" "$target_preloaded" || true - } - } - } -} - -# Detect that we have java installed. -checkJava() { - local required_version="$1" - # Now check to see if it's a good enough version - local good_enough="$(expr $java_version ">=" $required_version)" - if [[ "$java_version" == "" ]]; then - echo - echo "No Java Development Kit (JDK) installation was detected." - echo Please go to http://www.oracle.com/technetwork/java/javase/downloads/ and download. - echo - exit 1 - elif [[ "$good_enough" != "1" ]]; then - echo - echo "The Java Development Kit (JDK) installation you have is not up to date." - echo $script_name requires at least version $required_version+, you have - echo version $java_version - echo - echo Please go to http://www.oracle.com/technetwork/java/javase/downloads/ and download - echo a valid JDK and install before running $script_name. - echo - exit 1 - fi -} - -copyRt() { - local at_least_9="$(expr $java_version ">=" 9)" - if [[ "$at_least_9" == "1" ]]; then - # The grep for java9-rt-ext- matches the filename prefix printed in Export.java - java9_ext=$("$java_cmd" "${sbt_options[@]}" "${java_args[@]}" \ - -jar "$sbt_jar" --rt-ext-dir | grep java9-rt-ext- | tr -d '\r') - java9_rt=$(echo "$java9_ext/rt.jar") - vlog "[copyRt] java9_rt = '$java9_rt'" - if [[ ! -f "$java9_rt" ]]; then - echo copying runtime jar... - mkdir -p "$java9_ext" - "$java_cmd" \ - "${sbt_options[@]}" \ - "${java_args[@]}" \ - -jar "$sbt_jar" \ - --export-rt \ - "${java9_rt}" - fi - addJava "-Dscala.ext.dirs=${java9_ext}" - fi -} - -run() { - # Copy preloaded repo to user's preloaded directory - syncPreloaded - - # no jar? download it. - [[ -f "$sbt_jar" ]] || acquire_sbt_jar "$sbt_version" || { - exit 1 - } - - # TODO - java check should be configurable... - checkJava "6" - - # Java 9 support - copyRt - - # If we're in cygwin, we should use the windows config, and terminal hacks - if [[ "$CYGWIN_FLAG" == "true" ]]; then #" - stty -icanon min 1 -echo > /dev/null 2>&1 - addJava "-Djline.terminal=jline.UnixTerminal" - addJava "-Dsbt.cygwin=true" - fi - - if [[ $print_sbt_version ]]; then - execRunner "$java_cmd" -jar "$sbt_jar" "sbtVersion" | tail -1 | sed -e 's/\[info\]//g' - elif [[ $print_sbt_script_version ]]; then - echo "$init_sbt_version" - elif [[ $print_version ]]; then - execRunner "$java_cmd" -jar "$sbt_jar" "sbtVersion" | tail -1 | sed -e 's/\[info\]/sbt version in this project:/g' - echo "sbt script version: $init_sbt_version" - elif [[ $shutdownall ]]; then - local sbt_processes=( $(jps -v | grep sbt-launch | cut -f1 -d ' ') ) - for procId in "${sbt_processes[@]}"; do - kill -9 $procId - done - echo "shutdown ${#sbt_processes[@]} sbt processes" - else - # run sbt - execRunner "$java_cmd" \ - "${java_args[@]}" \ - "${sbt_options[@]}" \ - "${java_tool_options[@]}" \ - -jar "$sbt_jar" \ - "${sbt_commands[@]}" \ - "${residual_args[@]}" - fi - - exit_code=$? - - # Clean up the terminal from cygwin hacks. - if [[ "$CYGWIN_FLAG" == "true" ]]; then #" - stty icanon echo > /dev/null 2>&1 - fi - exit $exit_code -} - -declare -ra noshare_opts=(-Dsbt.global.base=project/.sbtboot -Dsbt.boot.directory=project/.boot -Dsbt.ivy.home=project/.ivy) -declare -r sbt_opts_file=".sbtopts" -declare -r build_props_file="$(pwd)/project/build.properties" -declare -r etc_sbt_opts_file="/etc/sbt/sbtopts" -# this allows /etc/sbt/sbtopts location to be changed -declare -r etc_file="${SBT_ETC_FILE:-$etc_sbt_opts_file}" -declare -r dist_sbt_opts_file="${sbt_home}/conf/sbtopts" -declare -r win_sbt_opts_file="${sbt_home}/conf/sbtconfig.txt" -declare sbt_jar="$(jar_file)" - -usage() { - cat < path to global settings/plugins directory (default: ~/.sbt) - --sbt-boot path to shared boot directory (default: ~/.sbt/boot in 0.11 series) - --sbt-cache path to global cache directory (default: operating system specific) - --ivy path to local Ivy repository (default: ~/.ivy2) - --mem set memory options (default: $sbt_default_mem) - --no-share use all local caches; no sharing - --no-global uses global caches, but does not use global ~/.sbt directory. - --jvm-debug Turn on JVM debugging, open at the given port. - --batch disable interactive mode - - # sbt version (default: from project/build.properties if present, else latest release) - --sbt-version use the specified version of sbt - --sbt-jar use the specified jar as the sbt launcher - - --java-home alternate JAVA_HOME - - # jvm options and output control - JAVA_OPTS environment variable, if unset uses "$default_java_opts" - .jvmopts if this file exists in the current directory, its contents - are appended to JAVA_OPTS - SBT_OPTS environment variable, if unset uses "$default_sbt_opts" - .sbtopts if this file exists in the current directory, its contents - are prepended to the runner args - /etc/sbt/sbtopts if this file exists, it is prepended to the runner args - -Dkey=val pass -Dkey=val directly to the java runtime - -J-X pass option -X directly to the java runtime - (-J is stripped) - -In the case of duplicated or conflicting options, the order above -shows precedence: JAVA_OPTS lowest, command line options highest. -EOM -} - -process_my_args () { - while [[ $# -gt 0 ]]; do - case "$1" in - -batch|--batch) exec - - -sbt-create|--sbt-create) sbt_create=true && shift ;; - - new) sbt_new=true && addResidual "$1" && shift ;; - - *) addResidual "$1" && shift ;; - esac - done - - # Now, ensure sbt version is used. - [[ "${sbt_version}XXX" != "XXX" ]] && addJava "-Dsbt.version=$sbt_version" - - # Confirm a user's intent if the current directory does not look like an sbt - # top-level directory and neither the -sbt-create option nor the "new" - # command was given. - [[ -f ./build.sbt || -d ./project || -n "$sbt_create" || -n "$sbt_new" ]] || { - echo "[warn] Neither build.sbt nor a 'project' directory in the current directory: $(pwd)" - while true; do - echo 'c) continue' - echo 'q) quit' - - read -p '? ' || exit 1 - case "$REPLY" in - c|C) break ;; - q|Q) exit 1 ;; - esac - done - } -} - -## map over argument array. this is used to process both command line arguments and SBT_OPTS -map_args () { - local options=() - local commands=() - while [[ $# -gt 0 ]]; do - case "$1" in - -no-colors|--no-colors) options=( "${options[@]}" "-Dsbt.log.noformat=true" ) && shift ;; - -timings|--timings) options=( "${options[@]}" "-Dsbt.task.timings=true" "-Dsbt.task.timings.on.shutdown=true" ) && shift ;; - -traces|--traces) options=( "${options[@]}" "-Dsbt.traces=true" ) && shift ;; - --supershell=*) options=( "${options[@]}" "-Dsbt.supershell=${1:13}" ) && shift ;; - -supershell=*) options=( "${options[@]}" "-Dsbt.supershell=${1:12}" ) && shift ;; - -no-server|--no-server) options=( "${options[@]}" "-Dsbt.io.virtual=false" "-Dsbt.server.autostart=false" ) && shift ;; - --color=*) options=( "${options[@]}" "-Dsbt.color=${1:8}" ) && shift ;; - -color=*) options=( "${options[@]}" "-Dsbt.color=${1:7}" ) && shift ;; - -no-share|--no-share) options=( "${options[@]}" "${noshare_opts[@]}" ) && shift ;; - -no-global|--no-global) options=( "${options[@]}" "-Dsbt.global.base=$(pwd)/project/.sbtboot" ) && shift ;; - -ivy|--ivy) require_arg path "$1" "$2" && options=( "${options[@]}" "-Dsbt.ivy.home=$2" ) && shift 2 ;; - -sbt-boot|--sbt-boot) require_arg path "$1" "$2" && options=( "${options[@]}" "-Dsbt.boot.directory=$2" ) && shift 2 ;; - -sbt-dir|--sbt-dir) require_arg path "$1" "$2" && options=( "${options[@]}" "-Dsbt.global.base=$2" ) && shift 2 ;; - -debug|--debug) commands=( "${commands[@]}" "-debug" ) && shift ;; - -debug-inc|--debug-inc) options=( "${options[@]}" "-Dxsbt.inc.debug=true" ) && shift ;; - *) options=( "${options[@]}" "$1" ) && shift ;; - esac - done - declare -p options - declare -p commands -} - -process_args () { - while [[ $# -gt 0 ]]; do - case "$1" in - -h|-help|--help) usage; exit 1 ;; - -v|-verbose|--verbose) sbt_verbose=1 && shift ;; - -V|-version|--version) print_version=1 && shift ;; - --numeric-version) print_sbt_version=1 && shift ;; - --script-version) print_sbt_script_version=1 && shift ;; - shutdownall) shutdownall=1 && shift ;; - -d|-debug|--debug) sbt_debug=1 && addSbt "-debug" && shift ;; - -client|--client) use_sbtn=1 && shift ;; - --server) use_sbtn=0 && shift ;; - - -mem|--mem) require_arg integer "$1" "$2" && addMemory "$2" && shift 2 ;; - -jvm-debug|--jvm-debug) require_arg port "$1" "$2" && addDebugger $2 && shift 2 ;; - -batch|--batch) exec = 2 )) || ( (( $sbtBinaryV_1 >= 1 )) && (( $sbtBinaryV_2 >= 4 )) ); then - if [[ "$use_sbtn" == "1" ]]; then - echo "true" - else - echo "false" - fi - else - echo "false" - fi -} - -runNativeClient() { - vlog "[debug] running native client" - detectNativeClient - [[ -f "$sbtn_command" ]] || acquire_sbtn "$sbtn_version" || { - exit 1 - } - for i in "${!original_args[@]}"; do - if [[ "${original_args[i]}" = "--client" ]]; then - unset 'original_args[i]' - fi - done - sbt_script=$0 - sbt_script=${sbt_script/ /%20} - execRunner "$sbtn_command" "--sbt-script=$sbt_script" "${original_args[@]}" -} - -original_args=("$@") - -# Here we pull in the default settings configuration. -[[ -f "$dist_sbt_opts_file" ]] && set -- $(loadConfigFile "$dist_sbt_opts_file") "$@" - -# Here we pull in the global settings configuration. -[[ -f "$etc_file" ]] && set -- $(loadConfigFile "$etc_file") "$@" - -# Pull in the project-level config file, if it exists. -[[ -f "$sbt_opts_file" ]] && set -- $(loadConfigFile "$sbt_opts_file") "$@" - -# Pull in the project-level java config, if it exists. -[[ -f ".jvmopts" ]] && export JAVA_OPTS="$JAVA_OPTS $(loadConfigFile .jvmopts)" - -# Pull in default JAVA_OPTS -[[ -z "${JAVA_OPTS// }" ]] && export JAVA_OPTS="$default_java_opts" - -[[ -f "$build_props_file" ]] && loadPropFile "$build_props_file" - -java_args=($JAVA_OPTS) -sbt_options0=(${SBT_OPTS:-$default_sbt_opts}) -java_tool_options=($JAVA_TOOL_OPTIONS) -if [[ "$SBT_NATIVE_CLIENT" == "true" ]]; then - use_sbtn=1 -fi - -# Split SBT_OPTS into options/commands -miniscript=$(map_args "${sbt_options0[@]}") && eval "${miniscript/options/sbt_options}" && \ -eval "${miniscript/commands/sbt_additional_commands}" - -# Combine command line options/commands and commands from SBT_OPTS -miniscript=$(map_args "$@") && eval "${miniscript/options/cli_options}" && eval "${miniscript/commands/cli_commands}" -args1=( "${cli_options[@]}" "${cli_commands[@]}" "${sbt_additional_commands[@]}" ) - -# process the combined args, then reset "$@" to the residuals -process_args "${args1[@]}" -vlog "[sbt_options] $(declare -p sbt_options)" - -if [[ "$(isRunNativeClient)" == "true" ]]; then - set -- "${residual_args[@]}" - argumentCount=$# - runNativeClient -else - java_version="$(jdk_version)" - vlog "[process_args] java_version = '$java_version'" - addDefaultMemory - addSbtScriptProperty - set -- "${residual_args[@]}" - argumentCount=$# - run -fi From b6397ceb8e544abe08da07ef52555d80d2f0fa9f Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 15:58:02 +0200 Subject: [PATCH 03/21] buildSrc: add shared dependencyUpdates precompiled plugin and expose plugin deps --- build.gradle.kts | 2 +- .../src/main/kotlin/tasks.versions.gradle.kts | 24 +++++++++++++++++++ gradle/libs.versions.toml | 4 ++-- tasks-jvm/build.gradle.kts | 2 +- 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 buildSrc/src/main/kotlin/tasks.versions.gradle.kts diff --git a/build.gradle.kts b/build.gradle.kts index 40d3151..48cfa20 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,7 +13,7 @@ repositories { buildscript { dependencies { - classpath("org.jetbrains.dokka:dokka-base:2.0.0") + classpath("org.jetbrains.dokka:dokka-base:2.1.0") // classpath("org.jetbrains.dokka:kotlin-as-java-plugin:2.0.0") } } diff --git a/buildSrc/src/main/kotlin/tasks.versions.gradle.kts b/buildSrc/src/main/kotlin/tasks.versions.gradle.kts new file mode 100644 index 0000000..2dc36a2 --- /dev/null +++ b/buildSrc/src/main/kotlin/tasks.versions.gradle.kts @@ -0,0 +1,24 @@ +import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask + +plugins { + id("com.github.ben-manes.versions") +} + +// Configure the plugin's task with shared defaults for all projects that apply this precompiled plugin +tasks.named("dependencyUpdates").configure { + fun isNonStable(version: String): Boolean { + val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } + val regex = "^[0-9,.v-]+(-r)?$".toRegex() + val isStable = stableKeyword || regex.matches(version) + return isStable.not() + } + + rejectVersionIf { + isNonStable(candidate.version) && !isNonStable(currentVersion) + } + + checkForGradleUpdate = true + outputFormatter = "html" + outputDir = "build/dependencyUpdates" + reportfileName = "report" +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 44521ee..67b6983 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] binary-compatibility-validator = "0.16.2" dokka = "2.1.0" -errorprone = "2.41.0" -errorprone-nullaway = "0.12.8" +errorprone = "2.46.0" +errorprone-nullaway = "0.13.1" errorprone-plugin = "4.3.0" jetbrains-annotations = "26.0.2" jspecify = "1.0.0" diff --git a/tasks-jvm/build.gradle.kts b/tasks-jvm/build.gradle.kts index 0076d9e..ae7e4c6 100644 --- a/tasks-jvm/build.gradle.kts +++ b/tasks-jvm/build.gradle.kts @@ -20,7 +20,7 @@ dependencies { compileOnly(libs.jetbrains.annotations) - testImplementation(platform("org.junit:junit-bom:5.12.1")) + testImplementation(platform("org.junit:junit-bom:6.0.2")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } From f2eed9439de1faab0ed9cb281321a0472157aca3 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 16:00:03 +0200 Subject: [PATCH 04/21] Integrate dependency updates --- tasks-jvm/build.gradle.kts | 1 + tasks-kotlin-coroutines/build.gradle.kts | 1 + tasks-kotlin/build.gradle.kts | 1 + 3 files changed, 3 insertions(+) diff --git a/tasks-jvm/build.gradle.kts b/tasks-jvm/build.gradle.kts index ae7e4c6..379fb1f 100644 --- a/tasks-jvm/build.gradle.kts +++ b/tasks-jvm/build.gradle.kts @@ -3,6 +3,7 @@ import net.ltgt.gradle.errorprone.errorprone plugins { id("tasks.java-project") + id("tasks.versions") } mavenPublishing { diff --git a/tasks-kotlin-coroutines/build.gradle.kts b/tasks-kotlin-coroutines/build.gradle.kts index 808d7fb..1175a8a 100644 --- a/tasks-kotlin-coroutines/build.gradle.kts +++ b/tasks-kotlin-coroutines/build.gradle.kts @@ -5,6 +5,7 @@ import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode plugins { id("tasks.kmp-project") + id("tasks.versions") } mavenPublishing { diff --git a/tasks-kotlin/build.gradle.kts b/tasks-kotlin/build.gradle.kts index 40a4518..0873570 100644 --- a/tasks-kotlin/build.gradle.kts +++ b/tasks-kotlin/build.gradle.kts @@ -5,6 +5,7 @@ import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode plugins { id("tasks.kmp-project") + id("tasks.versions") } mavenPublishing { From 6d8940dcc9c0d0e3e6d6ccccf9e9e112e6c6eae1 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 16:02:35 +0200 Subject: [PATCH 05/21] Update gradle --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ca025c8..37f78a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From a81844edfff13455aba36e9ae37790c42d7d1205 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 16:04:38 +0200 Subject: [PATCH 06/21] Maybe fix dokka --- build.gradle.kts | 6 ++++-- buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 48cfa20..d032896 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,8 +25,10 @@ buildscript { // } //} -tasks.dokkaHtmlMultiModule { - outputDirectory.set(file("build/dokka")) +dokka { + dokkaPublications.html { + outputDirectory.set(file("build/dokka")) + } } tasks.named("dependencyUpdates").configure { diff --git a/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts b/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts index 3108280..ded1750 100644 --- a/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts +++ b/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts @@ -11,18 +11,20 @@ plugins { val dokkaOutputDir = layout.buildDirectory.dir("dokka").get().asFile -tasks.dokkaHtml { - outputDirectory.set(dokkaOutputDir) +dokka { + dokkaPublications.html { + outputDirectory.set(dokkaOutputDir) + } } val deleteDokkaOutputDir by tasks.register("deleteDokkaOutputDirectory") { delete(dokkaOutputDir) } -val javadocJar = tasks.create("javadocJar") { +val javadocJar = tasks.register("javadocJar") { archiveClassifier.set("javadoc") duplicatesStrategy = DuplicatesStrategy.EXCLUDE - dependsOn(deleteDokkaOutputDir, tasks.dokkaHtml) + dependsOn(deleteDokkaOutputDir, tasks.named("dokkaGeneratePublicationHtml")) from(dokkaOutputDir) } From 8580d4df1f61e2c35f72eefd2bf13e9c9d9eaa2c Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 16:10:44 +0200 Subject: [PATCH 07/21] Remove errorprone from project --- buildSrc/build.gradle.kts | 2 +- .../src/main/kotlin/tasks.java-project.gradle.kts | 1 - gradle/libs.versions.toml | 6 ------ tasks-jvm/build.gradle.kts | 13 +------------ 4 files changed, 2 insertions(+), 20 deletions(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index c65250d..f96debf 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -23,7 +23,7 @@ fun version(k: String) = dependencies { implementation(libs.gradle.versions.plugin) implementation(libs.vanniktech.publish.plugin) - implementation(libs.errorprone.gradle.plugin) + // removed errorprone plugin // Provide plugins used by precompiled script plugins so their ids are available implementation(libs.kotlin.gradle.plugin) implementation(libs.kover.gradle.plugin) diff --git a/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts b/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts index 7bdb8a4..64565b2 100644 --- a/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts +++ b/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts @@ -1,6 +1,5 @@ plugins { `java-library` jacoco - id("net.ltgt.errorprone") id("tasks.base") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 67b6983..9df7dc8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,6 @@ [versions] binary-compatibility-validator = "0.16.2" dokka = "2.1.0" -errorprone = "2.46.0" -errorprone-nullaway = "0.13.1" -errorprone-plugin = "4.3.0" jetbrains-annotations = "26.0.2" jspecify = "1.0.0" junit-jupiter = "6.0.2" @@ -18,7 +15,6 @@ kotlinx-coroutines = "1.10.2" # Plugins specified in buildSrc/build.gradle.kts binary-compatibility-validator-plugin = { module = "org.jetbrains.kotlinx.binary-compatibility-validator:org.jetbrains.kotlinx.binary-compatibility-validator.gradle.plugin", version.ref = "binary-compatibility-validator" } dokka-gradle-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } -errorprone-gradle-plugin = { module = "net.ltgt.gradle:gradle-errorprone-plugin", version.ref = "errorprone-plugin" } gradle-versions-plugin = { module = "com.github.ben-manes:gradle-versions-plugin", version.ref = "versions-plugin" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kover-gradle-plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } @@ -32,5 +28,3 @@ kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotl kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } -errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone"} -errorprone-nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "errorprone-nullaway" } diff --git a/tasks-jvm/build.gradle.kts b/tasks-jvm/build.gradle.kts index 379fb1f..1df7cb5 100644 --- a/tasks-jvm/build.gradle.kts +++ b/tasks-jvm/build.gradle.kts @@ -1,6 +1,3 @@ -import net.ltgt.gradle.errorprone.CheckSeverity -import net.ltgt.gradle.errorprone.errorprone - plugins { id("tasks.java-project") id("tasks.versions") @@ -16,9 +13,6 @@ mavenPublishing { dependencies { api(libs.jspecify) - errorprone(libs.errorprone.core) - errorprone(libs.errorprone.nullaway) - compileOnly(libs.jetbrains.annotations) testImplementation(platform("org.junit:junit-bom:6.0.2")) @@ -42,12 +36,7 @@ tasks.withType { "-Xlint:deprecation", // "-Werror" )) - - options.errorprone { - disableAllChecks.set(true) - check("NullAway", CheckSeverity.ERROR) - option("NullAway:AnnotatedPackages", "org.funfix") - } + } tasks.register("testsOn21") { From 8b0c77030ff2328720281e66ddf631418e97e11d Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 16:15:29 +0200 Subject: [PATCH 08/21] Fix API checks --- .gitignore | 4 ++-- buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts | 2 +- tasks-kotlin/api/tasks-kotlin.api | 9 +++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 6b6a7b6..541cfbb 100644 --- a/.gitignore +++ b/.gitignore @@ -39,8 +39,8 @@ bin/ .DS_Store ### Kotlin 2? -/.kotlin -/kotlin-js-store/ +.kotlin +kotlin-js-store/ # sbt specific dist/* diff --git a/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts b/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts index ded1750..d11d970 100644 --- a/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts +++ b/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts @@ -59,7 +59,7 @@ kotlin { // explicitApiMode = ExplicitApiMode.Strict // allWarningsAsErrors = true jvmTarget.set(JvmTarget.JVM_17) - freeCompilerArgs.add("-Xjvm-default=all") + freeCompilerArgs.add("-jvm-default=enable") } kotlinJavaToolchain.toolchain.use( javaLauncher = javaToolchains.launcherFor { diff --git a/tasks-kotlin/api/tasks-kotlin.api b/tasks-kotlin/api/tasks-kotlin.api index 78fee6d..fca333b 100644 --- a/tasks-kotlin/api/tasks-kotlin.api +++ b/tasks-kotlin/api/tasks-kotlin.api @@ -39,6 +39,8 @@ public abstract interface class org/funfix/tasks/kotlin/Outcome { public final class org/funfix/tasks/kotlin/Outcome$Cancellation : org/funfix/tasks/kotlin/Outcome { public static final field INSTANCE Lorg/funfix/tasks/kotlin/Outcome$Cancellation; public fun equals (Ljava/lang/Object;)Z + public synthetic fun getOrThrow ()Ljava/lang/Object; + public fun getOrThrow ()Ljava/lang/Void; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -49,6 +51,10 @@ public final class org/funfix/tasks/kotlin/Outcome$Companion { public final fun success (Ljava/lang/Object;)Lorg/funfix/tasks/kotlin/Outcome; } +public final class org/funfix/tasks/kotlin/Outcome$DefaultImpls { + public static fun getOrThrow (Lorg/funfix/tasks/kotlin/Outcome;)Ljava/lang/Object; +} + public final class org/funfix/tasks/kotlin/Outcome$Failure : org/funfix/tasks/kotlin/Outcome { public fun (Ljava/lang/Throwable;)V public final fun component1 ()Ljava/lang/Throwable; @@ -56,6 +62,8 @@ public final class org/funfix/tasks/kotlin/Outcome$Failure : org/funfix/tasks/ko public static synthetic fun copy$default (Lorg/funfix/tasks/kotlin/Outcome$Failure;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/funfix/tasks/kotlin/Outcome$Failure; public fun equals (Ljava/lang/Object;)Z public final fun getException ()Ljava/lang/Throwable; + public synthetic fun getOrThrow ()Ljava/lang/Object; + public fun getOrThrow ()Ljava/lang/Void; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -66,6 +74,7 @@ public final class org/funfix/tasks/kotlin/Outcome$Success : org/funfix/tasks/ko public final fun copy (Ljava/lang/Object;)Lorg/funfix/tasks/kotlin/Outcome$Success; public static synthetic fun copy$default (Lorg/funfix/tasks/kotlin/Outcome$Success;Ljava/lang/Object;ILjava/lang/Object;)Lorg/funfix/tasks/kotlin/Outcome$Success; public fun equals (Ljava/lang/Object;)Z + public fun getOrThrow ()Ljava/lang/Object; public final fun getValue ()Ljava/lang/Object; public fun hashCode ()I public fun toString ()Ljava/lang/String; From 7f4e1bf1fab3ab3ae2b643e4a42b748949ccfcd0 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 16:31:45 +0200 Subject: [PATCH 09/21] Add sample test to fix build --- .../kotlin/org/funfix/tasks/kotlin/SampleJsTest.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tasks-kotlin/src/jsTest/kotlin/org/funfix/tasks/kotlin/SampleJsTest.kt diff --git a/tasks-kotlin/src/jsTest/kotlin/org/funfix/tasks/kotlin/SampleJsTest.kt b/tasks-kotlin/src/jsTest/kotlin/org/funfix/tasks/kotlin/SampleJsTest.kt new file mode 100644 index 0000000..8b67470 --- /dev/null +++ b/tasks-kotlin/src/jsTest/kotlin/org/funfix/tasks/kotlin/SampleJsTest.kt @@ -0,0 +1,11 @@ +package org.funfix.tasks.kotlin + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SampleJsTest { + @Test + fun sampleJsTest() { + assertEquals(4, 2 + 2) + } +} From 0771fbb46bd714f1d5abf0ff3462a2f446dc62b3 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 16:44:52 +0200 Subject: [PATCH 10/21] Remove sbt from build --- .github/workflows/build.yaml | 18 ------------------ .../main/kotlin/tasks.kmp-project.gradle.kts | 1 + 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d712155..745e49d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -38,21 +38,3 @@ jobs: path: | **/build/reports/ **/build/test-results/ - - test-sbt: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - cache: 'sbt' - java-version: 21 - distribution: 'temurin' - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 - - name: Setup sbt - uses: sbt/setup-sbt@v1 - - name: Build and test - run: sbt -v publishLocalGradleDependencies ++test - working-directory: ./tasks-scala diff --git a/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts b/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts index d11d970..f008acb 100644 --- a/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts +++ b/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts @@ -56,6 +56,7 @@ kotlin { tasks.withType { compilerOptions { + // Set on a project-by-project basis // explicitApiMode = ExplicitApiMode.Strict // allWarningsAsErrors = true jvmTarget.set(JvmTarget.JVM_17) From 0090c4377dcc61b15a364c7db6a596b3fd289f4d Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 17:22:24 +0200 Subject: [PATCH 11/21] Add utilities and tests --- .../org/funfix/tasks/kotlin/coroutines.kt | 6 +- .../org/funfix/tasks/kotlin/internals.kt | 4 +- .../org/funfix/tasks/kotlin/coroutines.js.kt | 2 +- .../funfix/tasks/kotlin/CancellablePromise.kt | 29 ++- .../kotlin/org/funfix/tasks/kotlin/Task.js.kt | 186 ++++++++++++++++++ .../org/funfix/tests/TaskFromPromiseJsTest.kt | 104 ++++++++++ .../funfix/tests/TaskRunToPromiseJsTest.kt | 87 ++++++++ 7 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskFromPromiseJsTest.kt create mode 100644 tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskRunToPromiseJsTest.kt diff --git a/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt b/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt index f0c3f19..3013b90 100644 --- a/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt +++ b/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt @@ -8,9 +8,9 @@ import kotlin.coroutines.EmptyCoroutineContext * to be executed in the context of [kotlinx.coroutines]. * * NOTES: - * - The [CoroutineDispatcher], made available via the "coroutine context", is - * used to execute the task, being passed to the task's implementation as an - * `Executor`. + * - The [kotlinx.coroutines.CoroutineDispatcher], made available via the + * "coroutine context", is used to execute the task, being passed to + * the task's implementation as an `Executor`. * - The coroutine's cancellation protocol cooperates with that of [Task], * so cancelling the coroutine will also cancel the task (including the * possibility for back-pressuring on the fiber's completion after diff --git a/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/internals.kt b/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/internals.kt index b67a24c..1cef288 100644 --- a/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/internals.kt +++ b/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/internals.kt @@ -3,13 +3,13 @@ package org.funfix.tasks.kotlin import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlin.coroutines.ContinuationInterceptor -import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.currentCoroutineContext /** * Internal API: gets the current [CoroutineDispatcher] from the coroutine context. */ internal suspend fun currentDispatcher(): CoroutineDispatcher { // Access the coroutineContext to get the ContinuationInterceptor - val continuationInterceptor = coroutineContext[ContinuationInterceptor] + val continuationInterceptor = currentCoroutineContext()[ContinuationInterceptor] return continuationInterceptor as? CoroutineDispatcher ?: Dispatchers.Default } diff --git a/tasks-kotlin-coroutines/src/jsMain/kotlin/org/funfix/tasks/kotlin/coroutines.js.kt b/tasks-kotlin-coroutines/src/jsMain/kotlin/org/funfix/tasks/kotlin/coroutines.js.kt index 95a6105..c76ac15 100644 --- a/tasks-kotlin-coroutines/src/jsMain/kotlin/org/funfix/tasks/kotlin/coroutines.js.kt +++ b/tasks-kotlin-coroutines/src/jsMain/kotlin/org/funfix/tasks/kotlin/coroutines.js.kt @@ -47,7 +47,7 @@ private class DispatcherExecutor(val dispatcher: CoroutineDispatcher) : Executor if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) { dispatcher.dispatch( EmptyCoroutineContext, - kotlinx.coroutines.Runnable { command.run() } + { command.run() } ) } else { command.run() diff --git a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/CancellablePromise.kt b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/CancellablePromise.kt index eccde07..c77e67f 100644 --- a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/CancellablePromise.kt +++ b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/CancellablePromise.kt @@ -10,8 +10,35 @@ import kotlin.js.Promise * asynchronous task and cannot be cancelled. Thus, if we want to cancel * a task, we need to keep a reference to a [Cancellable] object that * can do the job. + * + * Contract: + * - [join] completes regardless of how the underlying computation ends, + * including when it gets cancelled via [cancellable]. + * - [join] does not reveal any outcome; it is only a completion signal. + * + * Example: + * ```kotlin + * val promise = Promise { resolve, _ -> resolve(1) } + * val join = Promise { resolve, _ -> + * promise.then({ resolve(Unit) }, { resolve(Unit) }) + * } + * val cp = CancellablePromise(promise, join, Cancellable.empty) + * cp.cancelAndJoin().then { /* completion observed */ } + * ``` */ public data class CancellablePromise( val promise: Promise, + val join: Promise, val cancellable: Cancellable -) +) { + /** + * Triggers cancellation and then waits for [join] to complete. + * + * Note that [join] completes regardless of whether [promise] + * resolves, rejects, or the computation is cancelled. + */ + public fun cancelAndJoin(): Promise { + cancellable.cancel() + return join + } +} diff --git a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Task.js.kt b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Task.js.kt index 3457bfc..b61706c 100644 --- a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Task.js.kt +++ b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Task.js.kt @@ -2,6 +2,8 @@ package org.funfix.tasks.kotlin +import kotlin.js.Promise + public actual class PlatformTask( private val f: (Executor, (Outcome) -> Unit) -> Cancellable ) { @@ -71,3 +73,187 @@ public actual fun Task.ensureRunningOnExecutor(executor: Executor?): Task } cRef }) + +private fun promiseFailure(cause: Any?): Throwable = + when (cause) { + is Throwable -> cause + null -> RuntimeException("Promise rejected") + else -> RuntimeException(cause.toString()) + } + +/** + * Creates a task from a JavaScript [Promise] builder. + * + * Contract: + * - The resulting task is not cancellable; cancellation does not + * affect the underlying promise. + * - Completion mirrors the promise outcome: resolve maps to + * [Outcome.Success], reject maps to [Outcome.Failure]. + * + * Example: + * ```kotlin + * val task = Task.fromPromise { + * Promise { resolve, _ -> resolve(1) } + * } + * ``` + */ +public fun Task.Companion.fromPromise( + builder: () -> Promise +): Task = + Task.fromAsync { _, callback -> + var isDone = false + try { + val promise = builder() + promise.then( + { value -> + if (!isDone) { + isDone = true + callback(Outcome.Success(value)) + } + }, + { error -> + if (!isDone) { + isDone = true + callback(Outcome.Failure(promiseFailure(error))) + } + } + ) + } catch (e: Throwable) { + callback(Outcome.Failure(e)) + } + Cancellable.empty + } + +/** + * Creates a task from a [CancellablePromise] builder. + * + * Contract: + * - Cancellation triggers the provided [Cancellable], but the resulting + * task only completes when [CancellablePromise.join] completes. + * - If [CancellablePromise.join] rejects, the task fails with that error. + * - Otherwise, if cancellation was requested, the task completes with + * [Outcome.Cancellation]. + * - Otherwise, the task mirrors [CancellablePromise.promise]. + * + * Example: + * ```kotlin + * val task = Task.fromCancellablePromise { + * val promise = Promise { resolve, _ -> resolve(1) } + * val join = Promise { resolve, _ -> + * promise.then({ resolve(Unit) }, { resolve(Unit) }) + * } + * CancellablePromise(promise, join, Cancellable.empty) + * } + * ``` + */ +public fun Task.Companion.fromCancellablePromise( + builder: () -> CancellablePromise +): Task = + Task.fromAsync { _, callback -> + var isDone = false + var isCancelled = false + var token: Cancellable = Cancellable.empty + try { + val value = builder() + token = value.cancellable + value.join.then( + { + if (!isDone) { + if (isCancelled) { + isDone = true + callback(Outcome.Cancellation) + } else { + value.promise.then( + { result -> + if (!isDone) { + isDone = true + callback(Outcome.Success(result)) + } + }, + { error -> + if (!isDone) { + isDone = true + callback(Outcome.Failure(promiseFailure(error))) + } + } + ) + } + } + }, + { error -> + if (!isDone) { + isDone = true + callback(Outcome.Failure(promiseFailure(error))) + } + } + ) + } catch (e: Throwable) { + callback(Outcome.Failure(e)) + } + Cancellable { + isCancelled = true + token.cancel() + } + } + +/** + * Executes the task and returns its result as a JavaScript [Promise]. + * + * Contract: + * - [Outcome.Success] resolves the promise. + * - [Outcome.Failure] rejects the promise with the original exception. + * - [Outcome.Cancellation] rejects with [TaskCancellationException]. + * + * Example: + * ```kotlin + * Task.fromAsync { _, cb -> + * cb(Outcome.Success(1)) + * Cancellable.empty + * }.runToPromise() + * ``` + */ +public fun Task.runToPromise(): Promise = + Promise { resolve, reject -> + runAsync { outcome -> + when (outcome) { + is Outcome.Success -> resolve(outcome.value) + is Outcome.Failure -> reject(outcome.exception) + is Outcome.Cancellation -> reject(TaskCancellationException()) + } + } + } + +/** + * Executes the task and returns a [CancellablePromise]. + * + * The resulting [CancellablePromise.join] completes regardless of whether + * the task succeeds, fails, or is cancelled. + * + * Example: + * ```kotlin + * val cp = Task.fromAsync { _, cb -> + * cb(Outcome.Success(1)) + * Cancellable.empty + * }.runToCancellablePromise() + * cp.cancelAndJoin() + * ``` + */ +public fun Task.runToCancellablePromise(): CancellablePromise { + var token: Cancellable = Cancellable.empty + val promise = Promise { resolve, reject -> + token = runAsync { outcome -> + when (outcome) { + is Outcome.Success -> resolve(outcome.value) + is Outcome.Failure -> reject(outcome.exception) + is Outcome.Cancellation -> reject(TaskCancellationException()) + } + } + } + val join = Promise { resolve, _ -> + promise.then( + { resolve(Unit) }, + { resolve(Unit) } + ) + } + return CancellablePromise(promise, join, Cancellable { token.cancel() }) +} diff --git a/tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskFromPromiseJsTest.kt b/tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskFromPromiseJsTest.kt new file mode 100644 index 0000000..68c057c --- /dev/null +++ b/tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskFromPromiseJsTest.kt @@ -0,0 +1,104 @@ +package org.funfix.tests + +import kotlin.js.Promise +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.funfix.tasks.kotlin.Cancellable +import org.funfix.tasks.kotlin.CancellablePromise +import org.funfix.tasks.kotlin.Outcome +import org.funfix.tasks.kotlin.Task +import org.funfix.tasks.kotlin.fromCancellablePromise +import org.funfix.tasks.kotlin.fromPromise +import org.funfix.tasks.kotlin.runAsync + +class TaskFromPromiseJsTest { + private fun joinOf(promise: Promise): Promise = + Promise { resolve, _ -> + promise.then( + { resolve(Unit) }, + { resolve(Unit) } + ) + } + + @Test + fun `fromPromise (success)`() = runTest { + val task = Task.fromPromise { + Promise { resolve, _ -> resolve(1) } + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + assertEquals(Outcome.Success(1), deferred.await()) + } + + @Test + fun `fromPromise (failure)`() = runTest { + val ex = RuntimeException("Boom!") + val task = Task.fromPromise { + Promise { _, reject -> reject(ex) } + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + assertEquals(Outcome.Failure(ex), deferred.await()) + } + + @Test + fun `fromPromise (cancellation waits for completion)`() = runTest { + lateinit var resolve: (Int) -> Unit + val task = Task.fromPromise { + Promise { res, _ -> resolve = res } + } + val deferred = CompletableDeferred>() + val cancel = task.runAsync { outcome -> deferred.complete(outcome) } + cancel.cancel() + yield() + assertFalse(deferred.isCompleted) + resolve(1) + assertEquals(Outcome.Success(1), deferred.await()) + } + + @Test + fun `fromCancellablePromise (success)`() = runTest { + val task = Task.fromCancellablePromise { + val promise = Promise { resolve, _ -> resolve(1) } + CancellablePromise(promise, joinOf(promise), Cancellable.empty) + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + assertEquals(Outcome.Success(1), deferred.await()) + } + + @Test + fun `fromCancellablePromise (failure)`() = runTest { + val ex = RuntimeException("Boom!") + val task = Task.fromCancellablePromise { + val promise = Promise { _, reject -> reject(ex) } + CancellablePromise(promise, joinOf(promise), Cancellable.empty) + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + assertEquals(Outcome.Failure(ex), deferred.await()) + } + + @Test + fun `fromCancellablePromise (cancellation waits and triggers token)`() = runTest { + lateinit var resolve: (Int) -> Unit + var cancelled = false + val task = Task.fromCancellablePromise { + val promise = Promise { res, _ -> resolve = res } + CancellablePromise(promise, joinOf(promise), Cancellable { cancelled = true }) + } + val deferred = CompletableDeferred>() + val cancel = task.runAsync { outcome -> deferred.complete(outcome) } + cancel.cancel() + yield() + assertTrue(cancelled) + assertFalse(deferred.isCompleted) + resolve(1) + assertEquals(Outcome.Cancellation, deferred.await()) + } +} diff --git a/tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskRunToPromiseJsTest.kt b/tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskRunToPromiseJsTest.kt new file mode 100644 index 0000000..0d2afc8 --- /dev/null +++ b/tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskRunToPromiseJsTest.kt @@ -0,0 +1,87 @@ +package org.funfix.tests + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.funfix.tasks.kotlin.Cancellable +import org.funfix.tasks.kotlin.Outcome +import org.funfix.tasks.kotlin.Task +import org.funfix.tasks.kotlin.TaskCancellationException +import org.funfix.tasks.kotlin.fromAsync +import org.funfix.tasks.kotlin.runToCancellablePromise +import org.funfix.tasks.kotlin.runToPromise + +class TaskRunToPromiseJsTest { + @Test + fun `runToPromise (success)`() = runTest { + val task = Task.fromAsync { _, cb -> + cb(Outcome.Success(1)) + Cancellable.empty + } + val deferred = CompletableDeferred() + task.runToPromise().then({ value -> + deferred.complete(value) + }, { error -> + deferred.completeExceptionally(error) + }) + assertEquals(1, deferred.await()) + } + + @Test + fun `runToPromise (failure)`() = runTest { + val ex = RuntimeException("Boom!") + val task = Task.fromAsync { _, cb -> + cb(Outcome.Failure(ex)) + Cancellable.empty + } + val deferred = CompletableDeferred() + task.runToPromise().then({ _ -> + deferred.complete(RuntimeException("Unexpected")) + }, { error -> + deferred.complete(error) + }) + assertEquals(ex, deferred.await()) + } + + @Test + fun `runToPromise (cancellation)`() = runTest { + val task = Task.fromAsync { _, cb -> + cb(Outcome.Cancellation) + Cancellable.empty + } + val deferred = CompletableDeferred() + task.runToPromise().then({ _ -> + deferred.complete(RuntimeException("Unexpected")) + }, { error -> + deferred.complete(error) + }) + val error = deferred.await() + assertTrue(error is TaskCancellationException) + } + + @Test + fun `runToCancellablePromise cancelAndJoin`() = runTest { + val task = Task.fromAsync { _, cb -> + Cancellable { + cb(Outcome.Cancellation) + } + } + val cp = task.runToCancellablePromise() + val error = CompletableDeferred() + val joined = CompletableDeferred() + cp.promise.then({ _ -> + error.complete(RuntimeException("Unexpected")) + }, { err -> + error.complete(err) + }) + cp.cancelAndJoin().then({ + joined.complete(Unit) + }, { + joined.complete(Unit) + }) + joined.await() + assertTrue(error.await() is TaskCancellationException) + } +} From 05e1233b6f817e51da699ab9d046217c7c7b9f3b Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 17:53:23 +0200 Subject: [PATCH 12/21] Add tests, fix kdoc --- .../org/funfix/tasks/kotlin/coroutines.kt | 4 +- .../tasks/kotlin/CoroutinesCommonTest.kt | 97 +++++++++++++++++++ .../funfix/tasks/kotlin/CoroutinesJsTest.kt | 26 +++++ .../funfix/tasks/kotlin/CoroutinesJvmTest.kt | 31 ++++++ .../kotlin/org/funfix/tasks/kotlin/Task.kt | 9 +- 5 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 tasks-kotlin-coroutines/src/commonTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt create mode 100644 tasks-kotlin-coroutines/src/jsTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJsTest.kt create mode 100644 tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt diff --git a/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt b/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt index 3013b90..1eedc3f 100644 --- a/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt +++ b/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt @@ -5,10 +5,10 @@ import kotlin.coroutines.EmptyCoroutineContext /** * Similar with `runBlocking`, however this is a "suspended" function, - * to be executed in the context of [kotlinx.coroutines]. + * to be executed in the context of kotlinx.coroutines. * * NOTES: - * - The [kotlinx.coroutines.CoroutineDispatcher], made available via the + * - The `kotlinx.coroutines.CoroutineDispatcher`, made available via the * "coroutine context", is used to execute the task, being passed to * the task's implementation as an `Executor`. * - The coroutine's cancellation protocol cooperates with that of [Task], diff --git a/tasks-kotlin-coroutines/src/commonTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt b/tasks-kotlin-coroutines/src/commonTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt new file mode 100644 index 0000000..64a0901 --- /dev/null +++ b/tasks-kotlin-coroutines/src/commonTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt @@ -0,0 +1,97 @@ +package org.funfix.tasks.kotlin + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class CoroutinesCommonTest { + @Test + fun runSuspendedSuccess() = runTest { + val task = Task.fromAsync { _, cb -> + cb(Outcome.Success(42)) + Cancellable {} + } + + val result = task.runSuspended() + + assertEquals(42, result) + } + + @Test + fun runSuspendedFailure() = runTest { + val ex = RuntimeException("Boom") + val task = Task.fromAsync { _, cb -> + cb(Outcome.Failure(ex)) + Cancellable {} + } + + val thrown = assertFailsWith { task.runSuspended() } + + assertEquals("Boom", thrown.message) + } + + @Test + fun runSuspendedCancelsTaskToken() = runTest { + val cancelled = CompletableDeferred() + val started = CompletableDeferred() + val task = Task.fromAsync { _, cb -> + started.complete(Unit) + Cancellable { + cancelled.complete(Unit) + cb(Outcome.Cancellation) + } + } + + val deferred = async { task.runSuspended() } + started.await() + deferred.cancel() + + assertFailsWith { deferred.await() } + cancelled.await() + } + + @Test + fun fromSuspendedSuccess() = runTest { + val task = Task.fromSuspended { + 21 + 21 + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + + assertEquals(Outcome.Success(42), deferred.await()) + } + + @Test + fun fromSuspendedFailure() = runTest { + val ex = RuntimeException("Boom") + val task = Task.fromSuspended { + throw ex + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + + assertEquals(Outcome.Failure(ex), deferred.await()) + } + + @Test + fun fromSuspendedCancellation() = runTest { + val started = CompletableDeferred() + val task = Task.fromSuspended { + started.complete(Unit) + awaitCancellation() + } + val deferred = CompletableDeferred>() + val cancel = task.runAsync { outcome -> deferred.complete(outcome) } + + started.await() + cancel.cancel() + + assertEquals(Outcome.Cancellation, deferred.await()) + } + +} diff --git a/tasks-kotlin-coroutines/src/jsTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJsTest.kt b/tasks-kotlin-coroutines/src/jsTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJsTest.kt new file mode 100644 index 0000000..f939325 --- /dev/null +++ b/tasks-kotlin-coroutines/src/jsTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJsTest.kt @@ -0,0 +1,26 @@ +package org.funfix.tasks.kotlin + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CoroutinesJsTest { + @Test + fun runSuspendedUsesProvidedExecutor() = runTest { + var executed = false + val executor = Executor { command -> + executed = true + command.run() + } + val task = Task.fromAsync { exec, cb -> + exec.execute { cb(Outcome.Success(7)) } + Cancellable {} + } + + val result = task.runSuspended(executor) + + assertEquals(7, result) + assertTrue(executed) + } +} diff --git a/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt new file mode 100644 index 0000000..998a1c6 --- /dev/null +++ b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt @@ -0,0 +1,31 @@ +package org.funfix.tasks.kotlin + +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import java.util.concurrent.Executors +import kotlin.test.Test +import kotlin.test.assertTrue + +class CoroutinesJvmTest { + @Test + fun runSuspendedUsesCurrentDispatcherWhenExecutorNull() = runTest { + val executor = Executors.newSingleThreadExecutor { r -> + val thread = Thread(r) + thread.isDaemon = true + thread.name = "coroutines-test-${thread.id}" + thread + } + val dispatcher = executor.asCoroutineDispatcher() + try { + val threadName = withContext(dispatcher) { + Task.fromBlockingIO { Thread.currentThread().name }.runSuspended() + } + + assertTrue(threadName.startsWith("coroutines-test-")) + } finally { + dispatcher.close() + executor.shutdown() + } + } +} diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Task.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Task.kt index 7e3b021..f444713 100644 --- a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Task.kt +++ b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Task.kt @@ -104,17 +104,16 @@ public expect fun Task.runAsync( * The created task will execute the given function on the current * thread, by using a "trampoline" to avoid stack overflows. This may * be useful if the computation for initiating the async process is - * expected to be fast. However, if the computation can block the - * current thread, it is recommended to use [fromForkedAsync] instead, - * which will initiate the computation by yielding first (i.e., on the - * JVM this means the execution will start on a different thread). + * expected to be fast. If the computation can block the current + * thread, consider ensuring the task runs on a different executor + * (for example via [ensureRunningOnExecutor] or by wrapping the + * blocking portion in a `fromBlockingIO` task). * * @param start is the function that will trigger the async computation, * injecting a callback that will be used to signal the result, and an * executor that can be used for creating additional threads. * * @return a new task that will execute the given builder function upon execution - * @see fromForkedAsync */ public expect fun Task.Companion.fromAsync( start: (Executor, Callback) -> Cancellable From b04bbf4f679b07705d71cc9c2014ca3ac8b99f33 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 17:59:10 +0200 Subject: [PATCH 13/21] Configure dokka for multi-project builds --- build.gradle.kts | 6 ++++++ buildSrc/src/main/kotlin/tasks.java-project.gradle.kts | 1 + 2 files changed, 7 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index d032896..8c8940b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,12 @@ dokka { } } +dependencies { + dokka(project(":tasks-jvm")) + dokka(project(":tasks-kotlin")) + dokka(project(":tasks-kotlin-coroutines")) +} + tasks.named("dependencyUpdates").configure { fun isNonStable(version: String): Boolean { val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } diff --git a/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts b/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts index 64565b2..c316ff3 100644 --- a/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts +++ b/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts @@ -1,5 +1,6 @@ plugins { `java-library` jacoco + id("org.jetbrains.dokka") id("tasks.base") } From bb43f004144b8b056bc8b86a576f6b1d09e54209 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 18:15:24 +0200 Subject: [PATCH 14/21] Configure dokka --- .github/workflows/publish-release.yaml | 18 ++++++++++++++++++ build.gradle.kts | 9 +++++++++ docs/favicon.ico | Bin 0 -> 1220 bytes docs/funfix-512.png | Bin 0 -> 18848 bytes docs/logo-styles.css | 6 ++++++ 5 files changed, 33 insertions(+) create mode 100644 docs/favicon.ico create mode 100644 docs/funfix-512.png create mode 100644 docs/logo-styles.css diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index 95620da..f31efa4 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -2,6 +2,9 @@ name: Publish Release on: workflow_dispatch +permissions: + contents: write + jobs: publish-release: runs-on: ubuntu-latest @@ -12,6 +15,21 @@ jobs: with: java-version: 17 distribution: 'temurin' + - name: Generate API docs + run: ./gradlew -PbuildRelease=true dokkaGeneratePublicationHtml --no-daemon + - name: Prepare GitHub Pages content + run: | + VERSION=$(grep -E '^project\.version=' gradle.properties | cut -d= -f2 | tr -d '[:space:]') + mkdir -p "site/api/$VERSION" + cp -R build/dokka/. "site/api/$VERSION/" + ln -sfn "$VERSION" "site/api/current" + - name: Publish API docs to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: site + keep_files: true - name: Publish Snapshot run: ./gradlew -PbuildRelease=true build publish --no-daemon env: diff --git a/build.gradle.kts b/build.gradle.kts index 8c8940b..84233c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,15 @@ dokka { dokkaPublications.html { outputDirectory.set(file("build/dokka")) } + + pluginsConfiguration.html { + customAssets.from( + "docs/funfix-512.png", + "docs/favicon.ico" + ) + customStyleSheets.from("docs/logo-styles.css") + footerMessage.set("© Alexandru Nedelcu") + } } dependencies { diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0977e8723f8a0bad06716538def86530c3cc4e32 GIT binary patch literal 1220 zcmV;#1UvhQP)0002>0mku*_5c6?8FWQhbW?9;ba!ELWdL_~cP?peYja~^ zaAhuUa%Y?FJQ@H101|XXSaeirbZlh+O>b^#cpxnxLTq(qv8`WA000CdNkl6vPNV7)^Zgf+3k9Bs{o>O)w+q1A`8x8A}#3 zvSgY1VzOI!8GF!a)nGK-;(!s{WLyMX2SF}Y8i7GCd%&Kxr9C~Z?ECcp{r}(d|9$@F zoc~QAMm0i?8_oe51yt3+@3HhFMEighR6sRw7D#+iy$XB{)LX&vzW`W40h|Cn2hzgW z<^^al!dJ(^yia+c>%uuD_3YOFP8!USOS`p;M^H- z1~)2#fj)r};14f+2B;7ON?KahB?ox&;6bG107<}^B^b0q89{>{rknb z?&fB$S5}7fO9BWA1v-;SjP2dsk$B0ksuGjW4Gl3jG{jVIZ&<%1z^j>=D3g=LSk6zE zTUrXh(%c-E%x3;sSdjFu2M`1*+S`dsNFYI_3TP^8m=m3{Carjn9?I=#h$-eSSt)fF~*NC2M{@$sY>3}XCbU?6NK z!`H4c`|u&VtErhhFc6oRD8^DV;L**S02I~N128=>K+naC zl8Jo^-U~#f=H>>(k*44#jRy6WEwa4w0Jd)z%O01D*}*}{j?#AQ5X&0_;P(dRhlg=3 zFH3gh@0a>tVgiseqGVnii-hojE}Q8J1ZL?NXPJqS@znTSsguR?@g1WQtESmnJ2Jop73~u-W iAPzqI82*f2RxPi8~1$d4xzHgt;`FNgt{O;?%e%Jh7*L8<#sw*9#K2D7w$PpD~g&PQhhW|t(R0rVC zy2tlz_(Se2r=mjz|GcRzLg4=oIx6csBM7Z7>4#cFy0hURi_29#7j1`oF79Rz?jr8) z?gG~KHqPc|j&}td9$3aNN*;%c=tvjoSi9KXMbsSZtv$HTamm@b-p$J~QAH3gL`6YX z$0K23$UWiakIIdej#pA-7i)xs=hzP%r8x8_C@il&KJ($7??z9`%CvY)ZFMmsTq0C| zPK7q~+0&g^d7a!)Z+gg0Yc9E4LjD!Y0p|3CnJ9nu$%KnD2_wc{z9YFzxihX$+P8i= z-&gbHtODg)MtyT$~mtXvq zK6`pKg$vgyWO#WS>RxG;vd&gTRo}hjm_ivv))hf<40DVh!Ja_rC|YCs%O%k-K{}SlFkYnlE*^Q%Tl=+!H#s|0 z+FoM$!;RA4*D~!q#W5>~;9g|KRLqe{i0iBL_5{_S7|k2?l@10B59vlBjHhUg&S-?N z@Aqn)7RNW$wV4p**I7u2*f;+dHNR_4aY@T_8q)4Y_OX&Nuxww)sGMg&Fq99YDU6Uo zRN=+2yc6hS+jTxOvi@2JFeZ_1e~K)!@Yfy=d%-)-`=gTbhkD$PpB1E^?nZL zqOL!taUDg)(HYKhl53fYI~Gx%c~TJ4!~wKOjrurUUm{Pg7uR?_&n1%R6i-LrTm+e%MRlmxtT# zj~>6H^!wf~STc)@;}kBhS3b>^IVM>PbyS7BgR^n{K-)Cc_3wFg*sL@(S1gu;P0@B| z*sMY8@h>vdXOy}6uofEDI{BGJ9AB5Sxd98kiaDY?Ox_AFRC=B1ObqWsO?p2Q1+&AQ zgW?)Z^W=-C15|oti(Hv8OduS*W*vVjH;|$1f`CDTQC~@qgub`(SUNsfliWinpl@fl zky$Aw*HiS6Vq~Cb4y-8$Z%borJ5v>pYxT1o5YDI78a+5_K#OO!#H?I_`y9@L2-n?3 zNvDfi-N&7t?K6vU0OIp$IS%uQbc~FMh4EURaD19!9(?uBH42$Yo0D=Q2VPQpH;RX+ z*0(~4$ra$$L{KZJ_~lmdRiTXcfCR|a;}oa z$~<1N+t!BMz8BcUkI+5fNT3=#$Y#ZM^@7cL$LEwC!0^0xXs)-DvVx!PlJAa66qN1KKU@YSmAlM z>q{8Ce*M9M0@Lq$9z)_G%HmmwvgzDQW9xS;HG-0Q_6hR5=UlX&)x4q({NZ?)-kv-x zXDy`14cS18dVLxa2~)lgHzdzrPT#OkE_Y=-`0P^6kvEa-FAyzMw9HF{QnVp*?h0N! zoV;J6-k8-J6hK)U_Nm9-4_|I}RyRO;T4%f1WCGUmujBv z+lnF@p4d|ih*Kn!1nC)rVqsJ@e3()*Hc2~>NDMzRewcRqD4cVdIED3E%RS3{g>l+| z5)&FsjoW-fdCrf}b-r@k_jk<_j`d2WP&;-tM+d1dk!->ihjv&b=hz*O39t>V2{<*l z^!1uw$WkT}BKRcu*t@*mZI|o0rM4KMe*bD<`Ia~XvhqCst@D-ZSU1NgWzBC~#lAH+ z2wd1CkZzOC^Obbyaz9GHc*)EQ?O52KY3#&U!l~=1&w9a_W}GXIQgZUE2m{^uw`i_= zl42xLR64GN$y=5ZYVr3qq>ORAfr^e;8%0dn98c#x-@}?QP^8b)TrxKebY(us#DFw2 z)2?Ed*G^qmXj~8T{z+r{;NajRhWSDop0Q*!7t337&#UA4ulr8QiM#1_t5e8y-^Z>y z;3WLCa|%MlCU4#$kVzHNU~55it;b|f=RJJH-g724L2lc`GGI+ElDgt2tNLpA<^Let`z#WXy7@YXx4Kf!SM zeZ-q7@S559t$-r`q+~$0f(|-vzInK3hSyg}Z65yiIL3VJHmoPekF1udbiSpL1Tj$V zRPdXt`N5Gb+>Vuk4K$HTcSmd9NmNqkPCxJxJvdz}Fmd+IbqcP@KVWT{I_t#41C?(v zp8M>UR%odaCgGNvYOeIU#6hE=*zUB|&)8kYbEn|Xe1#;08b>EB1 z;`d=eL*-dH@9*^DbH3lw)i%6&)$ZqGq0` zH3wctQp+em{MQRuTsI@&U6?OSu1VXqv|109i~ihtHB$cxR<~m%9UfX%v~LBVCQZ<7u;<*7Y2-a2Q{xiI5n+x#}Wq10A@+95{GJdgA7(;b$7l{mHXtT0^%?EaiQ-Ag5@V(U|* zIB_;scCY&QwQKushkW@bp66gnuTZ;t=2epOj_M$CrjiMTmT!EMU0=8~oe{WKQ#SKpG?1zL zcb|EvMc@oY(9l3h!E~a#-X$Bx55wM1e>hY%f3zFOHV++mp)mU1X*t35sEQy14jFL1 zzQj9fL^wOwd9Mbcshq!Xz{G?5;n%NtCvWclKIN}p0?)=@etP$!W&Z9D-JeCSA6(Bm z^{Cb;(=zH&#;j|isJLiflj$ClcrWVMxbAj&nmMv;?11MJsuuChk`{>K z@Oh?o$mLvIz;_faeN7+XZMS$t>I-TaoCi3UlGZLI=kZh(mn(nt2&dFNr{9IYT-%($ zpRZSo=MmCsq@h&xIH^izk7lE(KuKP!vER$tSsxqBcrz|-;-w}^JlQAa{?V1(NtbYH zum~CcEmIwUdW%6CAEKnvMKeA{r{tDLrVwX*rA9)OVte#VI&!{#{l3&a?GQ_M5ohA@ zX;3U!ST@<7*=$@^i~JEL@O=IBE?HXR@>s%oyW=^Y0T2AYJo}(m7_zgoQ4+~rn}4h{ zKfUJvFoO?^!7ZaqQr#9=%5idR;G&0clLXyQ^$N&{N+YJCw}Vm5AP&P zb4FJlGwSpZA}^JoRx-3y2`=_O;MC4ZRE}SLKse=B;I!vWprSKk_fxu3dZ#0=&cT2( zb~6(Z8&z#w-y?4liG0dP=DR81zP;ekY={3QevQkTT;{@Pr{c@0+G90E9ND7nJUef> zSvlhmlo7`Axj1`Ro7<$S zG*CJic;q#a$%{|qi!JS=I7)MSoEqLwAs6*}jjPe0_&)G!&2#eI>5&Q_uf4UYIM=m~ zWHnDRnO$zm&D^0+9^UiOZ)HpfM#*T?CjVo8KWl`OpV+-#Z*}N;%vrgNwmWe6QzRY# zan#Wy>!KNt)rS*gQa|S04aiJu{Eh32myS%Huk?Cqs8dfNSfw17LRJ>&m#enW7xSyg zCdWKvzBR&@+4Y$BeRf`39pr-51>p~VHaG$fO^|#Q`}}F$*2R0BYu~kVQUcoB&l#4c zxY`hyu{+w7lE~ns>~p6Vot^YD_Y@>cGY?3i8ZhtTrE9X>hqL0VW1g%{>nj}%RT9X% zq?r^M9HT+uQg^Xh3#ELKmS)YAG0)s{U?9D@LVjYzp;^+h%Npvd2jXHb&qqQ>mB5)l~p5j%FC?}Zef#O_lqf2O?nU^MLfr!V4~N3F@Z zzNX8Kd6a(SU_>#|b;tY4ez=vemLVOJ7-z|T;=3@KD^enbgV7`JTp*e2Y8GcElG^DZ zR=}U!xPFqCZcLiPE8@k_XMMhd5DXr~VecHuaGiGyo0GPnzK-)}9kU;0CY*gFv~DRq z5kbjW7>V(``Qet6Wt;&1yuyuzg)5CT)pqRluE$YUj}4QHDBVTBl+RAn%Cs&HUB0_& zL12FTX79u&8ixQ;Ne^4P!UCF!3k*2k>;8i)LJ!<+C&JEjojG$phRRZUm5OAMRLFsd z!n31a#5e27mN=S9XeO}?IP!-=-t69`mS6oPLjq)+O$QlY zN=PY4d`J(&BlXtUbvy9YJM2KWPT%KCx@Qd(wDe!O(*HE?kLJ!Mj*`JnzFHTAfc*Gg zm_K#RBT{%LZYrGY-v4K;JWBG5EO9Fdf8HX>N}jAl*g^g8$J=-Qzmiq%V> z?<{@w5JPR^`uNP3il_8!7>Mejaqqx5>8;`~cv+6qs-$M$;%FS~tXCQ#)w ziId4pvbJ*-$BzziJY%+83H-!_Yw{2)Ily@Ii@9g~`;J$Z-Db0z1m=7uP@bP3B=2~Y zoLwEQu81BXlP{-oM6>lX$8(Bzi-&&@cy%efUFnx}(#ZFv!RB*czbddE&rS*19P-i^ zx^Fw*^r!RS&~b#825GqTB`u)h@1?6zPc`suBN-y+^pfMNmAz@KBiJP**-QKVpEo?b zz%MK+L%Z5grwnn{9uxPryGF3NLq(VP<9Yue;#E32?&9!KIa*0U1?}3mr3WK}FZx~e zGCtU)_*?D!TnJC$RBjY02TBX+Gx&7ESUHlN#s#%J%0+7&`o+X=vB3!~uR+{PM{{-a zCW3)|c^P%UFjyr+Mud&_TIi*+)-DyF(?8y+GARn&zQ$`v)StlE*`^lf$M*la|I2MY z`D1!e#rU242Mi6;cLi8J1)J_rjEYpn75k3WJytkzZU{SXflByF?A z#!w^tl%`tND=*VGx4Z){dAMx-#8(~l4MPqLYV`6bzu+yZgehCp z@*VlzUa}ON_ki%&a$WV`CsLLPX6$u3!pHB9tZdO!fNxz0%xYSZe@I>vWBp=mT+V7uXTL=>CX*)t}-L#JLTpR$Xrso!%P zc{uu=#z?*LfEX&0Y?#mOdanH*i_7wfxPa~HYTN1S9FntKYw^-DKYsm?^ji74dv8|r zMO(twbD~->5^7=ezks7AD@7}j8TBua!gqnPZ3;p{e08MQ;H zu#@%wAnZOtphzm z@!-i?KtDI}?}&@^iUNU|rDR#qckETT0Jgu+iv4c&FZV@@q4}R+t$9x}iGAjHCRg4IT%z8DoyB>OA#4SXBo(pRi^?g`6P9W6!+Ek*fS z>}hN0Hy(`{4KtwJj7Wl9_xc(Q`>e3-OL!Q>sAoD%oB~}BV`3sSQPDoX9BTgMEE^4-FdI@As}+}P(8<-WE3e)w+P?*IcOUAWa^l6th_{bs)WIEK zSyIqkUN`$c=*rD>mS=mm9iNumO5ML0^1@2$o*ts1`^dt8&)!vqZb;z%@Sj~HmVfwA z7I(Ye+2|U$5&mo?-5M(IfjEcSpa^@0 zFL*bW?g2G*)4AR8-9jtj<2!GbwtQ_qyw8NRNEebJR?!=kZs#OK?B00}iSz4bCqJ0F zcHC`AWLNb0b$DL~D+yDit-q)sG6usbwHo3|9^=CgMyuzOS)Q~Kn`~np4Nr9{?_3f? zu9Z2@4;%a^*Pj>o8PDCB7%a1WDG~R?c_!bv^+l?b$u8u?Rc}fDMX4zSB9}SxD=Ue& zu6W(p`@5w5sQt2w*yK@c%r}SF<6s6-oh`44yYDspPQL&9zDRp~W6)*iJ6}!P*+klt zmUegwrs8#wNmd%Mbxi^l%lVm=#vGmyq%-@r1WnZ>-GKzJk4qZEXXn0$#;PaIUb<61 zA;*SHAZ`lh38@sXasV68KJLS11L=1>Iym2M}3Fz=v7@3?V0ylTO zh>|Xjz^sL?ISC;1WoP}^|oOBjEJiv_Hbj5^tpIuh&&hk|zr|n!{HT!~lc&+Pp z-|2CJaLEj0*ry1+9pZ}pKHD9>21?Pm)-wXr5tJOJol%c0*uNV6?k}1jtoSJ&G!Kxj z_s5~pT0av+uiA#`!-#i}co4ZBWbI|6OTHU_cXnUpJ1rVP5J6j8)vqeof8@9YJNLq^ z%9R7Z%iSR0!rkvGI>S`8iOt#hQxrMQ%Bq8St5V2F^#bVxD(jNnskj^-Wtr!{mpTF= z>Q~meDe(84G!!)Irz3WIb_#((^kX6 zx4N7=NE$~tRaH3Eqmea`R|hZcPmM!5-9}|d^lj&qWb-KFET*O(4EJc*6cI~0cdM(j z5UQh*Tf|Ea5X#D;EjDvXx=EqG`Q~eC-2TbkEnnOZzby zCJ%CnRYbGw4p^guxOXCUVmlkve6eY*GkaFYxO{s=^>CxPC_PcHVpsBmkCg+Kq;`ec zBwRkJk9!>{Dze?0JVsz%J*R^Nyvx53)?b(x8FLGQfV@a9l+Yxf^jmPv291ti(gP1! zXXipDBQQbz^Asb2xxI6_ng~GUDMsWzxynF`G z@k0mM)u52E(l)kd=gx8Aj!mC+-kx)T?4Q7_o|TiG`rrpYM39{P2zPJx?eZGVJNa4} zF)GK{eE$B%V-gA=018peF>iX5i3AvR&JbmJkk|^2y5Jtrv21fOC5NF>S-_LNEK?`9 z^Tb)OJ5M};+fut14lv-N4{f7sKC?^o6?>Pt9(K~q;E7BgSZOqDbOf{)L{3g%RvQ5z zhL7apz%k&wI@z2oj#7Q`0MucQG29X{N`+4 zBk6Z)u`fp6ch8xoa`yqXLGfdaclOpJMy-D(?=@V+9uay>X)CEUEHe05Qd!PKO?$&KK2eMzeMxl`P}(7a1K_dgrH!EJM3P@6bnYnDwFcoMU1`0CD6CCbAL;TN6JmwJY6n zT4gEq#*l%|D8M;`y8lJs7jr98*HGY+@utBbIWJ* z)Y}24vORv^Brww*1siZ|eayF$9#=1zkzAAIe4df`I~Q~-$6Q$kMCLj$S20Da62;|Y z<38#K*4F??8@Eg<0y78OcR`jD1(9P|f6Fy6P*ib2M0bag_WV~D;eOoy{YnVvTc}vg zTMFD6W3MORBC`KEr3@OB|LAYFdFu0FBwC@6Q?gH7Ztz9*Za$dXmuv(?))6mH>$I%# z2rw}mPi|Qd{2D+}D$d`fCc5m7)KYw8xO0T4`W}E33L}H7XZJF!BfeSvtSrBnTff-< z`T1|oM_^>oiWJFNSWFALDlgLF#qgb`!*Te!Y|k~d%00f*8R$-hbLDO)s#EL1ohm@W z)AJ<|e_pOOP4o`vGs*IEG2l*iFIT7D_|fuw{^Q5uNDQ^W6D0cYNIXM0j1yVa{ zDtv$(J^xKmax;DduO4D$p3;sq9gSl|>=Q&gfMn3HSfkG&A;g;>^<;0WJ#mfDbOiWR z8;MotM^qlnVX=D|hkT|>Zw$NZ>S~ugNP|^`U`=aOa%$|f=S7|~qH*2Bl{x11cPDnY zjy?g*1<^(+gU?BgWkl{N5{4@i^~+PXd{so@E?{-{doOo*7p~W65Qp?dVSe7ZbaZrp z$kY^Ady?#f3b-DS2`arnq~#Bcg+javIO^2y;;Gi(q{G7KF82 zlbHx_Q(Ab)<_Hu`VA*}*tpB;K%msrX_GmhgIAi&NoNvU+O7x zv8VZwgPlFqsj;w~Y`W7#S=h15_7S050l@ige41+3NLi1#qGH1D<+{Sz?yvJ}w~wQk zAMGrBU+TZhK5@pF?TW!ylCsc^c54T0n&<_27Cd+$wyY$Lm5?404?Fgm&8N^cz#;Qz zDcIRmzx4kuoF=MzZ2(+lCc)3IgP^ZbaG{Q)qIIZSSrPtr`$MhDH<`1L`m9oOC2Cbw zk}(RH`trW$;(upUG|tCf>k10$Oh6^aTr3H^o5~-dgN&zUFsq~qRictd*#G}ypALVx zH#4K%{~^xxu=V3l-nwZd3sX$_Za;93J*8RX+beT+ZXTV4q4YN2$7&Q@99^f}k1yVN5#qeJ&UYZe z{SBl%KUY^82YX9$T@QyoW$gH@b_nrT(?(Fo*7-g{4hX3NIr-lmW*4(p@2Sr~rKq{R zHN|yl7=OO|ntZH%*SLNOsPCsNgj^YRao=hypMfA?Qp83PLM5=k$E>Ki`han~hwE}! zg(*fOipdTfZSFqNa1&D4Uq^7b9tTStxld`)+u|2FNssQc%a7BqFPV4F%}H;PZUc7| z&v-UDuWDF4s9wbZDJ4+|WjUG0#$fQ5UL^Z+`m6tQi0jLz(Q6bn=lBu(9(tK&AWDIm z(Fjym0{nS0>=1#uo17o1Wtx{z3<(&sR^>q!A()GHCAUUbqK?w=BPh%|LodX9IyuI6 zBtnAZj$0TY%>SG@v=~(8%zeBllG};ellzGHIpi=U0}e;!i2{u^e7xs#SiFrG>`yI& z&==mn3SppLBZ(I!khzptQ**Jw+i)E|^ic;H?1F@MKal5EOUB?Fp?roDI$ZJ|DFtxS z{=?5te(Vekpgv@zJ7y`8Jr@kz?1ydto~s?g`Jr}5`K&w`F6Aqy(9c8QMpwPSgv+b;O34G^*1zAF1fSMp}i_eP4pp{j?F zq5M66T0ICQ??}kq z`|idf$kjcFtf5r|^N}90vp?}D89gebE8wP<2PT0oV}94$;P<>WJC6W=@i-+vLUYIy zWtEw8x3!TI03W5o(}H|w8lT)%GZ?^8NAOnX;ay+VM86$j@me%A+>NR6@ht$2a`fJ45AWnhQgGPk#rwU zQozZw2YEKsN1k4*faLS^T=!RTMWgjf#%fKJ<1^K@#OA)+x7DUvo|B#q`2;Urs9s}8 zxMFd=pbo;u0GaL)=NUO+PztuO?j?VGafk^J*RoICb^cICYlZ7P$T$m6 zdI`M)W7oLx)3?U?;KY4xV|8AM0dYkG>kMHMaWe6Sp-Hex)AR7#+Ow&`p}Jc7Ec-|i zhUo|~eE(G1R=b6*N%b4{co9-yTgWcl1R;}f(TPXz(4)!8QOGRzbg1LphU|#l$}t9n zvdpDcHHZ|}3Oos!D?dlN>Lsbbzxcfw9z1tig+$RX=~ds~1ej4O13k(d=O(EN`55oJ zCpPG&vJtOcs4J4+x#5_|dUEhKHVO*3SiFBHK9Cz668Eh6X3SOIZU8;V$8-0263`i2+i8 zmew5Y)aP$vL4+ehtU*k`NGO5R3IvXkf>hwim**H9G%1i89N6V2A)RF#BM}E;Z>(Qd z_Uz9hRQd$Ajl)eqEwHl`rW=12Yz?ahAqRMuZ6ck&wwi5Te|3#Gt_w_XyjyiqpLM3K zN5z^>G>$q?5vk85E!a&V$nshF8W!l?w%=cgD>iZATcl-k_thJu{j%??Gz6wOz%!By zbp;<#AIbshimyFaD7}FcmObBmMU5jfD(kxxs0ehGUA2^WSx4s$Y9r$^Zu0 zsWFliJ&yUVNH8Et&el~IS)N0w#PH@_l6KwT#y7p#Or*wj?8~x1Y%EpWBhK|9#%iG8 za-4uibEk?m98;`^)+y5RXLBDq8@Lci)@WbqZiGPG(+NrkJ)cr|z`C$8Az*X?8dfND zvLKfkv2pUF#zmE_7Cy~x_$)3WXbc9Ot*b(wGOVSAv|ZYUv%6QOoD|DU-}1Nz8dLhacfs)|c)Z09Ba&XD7#7rdogA zz6~DDq8ccFzmO~oz^S@n+O7;ioUiOILR~^>iDjPpE=o*I%Wm2`2RfeJcF^uYqU@(SvXk3_${{CjZfB{9iEa38|7Jfp!X{2NO^%BrDL1NqcTOTcYi|zJJc}r`k zg(W1{fYI)+Yh|e3=Df#Cfk^+kJ-=rhSp1Yw@3VM_Wl)G@Tr}1BwfX&p>l5Gn* z0ydKlY$^{Vcd?|J{r~lV2(Q2m)G`JAS2SV)gU06+lKN!m8B=;5$!_4^jx zI6ok$-+*+1oBsh!r~*mscnrs}Aa)10sc`>S=%J5vGeniC!eZaf-&Ht!{7Jqw^rbLk z4}v;`CVY`Ia9sje=YNiPyoKQm)FmJnixmRiLg@i_5jI}+Oxg*On`SU{0;8r;`XEpB z7OA!A_UG>;OO=@&anEi5a?$XxoKlF}_>5jHUB`pv2fJLKv)>#NGN9N-3{Qa|&j-*gImO7jC7crP=8owG61;1$ckQDQiA`K;_;-utj1J9yqP72|7dyB=#>L znbCa;z`vlm_MhcOLz4OMRNi+`R>5P&)3uSWclnV9?9ZRjqcW)~&he-eo=&PhK*|Ji z&#OjJqi3CKJjuwQ4{^km6Dk$?Yio(G(0#(bRU|RXLYSSW?)1`=T+I)&rlW8ca!t}E zLGPtGCEJ4#*MP$JfP>r~C?w+$??PwNxUK;2n0m|LH z{7BGasa;w)S#6w=c#oaio-ph?BK}zUDLnO;M<2X=H-CSp4D^YS#NVewycOaF{Ch+h z$%G4_T@Eg|zt+g9U2Ow-jg!oWH&?7lurZIl&4?Ey=KBj-|0v))SMCZWt6RtS;yiv! z?AQVV)kTjZ2g`|xGj_iQ@j^YJhVw5e8sXRdn0jwQ5oA&=9c{{cn8LJ9i4)ptC?Q%2 z`9sNRyT78SUn|9p}NeKEbK2a|?Wc zJqh5?vx;#&{Le*#E6h6{ddk#a0tTq~<;4etgm_u#ZIwZK zrd&?KpM8eJL1@kE6r$kX8{23DmADjvx&N8 z15M{%N)3_NlZ9Axj&c&k=&MU8lk9|TL6ox*F|Mf{sYdjA`!rf13l zwM@oViC7+C4Sp|%P!e|51(HhOJ@~;WPwtT62}kIaN*!=&?1E4UV&W3;*J`Lu&7_O% zTp6D_1!Q=?oYqbkYI=KxSZzCYv19hP^g%WT+~NK9w15b`t!)CSL=Md?mmjWL2qa05 zOn$EfV9QcHz5%0q`}syb~N0KInpXUyYju< zA1!~oxI5Vf!ue)O!P8?fdl63r=Bt@7fqolwRXel|u|TVF|Nc4=Cnk z!=kIYCMWtkP1)tE(id>3k?m?G_xYw6U6VG_58xxfln=C;JPiEdP|4668cC8kD3FlQ z?d?Y!<^Nn3mE`;8pJunzKx&!eq@tWGlL@Gf`TBN}^nwk!sRIDJ4Q9GVweUmL zK%ZORXP47AKj>=fYS%5kK2B{|gawp;nq9o}v%3T2I6VwQgYzSMc#x}p=p*Yo{glu5 z?&77{7m^zZP}B@ppswj#!z zmw5|4G(r?YV<#QOn*;WRG^;El^aw4#B=bSG1G@tvYO9KauDT5(`YYk*IDNl0JY35* zZ!#9XyCQ<~eoBEP0 z-zeiK+WX~JeZ0f;OIDwYIZw5opV57wTVgMMx-A&Gjmz@Vg8)b+oND*@mEf#nm6r8i zJNeE;{i-SLEixuG+>WzhVQEQm{;DS7Iue*;=nOjlD&f;Klk*!7IZ@vve!_sRD3Sc^ zPH(l#T3oswvtQT5=v4yO%IX4r&2U6uiAL2#WJ-(gvOjT7%(jma>*kNTqAv9+$#-2V z(_s%_2C2>uH>%RMh?|!{-Z!Gzex5sBasr*wViv6`>g#AhKH5sN^|Rv_ZeX}%z{P|D8v;JU`%yfKOl<4VfP7JNM-{2p81M-|XMHqLAtUkC+YC@<0_4U% zi)@kXWQ4yR-Yj;kd~02y@{;SX(+j~ge~143CP+M5?=wH^Gk4Il{9AsYb z>sgYPFhQi&h6}BGH`t^G&)gpnA@(qtOis)sSx#l^+ZyNdW?lYOG2J%jIiKeuJ;&Pu zwJQUch1Wsf7yI+9k7-M|c3BUpMF3Xc#`?BS^-UmfZlq+-pkOMUGej!ZaptBLwhv#tNh_4RSfsH5@P#J!bQ z&ACX?3^g9twT;V(sO1HG#Tt!o<@yaE!yy-)BDvWquKZc-_i@vAG}ZI@M1n^Xcg~2< zs9*etTOYFv+Ka{fJ)F^vbK3M9fMi(3M$Yx*j7rNJ!ELN<2v2Wz`H7WO};m>Oy4zPj4k&d0cf`?mQ3yQNW|!TFWH6OFN>~ zWd#t>4t0$$ht14QFX8Z&UIf5t23`n4`8Tqw&7{hf9omg)*W^6cf{#Wa-alL4=;Dhj zygidHc1Q|9rA|Cj(9Yl!j`F|}Z2xSl_PHt5mX6E8&Pg=&QYX}k&Dz=CNG=G!@BLU5 ziQUuuxAmFP`K>qj>^A<&M#Qg&EvHMK{a!G%N^GXRRk6*#Z@q)+ zPtPAh2=0rMfiXO<{%M2r)uZ?O&`^5_FT1O#jqEJv&rB804{q&ASthz#Yw7D-^F$yjOLJx3-Hz8+; zhFN%a?oCF*17$a=jsu&c?$r~y(p#YbpKISzSD!zE10e7eOOXxXmNAR;8~4R>Xc6{z zWy`xsyDLE3Fo?uw*8y-MlW=dysqu7Tl=!c_b|sFV?c%EG^Mm<;u(9TiC;!rvz03CX z6`<|vaVX4-DbtW@4zJ!#tI9wx|2y2}0jMb?P1T) zPyzy#=Ai!Q5tot{)J87|8oNGsJ*=V$&)-gzXol`50M5wmN?HM%CKX0~FB83|iE|qJ zZ9}nr?@{*W0@rfM?AD``O>eVbnEw%a%&UY|a&s=3UBbxl=2tRG4&|vZ&4n-)v8#6d zoeP^2bKa9NF2_Y8Xyk*$%49X|ZZfFu{Sjrnp486gOOzlr6DiSo{Wpo|1XCi^yF0qo z+p--KQAzkmP0@^a{TpLHRRzZClsLdxrn|x-YRYgSbfv%{BPEdg)=5qUZ7pS5gL*+8G=r`M_}Fsm9)2mBz^S zk2!jqGtz6{FVBDpINSnL1-GtvcD`4lz2@~N6?#n#T;}9R{T|R>+SXs9F`y$SZ+8wn zY`*K<4U(}UwR}MQ^%cUV?$w9mg)kY&A*oKK{;L&gZS-DsYu8k_|MD+RQjYgtIsG}WLcjdbO8jlb3z7~J94UcI*$9+bN$4MT%fRQpQv zt(-&Y8kXC%kCLD6Db(L!UzR~dRB{wjotB5xwc=6hxnOt6^-kh~l(z?MD_iTm_DNq2 z>Dx;;!$Z>ILMri0Z?0tfKz`RLu`xny<={*5c7`hLp$mV1Lar0ss4gBlG*FazOX2$2 z10PpM9}>9C(k>xEk9Ky~9NyqR|6Uk)Az)|HUA9&W2q(*F3rKvE_!J{L36HRM%xuUT z$fW<6#<;!|>Y>7ERERs>*?{`it^UQ0!lN)azR-|OZ+s}LYH!A|}R7sNl7#x$qitYN0*D4@ZPq+UX#es>-c{3c%F zO2~@W%@uI?nPXUO5yc*`b?b7r#+5xtnu0y z9>~2(-0LY%a{Pi*zO}nUwa{Pu#8R*<;A)5D(*_qTHmT=)B@KY_O|04(Z$|!ZPWTS( zjrwnR%`f-6kKEm+^=-myNVh2nV`AJ3Oegg`dc;HOl>prDvJ$?q6v}76 zY<+T5Xj9$&ogsa@mS30!2j)ijunGKQ`_qcQ=Mc%hqrpVefJ6mvQKMcJ%2I9 z9De!lD7Eq`RM)=v*Wt?jVE9^rw^WMjj}*}cFPl22gFHSlUK?1mAmcT07E8wl!)JNY zgYF!{dciT@p;(vP+?jpgBkCjNuG8YDkWyus>4v@n+{4a8r4(YlVqqbrGihg;FEPSg z`2K7hidLO`5q*FKTC|CVbR_5GjF;uD93Q!*o-kE=-IQwiq6>9g$ox>TvTeU(wEf-~ zR0X5UU{d-qrO^Rza~^KY!9>MTmC^gOBF8Ob4&Ojgou=JJ?ayC@ge2jeWooycw?tRAh2a$`OP53Tv`pdX{aK zU#P{OHpgGt5hzfM2)>w-w?+Z;=1o;OCyVomPIo9bLsFkm<18X5A7VBi(w{_s#XnT= zwxAR$Be$=nb~6Z$fpYNv5Hw6H3!H#2DJbh*^_#ow*v1Sc55+IgSpLn!kqUShB^-&p zGxql^^JT_+`unrfM{SZ|kcMLR4!a^R86L#}Zyf>U7C{daHVJQbP1ATq-gE!Y*BdBg zcBk&m8gULN%*qkYVowW`G0)ZhH_g2|>TxIc2u-mXwwpH`*Tu05qw!6>))d8?v6Q)p zJdTr$Rjn?B*It!e5#=vAwEHy!N@rCwvZ{BKXrd0R+V+HafAAc2pM7cUgoEL=I)5qn zl*K;->VkI$Hrl~b(LsHhG}P|4DPW3oqQ~H(cpN9hJo;fk9t%|#mH$cX|2qwT69&Hl z&#t#Fks%BTF~a7)N-_65aTNC9=*gPeCwbO><~aq*$(8c+K6@qb!31GIp2=I@mQBchwNIVEg>(?2%!iIxM?S}7C;H~>uQfM0Bpu*W3Sp6CFErOoPYTg*vV3Y5d16t6 zhp%GLtV8x44UVXZqeSjQe_ubsF2N=VWB;qB-;VHmVqqVeM6U-_9m#{Z-#qc^jXNh{ zvi~xWy{kYWV*%r8d377e-zO=`4@hw$kY)juE`_SEF#P@od8RiLQ~Djz#tlgJ-A%Y! zUjnDbM`c+YkBPhWb-b(j-|Q)tQ~<*sTsEc3GY^hBABi>jZO<6Nb_m8;F;382u1xm_ zq>;9>O-DKiEL=GgC}dtq=5*u}`XUzgJT9~np8;=qm~?ocAZPiu3@za-OFs2K6YUCL zt{bAlnY~UF=PdhU`VOnnIeb3r|Z!v$?$Tir-Qcxj9Y-dw?Au_JfTY$cXHHKv5KUT6Qc+n7+8 z$qDh7^ktK%fEh>lhCoO>>;l^~*a)xLD6I1iB}{=M6Ow%|-geN(&_fa9AVRy1RHNIK z*OV%sk#uTT*f%mH6-qbCI1izOzTz)rH+ECvkm_|)#W3HGA3qnIkUpB$TQ~hNHF-kf< zBfZKGnXvSA6Mjq{=qVkYS0sDY|MMlFV-ht=3TV=oVH6~r!fQHGgfORu*W%ofr>diW zKA2|Q7RNO6!jeZL5sw;eyGkyGj E05KeCq5uE@ literal 0 HcmV?d00001 diff --git a/docs/logo-styles.css b/docs/logo-styles.css new file mode 100644 index 0000000..0c47ff9 --- /dev/null +++ b/docs/logo-styles.css @@ -0,0 +1,6 @@ +/* Override Dokka logo to use Funfix PNG */ +:root { + --dokka-logo-image-url: url('../images/funfix-512.png'); + --dokka-logo-height: 32px; + --dokka-logo-width: 32px; +} From bec4e248b77d3559649a25b400b765c86584b84d Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 18:22:41 +0200 Subject: [PATCH 15/21] Dokka site improvements --- build.gradle.kts | 13 ++++++++ .../includes/page_metadata.ftl | 6 ++++ docs/introduction.template.md | 30 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 docs/dokka-templates/includes/page_metadata.ftl create mode 100644 docs/introduction.template.md diff --git a/build.gradle.kts b/build.gradle.kts index 84233c8..39ebc35 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,6 +27,7 @@ buildscript { dokka { dokkaPublications.html { + includes.from(layout.buildDirectory.file("dokka/introduction.md")) outputDirectory.set(file("build/dokka")) } @@ -36,10 +37,22 @@ dokka { "docs/favicon.ico" ) customStyleSheets.from("docs/logo-styles.css") + templatesDir.set(file("docs/dokka-templates")) footerMessage.set("© Alexandru Nedelcu") } } +tasks.register("generateDokkaIntroduction") { + from("docs/introduction.template.md") + into(layout.buildDirectory.dir("dokka")) + rename { "introduction.md" } + expand(mapOf("version" to projectVersion)) +} + +tasks.named("dokkaGeneratePublicationHtml") { + dependsOn("generateDokkaIntroduction") +} + dependencies { dokka(project(":tasks-jvm")) dokka(project(":tasks-kotlin")) diff --git a/docs/dokka-templates/includes/page_metadata.ftl b/docs/dokka-templates/includes/page_metadata.ftl new file mode 100644 index 0000000..962e316 --- /dev/null +++ b/docs/dokka-templates/includes/page_metadata.ftl @@ -0,0 +1,6 @@ +<#macro display> + ${pageName} + <@template_cmd name="pathToRoot"> + + + diff --git a/docs/introduction.template.md b/docs/introduction.template.md new file mode 100644 index 0000000..bce2bc3 --- /dev/null +++ b/docs/introduction.template.md @@ -0,0 +1,30 @@ +# Module Tasks + +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. + +## Usage + +Read the [Javadoc](https://javadoc.io/doc/org.funfix/tasks-jvm/${version}/org/funfix/tasks/jvm/package-summary.html). Better documentation is coming. + +--- + +Maven: +```xml + + org.funfix + tasks-jvm + ${version} + +``` + +Gradle: +```kotlin +dependencies { + implementation("org.funfix:tasks-jvm:${version}") +} +``` + +sbt: +```scala +libraryDependencies += "org.funfix" % "tasks-jvm" % "${version}" +``` From 159769b61659a4a1cc6e4b426919e66da002013a Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 18:34:55 +0200 Subject: [PATCH 16/21] Update dokka website --- build.gradle.kts | 13 +------- .../main/kotlin/tasks.java-project.gradle.kts | 14 +++++++++ .../main/kotlin/tasks.kmp-project.gradle.kts | 11 +++++++ docs/introduction.md | 3 ++ docs/introduction.template.md | 30 ------------------- 5 files changed, 29 insertions(+), 42 deletions(-) create mode 100644 docs/introduction.md delete mode 100644 docs/introduction.template.md diff --git a/build.gradle.kts b/build.gradle.kts index 39ebc35..8f03191 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,7 +27,7 @@ buildscript { dokka { dokkaPublications.html { - includes.from(layout.buildDirectory.file("dokka/introduction.md")) + includes.from("docs/introduction.md") outputDirectory.set(file("build/dokka")) } @@ -42,17 +42,6 @@ dokka { } } -tasks.register("generateDokkaIntroduction") { - from("docs/introduction.template.md") - into(layout.buildDirectory.dir("dokka")) - rename { "introduction.md" } - expand(mapOf("version" to projectVersion)) -} - -tasks.named("dokkaGeneratePublicationHtml") { - dependsOn("generateDokkaIntroduction") -} - dependencies { dokka(project(":tasks-jvm")) dokka(project(":tasks-kotlin")) diff --git a/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts b/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts index c316ff3..bb787e5 100644 --- a/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts +++ b/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts @@ -1,6 +1,20 @@ +import java.net.URI + plugins { `java-library` jacoco id("org.jetbrains.dokka") id("tasks.base") } + +dokka { + dokkaSourceSets.configureEach { + val tag = "v${project.version}" + val relativePath = project.projectDir.relativeTo(project.rootDir).invariantSeparatorsPath + sourceLink { + localDirectory.set(file("src")) + remoteUrl.set(URI("https://github.com/funfix/tasks/tree/${tag}/${relativePath}/src")) + remoteLineSuffix.set("#L") + } + } +} diff --git a/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts b/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts index f008acb..814e22f 100644 --- a/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts +++ b/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts @@ -1,5 +1,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import java.net.URI plugins { id("org.jetbrains.kotlin.multiplatform") @@ -15,6 +16,16 @@ dokka { dokkaPublications.html { outputDirectory.set(dokkaOutputDir) } + + dokkaSourceSets.configureEach { + val tag = "v${project.version}" + val relativePath = project.projectDir.relativeTo(project.rootDir).invariantSeparatorsPath + sourceLink { + localDirectory.set(file("src")) + remoteUrl.set(URI("https://github.com/funfix/tasks/tree/${tag}/${relativePath}/src")) + remoteLineSuffix.set("#L") + } + } } val deleteDokkaOutputDir by tasks.register("deleteDokkaOutputDirectory") { diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 0000000..65eb2b0 --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,3 @@ +# Funfix Tasks + +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. diff --git a/docs/introduction.template.md b/docs/introduction.template.md deleted file mode 100644 index bce2bc3..0000000 --- a/docs/introduction.template.md +++ /dev/null @@ -1,30 +0,0 @@ -# Module Tasks - -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. - -## Usage - -Read the [Javadoc](https://javadoc.io/doc/org.funfix/tasks-jvm/${version}/org/funfix/tasks/jvm/package-summary.html). Better documentation is coming. - ---- - -Maven: -```xml - - org.funfix - tasks-jvm - ${version} - -``` - -Gradle: -```kotlin -dependencies { - implementation("org.funfix:tasks-jvm:${version}") -} -``` - -sbt: -```scala -libraryDependencies += "org.funfix" % "tasks-jvm" % "${version}" -``` From 5f60af0232d3fe3bd42d48b7f8d7be6b00d1e752 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 18:41:57 +0200 Subject: [PATCH 17/21] Add LICENSE --- LICENSE | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..862bfdc --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2024 Alexandru Nedelcu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From e6407058978cde3f9dbb866a963a388cc681daa2 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 19:24:37 +0200 Subject: [PATCH 18/21] Drop tasks-kotlin --- Makefile | 1 - build.gradle.kts | 1 - settings.gradle.kts | 1 - .../api/tasks-kotlin-coroutines.api | 10 +- tasks-kotlin-coroutines/build.gradle.kts | 32 -- .../org/funfix/tasks/kotlin/coroutines.kt | 40 --- .../org/funfix/tasks/kotlin/internals.kt | 15 - .../org/funfix/tasks/kotlin/coroutines.js.kt | 115 -------- .../funfix/tasks/kotlin/CoroutinesJsTest.kt | 26 -- .../org/funfix/tasks/kotlin/coroutines.jvm.kt | 129 +++++--- .../tasks/kotlin/CoroutinesCommonTest.kt | 26 +- .../funfix/tasks/kotlin/CoroutinesJvmTest.kt | 5 +- tasks-kotlin/api/tasks-kotlin.api | 129 -------- tasks-kotlin/build.gradle.kts | 60 ---- .../org/funfix/tasks/kotlin/Cancellable.kt | 22 -- .../kotlin/org/funfix/tasks/kotlin/Outcome.kt | 59 ---- .../kotlin/org/funfix/tasks/kotlin/Task.kt | 120 -------- .../tasks/kotlin/UncaughtExceptionHandler.kt | 19 -- .../org/funfix/tasks/kotlin/exceptions.kt | 24 -- .../org/funfix/tasks/kotlin/executors.kt | 49 ---- .../org/funfix/tasks/kotlin/AsyncTestUtils.kt | 20 -- .../org/funfix/tasks/kotlin/AsyncTests.kt | 135 --------- .../org/funfix/tasks/kotlin/Cancellable.js.kt | 46 --- .../funfix/tasks/kotlin/CancellablePromise.kt | 44 --- .../kotlin/org/funfix/tasks/kotlin/Task.js.kt | 259 ----------------- .../org/funfix/tasks/kotlin/exceptions.js.kt | 13 - .../org/funfix/tasks/kotlin/executors.js.kt | 129 -------- .../org/funfix/tasks/kotlin/SampleJsTest.kt | 11 - .../org/funfix/tests/TaskFromPromiseJsTest.kt | 104 ------- .../funfix/tests/TaskRunToPromiseJsTest.kt | 87 ------ .../org/funfix/tasks/kotlin/Fiber.jvm.kt | 197 ------------- .../org/funfix/tasks/kotlin/Task.jvm.kt | 220 -------------- .../kotlin/UncaughtExceptionHandler.jvm.kt | 16 - .../kotlin/org/funfix/tasks/kotlin/aliases.kt | 26 -- .../org/funfix/tasks/kotlin/executors.jvm.kt | 11 - .../org/funfix/tasks/kotlin/internals.kt | 26 -- .../tests/TaskEnsureRunningOnExecutorTest.kt | 34 --- .../org/funfix/tests/TaskFromAsyncTest.kt | 56 ---- .../funfix/tests/TaskFromBlockingIOTest.kt | 42 --- .../org/funfix/tests/TaskFromFutureTest.kt | 169 ----------- .../org/funfix/tests/TaskRunAsyncTest.kt | 105 ------- .../org/funfix/tests/TaskRunBlockingTest.kt | 106 ------- .../org/funfix/tests/TaskRunFiberTest.kt | 275 ------------------ .../kotlin/org/funfix/tests/TimedAwait.kt | 31 -- 44 files changed, 102 insertions(+), 2943 deletions(-) delete mode 100644 tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt delete mode 100644 tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/internals.kt delete mode 100644 tasks-kotlin-coroutines/src/jsMain/kotlin/org/funfix/tasks/kotlin/coroutines.js.kt delete mode 100644 tasks-kotlin-coroutines/src/jsTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJsTest.kt rename tasks-kotlin-coroutines/src/{commonTest => jvmTest}/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt (80%) delete mode 100644 tasks-kotlin/api/tasks-kotlin.api delete mode 100644 tasks-kotlin/build.gradle.kts delete mode 100644 tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Cancellable.kt delete mode 100644 tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Outcome.kt delete mode 100644 tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Task.kt delete mode 100644 tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.kt delete mode 100644 tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/exceptions.kt delete mode 100644 tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/executors.kt delete mode 100644 tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTestUtils.kt delete mode 100644 tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTests.kt delete mode 100644 tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Cancellable.js.kt delete mode 100644 tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/CancellablePromise.kt delete mode 100644 tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Task.js.kt delete mode 100644 tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/exceptions.js.kt delete mode 100644 tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/executors.js.kt delete mode 100644 tasks-kotlin/src/jsTest/kotlin/org/funfix/tasks/kotlin/SampleJsTest.kt delete mode 100644 tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskFromPromiseJsTest.kt delete mode 100644 tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskRunToPromiseJsTest.kt delete mode 100644 tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Fiber.jvm.kt delete mode 100644 tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Task.jvm.kt delete mode 100644 tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.jvm.kt delete mode 100644 tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/aliases.kt delete mode 100644 tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/executors.jvm.kt delete mode 100644 tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/internals.kt delete mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskEnsureRunningOnExecutorTest.kt delete mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromAsyncTest.kt delete mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromBlockingIOTest.kt delete mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromFutureTest.kt delete mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunAsyncTest.kt delete mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunBlockingTest.kt delete mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunFiberTest.kt delete mode 100644 tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TimedAwait.kt diff --git a/Makefile b/Makefile index 1d18824..634b4da 100644 --- a/Makefile +++ b/Makefile @@ -19,4 +19,3 @@ test-watch: test-coverage: ./gradlew clean build jacocoTestReport koverHtmlReportJvm open tasks-jvm/build/reports/jacoco/test/html/index.html - open ./tasks-kotlin/build/reports/kover/htmlJvm/index.html diff --git a/build.gradle.kts b/build.gradle.kts index 8f03191..01c0198 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,7 +44,6 @@ dokka { dependencies { dokka(project(":tasks-jvm")) - dokka(project(":tasks-kotlin")) dokka(project(":tasks-kotlin-coroutines")) } diff --git a/settings.gradle.kts b/settings.gradle.kts index c425a36..440904e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,6 @@ rootProject.name = "tasks" include("tasks-jvm") -include("tasks-kotlin") include("tasks-kotlin-coroutines") pluginManagement { diff --git a/tasks-kotlin-coroutines/api/tasks-kotlin-coroutines.api b/tasks-kotlin-coroutines/api/tasks-kotlin-coroutines.api index 86a457c..b8e5050 100644 --- a/tasks-kotlin-coroutines/api/tasks-kotlin-coroutines.api +++ b/tasks-kotlin-coroutines/api/tasks-kotlin-coroutines.api @@ -1,9 +1,7 @@ public final class org/funfix/tasks/kotlin/CoroutinesJvmKt { - public static final fun fromSuspended (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun fromSuspended$default (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static final fun runSuspended (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun runSuspended$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static final fun runSuspended-A-R0woo (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun runSuspended-A-R0woo$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun runSuspending (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun runSuspending$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun suspendAsTask (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;)Lorg/funfix/tasks/jvm/Task; + public static synthetic fun suspendAsTask$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/funfix/tasks/jvm/Task; } diff --git a/tasks-kotlin-coroutines/build.gradle.kts b/tasks-kotlin-coroutines/build.gradle.kts index 1175a8a..9da0455 100644 --- a/tasks-kotlin-coroutines/build.gradle.kts +++ b/tasks-kotlin-coroutines/build.gradle.kts @@ -17,25 +17,6 @@ mavenPublishing { kotlin { sourceSets { - val commonMain by getting { - compilerOptions { - explicitApi = ExplicitApiMode.Strict - allWarningsAsErrors = true - } - - dependencies { - implementation(project(":tasks-kotlin")) - implementation(libs.kotlinx.coroutines.core) - } - } - - val commonTest by getting { - dependencies { - implementation(libs.kotlin.test) - implementation(libs.kotlinx.coroutines.test) - } - } - val jvmMain by getting { compilerOptions { explicitApi = ExplicitApiMode.Strict @@ -44,7 +25,6 @@ kotlin { dependencies { implementation(project(":tasks-jvm")) - implementation(project(":tasks-kotlin")) implementation(libs.kotlinx.coroutines.core) } } @@ -55,17 +35,5 @@ kotlin { implementation(libs.kotlinx.coroutines.test) } } - - val jsMain by getting { - compilerOptions { - explicitApi = ExplicitApiMode.Strict - allWarningsAsErrors = true - } - - dependencies { - implementation(project(":tasks-kotlin")) - implementation(libs.kotlinx.coroutines.core) - } - } } } diff --git a/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt b/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt deleted file mode 100644 index 1eedc3f..0000000 --- a/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/coroutines.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.funfix.tasks.kotlin - -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext - -/** - * Similar with `runBlocking`, however this is a "suspended" function, - * to be executed in the context of kotlinx.coroutines. - * - * NOTES: - * - The `kotlinx.coroutines.CoroutineDispatcher`, made available via the - * "coroutine context", is used to execute the task, being passed to - * the task's implementation as an `Executor`. - * - The coroutine's cancellation protocol cooperates with that of [Task], - * so cancelling the coroutine will also cancel the task (including the - * possibility for back-pressuring on the fiber's completion after - * cancellation). - * - * @param executor is an override of the `Executor` to be used for executing - * the task. If `null`, the `Executor` will be derived from the - * `CoroutineDispatcher` - */ -public expect suspend fun Task.runSuspended( - executor: Executor? = null -): T - -/** - * See documentation for [Task.runSuspended]. - */ -public expect suspend fun PlatformTask.runSuspended( - executor: Executor? = null -): T - -/** - * Creates a [Task] from a suspended block of code. - */ -public expect suspend fun Task.Companion.fromSuspended( - coroutineContext: CoroutineContext = EmptyCoroutineContext, - block: suspend () -> T -): Task diff --git a/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/internals.kt b/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/internals.kt deleted file mode 100644 index 1cef288..0000000 --- a/tasks-kotlin-coroutines/src/commonMain/kotlin/org/funfix/tasks/kotlin/internals.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.funfix.tasks.kotlin - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlin.coroutines.ContinuationInterceptor -import kotlinx.coroutines.currentCoroutineContext - -/** - * Internal API: gets the current [CoroutineDispatcher] from the coroutine context. - */ -internal suspend fun currentDispatcher(): CoroutineDispatcher { - // Access the coroutineContext to get the ContinuationInterceptor - val continuationInterceptor = currentCoroutineContext()[ContinuationInterceptor] - return continuationInterceptor as? CoroutineDispatcher ?: Dispatchers.Default -} diff --git a/tasks-kotlin-coroutines/src/jsMain/kotlin/org/funfix/tasks/kotlin/coroutines.js.kt b/tasks-kotlin-coroutines/src/jsMain/kotlin/org/funfix/tasks/kotlin/coroutines.js.kt deleted file mode 100644 index c76ac15..0000000 --- a/tasks-kotlin-coroutines/src/jsMain/kotlin/org/funfix/tasks/kotlin/coroutines.js.kt +++ /dev/null @@ -1,115 +0,0 @@ -@file:OptIn(DelicateCoroutinesApi::class) - -package org.funfix.tasks.kotlin - -import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.coroutines.cancellation.CancellationException -import kotlin.coroutines.resumeWithException - -public actual suspend fun PlatformTask.runSuspended( - executor: Executor? -): T = run { - val executorOrDefault = executor ?: buildExecutor(currentDispatcher()) - suspendCancellableCoroutine { cont -> - val contCallback = cont.asCompletionCallback() - try { - val token = this.invoke(executorOrDefault, contCallback) - cont.invokeOnCancellation { - token.cancel() - } - } catch (e: Throwable) { - UncaughtExceptionHandler.rethrowIfFatal(e) - contCallback(Outcome.Failure(e)) - } - } -} - -internal fun buildExecutor(dispatcher: CoroutineDispatcher): Executor = - DispatcherExecutor(dispatcher) - -internal fun buildCoroutineDispatcher( - @Suppress("UNUSED_PARAMETER") executor: Executor -): CoroutineDispatcher = - // Building this CoroutineDispatcher from an Executor is problematic, and there's no - // point in even trying on top of JS engines. - Dispatchers.Default - -private class DispatcherExecutor(val dispatcher: CoroutineDispatcher) : Executor { - override fun execute(command: Runnable) { - if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) { - dispatcher.dispatch( - EmptyCoroutineContext, - { command.run() } - ) - } else { - command.run() - } - } - - override fun toString(): String = - dispatcher.toString() -} - -internal fun CancellableContinuation.asCompletionCallback(): Callback { - var isActive = true - return { outcome -> - if (outcome is Outcome.Failure) { - UncaughtExceptionHandler.rethrowIfFatal(outcome.exception) - } - if (isActive) { - isActive = false - when (outcome) { - is Outcome.Success -> - resume(outcome.value) { _, _, _ -> - // on cancellation? - } - is Outcome.Failure -> - resumeWithException(outcome.exception) - is Outcome.Cancellation -> - resumeWithException(kotlinx.coroutines.CancellationException()) - } - } else if (outcome is Outcome.Failure) { - UncaughtExceptionHandler.logOrRethrow(outcome.exception) - } - } -} - -/** - * Creates a [Task] from a suspended block of code. - */ -public actual suspend fun Task.Companion.fromSuspended( - coroutineContext: CoroutineContext, - block: suspend () -> T -): Task = - Task.fromAsync { executor, callback -> - val job = GlobalScope.launch( - buildCoroutineDispatcher(executor) + coroutineContext - ) { - try { - val r = block() - callback(Outcome.Success(r)) - } catch (e: Throwable) { - UncaughtExceptionHandler.rethrowIfFatal(e) - when (e) { - is CancellationException, is TaskCancellationException -> - callback(Outcome.Cancellation) - else -> - callback(Outcome.Failure(e)) - } - } - } - Cancellable { - job.cancel() - } - } - -public actual suspend fun Task.runSuspended(executor: Executor?): T = - asPlatform.runSuspended(executor) diff --git a/tasks-kotlin-coroutines/src/jsTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJsTest.kt b/tasks-kotlin-coroutines/src/jsTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJsTest.kt deleted file mode 100644 index f939325..0000000 --- a/tasks-kotlin-coroutines/src/jsTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJsTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.funfix.tasks.kotlin - -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class CoroutinesJsTest { - @Test - fun runSuspendedUsesProvidedExecutor() = runTest { - var executed = false - val executor = Executor { command -> - executed = true - command.run() - } - val task = Task.fromAsync { exec, cb -> - exec.execute { cb(Outcome.Success(7)) } - Cancellable {} - } - - val result = task.runSuspended(executor) - - assertEquals(7, result) - assertTrue(executed) - } -} diff --git a/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt b/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt index aff9371..b0496ca 100644 --- a/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt +++ b/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt @@ -5,34 +5,91 @@ package org.funfix.tasks.kotlin import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine +import org.funfix.tasks.jvm.Cancellable import org.funfix.tasks.jvm.CompletionCallback +import org.funfix.tasks.jvm.Outcome +import org.funfix.tasks.jvm.Task +import org.funfix.tasks.jvm.TaskCancellationException +import org.funfix.tasks.jvm.UncaughtExceptionHandler +import java.util.concurrent.Executor import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.ContinuationInterceptor import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.resumeWithException -import org.funfix.tasks.jvm.Outcome -public actual suspend fun PlatformTask.runSuspended(executor: Executor?): T = - run { - val executorOrDefault = executor ?: currentDispatcher().asExecutor() - suspendCancellableCoroutine { cont -> - val contCallback = CoroutineAsCompletionCallback(cont) - try { - val token = runAsync(executorOrDefault, contCallback) - cont.invokeOnCancellation { - token.cancel() - } - } catch (e: Throwable) { - UncaughtExceptionHandler.rethrowIfFatal(e) - contCallback.onFailure(e) +/** + * Similar with `runBlocking`, however this is a "suspended" function, + * to be executed in the context of kotlinx.coroutines. + * + * NOTES: + * - The `kotlinx.coroutines.CoroutineDispatcher`, made available via the + * "coroutine context", is used to execute the task, being passed to + * the task's implementation as an `Executor`. + * - The coroutine's cancellation protocol cooperates with that of [Task], + * so cancelling the coroutine will also cancel the task (including the + * possibility for back-pressuring on the fiber's completion after + * cancellation). + * + * @param executor is an override of the `Executor` to be used for executing + * the task. If `null`, the `Executor` will be derived from the + * `CoroutineDispatcher` + */ +public suspend fun Task.runSuspending( + executor: Executor? = null +): T = run { + val executorOrDefault = executor ?: currentDispatcher().asExecutor() + suspendCancellableCoroutine { cont -> + val contCallback = CoroutineAsCompletionCallback(cont) + try { + val token = runAsync(executorOrDefault, contCallback) + cont.invokeOnCancellation { + token.cancel() } + } catch (e: Throwable) { + UncaughtExceptionHandler.rethrowIfFatal(e) + contCallback.onFailure(e) } } +} + +/** + * Creates a [Task] from a suspended block of code. + */ +public fun suspendAsTask( + coroutineContext: CoroutineContext = EmptyCoroutineContext, + block: suspend () -> T +): Task = Task.fromAsync { executor, callback -> + val job = GlobalScope.launch( + executor.asCoroutineDispatcher() + coroutineContext + ) { + try { + val r = block() + callback.onSuccess(r) + } catch (e: Throwable) { + UncaughtExceptionHandler.rethrowIfFatal(e) + when (e) { + is CancellationException, + is TaskCancellationException, + is InterruptedException -> + callback.onCancellation() + else -> + callback.onFailure(e) + } + } + } + Cancellable { + job.cancel() + } +} /** * Internal API: wraps a [CancellableContinuation] into a [CompletionCallback]. @@ -52,8 +109,8 @@ internal class CoroutineAsCompletionCallback( override fun onOutcome(outcome: Outcome) { when (outcome) { - is Outcome.Success -> onSuccess(outcome.value) - is Outcome.Failure -> onFailure(outcome.exception) + is Outcome.Success -> onSuccess(outcome.value()) + is Outcome.Failure -> onFailure(outcome.exception()) is Outcome.Cancellation -> onCancellation() } } @@ -76,39 +133,15 @@ internal class CoroutineAsCompletionCallback( override fun onCancellation() { completeWith { - cont.resumeWithException(kotlinx.coroutines.CancellationException()) + cont.resumeWithException(CancellationException()) } } } -public actual suspend fun Task.Companion.fromSuspended( - coroutineContext: CoroutineContext, - block: suspend () -> T -): Task = Task( - PlatformTask.fromAsync { executor, callback -> - val job = GlobalScope.launch( - executor.asCoroutineDispatcher() + coroutineContext - ) { - try { - val r = block() - callback.onSuccess(r) - } catch (e: Throwable) { - UncaughtExceptionHandler.rethrowIfFatal(e) - when (e) { - is CancellationException, - is TaskCancellationException, - is InterruptedException -> - callback.onCancellation() - else -> - callback.onFailure(e) - } - } - } - Cancellable { - job.cancel() - } - } -) - -public actual suspend fun Task.runSuspended(executor: Executor?): T = - asPlatform.runSuspended(executor) +/** + * Internal API: gets the current [kotlinx.coroutines.CoroutineDispatcher] from the coroutine context. + */ +internal suspend fun currentDispatcher(): kotlinx.coroutines.CoroutineDispatcher { + val continuationInterceptor = currentCoroutineContext()[ContinuationInterceptor] + return continuationInterceptor as? kotlinx.coroutines.CoroutineDispatcher ?: Dispatchers.Default +} diff --git a/tasks-kotlin-coroutines/src/commonTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt similarity index 80% rename from tasks-kotlin-coroutines/src/commonTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt rename to tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt index 64a0901..cde7a07 100644 --- a/tasks-kotlin-coroutines/src/commonTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt +++ b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt @@ -5,6 +5,9 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest +import org.funfix.tasks.jvm.Cancellable +import org.funfix.tasks.jvm.Outcome +import org.funfix.tasks.jvm.Task import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -12,12 +15,12 @@ import kotlin.test.assertFailsWith class CoroutinesCommonTest { @Test fun runSuspendedSuccess() = runTest { - val task = Task.fromAsync { _, cb -> - cb(Outcome.Success(42)) + val task = Task.fromAsync { _, cb -> + cb.onSuccess(42) Cancellable {} } - val result = task.runSuspended() + val result = task.runSuspending() assertEquals(42, result) } @@ -26,11 +29,11 @@ class CoroutinesCommonTest { fun runSuspendedFailure() = runTest { val ex = RuntimeException("Boom") val task = Task.fromAsync { _, cb -> - cb(Outcome.Failure(ex)) + cb.onFailure(ex) Cancellable {} } - val thrown = assertFailsWith { task.runSuspended() } + val thrown = assertFailsWith { task.runSuspending() } assertEquals("Boom", thrown.message) } @@ -43,11 +46,11 @@ class CoroutinesCommonTest { started.complete(Unit) Cancellable { cancelled.complete(Unit) - cb(Outcome.Cancellation) + cb.onCancellation() } } - val deferred = async { task.runSuspended() } + val deferred = async { task.runSuspending() } started.await() deferred.cancel() @@ -57,7 +60,7 @@ class CoroutinesCommonTest { @Test fun fromSuspendedSuccess() = runTest { - val task = Task.fromSuspended { + val task = suspendAsTask { 21 + 21 } val deferred = CompletableDeferred>() @@ -69,7 +72,7 @@ class CoroutinesCommonTest { @Test fun fromSuspendedFailure() = runTest { val ex = RuntimeException("Boom") - val task = Task.fromSuspended { + val task = suspendAsTask { throw ex } val deferred = CompletableDeferred>() @@ -81,7 +84,7 @@ class CoroutinesCommonTest { @Test fun fromSuspendedCancellation() = runTest { val started = CompletableDeferred() - val task = Task.fromSuspended { + val task = suspendAsTask { started.complete(Unit) awaitCancellation() } @@ -91,7 +94,6 @@ class CoroutinesCommonTest { started.await() cancel.cancel() - assertEquals(Outcome.Cancellation, deferred.await()) + assertEquals(Outcome.Cancellation(), deferred.await()) } - } diff --git a/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt index 998a1c6..baaca1d 100644 --- a/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt +++ b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt @@ -3,13 +3,14 @@ package org.funfix.tasks.kotlin import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext +import org.funfix.tasks.jvm.Task import java.util.concurrent.Executors import kotlin.test.Test import kotlin.test.assertTrue class CoroutinesJvmTest { @Test - fun runSuspendedUsesCurrentDispatcherWhenExecutorNull() = runTest { + fun `runSuspending uses current Dispatcher when Executor is null`() = runTest { val executor = Executors.newSingleThreadExecutor { r -> val thread = Thread(r) thread.isDaemon = true @@ -19,7 +20,7 @@ class CoroutinesJvmTest { val dispatcher = executor.asCoroutineDispatcher() try { val threadName = withContext(dispatcher) { - Task.fromBlockingIO { Thread.currentThread().name }.runSuspended() + Task.fromBlockingIO { Thread.currentThread().name }.runSuspending() } assertTrue(threadName.startsWith("coroutines-test-")) diff --git a/tasks-kotlin/api/tasks-kotlin.api b/tasks-kotlin/api/tasks-kotlin.api deleted file mode 100644 index fca333b..0000000 --- a/tasks-kotlin/api/tasks-kotlin.api +++ /dev/null @@ -1,129 +0,0 @@ -public final class org/funfix/tasks/kotlin/ExecutorsJvmKt { - public static final fun getSharedIOExecutor ()Ljava/util/concurrent/Executor; - public static final fun getTrampolineExecutor ()Ljava/util/concurrent/Executor; -} - -public final class org/funfix/tasks/kotlin/Fiber : org/funfix/tasks/jvm/Cancellable { - public static final synthetic fun box-impl (Lorg/funfix/tasks/jvm/Fiber;)Lorg/funfix/tasks/kotlin/Fiber; - public fun cancel ()V - public static fun cancel-impl (Lorg/funfix/tasks/jvm/Fiber;)V - public static fun constructor-impl (Lorg/funfix/tasks/jvm/Fiber;)Lorg/funfix/tasks/jvm/Fiber; - public fun equals (Ljava/lang/Object;)Z - public static fun equals-impl (Lorg/funfix/tasks/jvm/Fiber;Ljava/lang/Object;)Z - public static final fun equals-impl0 (Lorg/funfix/tasks/jvm/Fiber;Lorg/funfix/tasks/jvm/Fiber;)Z - public final fun getAsPlatform ()Lorg/funfix/tasks/jvm/Fiber; - public fun hashCode ()I - public static fun hashCode-impl (Lorg/funfix/tasks/jvm/Fiber;)I - public fun toString ()Ljava/lang/String; - public static fun toString-impl (Lorg/funfix/tasks/jvm/Fiber;)Ljava/lang/String; - public final synthetic fun unbox-impl ()Lorg/funfix/tasks/jvm/Fiber; -} - -public final class org/funfix/tasks/kotlin/FiberJvmKt { - public static final fun asKotlin (Lorg/funfix/tasks/jvm/Fiber;)Lorg/funfix/tasks/jvm/Fiber; - public static final fun awaitAsync-bilpdk0 (Lorg/funfix/tasks/jvm/Fiber;Lkotlin/jvm/functions/Function1;)Lorg/funfix/tasks/jvm/Cancellable; - public static final fun awaitBlocking-eJoBjQM (Lorg/funfix/tasks/jvm/Fiber;)Ljava/lang/Object; - public static final fun awaitBlockingTimed-MI-qbaI (Lorg/funfix/tasks/jvm/Fiber;J)Ljava/lang/Object; - public static final fun getOutcomeOrNull-eJoBjQM (Lorg/funfix/tasks/jvm/Fiber;)Lorg/funfix/tasks/kotlin/Outcome; - public static final fun getResultOrThrow-eJoBjQM (Lorg/funfix/tasks/jvm/Fiber;)Ljava/lang/Object; - public static final fun joinAsync-bilpdk0 (Lorg/funfix/tasks/jvm/Fiber;Ljava/lang/Runnable;)Lorg/funfix/tasks/jvm/Cancellable; - public static final fun joinBlocking-eJoBjQM (Lorg/funfix/tasks/jvm/Fiber;)V - public static final fun joinBlockingTimed-MI-qbaI (Lorg/funfix/tasks/jvm/Fiber;J)V -} - -public abstract interface class org/funfix/tasks/kotlin/Outcome { - public static final field Companion Lorg/funfix/tasks/kotlin/Outcome$Companion; - public fun getOrThrow ()Ljava/lang/Object; -} - -public final class org/funfix/tasks/kotlin/Outcome$Cancellation : org/funfix/tasks/kotlin/Outcome { - public static final field INSTANCE Lorg/funfix/tasks/kotlin/Outcome$Cancellation; - public fun equals (Ljava/lang/Object;)Z - public synthetic fun getOrThrow ()Ljava/lang/Object; - public fun getOrThrow ()Ljava/lang/Void; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/funfix/tasks/kotlin/Outcome$Companion { - public final fun cancellation ()Lorg/funfix/tasks/kotlin/Outcome; - public final fun failure (Ljava/lang/Throwable;)Lorg/funfix/tasks/kotlin/Outcome; - public final fun success (Ljava/lang/Object;)Lorg/funfix/tasks/kotlin/Outcome; -} - -public final class org/funfix/tasks/kotlin/Outcome$DefaultImpls { - public static fun getOrThrow (Lorg/funfix/tasks/kotlin/Outcome;)Ljava/lang/Object; -} - -public final class org/funfix/tasks/kotlin/Outcome$Failure : org/funfix/tasks/kotlin/Outcome { - public fun (Ljava/lang/Throwable;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun copy (Ljava/lang/Throwable;)Lorg/funfix/tasks/kotlin/Outcome$Failure; - public static synthetic fun copy$default (Lorg/funfix/tasks/kotlin/Outcome$Failure;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/funfix/tasks/kotlin/Outcome$Failure; - public fun equals (Ljava/lang/Object;)Z - public final fun getException ()Ljava/lang/Throwable; - public synthetic fun getOrThrow ()Ljava/lang/Object; - public fun getOrThrow ()Ljava/lang/Void; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/funfix/tasks/kotlin/Outcome$Success : org/funfix/tasks/kotlin/Outcome { - public fun (Ljava/lang/Object;)V - public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lorg/funfix/tasks/kotlin/Outcome$Success; - public static synthetic fun copy$default (Lorg/funfix/tasks/kotlin/Outcome$Success;Ljava/lang/Object;ILjava/lang/Object;)Lorg/funfix/tasks/kotlin/Outcome$Success; - public fun equals (Ljava/lang/Object;)Z - public fun getOrThrow ()Ljava/lang/Object; - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/funfix/tasks/kotlin/Task { - public static final field Companion Lorg/funfix/tasks/kotlin/Task$Companion; - public static final synthetic fun box-impl (Lorg/funfix/tasks/jvm/Task;)Lorg/funfix/tasks/kotlin/Task; - public static fun constructor-impl (Lorg/funfix/tasks/jvm/Task;)Lorg/funfix/tasks/jvm/Task; - public fun equals (Ljava/lang/Object;)Z - public static fun equals-impl (Lorg/funfix/tasks/jvm/Task;Ljava/lang/Object;)Z - public static final fun equals-impl0 (Lorg/funfix/tasks/jvm/Task;Lorg/funfix/tasks/jvm/Task;)Z - public static final fun getAsJava-impl (Lorg/funfix/tasks/jvm/Task;)Lorg/funfix/tasks/jvm/Task; - public final fun getAsPlatform ()Lorg/funfix/tasks/jvm/Task; - public fun hashCode ()I - public static fun hashCode-impl (Lorg/funfix/tasks/jvm/Task;)I - public fun toString ()Ljava/lang/String; - public static fun toString-impl (Lorg/funfix/tasks/jvm/Task;)Ljava/lang/String; - public final synthetic fun unbox-impl ()Lorg/funfix/tasks/jvm/Task; -} - -public final class org/funfix/tasks/kotlin/Task$Companion { -} - -public final class org/funfix/tasks/kotlin/TaskJvmKt { - public static final fun ensureRunningOnExecutor-EZXAkWY (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;)Lorg/funfix/tasks/jvm/Task; - public static synthetic fun ensureRunningOnExecutor-EZXAkWY$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;ILjava/lang/Object;)Lorg/funfix/tasks/jvm/Task; - public static final fun fromAsync (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/jvm/functions/Function2;)Lorg/funfix/tasks/jvm/Task; - public static final fun fromBlockingFuture (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/jvm/functions/Function0;)Lorg/funfix/tasks/jvm/Task; - public static final fun fromBlockingIO (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/jvm/functions/Function0;)Lorg/funfix/tasks/jvm/Task; - public static final fun fromCancellableFuture (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/jvm/functions/Function0;)Lorg/funfix/tasks/jvm/Task; - public static final fun fromCompletionStage (Lorg/funfix/tasks/kotlin/Task$Companion;Lkotlin/jvm/functions/Function0;)Lorg/funfix/tasks/jvm/Task; - public static final fun runAsync-A-R0woo (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/jvm/functions/Function1;)Lorg/funfix/tasks/jvm/Cancellable; - public static synthetic fun runAsync-A-R0woo$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/funfix/tasks/jvm/Cancellable; - public static final fun runBlocking-EZXAkWY (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;)Ljava/lang/Object; - public static synthetic fun runBlocking-EZXAkWY$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;ILjava/lang/Object;)Ljava/lang/Object; - public static final fun runBlockingTimed-4GGJJa0 (Lorg/funfix/tasks/jvm/Task;JLjava/util/concurrent/Executor;)Ljava/lang/Object; - public static synthetic fun runBlockingTimed-4GGJJa0$default (Lorg/funfix/tasks/jvm/Task;JLjava/util/concurrent/Executor;ILjava/lang/Object;)Ljava/lang/Object; - public static final fun runFiber-EZXAkWY (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;)Lorg/funfix/tasks/jvm/Fiber; - public static synthetic fun runFiber-EZXAkWY$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;ILjava/lang/Object;)Lorg/funfix/tasks/jvm/Fiber; -} - -public final class org/funfix/tasks/kotlin/TaskKt { - public static final fun asKotlin (Lorg/funfix/tasks/jvm/Task;)Lorg/funfix/tasks/jvm/Task; -} - -public final class org/funfix/tasks/kotlin/UncaughtExceptionHandler { - public static final field INSTANCE Lorg/funfix/tasks/kotlin/UncaughtExceptionHandler; - public final fun logOrRethrow (Ljava/lang/Throwable;)V - public final fun rethrowIfFatal (Ljava/lang/Throwable;)V -} - diff --git a/tasks-kotlin/build.gradle.kts b/tasks-kotlin/build.gradle.kts deleted file mode 100644 index 0873570..0000000 --- a/tasks-kotlin/build.gradle.kts +++ /dev/null @@ -1,60 +0,0 @@ -@file:OptIn(ExperimentalKotlinGradlePluginApi::class) - -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode - -plugins { - id("tasks.kmp-project") - id("tasks.versions") -} - -mavenPublishing { - pom { - name = "Tasks / Kotlin" - description = "Integration with Kotlin Multiplatform" - } -} - -kotlin { - sourceSets { - val commonMain by getting { - compilerOptions { - explicitApi = ExplicitApiMode.Strict - allWarningsAsErrors = true - } - } - - val commonTest by getting { - dependencies { - implementation(libs.kotlin.test) - implementation(libs.kotlinx.coroutines.test) - } - } - - val jvmMain by getting { - compilerOptions { - explicitApi = ExplicitApiMode.Strict - allWarningsAsErrors = true - } - - dependencies { - implementation(project(":tasks-jvm")) - compileOnly(libs.jetbrains.annotations) - } - } - - val jvmTest by getting { - dependencies { - implementation(libs.kotlin.test) - implementation(libs.kotlinx.coroutines.test) - } - } - - val jsMain by getting { - compilerOptions { - explicitApi = ExplicitApiMode.Strict - allWarningsAsErrors = true - } - } - } -} diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Cancellable.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Cancellable.kt deleted file mode 100644 index d7b550c..0000000 --- a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Cancellable.kt +++ /dev/null @@ -1,22 +0,0 @@ -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") - -package org.funfix.tasks.kotlin - -/** - * Represents a non-blocking piece of logic that triggers the cancellation - * procedure of an asynchronous computation. - * - * MUST NOT block the calling thread. Interruption of the computation - * isn't guaranteed to have happened after this call returns. - * - * MUST BE idempotent, i.e. calling it multiple times should have the same - * effect as calling it once. - * - * MUST BE thread-safe. - */ -public expect fun interface Cancellable { - /** - * Triggers the cancellation of the computation. - */ - public fun cancel() -} diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Outcome.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Outcome.kt deleted file mode 100644 index 6f4dd4d..0000000 --- a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Outcome.kt +++ /dev/null @@ -1,59 +0,0 @@ -package org.funfix.tasks.kotlin - -/** - * Represents the result of a computation. - * - * This is a union type that can signal: - * - a successful result, via [Outcome.Success] - * - a failure (with an exception), via [Outcome.Failure] - * - a cancelled computation, via [Outcome.Cancellation] - */ -public sealed interface Outcome { - public val orThrow: T - /** - * Returns the successful result of a computation, or throws an exception - * if the computation failed or was cancelled. - * - * @throws TaskCancellationException in case this is an [Outcome.Cancellation] - * @throws Throwable in case this is an [Outcome.Failure] - */ - @Throws(TaskCancellationException::class) - get() = - when (this) { - is Success -> value - is Failure -> throw exception - is Cancellation -> throw TaskCancellationException("Task was cancelled") - } - - /** - * Returned in case the task was successful. - */ - public data class Success(val value: T): Outcome - - /** - * Returned in case the task failed with an exception. - */ - public data class Failure(val exception: Throwable): Outcome - - /** - * Returned in case the task was cancelled. - */ - public data object Cancellation: Outcome - - public companion object { - /** - * Constructs a successful [Outcome] with the given value. - */ - public fun success(value: T): Outcome = Success(value) - - /** - * Constructs a failed [Outcome] with the given exception. - */ - public fun failure(e: Throwable): Outcome = Failure(e) - - /** - * Constructs a cancelled [Outcome]. - */ - public fun cancellation(): Outcome = Cancellation - } -} diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Task.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Task.kt deleted file mode 100644 index f444713..0000000 --- a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/Task.kt +++ /dev/null @@ -1,120 +0,0 @@ -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") - -package org.funfix.tasks.kotlin - -/** - * An alias for a platform-specific implementation that powers [Task]. - */ -public expect class PlatformTask - -/** - * Kotlin-specific callback type used for signaling the completion of running - * tasks. - */ -public typealias Callback = (Outcome) -> Unit - -/** - * A task is a computation that can be executed asynchronously. - * - * In the vocabulary of "reactive streams", this is a "cold data source", - * meaning that the computation hasn't executed yet, and when it will execute, - * the result won't get cached (memoized). In the vocabulary of - * "functional programming", this is a pure value, being somewhat equivalent - * to `IO`. - * - * This is designed to be a compile-time type that's going to be erased at - * runtime. Therefore, for the JVM at least, when using it in your APIs, it - * won't pollute it with Kotlin-specific wrappers. - */ -public expect value class Task public constructor( - public val asPlatform: PlatformTask -) { - // Companion object currently doesn't do anything, but we - // need to define one to make the class open for extensions. - public companion object -} - -/** - * Converts a platform task to a Kotlin task. - * - * E.g., can convert a `jvm.Task` to a `kotlin.Task`. - */ -public fun PlatformTask.asKotlin(): Task = - Task(this) - -/** - * Ensures that the task starts asynchronously and runs on the given executor, - * regardless of the `run` method that is used, or the injected executor in - * any of those methods. - * - * One example where this is useful is for blocking I/O operations, for - * ensuring that the task runs on the thread-pool meant for blocking I/O, - * regardless of what executor is passed to [runAsync]. - * - * Example: - * ```kotlin - * Task.fromBlockingIO { - * // Reads a file from disk - * Files.readString(Paths.get("file.txt")) - * }.ensureRunningOnExecutor( - * BlockingIOExecutor - * ) - * ``` - * - * Another use-case is for ensuring that the task runs asynchronously, on - * another thread. Otherwise, tasks may be able to execute on the current thread: - * - * ```kotlin - * val task = Task.fromBlockingIO { - * // Reads a file from disk - * Files.readString(Paths.get("file.txt")) - * } - * - * task - * // Ensuring the task runs on a different thread - * .ensureRunningOnExecutor() - * // Blocking the current thread for the result (JVM API) - * .runBlocking() - * ``` - * - * @param executor is the [Executor] used as an override. If `null`, then - * the executor injected (e.g., in [runAsync]) will be used. - */ -public expect fun Task.ensureRunningOnExecutor(executor: Executor? = null): Task - -/** - * Executes the task asynchronously. - * - * @param executor is the [Executor] to use for running the task - * @param callback is the callback given for signaling completion - * @return a [Cancellable] that can be used to cancel the running task - */ -public expect fun Task.runAsync( - executor: Executor? = null, - callback: Callback -): Cancellable - -/** - * Creates a task from an asynchronous computation, initiated on the current thread. - * - * This method ensures: - * 1. Idempotent cancellation - * 2. Trampolined execution to avoid stack-overflows - * - * The created task will execute the given function on the current - * thread, by using a "trampoline" to avoid stack overflows. This may - * be useful if the computation for initiating the async process is - * expected to be fast. If the computation can block the current - * thread, consider ensuring the task runs on a different executor - * (for example via [ensureRunningOnExecutor] or by wrapping the - * blocking portion in a `fromBlockingIO` task). - * - * @param start is the function that will trigger the async computation, - * injecting a callback that will be used to signal the result, and an - * executor that can be used for creating additional threads. - * - * @return a new task that will execute the given builder function upon execution - */ -public expect fun Task.Companion.fromAsync( - start: (Executor, Callback) -> Cancellable -): Task diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.kt deleted file mode 100644 index eda1499..0000000 --- a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.kt +++ /dev/null @@ -1,19 +0,0 @@ -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") - -package org.funfix.tasks.kotlin - -/** - * Utilities for handling uncaught exceptions. - */ -public expect object UncaughtExceptionHandler { - /** - * Used for filtering the fatal exceptions that should - * crash the process (e.g., `OutOfMemoryError`). - */ - public fun rethrowIfFatal(e: Throwable) - - /** - * Logs a caught exception, or rethrows it if it's fatal. - */ - public fun logOrRethrow(e: Throwable) -} diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/exceptions.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/exceptions.kt deleted file mode 100644 index d29fa54..0000000 --- a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/exceptions.kt +++ /dev/null @@ -1,24 +0,0 @@ -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") - -package org.funfix.tasks.kotlin - -/** - * An exception that is thrown when waiting for the result of a task - * that has been cancelled. - * - * Note, this is unlike the JVM's `InterruptedException` or Kotlin's - * `CancellationException`, which are thrown when the current thread or fiber is - * interrupted. This exception is thrown when waiting for the result of a task - * that has been cancelled concurrently, but this doesn't mean that the current - * thread or fiber was interrupted. - */ -public expect open class TaskCancellationException(message: String?): Exception { - public constructor() -} - -/** - * Exception thrown when trying to get the result of a fiber that - * hasn't completed yet. - */ -public expect class FiberNotCompletedException public constructor() : - Exception diff --git a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/executors.kt b/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/executors.kt deleted file mode 100644 index db83c28..0000000 --- a/tasks-kotlin/src/commonMain/kotlin/org/funfix/tasks/kotlin/executors.kt +++ /dev/null @@ -1,49 +0,0 @@ -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") - -package org.funfix.tasks.kotlin - -/** - * An [Executor] is an abstraction for a thread-pool or a single-threaded - * event-loop, used for running tasks. - * - * On the JVM, this is an alias for the `java.util.concurrent.Executor` - * interface. On top of JavaScript, one way to implement this is via - * `setTimeout`. - */ -public expect fun interface Executor { - public fun execute(command: Runnable) -} - -/** - * A simple interface for a task that can be executed asynchronously. - * - * On the JVM, this is an alias for the `java.lang.Runnable` interface. - */ -public expect fun interface Runnable { - public fun run() -} - -/** - * The global executor, used for running tasks that don't specify an - * explicit executor. - * - * On top of the JVM, this is powered by "virtual threads" (project loom), if - * the runtime supports it (Java 21+). Otherwise, it's an unlimited "cached" - * thread-pool. On top of JavaScript, blocking I/O operations are not possible - * in the browser, and discouraged in Node.js. JS runtimes don't have - * multi-threading with shared-memory concurrency, so this will be just a plain - * executor. - */ -public expect val SharedIOExecutor: Executor - -/** - * An [Executor] that runs tasks on the current thread. - * - * Uses a [trampoline](https://en.wikipedia.org/wiki/Trampoline_(computing)) - * to ensure that recursive calls don't blow the stack. - * - * Using this executor is useful for making asynchronous callbacks stack-safe. - * Note, however, that the tasks get executed on the current thread, immediately, - * even if the implementation guards against stack overflows. - */ -public expect val TrampolineExecutor: Executor diff --git a/tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTestUtils.kt b/tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTestUtils.kt deleted file mode 100644 index aa186ec..0000000 --- a/tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTestUtils.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.funfix.tasks.kotlin - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.withContext -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext - -interface AsyncTestUtils { - fun runTest( - context: CoroutineContext = EmptyCoroutineContext, - testBody: suspend TestScope.() -> Unit - ) { - kotlinx.coroutines.test.runTest(context) { - withContext(Dispatchers.Unconfined) { - testBody() - } - } - } -} diff --git a/tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTests.kt b/tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTests.kt deleted file mode 100644 index 4e02a99..0000000 --- a/tasks-kotlin/src/commonTest/kotlin/org/funfix/tasks/kotlin/AsyncTests.kt +++ /dev/null @@ -1,135 +0,0 @@ -//package org.funfix.tasks.kotlin -// -//import kotlinx.coroutines.yield -//import kotlin.test.Test -//import kotlin.test.assertEquals -//import kotlin.test.fail -// -//class AsyncTests: AsyncTestUtils { -// @Test -// fun createAsync() = runTest { -// val task = taskFromAsync { executor, callback -> -// executor.execute { -// callback(Outcome.Success(1 + 1)) -// } -// EmptyCancellable -// } -// -// val r = task.executeSuspended() -// assertEquals(2, r) -// } -// -// @Test -// fun fromSuspendedHappy() = runTest { -// val task = taskFromSuspended { -// yield() -// 1 + 1 -// } -// -// val r = task.executeSuspended() -// assertEquals(2, r) -// } -// -// @Test -// fun fromSuspendedFailure() = runTest { -// val e = RuntimeException("Boom") -// val task = taskFromSuspended { -// yield() -// throw e -// } -// -// try { -// task.executeSuspended() -// fail("Should have thrown") -// } catch (e: RuntimeException) { -// assertEquals("Boom", e.message) -// } -// } -// -// @Test -// fun simpleSuspendedChaining() = runTest { -// val task = taskFromSuspended { -// yield() -// 1 + 1 -// } -// -// val task2 = taskFromSuspended { -// yield() -// task.executeSuspended() + 1 -// } -// -// val r = task2.executeSuspended() -// assertEquals(3, r) -// } -// -// @Test -// fun fiberChaining() = runTest { -// val task = taskFromSuspended { -// yield() -// 1 + 1 -// } -// -// val task2 = taskFromSuspended { -// yield() -// task.executeFiber().awaitSuspended() + 1 -// } -// -// val r = task2.executeSuspended() -// assertEquals(3, r) -// } -// -// @Test -// fun complexChaining() = runTest { -// val task = taskFromSuspended { -// yield() -// 1 + 1 -// } -// -// val task2 = taskFromSuspended { -// yield() -// task.executeSuspended() + 1 -// } -// -// val task3 = taskFromSuspended { -// yield() -// task2.executeFiber().awaitSuspended() + 1 -// } -// -// val task4 = taskFromSuspended { -// yield() -// val deferred = async { task3.executeSuspended() } -// deferred.await() + 1 -// } -// -// val r = task4.executeSuspended() -// assertEquals(5, r) -// } -// -// @Test -// fun cancellation() = runTest { -// val lock = Mutex() -// val latch = CompletableDeferred() -// val wasCancelled = CompletableDeferred() -// lock.lock() -// -// val job = async { -// taskFromSuspended { -// yield() -// latch.complete(Unit) -// try { -// lock.lock() -// } finally { -// wasCancelled.complete(Unit) -// lock.unlock() -// } -// }.executeSuspended() -// } -// -// withTimeout(5000) { latch.await() } -// job.cancel() -// -// withTimeout(5000) { -// wasCancelled.await() -// } -// } -//} diff --git a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Cancellable.js.kt b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Cancellable.js.kt deleted file mode 100644 index a90c1e0..0000000 --- a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Cancellable.js.kt +++ /dev/null @@ -1,46 +0,0 @@ -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") - -package org.funfix.tasks.kotlin - -public actual fun interface Cancellable { - public actual fun cancel() - - public companion object { - public val empty: Cancellable = - Cancellable {} - } -} - -internal class MutableCancellable : Cancellable { - private var ref: State = State.Active(Cancellable.empty, 0) - - override fun cancel() { - when (val current = ref) { - is State.Active -> { - ref = State.Cancelled - current.token.cancel() - } - State.Cancelled -> return - } - } - - fun set(token: Cancellable) { - while (true) { - when (val current = ref) { - is State.Active -> { - ref = State.Active(token, current.order + 1) - return - } - is State.Cancelled -> { - token.cancel() - return - } - } - } - } - - private sealed interface State { - data class Active(val token: Cancellable, val order: Int) : State - data object Cancelled : State - } -} diff --git a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/CancellablePromise.kt b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/CancellablePromise.kt deleted file mode 100644 index c77e67f..0000000 --- a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/CancellablePromise.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.funfix.tasks.kotlin - -import kotlin.js.Promise - -/** - * This is a wrapper around a JavaScript [Promise] with a - * [Cancellable] reference attached. - * - * A standard JavaScript [Promise] is not connected to its - * asynchronous task and cannot be cancelled. Thus, if we want to cancel - * a task, we need to keep a reference to a [Cancellable] object that - * can do the job. - * - * Contract: - * - [join] completes regardless of how the underlying computation ends, - * including when it gets cancelled via [cancellable]. - * - [join] does not reveal any outcome; it is only a completion signal. - * - * Example: - * ```kotlin - * val promise = Promise { resolve, _ -> resolve(1) } - * val join = Promise { resolve, _ -> - * promise.then({ resolve(Unit) }, { resolve(Unit) }) - * } - * val cp = CancellablePromise(promise, join, Cancellable.empty) - * cp.cancelAndJoin().then { /* completion observed */ } - * ``` - */ -public data class CancellablePromise( - val promise: Promise, - val join: Promise, - val cancellable: Cancellable -) { - /** - * Triggers cancellation and then waits for [join] to complete. - * - * Note that [join] completes regardless of whether [promise] - * resolves, rejects, or the computation is cancelled. - */ - public fun cancelAndJoin(): Promise { - cancellable.cancel() - return join - } -} diff --git a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Task.js.kt b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Task.js.kt deleted file mode 100644 index b61706c..0000000 --- a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/Task.js.kt +++ /dev/null @@ -1,259 +0,0 @@ -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") - -package org.funfix.tasks.kotlin - -import kotlin.js.Promise - -public actual class PlatformTask( - private val f: (Executor, (Outcome) -> Unit) -> Cancellable -) { - public operator fun invoke( - executor: Executor, - callback: (Outcome) -> Unit - ): Cancellable = - f(executor, callback) -} - -public actual value class Task public actual constructor( - public actual val asPlatform: PlatformTask -) { - public actual companion object -} - -public actual fun Task.runAsync( - executor: Executor?, - callback: (Outcome) -> Unit -): Cancellable { - val protected = callback.protect() - try { - return asPlatform.invoke( - executor ?: SharedIOExecutor, - protected - ) - } catch (e: Throwable) { - UncaughtExceptionHandler.rethrowIfFatal(e) - protected(Outcome.failure(e)) - return Cancellable.empty - } -} - -public actual fun Task.Companion.fromAsync( - start: (Executor, Callback) -> Cancellable -): Task = - Task(PlatformTask { executor, cb -> - val cRef = MutableCancellable() - TrampolineExecutor.execute { - cRef.set(start(executor, cb)) - } - cRef - }) - -internal fun Callback.protect(): Callback { - var isWaiting = true - return { o -> - if (o is Outcome.Failure) { - UncaughtExceptionHandler.logOrRethrow(o.exception) - } - if (isWaiting) { - isWaiting = false - TrampolineExecutor.execute { - this@protect.invoke(o) - } - } - } -} - -public actual fun Task.ensureRunningOnExecutor(executor: Executor?): Task = - Task(PlatformTask { injectedExecutor, callback -> - val ec = executor ?: injectedExecutor - val cRef = MutableCancellable() - ec.execute { - val c = this@ensureRunningOnExecutor.asPlatform.invoke(ec, callback) - cRef.set(c) - } - cRef - }) - -private fun promiseFailure(cause: Any?): Throwable = - when (cause) { - is Throwable -> cause - null -> RuntimeException("Promise rejected") - else -> RuntimeException(cause.toString()) - } - -/** - * Creates a task from a JavaScript [Promise] builder. - * - * Contract: - * - The resulting task is not cancellable; cancellation does not - * affect the underlying promise. - * - Completion mirrors the promise outcome: resolve maps to - * [Outcome.Success], reject maps to [Outcome.Failure]. - * - * Example: - * ```kotlin - * val task = Task.fromPromise { - * Promise { resolve, _ -> resolve(1) } - * } - * ``` - */ -public fun Task.Companion.fromPromise( - builder: () -> Promise -): Task = - Task.fromAsync { _, callback -> - var isDone = false - try { - val promise = builder() - promise.then( - { value -> - if (!isDone) { - isDone = true - callback(Outcome.Success(value)) - } - }, - { error -> - if (!isDone) { - isDone = true - callback(Outcome.Failure(promiseFailure(error))) - } - } - ) - } catch (e: Throwable) { - callback(Outcome.Failure(e)) - } - Cancellable.empty - } - -/** - * Creates a task from a [CancellablePromise] builder. - * - * Contract: - * - Cancellation triggers the provided [Cancellable], but the resulting - * task only completes when [CancellablePromise.join] completes. - * - If [CancellablePromise.join] rejects, the task fails with that error. - * - Otherwise, if cancellation was requested, the task completes with - * [Outcome.Cancellation]. - * - Otherwise, the task mirrors [CancellablePromise.promise]. - * - * Example: - * ```kotlin - * val task = Task.fromCancellablePromise { - * val promise = Promise { resolve, _ -> resolve(1) } - * val join = Promise { resolve, _ -> - * promise.then({ resolve(Unit) }, { resolve(Unit) }) - * } - * CancellablePromise(promise, join, Cancellable.empty) - * } - * ``` - */ -public fun Task.Companion.fromCancellablePromise( - builder: () -> CancellablePromise -): Task = - Task.fromAsync { _, callback -> - var isDone = false - var isCancelled = false - var token: Cancellable = Cancellable.empty - try { - val value = builder() - token = value.cancellable - value.join.then( - { - if (!isDone) { - if (isCancelled) { - isDone = true - callback(Outcome.Cancellation) - } else { - value.promise.then( - { result -> - if (!isDone) { - isDone = true - callback(Outcome.Success(result)) - } - }, - { error -> - if (!isDone) { - isDone = true - callback(Outcome.Failure(promiseFailure(error))) - } - } - ) - } - } - }, - { error -> - if (!isDone) { - isDone = true - callback(Outcome.Failure(promiseFailure(error))) - } - } - ) - } catch (e: Throwable) { - callback(Outcome.Failure(e)) - } - Cancellable { - isCancelled = true - token.cancel() - } - } - -/** - * Executes the task and returns its result as a JavaScript [Promise]. - * - * Contract: - * - [Outcome.Success] resolves the promise. - * - [Outcome.Failure] rejects the promise with the original exception. - * - [Outcome.Cancellation] rejects with [TaskCancellationException]. - * - * Example: - * ```kotlin - * Task.fromAsync { _, cb -> - * cb(Outcome.Success(1)) - * Cancellable.empty - * }.runToPromise() - * ``` - */ -public fun Task.runToPromise(): Promise = - Promise { resolve, reject -> - runAsync { outcome -> - when (outcome) { - is Outcome.Success -> resolve(outcome.value) - is Outcome.Failure -> reject(outcome.exception) - is Outcome.Cancellation -> reject(TaskCancellationException()) - } - } - } - -/** - * Executes the task and returns a [CancellablePromise]. - * - * The resulting [CancellablePromise.join] completes regardless of whether - * the task succeeds, fails, or is cancelled. - * - * Example: - * ```kotlin - * val cp = Task.fromAsync { _, cb -> - * cb(Outcome.Success(1)) - * Cancellable.empty - * }.runToCancellablePromise() - * cp.cancelAndJoin() - * ``` - */ -public fun Task.runToCancellablePromise(): CancellablePromise { - var token: Cancellable = Cancellable.empty - val promise = Promise { resolve, reject -> - token = runAsync { outcome -> - when (outcome) { - is Outcome.Success -> resolve(outcome.value) - is Outcome.Failure -> reject(outcome.exception) - is Outcome.Cancellation -> reject(TaskCancellationException()) - } - } - } - val join = Promise { resolve, _ -> - promise.then( - { resolve(Unit) }, - { resolve(Unit) } - ) - } - return CancellablePromise(promise, join, Cancellable { token.cancel() }) -} diff --git a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/exceptions.js.kt b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/exceptions.js.kt deleted file mode 100644 index bb51e11..0000000 --- a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/exceptions.js.kt +++ /dev/null @@ -1,13 +0,0 @@ -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") - -package org.funfix.tasks.kotlin - -public actual open class TaskCancellationException public actual constructor( - message: String? -): Exception(message) { - public actual constructor() : this(null) -} - -public actual class FiberNotCompletedException - public actual constructor(): Exception("Fiber not completed yet") - diff --git a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/executors.js.kt b/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/executors.js.kt deleted file mode 100644 index ca602ae..0000000 --- a/tasks-kotlin/src/jsMain/kotlin/org/funfix/tasks/kotlin/executors.js.kt +++ /dev/null @@ -1,129 +0,0 @@ -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") - -package org.funfix.tasks.kotlin - -import org.w3c.dom.WindowOrWorkerGlobalScope -import kotlin.js.Promise - -public actual fun interface Runnable { - public actual fun run() -} - -public actual fun interface Executor { - public actual fun execute(command: Runnable) -} - -public actual val SharedIOExecutor: Executor - get() = JSExecutor - -public actual val TrampolineExecutor: Executor - get() = Trampoline - -private external val self: dynamic -private external val global: dynamic - -private val globalOrSelfDynamic = - (self ?: global)!! -private val globalOrSelf = - globalOrSelfDynamic.unsafeCast() - -private object JSExecutor: Executor { - class NotSupported(execType: ExecType): Exception( - "Executor type $execType is not supported on this runtime" - ) - - private var execType = - if (globalOrSelfDynamic.setInterval != null) ExecType.ViaSetInterval - else if (globalOrSelfDynamic.setTimeout != null) ExecType.ViaSetTimeout - else ExecType.Trampolined - - inline fun withExecType(execType: ExecType, block: () -> T): T { - when (execType) { - ExecType.ViaSetInterval -> - if (globalOrSelfDynamic.setInterval == null) throw NotSupported(execType) - ExecType.ViaSetTimeout -> - if (globalOrSelfDynamic.setTimeout == null) throw NotSupported(execType) - ExecType.Trampolined -> - Unit - } - val oldRef = this.execType - this.execType = execType - try { - return block() - } finally { - this.execType = oldRef - } - } - - fun yield(): Promise { - return Promise { resolve, _ -> - execute { - resolve(Unit) - } - } - } - - override fun execute(command: Runnable) { - val handler: () -> Unit = { - try { - command.run() - } catch (e: Exception) { - UncaughtExceptionHandler.logOrRethrow(e) - } - } - when (execType) { - ExecType.ViaSetInterval -> - globalOrSelf.setInterval(handler) - ExecType.ViaSetTimeout -> - globalOrSelf.setTimeout(handler, -1) - ExecType.Trampolined -> - Trampoline.execute(command) - } - } - - sealed interface ExecType { - data object ViaSetInterval: ExecType - data object ViaSetTimeout: ExecType - data object Trampolined: ExecType - } -} - -private object Trampoline: Executor { - private var queue: MutableList? = null - - private fun eventLoop() { - while (true) { - val current = queue - if (current.isNullOrEmpty()) { - return - } - val next = current.removeFirstOrNull() - try { - next?.run() - } catch (e: Exception) { - UncaughtExceptionHandler.logOrRethrow(e) - } - } - } - - override fun execute(command: Runnable) { - val current = queue ?: mutableListOf() - current.add(command) - queue = current - try { - eventLoop() - } finally { - queue = null - } - } -} - -public actual object UncaughtExceptionHandler { - public actual fun rethrowIfFatal(e: Throwable) { - // Can we do something here? - } - - public actual fun logOrRethrow(e: Throwable) { - console.error(e) - } -} diff --git a/tasks-kotlin/src/jsTest/kotlin/org/funfix/tasks/kotlin/SampleJsTest.kt b/tasks-kotlin/src/jsTest/kotlin/org/funfix/tasks/kotlin/SampleJsTest.kt deleted file mode 100644 index 8b67470..0000000 --- a/tasks-kotlin/src/jsTest/kotlin/org/funfix/tasks/kotlin/SampleJsTest.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.funfix.tasks.kotlin - -import kotlin.test.Test -import kotlin.test.assertEquals - -class SampleJsTest { - @Test - fun sampleJsTest() { - assertEquals(4, 2 + 2) - } -} diff --git a/tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskFromPromiseJsTest.kt b/tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskFromPromiseJsTest.kt deleted file mode 100644 index 68c057c..0000000 --- a/tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskFromPromiseJsTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package org.funfix.tests - -import kotlin.js.Promise -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.yield -import org.funfix.tasks.kotlin.Cancellable -import org.funfix.tasks.kotlin.CancellablePromise -import org.funfix.tasks.kotlin.Outcome -import org.funfix.tasks.kotlin.Task -import org.funfix.tasks.kotlin.fromCancellablePromise -import org.funfix.tasks.kotlin.fromPromise -import org.funfix.tasks.kotlin.runAsync - -class TaskFromPromiseJsTest { - private fun joinOf(promise: Promise): Promise = - Promise { resolve, _ -> - promise.then( - { resolve(Unit) }, - { resolve(Unit) } - ) - } - - @Test - fun `fromPromise (success)`() = runTest { - val task = Task.fromPromise { - Promise { resolve, _ -> resolve(1) } - } - val deferred = CompletableDeferred>() - task.runAsync { outcome -> deferred.complete(outcome) } - assertEquals(Outcome.Success(1), deferred.await()) - } - - @Test - fun `fromPromise (failure)`() = runTest { - val ex = RuntimeException("Boom!") - val task = Task.fromPromise { - Promise { _, reject -> reject(ex) } - } - val deferred = CompletableDeferred>() - task.runAsync { outcome -> deferred.complete(outcome) } - assertEquals(Outcome.Failure(ex), deferred.await()) - } - - @Test - fun `fromPromise (cancellation waits for completion)`() = runTest { - lateinit var resolve: (Int) -> Unit - val task = Task.fromPromise { - Promise { res, _ -> resolve = res } - } - val deferred = CompletableDeferred>() - val cancel = task.runAsync { outcome -> deferred.complete(outcome) } - cancel.cancel() - yield() - assertFalse(deferred.isCompleted) - resolve(1) - assertEquals(Outcome.Success(1), deferred.await()) - } - - @Test - fun `fromCancellablePromise (success)`() = runTest { - val task = Task.fromCancellablePromise { - val promise = Promise { resolve, _ -> resolve(1) } - CancellablePromise(promise, joinOf(promise), Cancellable.empty) - } - val deferred = CompletableDeferred>() - task.runAsync { outcome -> deferred.complete(outcome) } - assertEquals(Outcome.Success(1), deferred.await()) - } - - @Test - fun `fromCancellablePromise (failure)`() = runTest { - val ex = RuntimeException("Boom!") - val task = Task.fromCancellablePromise { - val promise = Promise { _, reject -> reject(ex) } - CancellablePromise(promise, joinOf(promise), Cancellable.empty) - } - val deferred = CompletableDeferred>() - task.runAsync { outcome -> deferred.complete(outcome) } - assertEquals(Outcome.Failure(ex), deferred.await()) - } - - @Test - fun `fromCancellablePromise (cancellation waits and triggers token)`() = runTest { - lateinit var resolve: (Int) -> Unit - var cancelled = false - val task = Task.fromCancellablePromise { - val promise = Promise { res, _ -> resolve = res } - CancellablePromise(promise, joinOf(promise), Cancellable { cancelled = true }) - } - val deferred = CompletableDeferred>() - val cancel = task.runAsync { outcome -> deferred.complete(outcome) } - cancel.cancel() - yield() - assertTrue(cancelled) - assertFalse(deferred.isCompleted) - resolve(1) - assertEquals(Outcome.Cancellation, deferred.await()) - } -} diff --git a/tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskRunToPromiseJsTest.kt b/tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskRunToPromiseJsTest.kt deleted file mode 100644 index 0d2afc8..0000000 --- a/tasks-kotlin/src/jsTest/kotlin/org/funfix/tests/TaskRunToPromiseJsTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package org.funfix.tests - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runTest -import org.funfix.tasks.kotlin.Cancellable -import org.funfix.tasks.kotlin.Outcome -import org.funfix.tasks.kotlin.Task -import org.funfix.tasks.kotlin.TaskCancellationException -import org.funfix.tasks.kotlin.fromAsync -import org.funfix.tasks.kotlin.runToCancellablePromise -import org.funfix.tasks.kotlin.runToPromise - -class TaskRunToPromiseJsTest { - @Test - fun `runToPromise (success)`() = runTest { - val task = Task.fromAsync { _, cb -> - cb(Outcome.Success(1)) - Cancellable.empty - } - val deferred = CompletableDeferred() - task.runToPromise().then({ value -> - deferred.complete(value) - }, { error -> - deferred.completeExceptionally(error) - }) - assertEquals(1, deferred.await()) - } - - @Test - fun `runToPromise (failure)`() = runTest { - val ex = RuntimeException("Boom!") - val task = Task.fromAsync { _, cb -> - cb(Outcome.Failure(ex)) - Cancellable.empty - } - val deferred = CompletableDeferred() - task.runToPromise().then({ _ -> - deferred.complete(RuntimeException("Unexpected")) - }, { error -> - deferred.complete(error) - }) - assertEquals(ex, deferred.await()) - } - - @Test - fun `runToPromise (cancellation)`() = runTest { - val task = Task.fromAsync { _, cb -> - cb(Outcome.Cancellation) - Cancellable.empty - } - val deferred = CompletableDeferred() - task.runToPromise().then({ _ -> - deferred.complete(RuntimeException("Unexpected")) - }, { error -> - deferred.complete(error) - }) - val error = deferred.await() - assertTrue(error is TaskCancellationException) - } - - @Test - fun `runToCancellablePromise cancelAndJoin`() = runTest { - val task = Task.fromAsync { _, cb -> - Cancellable { - cb(Outcome.Cancellation) - } - } - val cp = task.runToCancellablePromise() - val error = CompletableDeferred() - val joined = CompletableDeferred() - cp.promise.then({ _ -> - error.complete(RuntimeException("Unexpected")) - }, { err -> - error.complete(err) - }) - cp.cancelAndJoin().then({ - joined.complete(Unit) - }, { - joined.complete(Unit) - }) - joined.await() - assertTrue(error.await() is TaskCancellationException) - } -} diff --git a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Fiber.jvm.kt b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Fiber.jvm.kt deleted file mode 100644 index 1d5d358..0000000 --- a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Fiber.jvm.kt +++ /dev/null @@ -1,197 +0,0 @@ -@file:JvmName("FiberJvmKt") - -package org.funfix.tasks.kotlin - -import org.jetbrains.annotations.Blocking -import org.jetbrains.annotations.NonBlocking -import java.util.concurrent.ExecutionException -import java.util.concurrent.TimeoutException -import kotlin.jvm.Throws -import kotlin.time.Duration -import kotlin.time.toJavaDuration - -public typealias PlatformFiber = org.funfix.tasks.jvm.Fiber - -/** - * A fiber is a running task being executed concurrently, and that can be - * joined/awaited or cancelled. - * - * This is the equivalent of Kotlin's `Deferred` type. - * - * This is designed to be a compile-time type that's going to be erased at - * runtime. Therefore, for the JVM at least, when using it in your APIs, it - * won't pollute it with Kotlin-specific wrappers. - */ -@JvmInline -public value class Fiber public constructor( - public val asPlatform: PlatformFiber -): Cancellable { - /** - * Cancels the fiber, which will eventually stop the running fiber (if - * it's still running), completing it via "cancellation". - * - * This manifests either in a [TaskCancellationException] being thrown by - * [resultOrThrow], or in the completion callback being triggered. - */ - @NonBlocking - override fun cancel(): Unit = asPlatform.cancel() -} - -/** - * Converts the source to a [Kotlin Fiber][Fiber]. - * - * E.g., can convert from a `jvm.Fiber` to a `kotlin.Fiber`. - */ -public fun PlatformFiber.asKotlin(): Fiber = - Fiber(this) - -/** - * Returns the result of the completed fiber. - * - * This method does not block for the result. In case the fiber is not - * completed, it throws [FiberNotCompletedException]. Therefore, by contract, - * it should be called only after the fiber was "joined". - * - * @return the result of the concurrent task, if successful. - * @throws TaskCancellationException if the task was cancelled concurrently, - * thus being completed via cancellation. - * @throws FiberNotCompletedException if the fiber is not completed yet. - * @throws Throwable if the task finished with an exception. - */ -public val Fiber.resultOrThrow: T - @NonBlocking - @Throws(TaskCancellationException::class, FiberNotCompletedException::class) - get() = asPlatform.resultOrThrow - -/** - * Returns the [Outcome] of the completed fiber, or `null` in case the - * fiber is not completed yet. - * - * This method does not block for the result. In case the fiber is not - * completed, it returns `null`. Therefore, it should be called after - * the fiber was "joined". - */ -public val Fiber.outcomeOrNull: Outcome? get() = - try { - Outcome.Success(asPlatform.resultOrThrow) - } catch (e: TaskCancellationException) { - Outcome.Cancellation - } catch (e: ExecutionException) { - Outcome.Failure(e.cause ?: e) - } catch (e: Throwable) { - UncaughtExceptionHandler.rethrowIfFatal(e) - Outcome.Failure(e) - } - -/** - * Waits until the fiber completes, and then runs the given callback to - * signal its completion. - * - * Completion includes cancellation. Triggering [Fiber.cancel] before - * [joinAsync] will cause the fiber to get cancelled, and then the - * "join" back-pressures on cancellation. - * - * @param onComplete is the callback to run when the fiber completes - * (successfully, or with failure, or cancellation) - */ -@NonBlocking -public fun Fiber.joinAsync(onComplete: Runnable): Cancellable = - asPlatform.joinAsync(onComplete) - -/** - * Waits until the fiber completes, and then runs the given callback - * to signal its completion. - * - * This method can be executed as many times as necessary, with the - * result of the `Fiber` being memoized. It can also be executed - * after the fiber has completed, in which case the callback will be - * executed immediately. - * - * @param callback will be called with the result when the fiber completes. - * - * @return a [Cancellable] that can be used to unregister the callback, - * in case the caller is no longer interested in the result. Note this - * does not cancel the fiber itself. - */ -@NonBlocking -public fun Fiber.awaitAsync(callback: Callback): Cancellable = - asPlatform.awaitAsync(callback.asJava()) - -/** - * Blocks the current thread until the fiber completes. - * - * This method does not return the outcome of the fiber. To check - * the outcome, use [resultOrThrow]. - * - * @throws InterruptedException if the current thread is interrupted, which - * will just stop waiting for the fiber, but will not cancel the running - * task. - */ -@Blocking -@Throws(InterruptedException::class) -public fun Fiber.joinBlocking(): Unit = - asPlatform.joinBlocking() - -/** - * Blocks the current thread until the fiber completes, then returns the - * result of the fiber. - * - * @throws InterruptedException if the current thread is interrupted, which - * will just stop waiting for the fiber, but will not cancel the running task. - * - * @throws TaskCancellationException if the fiber was cancelled concurrently. - * - * @throws Throwable if the task failed with an exception. - */ -@Blocking -@Throws(InterruptedException::class, TaskCancellationException::class) -public fun Fiber.awaitBlocking(): T = - try { - asPlatform.awaitBlocking() - } catch (e: ExecutionException) { - throw e.cause ?: e - } - -/** - * Blocks the current thread until the fiber completes, or until the - * timeout is reached. - * - * This method does not return the outcome of the fiber. To check the - * outcome, use [resultOrThrow]. - * - * @throws InterruptedException if the current thread is interrupted, which - * will just stop waiting for the fiber, but will not cancel the running - * task. - * - * @throws TimeoutException if the timeout is reached before the fiber - * completes. - */ -@Blocking -@Throws(InterruptedException::class, TimeoutException::class) -public fun Fiber.joinBlockingTimed(timeout: Duration): Unit = - asPlatform.joinBlockingTimed(timeout.toJavaDuration()) - -/** - * Blocks the current thread until the fiber completes, then returns the result of the fiber. - * - * @param timeout the maximum time to wait for the fiber to complete, before - * throwing a [TimeoutException]. - * - * @return the result of the fiber, if successful. - * - * @throws InterruptedException if the current thread is interrupted, which - * will just stop waiting for the fiber, but will not cancel the running - * task. - * @throws TimeoutException if the timeout is reached before the fiber completes. - * @throws TaskCancellationException if the fiber was cancelled concurrently. - * @throws Throwable if the task failed with an exception. - */ -@Blocking -@Throws(InterruptedException::class, TaskCancellationException::class, TimeoutException::class) -public fun Fiber.awaitBlockingTimed(timeout: Duration): T = - try { - asPlatform.awaitBlockingTimed(timeout.toJavaDuration()) - } catch (e: ExecutionException) { - throw e.cause ?: e - } - diff --git a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Task.jvm.kt b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Task.jvm.kt deleted file mode 100644 index 0c8f59c..0000000 --- a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/Task.jvm.kt +++ /dev/null @@ -1,220 +0,0 @@ -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -@file:JvmName("TaskJvmKt") - -package org.funfix.tasks.kotlin - -import org.jetbrains.annotations.Blocking -import org.jetbrains.annotations.NonBlocking -import java.util.concurrent.CompletionStage -import java.util.concurrent.ExecutionException -import java.util.concurrent.Future -import java.util.concurrent.TimeoutException -import kotlin.jvm.Throws -import kotlin.time.Duration -import kotlin.time.toJavaDuration - -public actual typealias PlatformTask = org.funfix.tasks.jvm.Task - -@JvmInline -public actual value class Task public actual constructor( - public actual val asPlatform: PlatformTask -) { - /** - * Converts this task to a `jvm.Task`. - */ - public val asJava: PlatformTask get() = asPlatform - - public actual companion object -} - -@NonBlocking -public actual fun Task.ensureRunningOnExecutor(executor: Executor?): Task = - Task(when (executor) { - null -> asPlatform.ensureRunningOnExecutor() - else -> asPlatform.ensureRunningOnExecutor(executor) - }) - -@NonBlocking -public actual fun Task.runAsync( - executor: Executor?, - callback: Callback -): Cancellable = - when (executor) { - null -> asPlatform.runAsync(callback.asJava()) - else -> asPlatform.runAsync(executor, callback.asJava()) - } - -/** - * Executes the task concurrently and returns a [Fiber] that can be - * used to wait for the result or cancel the task. - * - * Similar to [runAsync], this method starts the execution on a different thread. - * - * @param executor is the [Executor] that may be used to run the task. - * - * @return a [Fiber] that can be used to wait for the outcome, - * or to cancel the running fiber. - */ -@NonBlocking -public fun Task.runFiber(executor: Executor? = null): Fiber = - Fiber(when (executor) { - null -> asPlatform.runFiber() - else -> asPlatform.runFiber(executor) - }) - -/** - * Executes the task and blocks until it completes, or the current thread gets - * interrupted (in which case the task is also cancelled). - * - * Given that the intention is to block the current thread for the result, the - * task starts execution on the current thread. - * - * @param executor the [Executor] that may be used to run the task. - * @return the successful result of the task. - * - * @throws InterruptedException if the current thread is interrupted, which also - * cancels the running task. Note that on interruption, the running concurrent - * task must also be interrupted, as this method always blocks for its - * interruption or completion. - * - * @throws Throwable if the task fails with an exception - */ -@Blocking -@Throws(InterruptedException::class) -public fun Task.runBlocking(executor: Executor? = null): T = - try { - when (executor) { - null -> asPlatform.runBlocking() - else -> asPlatform.runBlocking(executor) - } - } catch (e: ExecutionException) { - throw e.cause ?: e - } - -/** - * Executes the task and blocks until it completes, the timeout is reached, or - * the current thread is interrupted. - * - * **EXECUTION MODEL:** Execution starts on a different thread, by necessity, - * otherwise the execution could block the current thread indefinitely, without - * the possibility of interrupting the task after the timeout occurs. - * - * @param timeout the maximum time to wait for the task to complete before - * throwing a [TimeoutException]. - * - * @param executor the [Executor] that may be used to run the task. If one isn't - * provided, the execution will use [SharedIOExecutor] as the default. - * - * @return the successful result of the task. - * - * @throws InterruptedException if the current thread is interrupted. The - * running task is also cancelled, and this method does not return until - * `onCancel` is signaled. - * - * @throws TimeoutException if the task doesn't complete within the specified - * timeout. The running task is also cancelled on timeout, and this method does - * not return until `onCancel` is signaled. - * - * @throws Throwable if the task fails with an exception. - */ -@Blocking -@Throws(InterruptedException::class, TimeoutException::class) -public fun Task.runBlockingTimed( - timeout: Duration, - executor: Executor? = null -): T = - try { - when (executor) { - null -> asPlatform.runBlockingTimed(timeout.toJavaDuration()) - else -> asPlatform.runBlockingTimed(executor, timeout.toJavaDuration()) - } - } catch (e: ExecutionException) { - throw e.cause ?: e - } - -// Builders - -@NonBlocking -public actual fun Task.Companion.fromAsync( - start: (Executor, Callback) -> Cancellable -): Task = - Task(PlatformTask.fromAsync { executor, cb -> - start(executor, cb.asKotlin()) - }) - -/** - * Creates a task from a function executing blocking I/O. - * - * This uses Java's interruption protocol (i.e., [Thread.interrupt]) for - * cancelling the task. - */ -@NonBlocking -public fun Task.Companion.fromBlockingIO(block: () -> T): Task = - Task(PlatformTask.fromBlockingIO(block)) - -/** - * Creates a task from a [Future] builder. - * - * This is compatible with Java's interruption protocol and [Future.cancel], - * with the resulting task being cancellable. - * - * **NOTE:** Use [fromCompletionStage] for directly converting - * [java.util.concurrent.CompletableFuture] builders, because it is not possible - * to cancel such values, and the logic needs to reflect it. Better yet, use - * [fromCancellableFuture] for working with [CompletionStage] values that can be - * cancelled. - * - * @param builder is the function that will create the [Future] upon this task's - * execution. - * - * @return a new task that will complete with the result of the created `Future` - * upon execution - * - * @see fromCompletionStage - * @see fromCancellableFuture - */ -@NonBlocking -public fun Task.Companion.fromBlockingFuture(builder: () -> Future): Task = - Task(PlatformTask.fromBlockingFuture(builder)) - -/** - * Creates tasks from a builder of [CompletionStage]. - * - * **NOTE:** `CompletionStage` isn't cancellable, and the resulting task should - * reflect this (i.e., on cancellation, the listener should not receive an - * `onCancel` signal until the `CompletionStage` actually completes). - * - * Prefer using [fromCancellableFuture] for working with [CompletionStage] - * values that can be cancelled. - * - * @param builder is the function that will create the [CompletionStage] - * value. It's a builder because `Task` values are cold values - * (lazy, not executed yet). - * - * @return a new task that upon execution will complete with the result of - * the created `CancellableCompletionStage` - * - * @see fromCancellableFuture - */ -@NonBlocking -public fun Task.Companion.fromCompletionStage(builder: () -> CompletionStage): Task = - Task(PlatformTask.fromCompletionStage(builder)) - -/** - * Creates tasks from a builder of [CancellableFuture]. - * - * This is the recommended way to work with [CompletionStage] builders, because - * cancelling such values (e.g., [java.util.concurrent.CompletableFuture]) - * doesn't work for cancelling the connecting computation. As such, the user - * should provide an explicit [Cancellable] token that can be used. - * - * @param builder the function that will create the [CancellableFuture] value. - * It's a builder because [Task] values are cold values (lazy, not executed - * yet). - * - * @return a new task that upon execution will complete with the result of the - * created [CancellableFuture] - */ -@NonBlocking -public fun Task.Companion.fromCancellableFuture(builder: () -> CancellableFuture): Task = - Task(PlatformTask.fromCancellableFuture(builder)) diff --git a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.jvm.kt b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.jvm.kt deleted file mode 100644 index 9d7679f..0000000 --- a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/UncaughtExceptionHandler.jvm.kt +++ /dev/null @@ -1,16 +0,0 @@ -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") - -package org.funfix.tasks.kotlin - -/** - * Utilities for handling uncaught exceptions. - */ -public actual object UncaughtExceptionHandler { - public actual fun rethrowIfFatal(e: Throwable) { - org.funfix.tasks.jvm.UncaughtExceptionHandler.rethrowIfFatal(e) - } - - public actual fun logOrRethrow(e: Throwable) { - org.funfix.tasks.jvm.UncaughtExceptionHandler.logOrRethrow(e) - } -} diff --git a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/aliases.kt b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/aliases.kt deleted file mode 100644 index 2ef84a3..0000000 --- a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/aliases.kt +++ /dev/null @@ -1,26 +0,0 @@ -@file:JvmName("AliasesKt") -@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") - -package org.funfix.tasks.kotlin - -public actual typealias Cancellable = org.funfix.tasks.jvm.Cancellable - -/** - * A `CancellableFuture` is a tuple of a `CompletableFuture` and a `Cancellable` - * reference. - * - * It's used to model the result of asynchronous computations that can be - * cancelled. Needed because `CompletableFuture` doesn't actually support - * cancellation. It's similar to [Fiber], which should be preferred, because - * it's more principled. `CancellableFuture` is useful for interop with - * Java libraries that use `CompletableFuture`. - */ -public typealias CancellableFuture = org.funfix.tasks.jvm.CancellableFuture - -public actual typealias TaskCancellationException = org.funfix.tasks.jvm.TaskCancellationException - -public actual typealias FiberNotCompletedException = org.funfix.tasks.jvm.Fiber.NotCompletedException - -public actual typealias Runnable = java.lang.Runnable - -public actual typealias Executor = java.util.concurrent.Executor diff --git a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/executors.jvm.kt b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/executors.jvm.kt deleted file mode 100644 index 1480b94..0000000 --- a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/executors.jvm.kt +++ /dev/null @@ -1,11 +0,0 @@ -@file:JvmName("ExecutorsJvmKt") - -package org.funfix.tasks.kotlin - -import org.funfix.tasks.jvm.TaskExecutors - -public actual val TrampolineExecutor: Executor - get() = TaskExecutors.trampoline() - -public actual val SharedIOExecutor: Executor - get() = TaskExecutors.sharedBlockingIO() diff --git a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/internals.kt b/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/internals.kt deleted file mode 100644 index 5d15ab3..0000000 --- a/tasks-kotlin/src/jvmMain/kotlin/org/funfix/tasks/kotlin/internals.kt +++ /dev/null @@ -1,26 +0,0 @@ -@file:JvmName("InternalsJvmKt") - -package org.funfix.tasks.kotlin - -import org.funfix.tasks.jvm.CompletionCallback - - -internal typealias KotlinCallback = (Outcome) -> Unit - -internal fun CompletionCallback.asKotlin(): KotlinCallback = - { outcome -> - when (outcome) { - is Outcome.Success -> this.onSuccess(outcome.value) - is Outcome.Failure -> this.onFailure(outcome.exception) - is Outcome.Cancellation -> this.onCancellation() - } - } - -internal fun KotlinCallback.asJava(): CompletionCallback = - CompletionCallback { outcome -> - when (outcome) { - is org.funfix.tasks.jvm.Outcome.Success -> this@asJava(Outcome.Success(outcome.value)) - is org.funfix.tasks.jvm.Outcome.Failure -> this@asJava(Outcome.Failure(outcome.exception)) - is org.funfix.tasks.jvm.Outcome.Cancellation -> this@asJava(Outcome.Cancellation) - } - } diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskEnsureRunningOnExecutorTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskEnsureRunningOnExecutorTest.kt deleted file mode 100644 index 2d68f79..0000000 --- a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskEnsureRunningOnExecutorTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.funfix.tests - -import org.funfix.tasks.kotlin.Task -import org.funfix.tasks.kotlin.ensureRunningOnExecutor -import org.funfix.tasks.kotlin.fromBlockingIO -import org.funfix.tasks.kotlin.runBlocking -import org.junit.jupiter.api.Assertions.assertTrue -import java.util.concurrent.Executors -import kotlin.test.Test -import kotlin.test.assertEquals - -class TaskEnsureRunningOnExecutorTest { - @Test - @Suppress("DEPRECATION") - fun ensureRunningOnExecutorWorks() { - val ex = Executors.newCachedThreadPool { r -> - val th = Thread(r) - th.name = "my-thread-" + th.id - th - } - try { - val t = Task.fromBlockingIO { Thread.currentThread().name } - val n1 = t.runBlocking() - val n2 = t.ensureRunningOnExecutor().runBlocking() - val n3 = t.ensureRunningOnExecutor(ex).runBlocking() - - assertEquals(Thread.currentThread().name, n1) - assertTrue(n2.startsWith("tasks-io-"), "tasks-io") - assertTrue(n3.startsWith("my-thread-"), "my-thread") - } finally { - ex.shutdown() - } - } -} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromAsyncTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromAsyncTest.kt deleted file mode 100644 index 79a1d37..0000000 --- a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromAsyncTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.funfix.tests - -import org.funfix.tasks.kotlin.Cancellable -import org.funfix.tasks.kotlin.Outcome -import org.funfix.tasks.kotlin.Task -import org.funfix.tasks.kotlin.fromAsync -import org.funfix.tasks.kotlin.joinBlocking -import org.funfix.tasks.kotlin.outcomeOrNull -import org.funfix.tasks.kotlin.runBlocking -import org.funfix.tasks.kotlin.runFiber -import java.util.concurrent.CountDownLatch -import kotlin.test.Test -import kotlin.test.assertEquals - -class TaskFromAsyncTest { - @Test - fun `fromAsync happy path`() { - val task = Task.fromAsync { _, cb -> - cb(Outcome.success(1)) - Cancellable.getEmpty() - } - assertEquals(1, task.runBlocking()) - } - - @Test - fun `fromAsync with error`() { - val ex = RuntimeException("Boom!") - val task = Task.fromAsync { _, cb -> - cb(Outcome.failure(ex)) - Cancellable.getEmpty() - } - try { - task.runBlocking() - } catch (e: RuntimeException) { - assertEquals(ex, e) - } - } - - @Test - fun `fromAsync can be cancelled`() { - val latch = CountDownLatch(1) - val task = Task.fromAsync { executor, cb -> - executor.execute { - latch.await() - cb(Outcome.Cancellation) - } - Cancellable { - latch.countDown() - } - } - val fiber = task.runFiber() - fiber.cancel() - fiber.joinBlocking() - assertEquals(Outcome.Cancellation, fiber.outcomeOrNull) - } -} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromBlockingIOTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromBlockingIOTest.kt deleted file mode 100644 index 3493719..0000000 --- a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromBlockingIOTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.funfix.tests - -import org.funfix.tasks.kotlin.Outcome -import org.funfix.tasks.kotlin.Task -import org.funfix.tasks.kotlin.fromBlockingIO -import org.funfix.tasks.kotlin.joinBlocking -import org.funfix.tasks.kotlin.outcomeOrNull -import org.funfix.tasks.kotlin.runBlocking -import org.funfix.tasks.kotlin.runFiber -import kotlin.test.Test -import kotlin.test.assertEquals - -class TaskFromBlockingIOTest { - @Test - fun `fromBlockingIO (success)`() { - val task = Task.fromBlockingIO { 1 } - assertEquals(1, task.runBlocking()) - } - - @Test - fun `fromBlockingIO (failure)`() { - val ex = RuntimeException("Boom!") - val task = Task.fromBlockingIO { throw ex } - try { - task.runBlocking() - } catch (e: RuntimeException) { - assertEquals(ex, e) - } - } - - @Test - fun `fromBlockingIO (cancellation)`() { - val task: Task = Task.fromBlockingIO { - Thread.sleep(10000) - 1 - } - val fiber = task.runFiber() - fiber.cancel() - fiber.joinBlocking() - assertEquals(Outcome.Cancellation, fiber.outcomeOrNull) - } -} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromFutureTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromFutureTest.kt deleted file mode 100644 index e6726a3..0000000 --- a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskFromFutureTest.kt +++ /dev/null @@ -1,169 +0,0 @@ -package org.funfix.tests - -import org.funfix.tasks.jvm.Cancellable -import org.funfix.tasks.jvm.CancellableFuture -import org.funfix.tasks.kotlin.Outcome -import org.funfix.tasks.kotlin.Task -import org.funfix.tasks.kotlin.fromBlockingFuture -import org.funfix.tasks.kotlin.fromCancellableFuture -import org.funfix.tasks.kotlin.fromCompletionStage -import org.funfix.tasks.kotlin.joinBlocking -import org.funfix.tasks.kotlin.outcomeOrNull -import org.funfix.tasks.kotlin.runBlocking -import org.funfix.tasks.kotlin.runFiber -import java.util.concurrent.Callable -import java.util.concurrent.CompletableFuture -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.fail - -class TaskFromFutureTest { - @Test - fun `fromBlockingFuture (success)`() { - val ec = Executors.newCachedThreadPool() - try { - val task = Task.fromBlockingFuture { - ec.submit(Callable { 1 }) - } - assertEquals(1, task.runBlocking()) - } finally { - ec.shutdown() - } - } - - @Test - fun `fromBlockingFuture (failure)`() { - val ex = RuntimeException("Boom!") - val ec = Executors.newCachedThreadPool() - try { - val task = Task.fromBlockingFuture { - ec.submit { throw ex } - } - try { - task.runBlocking() - fail("Expected exception") - } catch (e: RuntimeException) { - assertEquals(ex, e) - } - } finally { - ec.shutdown() - } - } - - @Test - fun `fromBlockingFuture (cancellation)`() { - val ec = Executors.newCachedThreadPool() - val wasStarted = CountDownLatch(1) - val wasCancelled = CountDownLatch(1) - try { - val task = Task.fromBlockingFuture { - ec.submit(Callable { - wasStarted.countDown() - try { - Thread.sleep(10000) - 1 - } catch (e: InterruptedException) { - wasCancelled.countDown() - throw e - } - }) - } - val fiber = task.runFiber() - TimedAwait.latchAndExpectCompletion(wasStarted, "wasStarted") - fiber.cancel() - fiber.joinBlocking() - assertEquals(Outcome.Cancellation, fiber.outcomeOrNull) - TimedAwait.latchAndExpectCompletion(wasCancelled, "wasCancelled") - } finally { - ec.shutdown() - } - } - - @Test - fun `fromCompletionStage (success)`() { - val task = Task.fromCompletionStage { - CompletableFuture.supplyAsync { 1 } - } - assertEquals(1, task.runBlocking()) - } - - @Test - fun `fromCompletionStage (failure)`() { - val ex = RuntimeException("Boom!") - val task = Task.fromCompletionStage { - CompletableFuture.supplyAsync { - throw ex - } - } - try { - task.runBlocking() - fail("Expected exception") - } catch (e: RuntimeException) { - assertEquals(ex, e) - } - } - - @Test - fun `fromCancellableFuture (success)`() { - val ec = Executors.newCachedThreadPool() - try { - val task = Task.fromCancellableFuture { - CancellableFuture( - CompletableFuture.supplyAsync { 1 }, - Cancellable.getEmpty() - ) - } - assertEquals(1, task.runBlocking()) - } finally { - ec.shutdown() - } - } - - @Test - fun `fromCancellableFuture (failure)`() { - val ex = RuntimeException("Boom!") - val ec = Executors.newCachedThreadPool() - try { - val task = Task.fromCancellableFuture { - CancellableFuture( - CompletableFuture.supplyAsync { - throw ex - }, - Cancellable.getEmpty() - ) - } - try { - task.runBlocking() - fail("Expected exception") - } catch (e: RuntimeException) { - assertEquals(ex, e) - } - } finally { - ec.shutdown() - } - } - - @Test - fun `fromCancellableFuture (cancellation)`() { - val wasStarted = CountDownLatch(1) - val wasCancelled = CountDownLatch(1) - val task = Task.fromCancellableFuture { - CancellableFuture( - CompletableFuture.supplyAsync { - wasStarted.countDown() - TimedAwait.latchNoExpectations(wasCancelled) - } - ) { - wasCancelled.countDown() - } - } - val fiber = task.runFiber() - TimedAwait.latchAndExpectCompletion(wasStarted, "wasStarted") - fiber.cancel() - fiber.joinBlocking() - assertEquals(Outcome.Cancellation, fiber.outcomeOrNull) - TimedAwait.latchAndExpectCompletion(wasCancelled, "wasCancelled") - } -} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunAsyncTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunAsyncTest.kt deleted file mode 100644 index be81a16..0000000 --- a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunAsyncTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -package org.funfix.tests - -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicReference -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.fail -import org.funfix.tasks.jvm.TaskCancellationException -import org.funfix.tasks.kotlin.Outcome -import org.funfix.tasks.kotlin.Task -import org.funfix.tasks.kotlin.fromBlockingIO -import org.funfix.tasks.kotlin.runAsync -import org.junit.jupiter.api.Assertions.assertTrue - -class TaskRunAsyncTest { - @Test - fun `runAsync (success)`() { - val latch = CountDownLatch(1) - val outcomeRef = AtomicReference?>(null) - val task = Task.fromBlockingIO { 1 } - task.runAsync { outcome -> - outcomeRef.set(outcome) - latch.countDown() - } - - TimedAwait.latchAndExpectCompletion(latch) - assertEquals(Outcome.Success(1), outcomeRef.get()) - assertEquals(1, outcomeRef.get()!!.orThrow) - } - - @Test - fun `runAsync (failure)`() { - val latch = CountDownLatch(1) - val outcomeRef = AtomicReference?>(null) - val ex = RuntimeException("Boom!") - val task = Task.fromBlockingIO { throw ex } - task.runAsync { outcome -> - outcomeRef.set(outcome) - latch.countDown() - } - - TimedAwait.latchAndExpectCompletion(latch) - assertEquals(Outcome.Failure(ex), outcomeRef.get()) - try { - outcomeRef.get()!!.orThrow - fail("Expected exception") - } catch (e: RuntimeException) { - assertEquals(ex, e) - } - } - - @Test - fun `runAsync (cancellation)`() { - val latch = CountDownLatch(1) - val wasStarted = CountDownLatch(1) - val outcomeRef = AtomicReference?>(null) - val task = Task.fromBlockingIO { - wasStarted.countDown() - Thread.sleep(10000) - 1 - } - val cancel = task.runAsync { outcome -> - outcomeRef.set(outcome) - latch.countDown() - } - - TimedAwait.latchAndExpectCompletion(wasStarted, "wasStarted") - cancel.cancel() - TimedAwait.latchAndExpectCompletion(latch) - assertEquals(Outcome.Cancellation, outcomeRef.get()) - try { - outcomeRef.get()!!.orThrow - fail("Expected exception") - } catch (e: TaskCancellationException) { - // expected - } - } - - @Test - @Suppress("DEPRECATION") - fun `runAsync runs with given executor`() { - val ec = Executors.newCachedThreadPool { r -> - val t = Thread(r) - t.isDaemon = true - t.name = "my-thread-${t.id}" - t - } - try { - val latch = CountDownLatch(1) - val outcomeRef = AtomicReference?>(null) - val task = Task.fromBlockingIO { - Thread.currentThread().name - } - task.runAsync(ec) { outcome -> - outcomeRef.set(outcome) - latch.countDown() - } - TimedAwait.latchAndExpectCompletion(latch) - assertTrue(outcomeRef.get()!!.orThrow.startsWith("my-thread-")) - } finally { - ec.shutdown() - } - } -} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunBlockingTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunBlockingTest.kt deleted file mode 100644 index 5d8ae69..0000000 --- a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunBlockingTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -package org.funfix.tests - -import org.funfix.tasks.kotlin.SharedIOExecutor -import org.funfix.tasks.kotlin.Task -import org.funfix.tasks.kotlin.fromBlockingIO -import org.funfix.tasks.kotlin.runBlocking -import org.funfix.tasks.kotlin.runBlockingTimed -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.fail -import kotlin.time.toKotlinDuration - -class TaskRunBlockingTest { - @Test - fun `runBlocking (success)`() { - val task = Task.fromBlockingIO { 1 } - - assertEquals(1, task.runBlocking()) - assertEquals(1, task.runBlocking(SharedIOExecutor)) - } - - @Test - fun `runBlocking (failure)`() { - val ex = RuntimeException("Boom!") - val task = Task.fromBlockingIO { throw ex } - - try { - task.runBlocking() - } catch (e: RuntimeException) { - assertEquals(ex, e) - } - - try { - task.runBlocking(SharedIOExecutor) - } catch (e: RuntimeException) { - assertEquals(ex, e) - } - } - - @Test - fun `runBlocking (cancellation)`() { - val task: Task = Task.fromBlockingIO { - throw InterruptedException() - } - try { - task.runBlocking() - } catch (e: InterruptedException) { - // expected - } - try { - task.runBlocking(SharedIOExecutor) - } catch (e: InterruptedException) { - // expected - } - } - - @Test - fun `runBlockingTimed (success)`() { - val task = Task.fromBlockingIO { 1 } - - assertEquals(1, task.runBlockingTimed( - TimedAwait.TIMEOUT.toKotlinDuration() - )) - assertEquals(1, task.runBlockingTimed( - TimedAwait.TIMEOUT.toKotlinDuration(), - SharedIOExecutor - )) - } - - @Test - fun `runBlockingTimed (failure)`() { - val ex = RuntimeException("Boom!") - val task = Task.fromBlockingIO { throw ex } - - try { - task.runBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) - } catch (e: RuntimeException) { - assertEquals(ex, e) - } - - try { - task.runBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration(), SharedIOExecutor) - } catch (e: RuntimeException) { - assertEquals(ex, e) - } - } - - @Test - fun `runBlockingTimed (cancellation)`() { - val task: Task = Task.fromBlockingIO { - throw InterruptedException() - } - try { - task.runBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) - fail("Expected exception") - } catch (e: InterruptedException) { - // expected - } - try { - task.runBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration(), SharedIOExecutor) - fail("Expected exception") - } catch (e: InterruptedException) { - // expected - } - } -} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunFiberTest.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunFiberTest.kt deleted file mode 100644 index d4ecd20..0000000 --- a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TaskRunFiberTest.kt +++ /dev/null @@ -1,275 +0,0 @@ -package org.funfix.tests - -import org.funfix.tasks.jvm.TaskCancellationException -import org.funfix.tasks.kotlin.Outcome -import org.funfix.tasks.kotlin.Task -import org.funfix.tasks.kotlin.awaitAsync -import org.funfix.tasks.kotlin.awaitBlocking -import org.funfix.tasks.kotlin.awaitBlockingTimed -import org.funfix.tasks.kotlin.fromBlockingIO -import org.funfix.tasks.kotlin.joinAsync -import org.funfix.tasks.kotlin.joinBlocking -import org.funfix.tasks.kotlin.joinBlockingTimed -import org.funfix.tasks.kotlin.outcomeOrNull -import org.funfix.tasks.kotlin.resultOrThrow -import org.funfix.tasks.kotlin.runFiber -import org.junit.jupiter.api.Test -import java.util.concurrent.CountDownLatch -import java.util.concurrent.atomic.AtomicReference -import kotlin.test.assertEquals -import kotlin.test.fail -import kotlin.time.toKotlinDuration - -class TaskRunFiberTest { - @Test - fun `runFiber + joinAsync (success)`() { - val fiber = Task - .fromBlockingIO { 1 } - .runFiber() - - val latch = CountDownLatch(1) - val outcomeRef = AtomicReference?>(null) - fiber.joinAsync { - outcomeRef.set(fiber.outcomeOrNull!!) - latch.countDown() - } - - TimedAwait.latchAndExpectCompletion(latch) - assertEquals(Outcome.Success(1), outcomeRef.get()) - } - - @Test - fun `runFiber + joinAsync (failure)`() { - val ex = RuntimeException("Boom!") - val fiber = Task - .fromBlockingIO { throw ex } - .runFiber() - - val latch = CountDownLatch(1) - val outcomeRef = AtomicReference?>(null) - fiber.joinAsync { - outcomeRef.set(fiber.outcomeOrNull!!) - latch.countDown() - } - - TimedAwait.latchAndExpectCompletion(latch) - assertEquals(Outcome.Failure(ex), outcomeRef.get()) - } - - @Test - fun `runFiber + joinAsync (cancellation)`() { - val fiber = Task - .fromBlockingIO { Thread.sleep(10000) } - .runFiber() - - val latch = CountDownLatch(1) - val outcomeRef = AtomicReference?>(null) - fiber.joinAsync { - outcomeRef.set(fiber.outcomeOrNull!!) - latch.countDown() - } - - fiber.cancel() - TimedAwait.latchAndExpectCompletion(latch) - assertEquals(Outcome.Cancellation, outcomeRef.get()) - } - - @Test - fun `runFiber + awaitAsync (success)`() { - val fiber = Task - .fromBlockingIO { 1 } - .runFiber() - - val latch = CountDownLatch(1) - val outcomeRef = AtomicReference?>(null) - fiber.awaitAsync { outcome -> - outcomeRef.set(outcome) - latch.countDown() - } - - TimedAwait.latchAndExpectCompletion(latch) - assertEquals(Outcome.Success(1), outcomeRef.get()) - } - - @Test - fun `runFiber + awaitAsync (failure)`() { - val ex = RuntimeException("Boom!") - val fiber = Task - .fromBlockingIO { throw ex } - .runFiber() - - val latch = CountDownLatch(1) - val outcomeRef = AtomicReference?>(null) - fiber.awaitAsync { outcome -> - outcomeRef.set(outcome) - latch.countDown() - } - - TimedAwait.latchAndExpectCompletion(latch) - assertEquals(Outcome.Failure(ex), outcomeRef.get()) - } - - @Test - fun `runFiber + awaitAsync (cancellation)`() { - val fiber = Task - .fromBlockingIO { Thread.sleep(10000) } - .runFiber() - - val latch = CountDownLatch(1) - val outcomeRef = AtomicReference?>(null) - fiber.awaitAsync { outcome -> - outcomeRef.set(outcome) - latch.countDown() - } - - fiber.cancel() - TimedAwait.latchAndExpectCompletion(latch) - assertEquals(Outcome.Cancellation, outcomeRef.get()) - } - - @Test - fun `runFiber + joinBlocking (success)`() { - val fiber = Task - .fromBlockingIO { 1 } - .runFiber() - - fiber.joinBlocking() - assertEquals(1, fiber.resultOrThrow) - assertEquals(Outcome.Success(1), fiber.outcomeOrNull) - } - - @Test - fun `runFiber + joinBlocking (failure)`() { - val ex = RuntimeException("Boom!") - val fiber = Task - .fromBlockingIO { throw ex } - .runFiber() - - fiber.joinBlocking() - assertEquals(Outcome.Failure(ex), fiber.outcomeOrNull) - } - - @Test - fun `runFiber + joinBlocking (cancellation)`() { - val fiber = Task - .fromBlockingIO { Thread.sleep(10000) } - .runFiber() - - fiber.cancel() - fiber.joinBlocking() - assertEquals(Outcome.Cancellation, fiber.outcomeOrNull) - } - - @Test - fun `runFiber + awaitBlocking (success)`() { - val fiber = Task - .fromBlockingIO { 1 } - .runFiber() - - val result = fiber.awaitBlocking() - assertEquals(1, result) - } - - @Test - fun `runFiber + awaitBlocking (failure)`() { - val ex = RuntimeException("Boom!") - val fiber = Task - .fromBlockingIO { throw ex } - .runFiber() - - try { - fiber.awaitBlocking() - fail("Expected exception") - } catch (e: RuntimeException) { - assertEquals(ex, e) - } - } - - @Test - fun `runFiber + awaitBlocking (cancellation)`() { - val fiber = Task - .fromBlockingIO { Thread.sleep(10000) } - .runFiber() - - fiber.cancel() - try { - fiber.awaitBlocking() - fail("Expected exception") - } catch (e: TaskCancellationException) { - // expected - } - } - - @Test - fun `runFiber + joinBlockingTimed (success)`() { - val fiber = Task - .fromBlockingIO { 1 } - .runFiber() - - fiber.joinBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) - assertEquals(1, fiber.resultOrThrow) - assertEquals(Outcome.Success(1), fiber.outcomeOrNull) - } - - @Test - fun `runFiber + joinBlockingTimed (failure)`() { - val ex = RuntimeException("Boom!") - val fiber = Task - .fromBlockingIO { throw ex } - .runFiber() - - fiber.joinBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) - assertEquals(Outcome.Failure(ex), fiber.outcomeOrNull) - } - - @Test - fun `runFiber + joinBlockingTimed (cancellation)`() { - val fiber = Task - .fromBlockingIO { Thread.sleep(10000) } - .runFiber() - - fiber.cancel() - fiber.joinBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) - assertEquals(Outcome.Cancellation, fiber.outcomeOrNull) - } - - @Test - fun `runFiber + awaitBlockingTimed (success)`() { - val fiber = Task - .fromBlockingIO { 1 } - .runFiber() - - val result = fiber.awaitBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) - assertEquals(1, result) - } - - @Test - fun `runFiber + awaitBlockingTimed (failure)`() { - val ex = RuntimeException("Boom!") - val fiber = Task - .fromBlockingIO { throw ex } - .runFiber() - - try { - fiber.awaitBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) - fail("Expected exception") - } catch (e: RuntimeException) { - assertEquals(ex, e) - } - } - - @Test - fun `runFiber + awaitBlockingTimed (cancellation)`() { - val fiber = Task - .fromBlockingIO { Thread.sleep(10000) } - .runFiber() - - fiber.cancel() - try { - fiber.awaitBlockingTimed(TimedAwait.TIMEOUT.toKotlinDuration()) - fail("Expected exception") - } catch (e: TaskCancellationException) { - // expected - } - } -} diff --git a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TimedAwait.kt b/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TimedAwait.kt deleted file mode 100644 index 3b89538..0000000 --- a/tasks-kotlin/src/jvmTest/kotlin/org/funfix/tests/TimedAwait.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.funfix.tests - -import java.time.Duration -import java.util.concurrent.* - -object TimedAwait { - val TIMEOUT: Duration = - if (System.getenv("CI") != null) Duration.ofSeconds(20) - else Duration.ofSeconds(10) - - @Throws(InterruptedException::class) - fun latchNoExpectations(latch: CountDownLatch): Boolean = - latch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) - - @Throws(InterruptedException::class) - fun latchAndExpectCompletion(latch: CountDownLatch) = latchAndExpectCompletion(latch, "latch") - - @Throws(InterruptedException::class) - fun latchAndExpectCompletion(latch: CountDownLatch, name: String) { - assert(latch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) { "$name.await" } - } - - @Throws(InterruptedException::class, TimeoutException::class) - fun future(future: Future<*>) { - try { - future.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) - } catch (e: ExecutionException) { - throw RuntimeException(e) - } - } -} From c70b252bc94d6183706a18557b4bdb93a092d733 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 20:02:22 +0200 Subject: [PATCH 19/21] More tests, but broken build --- .../org/funfix/tasks/kotlin/coroutines.jvm.kt | 22 +- .../tasks/kotlin/CoroutinesCommonTest.kt | 99 -------- .../funfix/tasks/kotlin/CoroutinesJvmTest.kt | 233 +++++++++++++++++- 3 files changed, 233 insertions(+), 121 deletions(-) delete mode 100644 tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt diff --git a/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt b/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt index b0496ca..7b5929d 100644 --- a/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt +++ b/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt @@ -3,21 +3,6 @@ package org.funfix.tasks.kotlin -import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import org.funfix.tasks.jvm.Cancellable -import org.funfix.tasks.jvm.CompletionCallback -import org.funfix.tasks.jvm.Outcome -import org.funfix.tasks.jvm.Task -import org.funfix.tasks.jvm.TaskCancellationException -import org.funfix.tasks.jvm.UncaughtExceptionHandler import java.util.concurrent.Executor import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.ContinuationInterceptor @@ -25,6 +10,9 @@ import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.* +import kotlinx.coroutines.CancellableContinuation +import org.funfix.tasks.jvm.* /** * Similar with `runBlocking`, however this is a "suspended" function, @@ -141,7 +129,7 @@ internal class CoroutineAsCompletionCallback( /** * Internal API: gets the current [kotlinx.coroutines.CoroutineDispatcher] from the coroutine context. */ -internal suspend fun currentDispatcher(): kotlinx.coroutines.CoroutineDispatcher { +internal suspend fun currentDispatcher(): CoroutineDispatcher { val continuationInterceptor = currentCoroutineContext()[ContinuationInterceptor] - return continuationInterceptor as? kotlinx.coroutines.CoroutineDispatcher ?: Dispatchers.Default + return continuationInterceptor as? CoroutineDispatcher ?: Dispatchers.Default } diff --git a/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt deleted file mode 100644 index cde7a07..0000000 --- a/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesCommonTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.funfix.tasks.kotlin - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.async -import kotlinx.coroutines.test.runTest -import org.funfix.tasks.jvm.Cancellable -import org.funfix.tasks.jvm.Outcome -import org.funfix.tasks.jvm.Task -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -class CoroutinesCommonTest { - @Test - fun runSuspendedSuccess() = runTest { - val task = Task.fromAsync { _, cb -> - cb.onSuccess(42) - Cancellable {} - } - - val result = task.runSuspending() - - assertEquals(42, result) - } - - @Test - fun runSuspendedFailure() = runTest { - val ex = RuntimeException("Boom") - val task = Task.fromAsync { _, cb -> - cb.onFailure(ex) - Cancellable {} - } - - val thrown = assertFailsWith { task.runSuspending() } - - assertEquals("Boom", thrown.message) - } - - @Test - fun runSuspendedCancelsTaskToken() = runTest { - val cancelled = CompletableDeferred() - val started = CompletableDeferred() - val task = Task.fromAsync { _, cb -> - started.complete(Unit) - Cancellable { - cancelled.complete(Unit) - cb.onCancellation() - } - } - - val deferred = async { task.runSuspending() } - started.await() - deferred.cancel() - - assertFailsWith { deferred.await() } - cancelled.await() - } - - @Test - fun fromSuspendedSuccess() = runTest { - val task = suspendAsTask { - 21 + 21 - } - val deferred = CompletableDeferred>() - task.runAsync { outcome -> deferred.complete(outcome) } - - assertEquals(Outcome.Success(42), deferred.await()) - } - - @Test - fun fromSuspendedFailure() = runTest { - val ex = RuntimeException("Boom") - val task = suspendAsTask { - throw ex - } - val deferred = CompletableDeferred>() - task.runAsync { outcome -> deferred.complete(outcome) } - - assertEquals(Outcome.Failure(ex), deferred.await()) - } - - @Test - fun fromSuspendedCancellation() = runTest { - val started = CompletableDeferred() - val task = suspendAsTask { - started.complete(Unit) - awaitCancellation() - } - val deferred = CompletableDeferred>() - val cancel = task.runAsync { outcome -> deferred.complete(outcome) } - - started.await() - cancel.cancel() - - assertEquals(Outcome.Cancellation(), deferred.await()) - } -} diff --git a/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt index baaca1d..64f0af0 100644 --- a/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt +++ b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt @@ -1,14 +1,102 @@ package org.funfix.tasks.kotlin -import kotlinx.coroutines.asCoroutineDispatcher +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.* +import kotlinx.coroutines.* import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext +import org.funfix.tasks.jvm.Cancellable +import org.funfix.tasks.jvm.Outcome import org.funfix.tasks.jvm.Task -import java.util.concurrent.Executors -import kotlin.test.Test -import kotlin.test.assertTrue +import org.funfix.tasks.jvm.TaskCancellationException class CoroutinesJvmTest { + @Test + fun `runSuspending signals Success`() = runTest { + val task = Task.fromAsync { _, cb -> + cb.onSuccess(42) + Cancellable {} + } + + val result = task.runSuspending() + + assertEquals(42, result) + } + + @Test + fun `runSuspending signals Failure`() = runTest { + val ex = RuntimeException("Boom") + val task = Task.fromAsync { _, cb -> + cb.onFailure(ex) + Cancellable {} + } + + val thrown = assertFailsWith { task.runSuspending() } + + assertEquals("Boom", thrown.message) + } + + + @Test + fun `runSuspending cancels the task token`() = runTest { + val cancelled = CompletableDeferred() + val started = CompletableDeferred() + val task = Task.fromAsync { _, cb -> + started.complete(Unit) + Cancellable { + cancelled.complete(Unit) + cb.onCancellation() + } + } + + val deferred = async { task.runSuspending() } + started.await() + deferred.cancel() + + assertFailsWith { deferred.await() } + cancelled.await() + } + + @Test + fun `suspendAsTask signals Success`() = runTest { + val task = suspendAsTask { + 21 + 21 + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + + assertEquals(Outcome.Success(42), deferred.await()) + } + + @Test + fun `suspendAsTask signals Failure`() = runTest { + val ex = RuntimeException("Boom") + val task = suspendAsTask { + throw ex + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + + assertEquals(Outcome.Failure(ex), deferred.await()) + } + + @Test + fun `suspendAsTask signals Cancellation`() = runTest { + val started = CompletableDeferred() + val task = suspendAsTask { + started.complete(Unit) + awaitCancellation() + } + val deferred = CompletableDeferred>() + val cancel = task.runAsync { outcome -> deferred.complete(outcome) } + + started.await() + cancel.cancel() + + assertEquals(Outcome.Cancellation(), deferred.await()) + } + @Test fun `runSuspending uses current Dispatcher when Executor is null`() = runTest { val executor = Executors.newSingleThreadExecutor { r -> @@ -29,4 +117,139 @@ class CoroutinesJvmTest { executor.shutdown() } } + + @Test + fun `runSuspending translates async callback success`() = runTest { + val task = Task.fromAsync { _, cb -> + cb.onSuccess(42) + Cancellable {} + } + + assertEquals(42, task.runSuspending()) + } + + @Test + fun `runSuspending translates async callback failure`() = runTest { + val ex = RuntimeException("Boom") + val task = Task.fromAsync { _, cb -> + cb.onFailure(ex) + Cancellable {} + } + + val thrown = assertFailsWith { task.runSuspending() } + + assertEquals("Boom", thrown.message) + } + + @Test + fun `runSuspending resumes with task cancellation`() = runTest { + val task = Task.fromAsync { _, cb -> + cb.onCancellation() + Cancellable {} + } + + assertFailsWith { task.runSuspending() } + } + + @Test + fun `runSuspending forwards runAsync failure`() = runTest { + val ex = RuntimeException("Boom") + val task = Task.fromAsync { _, _ -> + throw ex + } + + val thrown = assertFailsWith { task.runSuspending() } + + assertEquals("Boom", thrown.message) + } + + @Test + fun `runSuspending cancels task when coroutine job is cancelled`() = runTest { + val started = CompletableDeferred() + val latch = CountDownLatch(1) + val task = Task.fromBlockingIO { + started.complete(Unit) + latch.await() + 42 + } + val executor = Executors.newSingleThreadExecutor() + try { + val deferred = async { task.runSuspending(executor) } + + started.await() + deferred.cancel() + latch.countDown() + + deferred.join() + + assertFailsWith { deferred.await() } + } finally { + executor.shutdown() + } + } + + @Test + fun `suspendAsTask maps CancellationException to cancellation`() = runTest { + val task = suspendAsTask { + throw CancellationException("cancelled") + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + + assertEquals(Outcome.Cancellation(), deferred.await()) + } + + @Test + fun `suspendAsTask maps TaskCancellationException to cancellation`() = runTest { + val task = suspendAsTask { + throw TaskCancellationException("stop") + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + + assertEquals(Outcome.Cancellation(), deferred.await()) + } + + @Test + fun `suspendAsTask maps InterruptedException to cancellation`() = runTest { + val task = suspendAsTask { + throw InterruptedException("stop") + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + + assertEquals(Outcome.Cancellation(), deferred.await()) + } + + @Test + fun `suspendAsTask cancels when coroutine is cancelled while blocked`() = runTest { + val started = CountDownLatch(1) + val latch = CountDownLatch(1) + val wasTriggered = CountDownLatch(1) + val task = suspendAsTask { + runInterruptible { + started.countDown() + try { + assertFalse("Should have been cancelled") { + latch.await(10, TimeUnit.SECONDS) + } + } catch (_: InterruptedException) { + wasTriggered.countDown() + } + } + } + + val job = async { + task.runSuspending() + } + + assertTrue("coroutine not started") { + started.await(10, TimeUnit.SECONDS) + } + + job.cancel() + assertTrue("cancellation not triggered") { + wasTriggered.await(10, TimeUnit.SECONDS) + } + } } From a925af99fea083c36a732f1710697a6ba85b0820 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 20:25:56 +0200 Subject: [PATCH 20/21] Fix tests --- .github/workflows/publish-release.yaml | 17 +---------- gradle/libs.versions.toml | 4 +-- tasks-kotlin-coroutines/build.gradle.kts | 1 + .../funfix/tasks/kotlin/CoroutinesJvmTest.kt | 29 +++++++------------ 4 files changed, 14 insertions(+), 37 deletions(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index f31efa4..602f6ea 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -15,22 +15,7 @@ jobs: with: java-version: 17 distribution: 'temurin' - - name: Generate API docs - run: ./gradlew -PbuildRelease=true dokkaGeneratePublicationHtml --no-daemon - - name: Prepare GitHub Pages content - run: | - VERSION=$(grep -E '^project\.version=' gradle.properties | cut -d= -f2 | tr -d '[:space:]') - mkdir -p "site/api/$VERSION" - cp -R build/dokka/. "site/api/$VERSION/" - ln -sfn "$VERSION" "site/api/current" - - name: Publish API docs to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_branch: gh-pages - publish_dir: site - keep_files: true - - name: Publish Snapshot + - name: Publish to Maven Central run: ./gradlew -PbuildRelease=true build publish --no-daemon env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9df7dc8..263078a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +arrow = "2.2.1.1" binary-compatibility-validator = "0.16.2" dokka = "2.1.0" jetbrains-annotations = "26.0.2" @@ -23,8 +24,7 @@ vanniktech-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plug # Actual libraries jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" } jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" } -junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit-jupiter" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } -lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } +arrow-fx-coroutines = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" } diff --git a/tasks-kotlin-coroutines/build.gradle.kts b/tasks-kotlin-coroutines/build.gradle.kts index 9da0455..268a8b8 100644 --- a/tasks-kotlin-coroutines/build.gradle.kts +++ b/tasks-kotlin-coroutines/build.gradle.kts @@ -33,6 +33,7 @@ kotlin { dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + implementation(libs.arrow.fx.coroutines) } } } diff --git a/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt index 64f0af0..6c6c84d 100644 --- a/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt +++ b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt @@ -1,8 +1,7 @@ package org.funfix.tasks.kotlin -import java.util.concurrent.CountDownLatch +import arrow.fx.coroutines.CountDownLatch import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit import kotlin.test.* import kotlinx.coroutines.* import kotlinx.coroutines.test.runTest @@ -166,7 +165,7 @@ class CoroutinesJvmTest { @Test fun `runSuspending cancels task when coroutine job is cancelled`() = runTest { val started = CompletableDeferred() - val latch = CountDownLatch(1) + val latch = java.util.concurrent.CountDownLatch(1) val task = Task.fromBlockingIO { started.complete(Unit) latch.await() @@ -227,15 +226,12 @@ class CoroutinesJvmTest { val latch = CountDownLatch(1) val wasTriggered = CountDownLatch(1) val task = suspendAsTask { - runInterruptible { - started.countDown() - try { - assertFalse("Should have been cancelled") { - latch.await(10, TimeUnit.SECONDS) - } - } catch (_: InterruptedException) { - wasTriggered.countDown() - } + started.countDown() + try { + latch.await() + fail("should have been cancelled") + } catch (_: CancellationException) { + wasTriggered.countDown() } } @@ -243,13 +239,8 @@ class CoroutinesJvmTest { task.runSuspending() } - assertTrue("coroutine not started") { - started.await(10, TimeUnit.SECONDS) - } - + started.await() job.cancel() - assertTrue("cancellation not triggered") { - wasTriggered.await(10, TimeUnit.SECONDS) - } + wasTriggered.await() } } From 5413d9771ddd709394150f59dff17a34f9285c84 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 1 Feb 2026 20:28:08 +0200 Subject: [PATCH 21/21] Prepare for release --- README.md | 8 ++++---- gradle.properties | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2fc4f9f..4d62730 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This is a library meant for library authors that want to build libraries that wo ## Usage -Read the [Javadoc](https://javadoc.io/doc/org.funfix/tasks-jvm/0.3.1/org/funfix/tasks/jvm/package-summary.html). +Read the [Javadoc](https://javadoc.io/doc/org.funfix/tasks-jvm/0.4.0/org/funfix/tasks/jvm/package-summary.html). Better documentation is coming. --- @@ -16,18 +16,18 @@ Maven: org.funfix tasks-jvm - 0.3.1 + 0.4.0 ``` Gradle: ```kotlin dependencies { - implementation("org.funfix:tasks-jvm:0.3.1") + implementation("org.funfix:tasks-jvm:0.4.0") } ``` sbt: ```scala -libraryDependencies += "org.funfix" % "tasks-jvm" % "0.3.1" +libraryDependencies += "org.funfix" % "tasks-jvm" % "0.4.0" ``` diff --git a/gradle.properties b/gradle.properties index f185d4e..24b7c12 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ kotlin.code.style=official # TO BE modified whenever a new version is released -project.version=0.3.1 +project.version=0.4.0