From d7c5362af508b6117e1ee8f83d1be0fc8e4ee3c5 Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Thu, 14 Jul 2022 20:45:29 +0200 Subject: [PATCH 1/7] Add failing test case --- .../arrow/core/continuations/EffectSpec.kt | 70 ++++++++++++------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/arrow-libs/core/arrow-core/src/commonTest/kotlin/arrow/core/continuations/EffectSpec.kt b/arrow-libs/core/arrow-core/src/commonTest/kotlin/arrow/core/continuations/EffectSpec.kt index f5566949003..4a45a0bb066 100644 --- a/arrow-libs/core/arrow-core/src/commonTest/kotlin/arrow/core/continuations/EffectSpec.kt +++ b/arrow-libs/core/arrow-core/src/commonTest/kotlin/arrow/core/continuations/EffectSpec.kt @@ -21,6 +21,7 @@ import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn import kotlin.coroutines.startCoroutine import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class EffectSpec : StringSpec({ @@ -35,7 +36,7 @@ class EffectSpec : }.fold({ fail("Should never come here") }, ::identity) shouldBe i } } - + "try/catch - can recover from shift suspended" { checkAll(Arb.int(), Arb.string()) { i, s -> effect { @@ -47,7 +48,7 @@ class EffectSpec : }.fold({ fail("Should never come here") }, ::identity) shouldBe i } } - + "try/catch - finally works" { checkAll(Arb.string(), Arb.int()) { s, i -> val promise = CompletableDeferred() @@ -62,7 +63,7 @@ class EffectSpec : promise.await() shouldBe i } } - + "try/catch - finally works suspended" { checkAll(Arb.string(), Arb.int()) { s, i -> val promise = CompletableDeferred() @@ -77,7 +78,7 @@ class EffectSpec : promise.await() shouldBe i } } - + "try/catch - First shift is ignored and second is returned" { checkAll(Arb.int(), Arb.string(), Arb.string()) { i, s, s2 -> effect { @@ -92,7 +93,7 @@ class EffectSpec : .fold(::identity) { fail("Should never come here") } shouldBe s2 } } - + "try/catch - First shift is ignored and second is returned suspended" { checkAll(Arb.int(), Arb.string(), Arb.string()) { i, s, s2 -> effect { @@ -107,53 +108,53 @@ class EffectSpec : .fold(::identity) { fail("Should never come here") } shouldBe s2 } } - + "eagerEffect can be consumed within an Effect computation" { checkAll(Arb.int(), Arb.int()) { a, b -> val eager: EagerEffect = eagerEffect { a } - + effect { val aa = eager.bind() aa + b.suspend() }.runCont() shouldBe (a + b) } } - + "eagerEffect shift short-circuits effect computation" { checkAll(Arb.string(), Arb.int()) { a, b -> val eager: EagerEffect = eagerEffect { shift(a) } - + effect { val aa = eager.bind() aa + b.suspend() }.runCont() shouldBe a } } - + "immediate values" { effect { 1 }.value() shouldBe 1 } - + "suspended value" { effect { 1.suspend() }.value() shouldBe 1 } - + "immediate short-circuit" { effect { shift("hello") }.runCont() shouldBe "hello" } - + "suspended short-circuit" { effect { shift("hello".suspend()) }.runCont() shouldBe "hello" } - + "Rethrows immediate exceptions" { val e = RuntimeException("test") Either.catch { effect { throw e }.runCont() } shouldBe Either.Left(e) } - + "Rethrows suspended exceptions" { val e = RuntimeException("test") Either.catch { effect { e.suspend() }.runCont() } shouldBe Either.Left(e) } - + "Can short-circuit immediately from nested blocks" { effect { effect { shift("test") }.runCont() @@ -161,7 +162,7 @@ class EffectSpec : } .runCont() shouldBe "test" } - + "Can short-circuit suspended from nested blocks" { effect { effect { shift("test".suspend()) }.runCont() @@ -169,7 +170,7 @@ class EffectSpec : } .runCont() shouldBe "test" } - + "Can short-circuit immediately after suspending from nested blocks" { effect { effect { @@ -181,7 +182,7 @@ class EffectSpec : } .runCont() shouldBe "test" } - + "ensure null in either computation" { checkAll(Arb.boolean(), Arb.int(), Arb.string()) { predicate, success, shift -> either { @@ -190,10 +191,10 @@ class EffectSpec : } shouldBe if (predicate) success.right() else shift.left() } } - + "ensureNotNull in either computation" { fun square(i: Int): Int = i * i - + checkAll(Arb.int().orNull(), Arb.string()) { i: Int?, shift: String -> val res = either { @@ -205,7 +206,7 @@ class EffectSpec : res shouldBe expected } } - + "low-level use-case: distinguish between concurrency error and shift exception" { val effect = effect { shift("Shift") } val e = RuntimeException("test") @@ -223,7 +224,7 @@ class EffectSpec : }.runCont() } shouldBe Either.Left(e) } - + "low-level use-case: eager shift exception within effect computations doesn't change shift exception" { val effect = eagerEffect { shift("Shift") } val e = RuntimeException("test") @@ -241,20 +242,39 @@ class EffectSpec : }.runCont() } shouldBe Either.Left(e) } + + "Dispatching in nested Effect causes incorrect behavior" { + checkAll(Arb.string()) { msg -> + fun failure(): Effect = effect { + withContext(Dispatchers.Default) {} + shift(Failure(msg)) + } + + effect { + failure().bind() + 1 + }.fold( + recover = { it }, + transform = { fail("Should never come here") }, + ) shouldBe Failure(msg) + } + } }) +private data class Failure(val msg: String) + suspend fun currentContext(): CoroutineContext = kotlin.coroutines.coroutineContext internal suspend fun Throwable.suspend(): Nothing = suspendCoroutineUninterceptedOrReturn { cont -> suspend { throw this } .startCoroutine(Continuation(Dispatchers.Default) { cont.intercepted().resumeWith(it) }) - + COROUTINE_SUSPENDED } internal suspend fun A.suspend(): A = suspendCoroutineUninterceptedOrReturn { cont -> suspend { this } .startCoroutine(Continuation(Dispatchers.Default) { cont.intercepted().resumeWith(it) }) - + COROUTINE_SUSPENDED } From ead797ea7bb5f501d5d0f5ff2a4b8df797f7db42 Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Thu, 14 Jul 2022 20:52:15 +0200 Subject: [PATCH 2/7] Fix issue... --- arrow-libs/core/arrow-core/api/arrow-core.api | 2 +- .../kotlin/arrow/core/continuations/Effect.kt | 73 ++------------- .../arrow/core/continuations/EffectScope.kt | 88 +++++++++++++++++-- 3 files changed, 86 insertions(+), 77 deletions(-) diff --git a/arrow-libs/core/arrow-core/api/arrow-core.api b/arrow-libs/core/arrow-core/api/arrow-core.api index 9d6ddfba8c1..4d3fb89b298 100644 --- a/arrow-libs/core/arrow-core/api/arrow-core.api +++ b/arrow-libs/core/arrow-core/api/arrow-core.api @@ -2683,7 +2683,6 @@ public final class arrow/core/continuations/Effect$DefaultImpls { } public final class arrow/core/continuations/EffectKt { - public static final fun effect (Lkotlin/jvm/functions/Function2;)Larrow/core/continuations/Effect; public static final fun merge (Larrow/core/continuations/Effect;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } @@ -2709,6 +2708,7 @@ public final class arrow/core/continuations/EffectScope$DefaultImpls { } public final class arrow/core/continuations/EffectScopeKt { + public static final fun effect (Lkotlin/jvm/functions/Function2;)Larrow/core/continuations/Effect; public static final fun ensureNotNull (Larrow/core/continuations/EffectScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt index e0bbf54beb5..4db1d029ffc 100644 --- a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt +++ b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt @@ -568,7 +568,7 @@ import kotlin.coroutines.resume * ``` * */ -public interface Effect { +public sealed interface Effect { /** * Runs the suspending computation by creating a [Continuation], and running the `fold` function * over the computation. @@ -704,6 +704,10 @@ internal class Token { * the result. */ @PublishedApi +@Deprecated( + "This will become private in Arrow 2.0, and is not going to be visible from binary anymore", + level = DeprecationLevel.WARNING +) internal class FoldContinuation( private val token: Token, override val context: CoroutineContext, @@ -722,71 +726,4 @@ internal class FoldContinuation( } } -/** - * DSL for constructing Effect values - * - * ```kotlin - * import arrow.core.Either - * import arrow.core.None - * import arrow.core.Option - * import arrow.core.Validated - * import arrow.core.continuations.effect - * import io.kotest.assertions.fail - * import io.kotest.matchers.shouldBe - * - * suspend fun main() { - * effect { - * val x = Either.Right(1).bind() - * val y = Validated.Valid(2).bind() - * val z = Option(3).bind { "Option was empty" } - * x + y + z - * }.fold({ fail("Shift can never be the result") }, { it shouldBe 6 }) - * - * effect { - * val x = Either.Right(1).bind() - * val y = Validated.Valid(2).bind() - * val z: Int = None.bind { "Option was empty" } - * x + y + z - * }.fold({ it shouldBe "Option was empty" }, { fail("Int can never be the result") }) - * } - * ``` - * - */ -public fun effect(f: suspend EffectScope.() -> A): Effect = DefaultEffect(f) - -private class DefaultEffect(private val f: suspend EffectScope.() -> A) : Effect { - // We create a `Token` for fold Continuation, so we can properly differentiate between nested - // folds - override suspend fun fold(recover: suspend (R) -> B, transform: suspend (A) -> B): B = - suspendCoroutineUninterceptedOrReturn { cont -> - val token = Token() - val effectScope = - object : EffectScope { - // Shift away from this Continuation by intercepting it, and completing it with - // ShiftCancellationException - // This is needed because this function will never yield a result, - // so it needs to be cancelled to properly support coroutine cancellation - override suspend fun shift(r: R): B = - // Some interesting consequences of how Continuation Cancellation works in Kotlin. - // We have to throw CancellationException to signal the Continuation was cancelled, and we - // shifted away. - // This however also means that the user can try/catch shift and recover from the - // CancellationException and thus effectively recovering from the cancellation/shift. - // This means try/catch is also capable of recovering from monadic errors. - // See: EffectSpec - try/catch tests - throw Suspend(token, r, recover as suspend (Any?) -> Any?) - } - - try { - suspend { transform(f(effectScope)) } - .startCoroutineUninterceptedOrReturn(FoldContinuation(token, cont.context, cont)) - } catch (e: Suspend) { - if (token == e.token) { - val f: suspend () -> B = { e.recover(e.shifted) as B } - f.startCoroutineUninterceptedOrReturn(cont) - } else throw e - } - } -} - public suspend fun Effect.merge(): A = fold(::identity, ::identity) diff --git a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/EffectScope.kt b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/EffectScope.kt index 5a1f7d23d6d..15101501129 100644 --- a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/EffectScope.kt +++ b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/EffectScope.kt @@ -9,6 +9,8 @@ import arrow.core.Validated import arrow.core.identity import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract +import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn +import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn /** Context of the [Effect] DSL. */ public interface EffectScope { @@ -29,7 +31,7 @@ public interface EffectScope { * */ public suspend fun shift(r: R): B - + /** * Runs the [Effect] to finish, returning [B] or [shift] in case of [R]. * @@ -54,8 +56,11 @@ public interface EffectScope { * ``` * */ - public suspend fun Effect.bind(): B = fold(this@EffectScope::shift, ::identity) - + public suspend fun Effect.bind(): B = + when (this) { + is DefaultEffect -> f(this@EffectScope) + } + /** * Runs the [EagerEffect] to finish, returning [B] or [shift] in case of [R], * bridging eager computations into suspending. @@ -88,7 +93,7 @@ public interface EffectScope { fold({ r -> left = r }, { a -> right = a }) return if (left === EmptyValue) EmptyValue.unbox(right) else shift(EmptyValue.unbox(left)) } - + /** * Folds [Either] into [Effect], by returning [B] or a shift with [R]. * @@ -112,7 +117,7 @@ public interface EffectScope { is Either.Left -> shift(value) is Either.Right -> value } - + /** * Folds [Validated] into [Effect], by returning [B] or a shift with [R]. * @@ -136,7 +141,7 @@ public interface EffectScope { is Validated.Valid -> value is Validated.Invalid -> shift(value) } - + /** * Folds [Result] into [Effect], by returning [B] or a transforming [Throwable] into [R] and * shifting the result. @@ -159,7 +164,7 @@ public interface EffectScope { */ public suspend fun Result.bind(transform: (Throwable) -> R): B = fold(::identity) { throwable -> shift(transform(throwable)) } - + /** * Folds [Option] into [Effect], by returning [B] or a transforming [None] into [R] and shifting the * result. @@ -188,7 +193,7 @@ public interface EffectScope { None -> shift(shift()) is Some -> value } - + /** * ensure that condition is `true`, if it's `false` it will `shift` with the provided value [R]. * Monadic version of [kotlin.require]. @@ -240,3 +245,70 @@ public suspend fun EffectScope.ensureNotNull(value: B?, shift: ( contract { returns() implies (value != null) } return value ?: shift(shift()) } + +/** + * DSL for constructing Effect values + * + * ```kotlin + * import arrow.core.Either + * import arrow.core.None + * import arrow.core.Option + * import arrow.core.Validated + * import arrow.core.continuations.effect + * import io.kotest.assertions.fail + * import io.kotest.matchers.shouldBe + * + * suspend fun main() { + * effect { + * val x = Either.Right(1).bind() + * val y = Validated.Valid(2).bind() + * val z = Option(3).bind { "Option was empty" } + * x + y + z + * }.fold({ fail("Shift can never be the result") }, { it shouldBe 6 }) + * + * effect { + * val x = Either.Right(1).bind() + * val y = Validated.Valid(2).bind() + * val z: Int = None.bind { "Option was empty" } + * x + y + z + * }.fold({ it shouldBe "Option was empty" }, { fail("Int can never be the result") }) + * } + * ``` + * + */ +public fun effect(f: suspend EffectScope.() -> A): Effect = DefaultEffect(f) + +private class DefaultEffect(val f: suspend EffectScope.() -> A) : Effect { + // We create a `Token` for fold Continuation, so we can properly differentiate between nested + // folds + override suspend fun fold(recover: suspend (R) -> B, transform: suspend (A) -> B): B = + suspendCoroutineUninterceptedOrReturn { cont -> + val token = Token() + val effectScope = + object : EffectScope { + // Shift away from this Continuation by intercepting it, and completing it with + // ShiftCancellationException + // This is needed because this function will never yield a result, + // so it needs to be cancelled to properly support coroutine cancellation + override suspend fun shift(r: R): B = + // Some interesting consequences of how Continuation Cancellation works in Kotlin. + // We have to throw CancellationException to signal the Continuation was cancelled, and we + // shifted away. + // This however also means that the user can try/catch shift and recover from the + // CancellationException and thus effectively recovering from the cancellation/shift. + // This means try/catch is also capable of recovering from monadic errors. + // See: EffectSpec - try/catch tests + throw Suspend(token, r, recover as suspend (Any?) -> Any?) + } + + try { + suspend { transform(f(effectScope)) } + .startCoroutineUninterceptedOrReturn(FoldContinuation(token, cont.context, cont)) + } catch (e: Suspend) { + if (token == e.token) { + val f: suspend () -> B = { e.recover(e.shifted) as B } + f.startCoroutineUninterceptedOrReturn(cont) + } else throw e + } + } +} From febf42c2bdfab806510aa542ba79fd5dacd33b92 Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Thu, 14 Jul 2022 20:57:53 +0200 Subject: [PATCH 3/7] Fix binary breaking change --- arrow-libs/core/arrow-core/api/arrow-core.api | 2 +- .../kotlin/arrow/core/continuations/Effect.kt | 131 ++++++++++++++---- .../arrow/core/continuations/EffectScope.kt | 67 --------- 3 files changed, 102 insertions(+), 98 deletions(-) diff --git a/arrow-libs/core/arrow-core/api/arrow-core.api b/arrow-libs/core/arrow-core/api/arrow-core.api index 4d3fb89b298..9d6ddfba8c1 100644 --- a/arrow-libs/core/arrow-core/api/arrow-core.api +++ b/arrow-libs/core/arrow-core/api/arrow-core.api @@ -2683,6 +2683,7 @@ public final class arrow/core/continuations/Effect$DefaultImpls { } public final class arrow/core/continuations/EffectKt { + public static final fun effect (Lkotlin/jvm/functions/Function2;)Larrow/core/continuations/Effect; public static final fun merge (Larrow/core/continuations/Effect;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } @@ -2708,7 +2709,6 @@ public final class arrow/core/continuations/EffectScope$DefaultImpls { } public final class arrow/core/continuations/EffectScopeKt { - public static final fun effect (Lkotlin/jvm/functions/Function2;)Larrow/core/continuations/Effect; public static final fun ensureNotNull (Larrow/core/continuations/EffectScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt index 4db1d029ffc..a75e6bf89a6 100644 --- a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt +++ b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt @@ -24,21 +24,21 @@ import kotlin.coroutines.resume * to map both values of [R] and [A] to a value of `B`. * * - - * [Writing a program with Effect](#writing-a-program-with-effect) - * [Handling errors](#handling-errors) - * [Structured Concurrency](#structured-concurrency) - * [Arrow Fx Coroutines](#arrow-fx-coroutines) - * [parZip](#parzip) - * [parTraverse](#partraverse) - * [raceN](#racen) - * [bracketCase / Resource](#bracketcase--resource) - * [KotlinX](#kotlinx) - * [withContext](#withcontext) - * [async](#async) - * [launch](#launch) - * [Strange edge cases](#strange-edge-cases) - + + * [Writing a program with Effect](#writing-a-program-with-effect) + * [Handling errors](#handling-errors) + * [Structured Concurrency](#structured-concurrency) + * [Arrow Fx Coroutines](#arrow-fx-coroutines) + * [parZip](#parzip) + * [parTraverse](#partraverse) + * [raceN](#racen) + * [bracketCase / Resource](#bracketcase--resource) + * [KotlinX](#kotlinx) + * [withContext](#withcontext) + * [async](#async) + * [launch](#launch) + * [Strange edge cases](#strange-edge-cases) + * * * @@ -596,9 +596,9 @@ public sealed interface Effect { */ public suspend fun fold( recover: suspend (shifted: R) -> B, - transform: suspend (value: A) -> B + transform: suspend (value: A) -> B, ): B - + /** * Like [fold] but also allows folding over any unexpected [Throwable] that might have occurred. * @see fold @@ -606,45 +606,45 @@ public sealed interface Effect { public suspend fun fold( error: suspend (error: Throwable) -> B, recover: suspend (shifted: R) -> B, - transform: suspend (value: A) -> B + transform: suspend (value: A) -> B, ): B = try { fold(recover, transform) } catch (e: Throwable) { error(e.nonFatalOrThrow()) } - + /** * [fold] the [Effect] into an [Either]. Where the shifted value [R] is mapped to [Either.Left], and * result value [A] is mapped to [Either.Right]. */ public suspend fun toEither(): Either = fold({ Either.Left(it) }) { Either.Right(it) } - + /** * [fold] the [Effect] into an [Ior]. Where the shifted value [R] is mapped to [Ior.Left], and * result value [A] is mapped to [Ior.Right]. */ public suspend fun toIor(): Ior = fold({ Ior.Left(it) }) { Ior.Right(it) } - + /** * [fold] the [Effect] into an [Validated]. Where the shifted value [R] is mapped to * [Validated.Invalid], and result value [A] is mapped to [Validated.Valid]. */ public suspend fun toValidated(): Validated = fold({ Validated.Invalid(it) }) { Validated.Valid(it) } - + /** * [fold] the [Effect] into an [Option]. Where the shifted value [R] is mapped to [Option] by the * provided function [orElse], and result value [A] is mapped to [Some]. */ public suspend fun toOption(orElse: suspend (R) -> Option<@UnsafeVariance A>): Option = fold(orElse, ::Some) - + /** * [fold] the [Effect] into an [A?]. Where the shifted value [R] is mapped to * [null], and result value [A]. */ public suspend fun orNull(): A? = fold({ null }, ::identity) - + /** Runs the [Effect] and captures any [NonFatal] exception into [Result]. */ public fun attempt(): Effect> = effect { try { @@ -653,23 +653,23 @@ public sealed interface Effect { Result.failure(e.nonFatalOrThrow()) } } - + public fun handleError(recover: suspend (R) -> @UnsafeVariance A): Effect = effect { fold(recover, ::identity) } - + public fun handleErrorWith(recover: suspend (R) -> Effect): Effect = effect { fold({ recover(it).bind() }, ::identity) } - + public fun redeem(recover: suspend (R) -> B, transform: suspend (A) -> B): Effect = effect { fold(recover, transform) } - + public fun redeemWith( recover: suspend (R) -> Effect, - transform: suspend (A) -> Effect + transform: suspend (A) -> Effect, ): Effect = effect { fold(recover, transform).bind() } } @@ -711,7 +711,7 @@ internal class Token { internal class FoldContinuation( private val token: Token, override val context: CoroutineContext, - private val parent: Continuation + private val parent: Continuation, ) : Continuation { override fun resumeWith(result: Result) { result.fold(parent::resume) { throwable -> @@ -727,3 +727,74 @@ internal class FoldContinuation( } public suspend fun Effect.merge(): A = fold(::identity, ::identity) + +/** + * DSL for constructing Effect values + * + * ```kotlin + * import arrow.core.Either + * import arrow.core.None + * import arrow.core.Option + * import arrow.core.Validated + * import arrow.core.continuations.effect + * import io.kotest.assertions.fail + * import io.kotest.matchers.shouldBe + * + * suspend fun main() { + * effect { + * val x = Either.Right(1).bind() + * val y = Validated.Valid(2).bind() + * val z = Option(3).bind { "Option was empty" } + * x + y + z + * }.fold({ fail("Shift can never be the result") }, { it shouldBe 6 }) + * + * effect { + * val x = Either.Right(1).bind() + * val y = Validated.Valid(2).bind() + * val z: Int = None.bind { "Option was empty" } + * x + y + z + * }.fold({ it shouldBe "Option was empty" }, { fail("Int can never be the result") }) + * } + * ``` + * + */ +public fun effect(f: suspend EffectScope.() -> A): Effect = DefaultEffect(f) + +@Deprecated( + "This will be removed in Arrow 2.0", + level = DeprecationLevel.WARNING +) +internal class DefaultEffect(val f: suspend EffectScope.() -> A) : Effect { + // We create a `Token` for fold Continuation, so we can properly differentiate between nested + // folds + override suspend fun fold(recover: suspend (R) -> B, transform: suspend (A) -> B): B = + suspendCoroutineUninterceptedOrReturn { cont -> + val token = Token() + val effectScope = + object : EffectScope { + // Shift away from this Continuation by intercepting it, and completing it with + // ShiftCancellationException + // This is needed because this function will never yield a result, + // so it needs to be cancelled to properly support coroutine cancellation + override suspend fun shift(r: R): B = + // Some interesting consequences of how Continuation Cancellation works in Kotlin. + // We have to throw CancellationException to signal the Continuation was cancelled, and we + // shifted away. + // This however also means that the user can try/catch shift and recover from the + // CancellationException and thus effectively recovering from the cancellation/shift. + // This means try/catch is also capable of recovering from monadic errors. + // See: EffectSpec - try/catch tests + throw Suspend(token, r, recover as suspend (Any?) -> Any?) + } + + try { + suspend { transform(f(effectScope)) } + .startCoroutineUninterceptedOrReturn(FoldContinuation(token, cont.context, cont)) + } catch (e: Suspend) { + if (token == e.token) { + val f: suspend () -> B = { e.recover(e.shifted) as B } + f.startCoroutineUninterceptedOrReturn(cont) + } else throw e + } + } +} diff --git a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/EffectScope.kt b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/EffectScope.kt index 15101501129..4d4756dbca3 100644 --- a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/EffectScope.kt +++ b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/EffectScope.kt @@ -245,70 +245,3 @@ public suspend fun EffectScope.ensureNotNull(value: B?, shift: ( contract { returns() implies (value != null) } return value ?: shift(shift()) } - -/** - * DSL for constructing Effect values - * - * ```kotlin - * import arrow.core.Either - * import arrow.core.None - * import arrow.core.Option - * import arrow.core.Validated - * import arrow.core.continuations.effect - * import io.kotest.assertions.fail - * import io.kotest.matchers.shouldBe - * - * suspend fun main() { - * effect { - * val x = Either.Right(1).bind() - * val y = Validated.Valid(2).bind() - * val z = Option(3).bind { "Option was empty" } - * x + y + z - * }.fold({ fail("Shift can never be the result") }, { it shouldBe 6 }) - * - * effect { - * val x = Either.Right(1).bind() - * val y = Validated.Valid(2).bind() - * val z: Int = None.bind { "Option was empty" } - * x + y + z - * }.fold({ it shouldBe "Option was empty" }, { fail("Int can never be the result") }) - * } - * ``` - * - */ -public fun effect(f: suspend EffectScope.() -> A): Effect = DefaultEffect(f) - -private class DefaultEffect(val f: suspend EffectScope.() -> A) : Effect { - // We create a `Token` for fold Continuation, so we can properly differentiate between nested - // folds - override suspend fun fold(recover: suspend (R) -> B, transform: suspend (A) -> B): B = - suspendCoroutineUninterceptedOrReturn { cont -> - val token = Token() - val effectScope = - object : EffectScope { - // Shift away from this Continuation by intercepting it, and completing it with - // ShiftCancellationException - // This is needed because this function will never yield a result, - // so it needs to be cancelled to properly support coroutine cancellation - override suspend fun shift(r: R): B = - // Some interesting consequences of how Continuation Cancellation works in Kotlin. - // We have to throw CancellationException to signal the Continuation was cancelled, and we - // shifted away. - // This however also means that the user can try/catch shift and recover from the - // CancellationException and thus effectively recovering from the cancellation/shift. - // This means try/catch is also capable of recovering from monadic errors. - // See: EffectSpec - try/catch tests - throw Suspend(token, r, recover as suspend (Any?) -> Any?) - } - - try { - suspend { transform(f(effectScope)) } - .startCoroutineUninterceptedOrReturn(FoldContinuation(token, cont.context, cont)) - } catch (e: Suspend) { - if (token == e.token) { - val f: suspend () -> B = { e.recover(e.shifted) as B } - f.startCoroutineUninterceptedOrReturn(cont) - } else throw e - } - } -} From 41a4ee898f31e23d36c858a6a81f2954f574b70a Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Thu, 14 Jul 2022 20:58:33 +0200 Subject: [PATCH 4/7] Revert changes docs --- .../kotlin/arrow/core/continuations/Effect.kt | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt index a75e6bf89a6..3be36a68083 100644 --- a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt +++ b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt @@ -24,21 +24,21 @@ import kotlin.coroutines.resume * to map both values of [R] and [A] to a value of `B`. * * - - * [Writing a program with Effect](#writing-a-program-with-effect) - * [Handling errors](#handling-errors) - * [Structured Concurrency](#structured-concurrency) - * [Arrow Fx Coroutines](#arrow-fx-coroutines) - * [parZip](#parzip) - * [parTraverse](#partraverse) - * [raceN](#racen) - * [bracketCase / Resource](#bracketcase--resource) - * [KotlinX](#kotlinx) - * [withContext](#withcontext) - * [async](#async) - * [launch](#launch) - * [Strange edge cases](#strange-edge-cases) - + + * [Writing a program with Effect](#writing-a-program-with-effect) + * [Handling errors](#handling-errors) + * [Structured Concurrency](#structured-concurrency) + * [Arrow Fx Coroutines](#arrow-fx-coroutines) + * [parZip](#parzip) + * [parTraverse](#partraverse) + * [raceN](#racen) + * [bracketCase / Resource](#bracketcase--resource) + * [KotlinX](#kotlinx) + * [withContext](#withcontext) + * [async](#async) + * [launch](#launch) + * [Strange edge cases](#strange-edge-cases) + * * * From 94ce611d564241ab847d8418e6dfc4b42ae900c6 Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Thu, 14 Jul 2022 20:59:13 +0200 Subject: [PATCH 5/7] Move code back to original place --- .../src/commonMain/kotlin/arrow/core/continuations/Effect.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt index 3be36a68083..dee17ab1b38 100644 --- a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt +++ b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt @@ -726,8 +726,6 @@ internal class FoldContinuation( } } -public suspend fun Effect.merge(): A = fold(::identity, ::identity) - /** * DSL for constructing Effect values * @@ -798,3 +796,5 @@ internal class DefaultEffect(val f: suspend EffectScope.() -> A) : Effe } } } + +public suspend fun Effect.merge(): A = fold(::identity, ::identity) From 0d763479c3008b75ba2be544df880303831792da Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Thu, 14 Jul 2022 21:02:21 +0200 Subject: [PATCH 6/7] Revert space edits --- .../kotlin/arrow/core/continuations/Effect.kt | 32 ++++++------ .../arrow/core/continuations/EffectScope.kt | 16 +++--- .../arrow/core/continuations/EffectSpec.kt | 50 +++++++++---------- 3 files changed, 48 insertions(+), 50 deletions(-) diff --git a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt index dee17ab1b38..4152a05e116 100644 --- a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt +++ b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/Effect.kt @@ -596,9 +596,9 @@ public sealed interface Effect { */ public suspend fun fold( recover: suspend (shifted: R) -> B, - transform: suspend (value: A) -> B, + transform: suspend (value: A) -> B ): B - + /** * Like [fold] but also allows folding over any unexpected [Throwable] that might have occurred. * @see fold @@ -606,45 +606,45 @@ public sealed interface Effect { public suspend fun fold( error: suspend (error: Throwable) -> B, recover: suspend (shifted: R) -> B, - transform: suspend (value: A) -> B, + transform: suspend (value: A) -> B ): B = try { fold(recover, transform) } catch (e: Throwable) { error(e.nonFatalOrThrow()) } - + /** * [fold] the [Effect] into an [Either]. Where the shifted value [R] is mapped to [Either.Left], and * result value [A] is mapped to [Either.Right]. */ public suspend fun toEither(): Either = fold({ Either.Left(it) }) { Either.Right(it) } - + /** * [fold] the [Effect] into an [Ior]. Where the shifted value [R] is mapped to [Ior.Left], and * result value [A] is mapped to [Ior.Right]. */ public suspend fun toIor(): Ior = fold({ Ior.Left(it) }) { Ior.Right(it) } - + /** * [fold] the [Effect] into an [Validated]. Where the shifted value [R] is mapped to * [Validated.Invalid], and result value [A] is mapped to [Validated.Valid]. */ public suspend fun toValidated(): Validated = fold({ Validated.Invalid(it) }) { Validated.Valid(it) } - + /** * [fold] the [Effect] into an [Option]. Where the shifted value [R] is mapped to [Option] by the * provided function [orElse], and result value [A] is mapped to [Some]. */ public suspend fun toOption(orElse: suspend (R) -> Option<@UnsafeVariance A>): Option = fold(orElse, ::Some) - + /** * [fold] the [Effect] into an [A?]. Where the shifted value [R] is mapped to * [null], and result value [A]. */ public suspend fun orNull(): A? = fold({ null }, ::identity) - + /** Runs the [Effect] and captures any [NonFatal] exception into [Result]. */ public fun attempt(): Effect> = effect { try { @@ -653,23 +653,23 @@ public sealed interface Effect { Result.failure(e.nonFatalOrThrow()) } } - + public fun handleError(recover: suspend (R) -> @UnsafeVariance A): Effect = effect { fold(recover, ::identity) } - + public fun handleErrorWith(recover: suspend (R) -> Effect): Effect = effect { fold({ recover(it).bind() }, ::identity) } - + public fun redeem(recover: suspend (R) -> B, transform: suspend (A) -> B): Effect = effect { fold(recover, transform) } - + public fun redeemWith( recover: suspend (R) -> Effect, - transform: suspend (A) -> Effect, + transform: suspend (A) -> Effect ): Effect = effect { fold(recover, transform).bind() } } @@ -711,7 +711,7 @@ internal class Token { internal class FoldContinuation( private val token: Token, override val context: CoroutineContext, - private val parent: Continuation, + private val parent: Continuation ) : Continuation { override fun resumeWith(result: Result) { result.fold(parent::resume) { throwable -> @@ -784,7 +784,7 @@ internal class DefaultEffect(val f: suspend EffectScope.() -> A) : Effe // See: EffectSpec - try/catch tests throw Suspend(token, r, recover as suspend (Any?) -> Any?) } - + try { suspend { transform(f(effectScope)) } .startCoroutineUninterceptedOrReturn(FoldContinuation(token, cont.context, cont)) diff --git a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/EffectScope.kt b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/EffectScope.kt index 4d4756dbca3..60e9332711d 100644 --- a/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/EffectScope.kt +++ b/arrow-libs/core/arrow-core/src/commonMain/kotlin/arrow/core/continuations/EffectScope.kt @@ -9,8 +9,6 @@ import arrow.core.Validated import arrow.core.identity import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract -import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn -import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn /** Context of the [Effect] DSL. */ public interface EffectScope { @@ -31,7 +29,7 @@ public interface EffectScope { * */ public suspend fun shift(r: R): B - + /** * Runs the [Effect] to finish, returning [B] or [shift] in case of [R]. * @@ -60,7 +58,7 @@ public interface EffectScope { when (this) { is DefaultEffect -> f(this@EffectScope) } - + /** * Runs the [EagerEffect] to finish, returning [B] or [shift] in case of [R], * bridging eager computations into suspending. @@ -93,7 +91,7 @@ public interface EffectScope { fold({ r -> left = r }, { a -> right = a }) return if (left === EmptyValue) EmptyValue.unbox(right) else shift(EmptyValue.unbox(left)) } - + /** * Folds [Either] into [Effect], by returning [B] or a shift with [R]. * @@ -117,7 +115,7 @@ public interface EffectScope { is Either.Left -> shift(value) is Either.Right -> value } - + /** * Folds [Validated] into [Effect], by returning [B] or a shift with [R]. * @@ -141,7 +139,7 @@ public interface EffectScope { is Validated.Valid -> value is Validated.Invalid -> shift(value) } - + /** * Folds [Result] into [Effect], by returning [B] or a transforming [Throwable] into [R] and * shifting the result. @@ -164,7 +162,7 @@ public interface EffectScope { */ public suspend fun Result.bind(transform: (Throwable) -> R): B = fold(::identity) { throwable -> shift(transform(throwable)) } - + /** * Folds [Option] into [Effect], by returning [B] or a transforming [None] into [R] and shifting the * result. @@ -193,7 +191,7 @@ public interface EffectScope { None -> shift(shift()) is Some -> value } - + /** * ensure that condition is `true`, if it's `false` it will `shift` with the provided value [R]. * Monadic version of [kotlin.require]. diff --git a/arrow-libs/core/arrow-core/src/commonTest/kotlin/arrow/core/continuations/EffectSpec.kt b/arrow-libs/core/arrow-core/src/commonTest/kotlin/arrow/core/continuations/EffectSpec.kt index 4a45a0bb066..2882af92b6d 100644 --- a/arrow-libs/core/arrow-core/src/commonTest/kotlin/arrow/core/continuations/EffectSpec.kt +++ b/arrow-libs/core/arrow-core/src/commonTest/kotlin/arrow/core/continuations/EffectSpec.kt @@ -36,7 +36,7 @@ class EffectSpec : }.fold({ fail("Should never come here") }, ::identity) shouldBe i } } - + "try/catch - can recover from shift suspended" { checkAll(Arb.int(), Arb.string()) { i, s -> effect { @@ -48,7 +48,7 @@ class EffectSpec : }.fold({ fail("Should never come here") }, ::identity) shouldBe i } } - + "try/catch - finally works" { checkAll(Arb.string(), Arb.int()) { s, i -> val promise = CompletableDeferred() @@ -63,7 +63,7 @@ class EffectSpec : promise.await() shouldBe i } } - + "try/catch - finally works suspended" { checkAll(Arb.string(), Arb.int()) { s, i -> val promise = CompletableDeferred() @@ -78,7 +78,7 @@ class EffectSpec : promise.await() shouldBe i } } - + "try/catch - First shift is ignored and second is returned" { checkAll(Arb.int(), Arb.string(), Arb.string()) { i, s, s2 -> effect { @@ -93,7 +93,7 @@ class EffectSpec : .fold(::identity) { fail("Should never come here") } shouldBe s2 } } - + "try/catch - First shift is ignored and second is returned suspended" { checkAll(Arb.int(), Arb.string(), Arb.string()) { i, s, s2 -> effect { @@ -108,53 +108,53 @@ class EffectSpec : .fold(::identity) { fail("Should never come here") } shouldBe s2 } } - + "eagerEffect can be consumed within an Effect computation" { checkAll(Arb.int(), Arb.int()) { a, b -> val eager: EagerEffect = eagerEffect { a } - + effect { val aa = eager.bind() aa + b.suspend() }.runCont() shouldBe (a + b) } } - + "eagerEffect shift short-circuits effect computation" { checkAll(Arb.string(), Arb.int()) { a, b -> val eager: EagerEffect = eagerEffect { shift(a) } - + effect { val aa = eager.bind() aa + b.suspend() }.runCont() shouldBe a } } - + "immediate values" { effect { 1 }.value() shouldBe 1 } - + "suspended value" { effect { 1.suspend() }.value() shouldBe 1 } - + "immediate short-circuit" { effect { shift("hello") }.runCont() shouldBe "hello" } - + "suspended short-circuit" { effect { shift("hello".suspend()) }.runCont() shouldBe "hello" } - + "Rethrows immediate exceptions" { val e = RuntimeException("test") Either.catch { effect { throw e }.runCont() } shouldBe Either.Left(e) } - + "Rethrows suspended exceptions" { val e = RuntimeException("test") Either.catch { effect { e.suspend() }.runCont() } shouldBe Either.Left(e) } - + "Can short-circuit immediately from nested blocks" { effect { effect { shift("test") }.runCont() @@ -162,7 +162,7 @@ class EffectSpec : } .runCont() shouldBe "test" } - + "Can short-circuit suspended from nested blocks" { effect { effect { shift("test".suspend()) }.runCont() @@ -170,7 +170,7 @@ class EffectSpec : } .runCont() shouldBe "test" } - + "Can short-circuit immediately after suspending from nested blocks" { effect { effect { @@ -182,7 +182,7 @@ class EffectSpec : } .runCont() shouldBe "test" } - + "ensure null in either computation" { checkAll(Arb.boolean(), Arb.int(), Arb.string()) { predicate, success, shift -> either { @@ -191,10 +191,10 @@ class EffectSpec : } shouldBe if (predicate) success.right() else shift.left() } } - + "ensureNotNull in either computation" { fun square(i: Int): Int = i * i - + checkAll(Arb.int().orNull(), Arb.string()) { i: Int?, shift: String -> val res = either { @@ -206,7 +206,7 @@ class EffectSpec : res shouldBe expected } } - + "low-level use-case: distinguish between concurrency error and shift exception" { val effect = effect { shift("Shift") } val e = RuntimeException("test") @@ -224,7 +224,7 @@ class EffectSpec : }.runCont() } shouldBe Either.Left(e) } - + "low-level use-case: eager shift exception within effect computations doesn't change shift exception" { val effect = eagerEffect { shift("Shift") } val e = RuntimeException("test") @@ -268,13 +268,13 @@ suspend fun currentContext(): CoroutineContext = kotlin.coroutines.coroutineCont internal suspend fun Throwable.suspend(): Nothing = suspendCoroutineUninterceptedOrReturn { cont -> suspend { throw this } .startCoroutine(Continuation(Dispatchers.Default) { cont.intercepted().resumeWith(it) }) - + COROUTINE_SUSPENDED } internal suspend fun A.suspend(): A = suspendCoroutineUninterceptedOrReturn { cont -> suspend { this } .startCoroutine(Continuation(Dispatchers.Default) { cont.intercepted().resumeWith(it) }) - + COROUTINE_SUSPENDED } From dc4d7df1c368df82e31825381f1c7990e39509c9 Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Sat, 16 Jul 2022 12:13:22 +0200 Subject: [PATCH 7/7] Improve test name, and mention issue number --- .../commonTest/kotlin/arrow/core/continuations/EffectSpec.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arrow-libs/core/arrow-core/src/commonTest/kotlin/arrow/core/continuations/EffectSpec.kt b/arrow-libs/core/arrow-core/src/commonTest/kotlin/arrow/core/continuations/EffectSpec.kt index 2882af92b6d..4bc582ee6e2 100644 --- a/arrow-libs/core/arrow-core/src/commonTest/kotlin/arrow/core/continuations/EffectSpec.kt +++ b/arrow-libs/core/arrow-core/src/commonTest/kotlin/arrow/core/continuations/EffectSpec.kt @@ -243,7 +243,7 @@ class EffectSpec : } shouldBe Either.Left(e) } - "Dispatching in nested Effect causes incorrect behavior" { + "#2760 - dispatching in nested Effect blocks does not make the nested Continuation to hang" { checkAll(Arb.string()) { msg -> fun failure(): Effect = effect { withContext(Dispatchers.Default) {}