diff --git a/arrow-libs/core/arrow-autoclose/src/commonTest/kotlin/arrow/AutoCloseTest.kt b/arrow-libs/core/arrow-autoclose/src/commonTest/kotlin/arrow/AutoCloseTest.kt index b68e8ad1fbb..be7f3b9a55b 100644 --- a/arrow-libs/core/arrow-autoclose/src/commonTest/kotlin/arrow/AutoCloseTest.kt +++ b/arrow-libs/core/arrow-autoclose/src/commonTest/kotlin/arrow/AutoCloseTest.kt @@ -1,9 +1,12 @@ package arrow import arrow.atomic.AtomicBoolean +import arrow.core.ControlCancellationException +import arrow.core.InternalArrowApi import io.kotest.assertions.AssertionErrorBuilder +import io.kotest.assertions.assertionCounter import io.kotest.common.reflection.bestName -import io.kotest.matchers.assertionCounter +import io.kotest.matchers.collections.shouldHaveSingleElement import io.kotest.matchers.shouldBe import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel @@ -12,6 +15,7 @@ import kotlinx.coroutines.test.runTest import kotlin.coroutines.cancellation.CancellationException import kotlin.test.Test +@OptIn(InternalArrowApi::class) class AutoCloseTest { @Test @@ -72,7 +76,7 @@ class AutoCloseTest { r.shutdown() throw error2 } - autoClose({ Resource() }) { _, _ -> throw error3 } + val _ = autoClose({ Resource() }) { _, _ -> throw error3 } require(wasActive.complete(r.isActive())) throw error } @@ -93,7 +97,7 @@ class AutoCloseTest { val e = shouldThrow { autoCloseScope { - autoClose({ Resource() }) { r, e -> + val _ = autoClose({ Resource() }) { r, e -> require(promise.complete(e)) r.shutdown() throw error2 @@ -163,7 +167,7 @@ class AutoCloseTest { val wasActive = Channel(Channel.UNLIMITED) val closed = Channel(Channel.UNLIMITED) - autoCloseScope { + val _ = autoCloseScope { val r1 = autoClose({ res1 }) { r, _ -> closed.trySend(r).getOrThrow() r.shutdown() @@ -190,28 +194,50 @@ class AutoCloseTest { closed.cancel() } + @Test + fun normalRaise() = shouldAutoCloseWithSecond({}) { throw ControlCancellationException("second") } - @OptIn(ExperimentalStdlibApi::class) // 'AutoCloseable' in stdlib < 2.0 - private class Resource : AutoCloseable { - private val isActive = AtomicBoolean(true) + @Test + fun returnRaise() = shouldAutoCloseWithSecond({ return }) { throw ControlCancellationException("second") } - fun isActive(): Boolean = isActive.get() + @Test + fun raiseRaise() = shouldAutoCloseWithSecond({ throw ControlCancellationException("first") }) { throw ControlCancellationException("second") } - fun shutdown() { - require(isActive.compareAndSet(expected = true, new = false)) { - "Already shut down" - } - } + @Test + fun cancelRaise() = shouldAutoCloseWithFirst({ throw CancellationException("first") }) { throw ControlCancellationException("second") } - override fun close() { - shutdown() - } - } + @Test + fun throwRaise() = shouldAutoCloseWithFirst({ throw RuntimeException("first") }) { throw ControlCancellationException("second") } - private suspend fun CompletableDeferred.shouldHaveCompleted(): T { - isCompleted shouldBe true - return await() - } + @Test + fun normalCancel() = shouldAutoCloseWithSecond({}) { throw CancellationException("second") } + + @Test + fun returnCancel() = shouldAutoCloseWithSecond({ return }) { throw CancellationException("second") } + + @Test + fun raiseCancel() = shouldAutoCloseWithSecond({ throw ControlCancellationException("first") }) { throw CancellationException("second") } + + @Test + fun cancelCancel() = shouldAutoCloseWithFirst({ throw CancellationException("first") }) { throw CancellationException("second") } + + @Test + fun throwCancel() = shouldAutoCloseWithFirst({ throw RuntimeException("first") }) { throw CancellationException("second") } + + @Test + fun normalThrow() = shouldAutoCloseWithSecond({}) { throw RuntimeException("second") } + + @Test + fun returnThrow() = shouldAutoCloseWithSecond({ return }) { throw RuntimeException("second") } + + @Test + fun raiseThrow() = shouldAutoCloseWithSecond({ throw ControlCancellationException("first") }) { throw RuntimeException("second") } + + @Test + fun cancelThrow() = shouldAutoCloseWithFirst({ throw CancellationException("first") }) { throw RuntimeException("second") } + + @Test + fun throwThrow() = shouldAutoCloseWithFirst({ throw RuntimeException("first") }) { throw RuntimeException("second") } } // copied from Kotest so we can inline it @@ -219,7 +245,7 @@ inline fun shouldThrow(block: () -> Any?): T { assertionCounter.inc() val expectedExceptionClass = T::class val thrownThrowable = try { - block() + val _ = block() null // Can't throw failure here directly, as it would be caught by the catch clause, and it's an AssertionError, which is a special case } catch (thrown: Throwable) { thrown @@ -237,3 +263,71 @@ inline fun shouldThrow(block: () -> Any?): T { .build() } } + +private class Resource : AutoCloseable { + private val isActive = AtomicBoolean(true) + + fun isActive(): Boolean = isActive.get() + + fun shutdown() { + require(isActive.compareAndSet(expected = true, new = false)) { + "Already shut down" + } + } + + override fun close() { + shutdown() + } +} + +private suspend fun CompletableDeferred.shouldHaveCompleted(): T { + isCompleted shouldBe true + return await() +} + +private inline fun shouldAutoCloseWithFirst(first: () -> Unit, crossinline second: () -> Nothing) { + var firstThrowable: Throwable? = null + lateinit var secondThrowable: Throwable + try { + autoCloseScope { + onClose { + it shouldBe firstThrowable + peekThrowable(second) { secondThrowable = it } + } + peekThrowable(first) { firstThrowable = it } + } + } catch (e: Throwable) { + e shouldBe firstThrowable + e.suppressedExceptions shouldHaveSingleElement secondThrowable + } finally { + val _ = secondThrowable // ensure that onClose ran + } +} + +private inline fun shouldAutoCloseWithSecond(first: () -> Unit, crossinline second: () -> Nothing) { + var firstThrowable: Throwable? = null + lateinit var secondThrowable: Throwable + var finishedWithThrowable = false + try { + autoCloseScope { + onClose { + it shouldBe firstThrowable + peekThrowable(second) { secondThrowable = it } + } + peekThrowable(first) { firstThrowable = it } + } + } catch (e: Throwable) { + e shouldBe secondThrowable + if (firstThrowable != null) e.suppressedExceptions shouldHaveSingleElement firstThrowable + finishedWithThrowable = true + } finally { + finishedWithThrowable shouldBe true // otherwise, we finished with first somehow, either non-locally, or with Unit + } +} + +private inline fun peekThrowable(block: () -> R, peek: (Throwable) -> Unit): R = try { + block() +} catch (e: Throwable) { + peek(e) + throw e +} diff --git a/arrow-libs/core/arrow-core/api/arrow-core.klib.api b/arrow-libs/core/arrow-core/api/arrow-core.klib.api index 40318b916cb..d61c2e96ee0 100644 --- a/arrow-libs/core/arrow-core/api/arrow-core.klib.api +++ b/arrow-libs/core/arrow-core/api/arrow-core.klib.api @@ -699,7 +699,7 @@ sealed class <#A: out kotlin/Any?> arrow.core/Option { // arrow.core/Option|null } } -sealed class arrow.core.raise/RaiseCancellationException : kotlin.coroutines.cancellation/CancellationException // arrow.core.raise/RaiseCancellationException|null[0] +sealed class arrow.core.raise/RaiseCancellationException : arrow.core/ControlCancellationException // arrow.core.raise/RaiseCancellationException|null[0] final object arrow.core/ArrowCoreInternalException : kotlin/RuntimeException // arrow.core/ArrowCoreInternalException|null[0] diff --git a/arrow-libs/core/arrow-core/api/jvm/arrow-core.api b/arrow-libs/core/arrow-core/api/jvm/arrow-core.api index d71d161485d..a51b8dae8de 100644 --- a/arrow-libs/core/arrow-core/api/jvm/arrow-core.api +++ b/arrow-libs/core/arrow-core/api/jvm/arrow-core.api @@ -1024,7 +1024,7 @@ public abstract class arrow/core/raise/RaiseAccumulate$Value { public final fun getValue (Ljava/lang/Void;Lkotlin/reflect/KProperty;)Ljava/lang/Object; } -public abstract class arrow/core/raise/RaiseCancellationException : java/util/concurrent/CancellationException { +public abstract class arrow/core/raise/RaiseCancellationException : arrow/core/ControlCancellationException { public synthetic fun (Ljava/lang/Object;Larrow/core/raise/Raise;Lkotlin/jvm/internal/DefaultConstructorMarker;)V } diff --git a/arrow-libs/core/arrow-core/src/androidAndJvmMain/kotlin/arrow/core/raise/RaiseCancellationException.kt b/arrow-libs/core/arrow-core/src/androidAndJvmMain/kotlin/arrow/core/raise/RaiseCancellationException.kt index a5082ef1f62..d50982eb0b6 100644 --- a/arrow-libs/core/arrow-core/src/androidAndJvmMain/kotlin/arrow/core/raise/RaiseCancellationException.kt +++ b/arrow-libs/core/arrow-core/src/androidAndJvmMain/kotlin/arrow/core/raise/RaiseCancellationException.kt @@ -1,9 +1,11 @@ package arrow.core.raise -import kotlin.coroutines.cancellation.CancellationException +import arrow.core.ControlCancellationException +import arrow.core.InternalArrowApi +@OptIn(InternalArrowApi::class) @DelicateRaiseApi public actual sealed class RaiseCancellationException actual constructor( internal actual val raised: Any?, internal actual val raise: Raise -) : CancellationException(RaiseCancellationExceptionCaptured) +) : ControlCancellationException(RaiseCancellationExceptionCaptured) diff --git a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/raise/Fold.kt b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/raise/Fold.kt index 8c120211528..7762102a875 100644 --- a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/raise/Fold.kt +++ b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/raise/Fold.kt @@ -6,7 +6,9 @@ package arrow.core.raise import arrow.atomic.AtomicBoolean +import arrow.core.ControlCancellationException import arrow.core.Either +import arrow.core.InternalArrowApi import arrow.core.nonFatalOrThrow import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind.AT_MOST_ONCE @@ -271,11 +273,12 @@ public annotation class DelicateRaiseApi * [RaiseCancellationException] is a _delicate_ api, and should be used with care. * It drives the short-circuiting behavior of [Raise]. */ +@OptIn(InternalArrowApi::class) @DelicateRaiseApi public expect sealed class RaiseCancellationException( raised: Any?, raise: Raise -) : CancellationException { +) : ControlCancellationException { internal val raised: Any? internal val raise: Raise } diff --git a/arrow-libs/core/arrow-core/src/jsMain/kotlin/arrow/core/raise/RaiseCancellationException.kt b/arrow-libs/core/arrow-core/src/jsMain/kotlin/arrow/core/raise/RaiseCancellationException.kt index e96ccd8ab28..ecb2fd74774 100644 --- a/arrow-libs/core/arrow-core/src/jsMain/kotlin/arrow/core/raise/RaiseCancellationException.kt +++ b/arrow-libs/core/arrow-core/src/jsMain/kotlin/arrow/core/raise/RaiseCancellationException.kt @@ -1,7 +1,8 @@ package arrow.core.raise +import arrow.core.ControlCancellationException +import arrow.core.InternalArrowApi import kotlinx.js.JsPlainObject -import kotlin.coroutines.cancellation.CancellationException /** * There is no direct way to create an instance of [Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) without a stack in JS. @@ -15,11 +16,12 @@ internal external interface RaiseCancellationExceptionLike { val raise: Raise } +@OptIn(InternalArrowApi::class) @DelicateRaiseApi public actual sealed class RaiseCancellationException actual constructor( raised: Any?, raise: Raise -) : CancellationException(RaiseCancellationExceptionCaptured) { +) : ControlCancellationException(RaiseCancellationExceptionCaptured) { private val _raised = raised private val _raise = raise diff --git a/arrow-libs/core/arrow-core/src/nonJvmAndJsMain/kotlin/arrow/core/raise/RaiseCancellationException.kt b/arrow-libs/core/arrow-core/src/nonJvmAndJsMain/kotlin/arrow/core/raise/RaiseCancellationException.kt index a5082ef1f62..d50982eb0b6 100644 --- a/arrow-libs/core/arrow-core/src/nonJvmAndJsMain/kotlin/arrow/core/raise/RaiseCancellationException.kt +++ b/arrow-libs/core/arrow-core/src/nonJvmAndJsMain/kotlin/arrow/core/raise/RaiseCancellationException.kt @@ -1,9 +1,11 @@ package arrow.core.raise -import kotlin.coroutines.cancellation.CancellationException +import arrow.core.ControlCancellationException +import arrow.core.InternalArrowApi +@OptIn(InternalArrowApi::class) @DelicateRaiseApi public actual sealed class RaiseCancellationException actual constructor( internal actual val raised: Any?, internal actual val raise: Raise -) : CancellationException(RaiseCancellationExceptionCaptured) +) : ControlCancellationException(RaiseCancellationExceptionCaptured) diff --git a/arrow-libs/core/arrow-exception-utils/api/arrow-exception-utils.klib.api b/arrow-libs/core/arrow-exception-utils/api/arrow-exception-utils.klib.api index 8d53f8eaeed..f572c30ab77 100644 --- a/arrow-libs/core/arrow-exception-utils/api/arrow-exception-utils.klib.api +++ b/arrow-libs/core/arrow-exception-utils/api/arrow-exception-utils.klib.api @@ -6,6 +6,15 @@ // - Show declarations: true // Library unique name: +open annotation class arrow.core/InternalArrowApi : kotlin/Annotation { // arrow.core/InternalArrowApi|null[0] + constructor () // arrow.core/InternalArrowApi.|(){}[0] +} + +open class arrow.core/ControlCancellationException : kotlin.coroutines.cancellation/CancellationException { // arrow.core/ControlCancellationException|null[0] + constructor () // arrow.core/ControlCancellationException.|(){}[0] + constructor (kotlin/String?) // arrow.core/ControlCancellationException.|(kotlin.String?){}[0] +} + final fun (kotlin/Throwable).arrow.core/nonFatalOrThrow(): kotlin/Throwable // arrow.core/nonFatalOrThrow|nonFatalOrThrow@kotlin.Throwable(){}[0] final fun (kotlin/Throwable?).arrow.core/mergeSuppressed(kotlin/Throwable?): kotlin/Throwable? // arrow.core/mergeSuppressed|mergeSuppressed@kotlin.Throwable?(kotlin.Throwable?){}[0] final fun (kotlin/Throwable?).arrow.core/throwIfNotNull() // arrow.core/throwIfNotNull|throwIfNotNull@kotlin.Throwable?(){}[0] diff --git a/arrow-libs/core/arrow-exception-utils/api/jvm/arrow-exception-utils.api b/arrow-libs/core/arrow-exception-utils/api/jvm/arrow-exception-utils.api index 7108f8bbefe..dac6a1a65a9 100644 --- a/arrow-libs/core/arrow-exception-utils/api/jvm/arrow-exception-utils.api +++ b/arrow-libs/core/arrow-exception-utils/api/jvm/arrow-exception-utils.api @@ -1,3 +1,11 @@ +public class arrow/core/ControlCancellationException : java/util/concurrent/CancellationException { + public fun ()V + public fun (Ljava/lang/String;)V +} + +public abstract interface annotation class arrow/core/InternalArrowApi : java/lang/annotation/Annotation { +} + public final class arrow/core/NonFatalKt { public static final fun NonFatal (Ljava/lang/Throwable;)Z } diff --git a/arrow-libs/core/arrow-exception-utils/src/commonMain/kotlin/arrow/core/ControlCancellationException.kt b/arrow-libs/core/arrow-exception-utils/src/commonMain/kotlin/arrow/core/ControlCancellationException.kt new file mode 100644 index 00000000000..d217bc60511 --- /dev/null +++ b/arrow-libs/core/arrow-exception-utils/src/commonMain/kotlin/arrow/core/ControlCancellationException.kt @@ -0,0 +1,21 @@ +package arrow.core + +import kotlin.coroutines.cancellation.CancellationException + +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +@RequiresOptIn("This declaration is public only to allow other arrow libraries to use it", RequiresOptIn.Level.ERROR) +public annotation class InternalArrowApi + +/** + * [ControlCancellationException] is a _delicate_ api, and should be used with care. + * It denotes a short-circuiting exception. + * Exceptions of this type are deprioritized w.r.t. exception suppression. + * + * @see mergeSuppressed + */ +@InternalArrowApi +public open class ControlCancellationException : CancellationException { + public constructor() : super() + public constructor(message: String?) : super(message) +} diff --git a/arrow-libs/core/arrow-exception-utils/src/commonMain/kotlin/arrow/core/ThrowableUtils.kt b/arrow-libs/core/arrow-exception-utils/src/commonMain/kotlin/arrow/core/ThrowableUtils.kt index e6918deaf11..e11afaae3be 100644 --- a/arrow-libs/core/arrow-exception-utils/src/commonMain/kotlin/arrow/core/ThrowableUtils.kt +++ b/arrow-libs/core/arrow-exception-utils/src/commonMain/kotlin/arrow/core/ThrowableUtils.kt @@ -6,15 +6,30 @@ import kotlin.coroutines.cancellation.CancellationException public fun Throwable?.throwIfNotNull() { if (this != null) throw this } -@OptIn(ExperimentalContracts::class) +/** + * Merges two nullable [Throwable] values by adding one as suppressed to the other. + * + * Returns the non-null throwable when only one is present, or `null` when both are `null`. + * [ControlCancellationException]s are deprioritized in the presence of other exceptions. + * + * @param other Another throwable to merge with this one. + * @return The merged throwable, or `null` if both are `null`. + */ +@OptIn(ExperimentalContracts::class, InternalArrowApi::class) public infix fun Throwable?.mergeSuppressed(other: Throwable?): Throwable? { contract { returns(null) implies (this@mergeSuppressed == null && other == null) } return when { + // other completed normally other == null -> this + // this completed normally or with a non-local return this == null -> other + // this completed with raise + this is ControlCancellationException -> other.also { other.addSuppressed(this) } + // other completed with cancellation or raise other is CancellationException -> this.also { addSuppressed(other) } + // both completed exceptionally or this completed with cancellation else -> this.also { addSuppressed(other.nonFatalOrThrow()) } } } diff --git a/arrow-libs/fx/arrow-fx-coroutines/src/commonMain/kotlin/arrow/fx/coroutines/Race3.kt b/arrow-libs/fx/arrow-fx-coroutines/src/commonMain/kotlin/arrow/fx/coroutines/Race3.kt index 6e8073bc675..28f5f18f60e 100644 --- a/arrow-libs/fx/arrow-fx-coroutines/src/commonMain/kotlin/arrow/fx/coroutines/Race3.kt +++ b/arrow-libs/fx/arrow-fx-coroutines/src/commonMain/kotlin/arrow/fx/coroutines/Race3.kt @@ -4,7 +4,7 @@ package arrow.fx.coroutines import arrow.core.mergeSuppressed -import arrow.core.nonFatalOrThrow +import arrow.core.throwIfFatal import arrow.core.throwIfNotNull import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -20,6 +20,7 @@ import kotlin.contracts.contract import kotlin.coroutines.ContinuationInterceptor import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.cancellation.CancellationException public sealed class Race3 { public data class First(val winner: A) : Race3() @@ -114,13 +115,13 @@ internal suspend fun cancelAndCompose(first: Deferred<*>, second: Deferred<*>) { first.cancelAndJoin() null } catch (e: Throwable) { - e.nonFatalOrThrow() + e.also { if (e !is CancellationException) e.throwIfFatal() } } val e2 = try { second.cancelAndJoin() null } catch (e: Throwable) { - e.nonFatalOrThrow() + e.also { if (e !is CancellationException) e.throwIfFatal() } } (e1 mergeSuppressed e2).throwIfNotNull() } diff --git a/arrow-libs/fx/arrow-fx-coroutines/src/commonTest/kotlin/arrow/fx/coroutines/ResourceTest.kt b/arrow-libs/fx/arrow-fx-coroutines/src/commonTest/kotlin/arrow/fx/coroutines/ResourceTest.kt index f13acc790cf..a4c6ca983cd 100644 --- a/arrow-libs/fx/arrow-fx-coroutines/src/commonTest/kotlin/arrow/fx/coroutines/ResourceTest.kt +++ b/arrow-libs/fx/arrow-fx-coroutines/src/commonTest/kotlin/arrow/fx/coroutines/ResourceTest.kt @@ -1,7 +1,9 @@ package arrow.fx.coroutines import arrow.atomic.AtomicBoolean +import arrow.core.ControlCancellationException import arrow.core.Either +import arrow.core.InternalArrowApi import arrow.core.getOrElse import arrow.core.left import arrow.core.none @@ -671,6 +673,36 @@ class ResourceTest { } } + @OptIn(DelicateCoroutinesApi::class, InternalArrowApi::class) + @Test + fun allocateControlCancellationException() = runTest { + checkAll( + Arb.int(), + Arb.string().map { ControlCancellationException(it) }, + Arb.string().map(::IllegalStateException) + ) { seed, cancellation, thrown -> + val released = CompletableDeferred() + val (allocated, release) = + resource({ seed }) { _, exitCase -> + require(released.complete(exitCase)) + throw thrown + }.allocate() + + val exception = shouldThrow { + try { + allocated shouldBe seed + throw cancellation + } catch (e: Throwable) { + release(ExitCase(e)) + } + } + + exception shouldBe thrown + exception.suppressedExceptions.firstOrNull().shouldNotBeNull() shouldBe cancellation + released.shouldHaveCompleted().shouldBeTypeOf() + } + } + @OptIn(DelicateCoroutinesApi::class) @Test fun allocatedSuppressedExceptions() = runTest { diff --git a/arrow-libs/resilience/arrow-resilience/src/commonMain/kotlin/arrow/resilience/Saga.kt b/arrow-libs/resilience/arrow-resilience/src/commonMain/kotlin/arrow/resilience/Saga.kt index 23e84a8d42e..920f7ff2ba8 100644 --- a/arrow-libs/resilience/arrow-resilience/src/commonMain/kotlin/arrow/resilience/Saga.kt +++ b/arrow-libs/resilience/arrow-resilience/src/commonMain/kotlin/arrow/resilience/Saga.kt @@ -157,7 +157,7 @@ internal class SagaBuilder( finalizer() acc } catch (e: Throwable) { - acc mergeSuppressed e.nonFatalOrThrow() + acc mergeSuppressed e } }.throwIfNotNull() }