diff --git a/kotlin-result-coroutines/src/commonMain/kotlin/com/github/michaelbull/result/coroutines/RunSuspendCatching.kt b/kotlin-result-coroutines/src/commonMain/kotlin/com/github/michaelbull/result/coroutines/RunSuspendCatching.kt new file mode 100644 index 0000000..3137f2a --- /dev/null +++ b/kotlin-result-coroutines/src/commonMain/kotlin/com/github/michaelbull/result/coroutines/RunSuspendCatching.kt @@ -0,0 +1,33 @@ +package com.github.michaelbull.result.coroutines + +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.runCatching +import com.github.michaelbull.result.throwIf +import kotlinx.coroutines.CancellationException + +/** + * Calls the specified function [block] and returns its encapsulated result if + * invocation was successful, catching and encapsulating any thrown exception + * as a failure, excepting that any [CancellationException] will be rethrown + * in order to propagate cancellation from any parent + * [CoroutineContext][kotlin.coroutines.CoroutineContext]. + * + * @throws CancellationException if is thrown from the block + */ +public suspend inline fun runSuspendCatching(block: () -> V) + : Result = + runCatching(block) + .throwIf { it is CancellationException } +/** + * Calls the specified function [block] with [this] value as its receiver and + * returns its encapsulated result if invocation was successful, catching and + * encapsulating any thrown exception as a failure, excepting that any + * [CancellationException] will be rethrown in order to propagate cancellation + * from any parent [CoroutineContext][kotlin.coroutines.CoroutineContext]. + * + * @throws CancellationException if is thrown from the block + */ +public suspend inline fun T.runSuspendCatching(block: T.() -> V) + : Result = + runCatching(block) + .throwIf { it is CancellationException } diff --git a/kotlin-result-coroutines/src/jvmTest/kotlin/com/github/michaelbull/result/coroutines/RunSuspendCatchingTest.kt b/kotlin-result-coroutines/src/jvmTest/kotlin/com/github/michaelbull/result/coroutines/RunSuspendCatchingTest.kt new file mode 100644 index 0000000..f68d751 --- /dev/null +++ b/kotlin-result-coroutines/src/jvmTest/kotlin/com/github/michaelbull/result/coroutines/RunSuspendCatchingTest.kt @@ -0,0 +1,80 @@ +package com.github.michaelbull.result.coroutines + +import com.github.michaelbull.result.get +import com.github.michaelbull.result.getError +import com.github.michaelbull.result.onSuccess +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.runBlockingTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +@ExperimentalCoroutinesApi +class RunSuspendCatchingTest { + + @Test + fun propagatesCoroutineCancellation() { + val testDispatcher = TestCoroutineDispatcher() + val testScope = TestCoroutineScope(testDispatcher) + + testScope.runBlockingTest { + var value: String? = null + + launch { // Outer scope + launch { // Inner scope + val result = runSuspendCatching { + delay(4_000) + "value" + } + + // The coroutine should be cancelled before reaching here + result.onSuccess { value = it } + } + testDispatcher.advanceTimeBy(2_000) + + // Cancel outer scope, which should cancel inner scope + cancel() + } + assertNull(value) + } + } + + @Test + fun returnsOkIfInvocationSuccessful() { + val testDispatcher = TestCoroutineDispatcher() + val testScope = TestCoroutineScope(testDispatcher) + + testScope.runBlockingTest { + val callback = { "example" } + val result = runSuspendCatching(callback) + + assertEquals( + expected = "example", + actual = result.get() + ) + } + } + + @Test + fun returnsErrIfInvocationFailsWithAnythingOtherThanCancellationException() { + val testDispatcher = TestCoroutineDispatcher() + val testScope = TestCoroutineScope(testDispatcher) + + testScope.runBlockingTest { + val exception = IllegalArgumentException("throw me") + val callback = { throw exception } + val result = runSuspendCatching(callback) + + assertSame( + expected = exception, + actual = result.getError() + ) + } + } +} diff --git a/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Factory.kt b/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Factory.kt index 53a5ed8..3310019 100644 --- a/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Factory.kt +++ b/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Factory.kt @@ -7,6 +7,11 @@ import kotlin.contracts.contract * Calls the specified function [block] and returns its encapsulated result if * invocation was successful, catching and encapsulating any thrown exception * as a failure. + * + * N.B. [runCatching] catches *all* exceptions thrown in the block, including + * [CancellationException][kotlinx.coroutines.CancellationException], preventing + * correct cancellation in structured concurrency. Use [runSuspendCatching] in + * such a context. */ public inline fun runCatching(block: () -> V): Result { contract { @@ -24,6 +29,11 @@ public inline fun runCatching(block: () -> V): Result { * Calls the specified function [block] with [this] value as its receiver and * returns its encapsulated result if invocation was successful, catching and * encapsulating any thrown exception as a failure. + * + * N.B. [runCatching] catches *all* exceptions thrown in the block, including + * [CancellationException][kotlinx.coroutines.CancellationException], preventing + * correct cancellation in structured concurrency. Use [runSuspendCatching] in + * such a context. */ public inline infix fun T.runCatching(block: T.() -> V): Result { contract {