diff --git a/kotlin-result-coroutines/src/commonMain/kotlin/com/github/michaelbull/result/coroutines/binding/SuspendableBinding.kt b/kotlin-result-coroutines/src/commonMain/kotlin/com/github/michaelbull/result/coroutines/binding/SuspendableBinding.kt index 0707361..c0466af 100644 --- a/kotlin-result-coroutines/src/commonMain/kotlin/com/github/michaelbull/result/coroutines/binding/SuspendableBinding.kt +++ b/kotlin-result-coroutines/src/commonMain/kotlin/com/github/michaelbull/result/coroutines/binding/SuspendableBinding.kt @@ -4,6 +4,9 @@ import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.contracts.InvocationKind @@ -11,16 +14,20 @@ import kotlin.contracts.contract /** * Suspending variant of [binding][com.github.michaelbull.result.binding]. + * Wraps the suspendable block in a new coroutine scope. + * This scope is cancelled once a failing bind is encountered, allowing deferred child jobs to be eagerly cancelled. */ public suspend inline fun binding(crossinline block: suspend SuspendableResultBinding.() -> V): Result { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } - val receiver = SuspendableResultBindingImpl() return try { - with(receiver) { Ok(block()) } + coroutineScope { + receiver.coroutineScope = this@coroutineScope + with(receiver) { Ok(block()) } + } } catch (ex: BindCancellationException) { receiver.internalError } @@ -37,6 +44,7 @@ internal class SuspendableResultBindingImpl : SuspendableResultBinding { private val mutex = Mutex() lateinit var internalError: Err + var coroutineScope: CoroutineScope? = null override suspend fun Result.bind(): V { return when (this) { @@ -47,6 +55,7 @@ internal class SuspendableResultBindingImpl : SuspendableResultBinding { internalError = this } } + coroutineScope?.cancel(BindCancellationException) throw BindCancellationException } } diff --git a/kotlin-result-coroutines/src/commonTest/kotlin/com.github.michaelbull.result.coroutines/binding/SuspendableBindingTest.kt b/kotlin-result-coroutines/src/commonTest/kotlin/com.github.michaelbull.result.coroutines/binding/SuspendableBindingTest.kt index 6c4fdd0..f9430e1 100644 --- a/kotlin-result-coroutines/src/commonTest/kotlin/com.github.michaelbull.result.coroutines/binding/SuspendableBindingTest.kt +++ b/kotlin-result-coroutines/src/commonTest/kotlin/com.github.michaelbull.result.coroutines/binding/SuspendableBindingTest.kt @@ -7,6 +7,7 @@ import com.github.michaelbull.result.coroutines.runBlockingTest import kotlinx.coroutines.delay import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class SuspendableBindingTest { @@ -100,6 +101,48 @@ class SuspendableBindingTest { } } + @Test + fun returnsStateChangedUntilFirstBindFailed() { + var xStateChange = false + var yStateChange = false + var zStateChange = false + suspend fun provideX(): Result { + delay(1) + xStateChange = true + return Ok(1) + } + + suspend fun provideY(): Result { + delay(10) + yStateChange = true + return Err(BindingError) + } + + suspend fun provideZ(): Result { + delay(1) + zStateChange = true + return Err(BindingError) + } + + runBlockingTest { + val result = binding { + val x = provideX().bind() + val y = provideY().bind() + val z = provideZ().bind() + x + y + z + } + + assertTrue(result is Err) + assertEquals( + expected = BindingError, + actual = result.error + ) + assertTrue(xStateChange) + assertTrue(yStateChange) + assertFalse(zStateChange) + } + } + @Test fun returnsFirstErrIfBindingsOfDifferentTypesFailed() { suspend fun provideX(): Result { diff --git a/kotlin-result-coroutines/src/jvmTest/kotlin/com/github/michaelbull/result/coroutines/binding/AsyncSuspendableBindingTest.kt b/kotlin-result-coroutines/src/jvmTest/kotlin/com/github/michaelbull/result/coroutines/binding/AsyncSuspendableBindingTest.kt index f8910d6..7d3cb11 100644 --- a/kotlin-result-coroutines/src/jvmTest/kotlin/com/github/michaelbull/result/coroutines/binding/AsyncSuspendableBindingTest.kt +++ b/kotlin-result-coroutines/src/jvmTest/kotlin/com/github/michaelbull/result/coroutines/binding/AsyncSuspendableBindingTest.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class AsyncSuspendableBindingTest { @@ -47,7 +48,7 @@ class AsyncSuspendableBindingTest { @Test fun returnsFirstErrIfBindingFailed() { suspend fun provideX(): Result { - delay(1) + delay(3) return Ok(1) } @@ -76,4 +77,46 @@ class AsyncSuspendableBindingTest { ) } } + + @Test + fun returnsStateChangedForOnlyTheFirstAsyncBindFailWhenEagerlyCancellingBinding() { + var xStateChange = false + var yStateChange = false + var zStateChange = false + suspend fun provideX(): Result { + delay(20) + xStateChange = true + return Ok(1) + } + + suspend fun provideY(): Result { + delay(10) + yStateChange = true + return Err(BindingError.BindingErrorA) + } + + suspend fun provideZ(): Result { + delay(1) + zStateChange = true + return Err(BindingError.BindingErrorB) + } + + runBlocking { + val result = binding { + val x = async { provideX().bind() } + val y = async { provideY().bind() } + val z = async { provideZ().bind() } + x.await() + y.await() + z.await() + } + + assertTrue(result is Err) + assertEquals( + expected = BindingError.BindingErrorB, + actual = result.error + ) + assertFalse(xStateChange) + assertFalse(yStateChange) + assertTrue(zStateChange) + } + } }