Skip to content

Commit 5f77316

Browse files
authored
* Change Outcome conversion to always treat typed failure as Outcome.Erorred (typed failure excludes possibility of external interruption) (#549)
* Treat `Cause.Empty` + external interruption as `Outcome.Canceled`, since this combination manifests sometimes due to a bug in ZIO runtime * Fix Cause comparison in tests, fix Cogen[Cause] instance * Compare Cause by converting to Outcome first to ignore Cause tree details not important to cats-effect * Enable `genFail` and `genCancel` generators since with above fixes the laws pass with them now * Replace `genRace` and `genParallel` with cats-effect based impls to preserve Outcome when F.canceled is generated * Add test that `Cause.Fail` cannot be present after external interruption * Delegate `race` & `both` to default implementations, because `raceFirst` & `zipPar` semantics do not match them
1 parent d745d7e commit 5f77316

File tree

6 files changed

+223
-121
lines changed

6 files changed

+223
-121
lines changed

zio-interop-cats-tests/jvm/src/test/scala/zio/interop/CatsInteropSpec.scala

+53-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import cats.effect.{ Async, IO as CIO, LiftIO, Outcome }
44
import cats.effect.kernel.{ Concurrent, Resource }
55
import zio.interop.catz.*
66
import zio.test.*
7-
import zio.{ Promise, Task, ZIO }
7+
import zio.{ Cause, Exit, Promise, Task, ZIO }
88

99
object CatsInteropSpec extends CatsRunnableSpec {
1010
def spec = suite("Cats interop")(
@@ -130,6 +130,58 @@ object CatsInteropSpec extends CatsRunnableSpec {
130130
res <- counter.get
131131
} yield assertTrue(!res.contains("1")) && assertTrue(res == "AC")
132132
},
133+
testM(
134+
"onCancel is triggered when a fiber executing ZIO.parTraverse + ZIO.fail is interrupted and the inner typed" +
135+
" error is lost in final Cause (Fail & Interrupt nodes cannot both exist in Cause after external interruption)"
136+
) {
137+
val F = Concurrent[Task]
138+
139+
for {
140+
latch1 <- F.deferred[Unit]
141+
latch2 <- F.deferred[Unit]
142+
latch3 <- F.deferred[Unit]
143+
counter <- F.ref("")
144+
cause <- F.ref(Option.empty[Cause[Throwable]])
145+
outerScope <- ZIO.forkScope
146+
fiber <- F.guaranteeCase(
147+
F.onError(
148+
F.onCancel(
149+
ZIO
150+
.collectAllPar(
151+
List(
152+
F.onCancel(
153+
ZIO.never,
154+
latch2.complete(()).unit
155+
),
156+
(latch1.complete(()) *> latch3.get).uninterruptible,
157+
counter.update(_ + "A") *>
158+
latch1.get *>
159+
ZIO.fail(new RuntimeException("The_Error")).unit
160+
)
161+
)
162+
.overrideForkScope(outerScope)
163+
.onExit {
164+
case Exit.Success(_) => ZIO.unit
165+
case Exit.Failure(c) => cause.set(Some(c)).orDie
166+
},
167+
counter.update(_ + "B")
168+
)
169+
) { case _ => counter.update(_ + "1") }
170+
) {
171+
case Outcome.Errored(_) => counter.update(_ + "2")
172+
case Outcome.Canceled() => counter.update(_ + "C")
173+
case Outcome.Succeeded(_) => counter.update(_ + "3")
174+
}.fork
175+
_ <- latch2.get
176+
_ <- fiber.interrupt
177+
_ <- latch3.complete(())
178+
res <- counter.get
179+
cause <- cause.get
180+
} yield assertTrue(!res.contains("1")) &&
181+
assertTrue(res == "ABC") &&
182+
assertTrue(cause.isDefined) &&
183+
assertTrue(!cause.get.prettyPrint.contains("The_Error"))
184+
},
133185
test("F.canceled.toEffect results in CancellationException, not BoxedException") {
134186
val F = Concurrent[Task]
135187

zio-interop-cats-tests/shared/src/test/scala/zio/interop/CatsSpecBase.scala

+37-25
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import cats.effect.testkit.TestInstances
44
import cats.effect.kernel.Outcome
55
import cats.effect.IO as CIO
66
import cats.syntax.all.*
7-
import cats.{ Eq, Order }
7+
import cats.{ Eq, Id, Order }
88
import org.scalacheck.{ Arbitrary, Cogen, Gen, Prop }
99
import org.scalatest.funsuite.AnyFunSuite
1010
import org.scalatest.prop.Configuration
@@ -69,12 +69,15 @@ private[zio] trait CatsSpecBase
6969
ZEnv.Services.live ++ Has(testClock) ++ Has(testBlocking)
7070
}
7171

72-
def unsafeRun[E, A](io: IO[E, A])(implicit ticker: Ticker): Exit[E, Option[A]] =
72+
def unsafeRun[E, A](io: IO[E, A])(implicit ticker: Ticker): (Exit[E, Option[A]], Boolean) =
7373
try {
7474
var exit: Exit[E, Option[A]] = Exit.succeed(Option.empty[A])
75-
runtime.unsafeRunAsync[E, Option[A]](io.asSome)(exit = _)
75+
var interrupted: Boolean = true
76+
runtime.unsafeRunAsync[E, Option[A]] {
77+
signalOnNoExternalInterrupt(io)(ZIO.effectTotal { interrupted = false }).asSome
78+
}(exit = _)
7679
ticker.ctx.tickAll(FiniteDuration(1, TimeUnit.SECONDS))
77-
exit
80+
(exit, interrupted)
7881
} catch {
7982
case error: Throwable =>
8083
error.printStackTrace()
@@ -102,23 +105,15 @@ private[zio] trait CatsSpecBase
102105
Eq.allEqual
103106

104107
implicit val eqForCauseOfNothing: Eq[Cause[Nothing]] =
105-
eqForCauseOf[Nothing]
106-
107-
implicit def eqForCauseOf[E]: Eq[Cause[E]] =
108-
(x, y) => (x.interrupted && y.interrupted) || x == y
109-
110-
implicit def eqForExitOfNothing[A: Eq]: Eq[Exit[Nothing, A]] = {
111-
case (Exit.Success(x), Exit.Success(y)) => x eqv y
112-
case (Exit.Failure(x), Exit.Failure(y)) => x eqv y
113-
case _ => false
114-
}
108+
(x, y) => (x.interrupted && y.interrupted && x.failureOption.isEmpty && y.failureOption.isEmpty) || x == y
115109

116110
implicit def eqForUIO[A: Eq](implicit ticker: Ticker): Eq[UIO[A]] = { (uio1, uio2) =>
117-
val exit1 = unsafeRun(uio1)
118-
val exit2 = unsafeRun(uio2)
119-
// println(s"comparing $exit1 $exit2")
120-
(exit1 eqv exit2) || {
121-
println(s"$exit1 was not equal to $exit2")
111+
val (exit1, i1) = unsafeRun(uio1)
112+
val (exit2, i2) = unsafeRun(uio2)
113+
val out1 = toOutcomeCauseOtherFiber[Id, Nothing, Option[A]](i1)(identity, exit1)
114+
val out2 = toOutcomeCauseOtherFiber[Id, Nothing, Option[A]](i2)(identity, exit2)
115+
(out1 eqv out2) || {
116+
println(s"$out1 was not equal to $out2")
122117
false
123118
}
124119
}
@@ -136,7 +131,7 @@ private[zio] trait CatsSpecBase
136131
.toEffect[CIO]
137132

138133
implicit def orderForUIOofFiniteDuration(implicit ticker: Ticker): Order[UIO[FiniteDuration]] =
139-
Order.by(unsafeRun(_).toEither.toOption)
134+
Order.by(unsafeRun(_)._1.toEither.toOption)
140135

141136
implicit def orderForRIOofFiniteDuration[R: Arbitrary](implicit ticker: Ticker): Order[RIO[R, FiniteDuration]] =
142137
(x, y) =>
@@ -149,7 +144,7 @@ private[zio] trait CatsSpecBase
149144
ticker: Ticker
150145
): Order[ZIO[R, E, FiniteDuration]] = {
151146
implicit val orderForIOofFiniteDuration: Order[IO[E, FiniteDuration]] =
152-
Order.by(unsafeRun(_) match {
147+
Order.by(unsafeRun(_)._1 match {
153148
case Exit.Success(value) => Right(value)
154149
case Exit.Failure(cause) => Left(cause.failureOption)
155150
})
@@ -167,11 +162,11 @@ private[zio] trait CatsSpecBase
167162
Cogen[Outcome[Option, E, A]].contramap { (zio: ZIO[R, E, A]) =>
168163
Arbitrary.arbitrary[R].sample match {
169164
case Some(r) =>
170-
val result = unsafeRun(zio.provide(r))
165+
val (result, extInterrupted) = unsafeRun(zio.provide(r))
171166

172167
result match {
173168
case Exit.Failure(cause) =>
174-
if (cause.interrupted) Outcome.canceled[Option, E, A]
169+
if (cause.interrupted && extInterrupted) Outcome.canceled[Option, E, A]
175170
else Outcome.errored(cause.failureOption.get)
176171
case Exit.Success(value) => Outcome.succeeded(value)
177172
}
@@ -181,8 +176,8 @@ private[zio] trait CatsSpecBase
181176

182177
implicit def cogenOutcomeZIO[R, A](implicit
183178
cogen: Cogen[ZIO[R, Throwable, A]]
184-
): Cogen[Outcome[ZIO[R, Throwable, *], Throwable, A]] =
185-
cogenOutcome[RIO[R, *], Throwable, A]
179+
): Cogen[Outcome[ZIO[R, Throwable, _], Throwable, A]] =
180+
cogenOutcome[RIO[R, _], Throwable, A]
186181
}
187182

188183
private[interop] sealed trait CatsSpecBaseLowPriority { this: CatsSpecBase =>
@@ -213,4 +208,21 @@ private[interop] sealed trait CatsSpecBaseLowPriority { this: CatsSpecBase =>
213208

214209
implicit def eqForTaskManaged[A: Eq](implicit ticker: Ticker): Eq[TaskManaged[A]] =
215210
zManagedEq[Any, Throwable, A]
211+
212+
implicit def eqForCauseOf[E: Eq]: Eq[Cause[E]] = { (exit1, exit2) =>
213+
val out1 =
214+
toOutcomeOtherFiber0[Id, E, Either[E, Cause[Nothing]], Unit](true)(identity, Exit.Failure(exit1))(
215+
(e, _) => Left(e),
216+
Right(_)
217+
)
218+
val out2 =
219+
toOutcomeOtherFiber0[Id, E, Either[E, Cause[Nothing]], Unit](true)(identity, Exit.Failure(exit2))(
220+
(e, _) => Left(e),
221+
Right(_)
222+
)
223+
(out1 eqv out2) || {
224+
println(s"cause $out1 was not equal to cause $out2")
225+
false
226+
}
227+
}
216228
}

zio-interop-cats-tests/shared/src/test/scala/zio/interop/GenIOInteropCats.scala

+46-16
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@ import zio.*
66

77
trait GenIOInteropCats {
88

9-
// FIXME generating anything but success (even genFail)
10-
// surfaces multiple further unaddressed law failures
9+
// FIXME `genDie` and `genInternalInterrupt` surface multiple further unaddressed law failures
10+
// See `genDie` scaladoc
1111
def betterGenerators: Boolean = false
1212

13-
// FIXME cats conversion surfaces failures in the following laws:
14-
// `async left is uncancelable sequenced raiseError`
15-
// `async right is uncancelable sequenced pure`
16-
// `applicativeError onError raise`
17-
// `canceled sequences onCanceled in order`
13+
// FIXME cats conversion generator works most of the time
14+
// but generates rare law failures in
15+
// - `canceled sequences onCanceled in order`
16+
// - `uncancelable eliminates onCancel`
17+
// - `fiber join is guarantee case`
18+
// possibly coming from the `GenSpawnGenerators#genRacePair` generator + `F.canceled`.
19+
// Errors occur more often when combined with `genOfRace` or `genOfParallel`
1820
def catsConversionGenerator: Boolean = false
1921

2022
/**
@@ -35,9 +37,28 @@ trait GenIOInteropCats {
3537

3638
def genFail[E: Arbitrary, A]: Gen[IO[E, A]] = Arbitrary.arbitrary[E].map(IO.fail[E](_))
3739

40+
/**
41+
* We can't pass laws like `cats.effect.laws.GenSpawnLaws#fiberJoinIsGuaranteeCase`
42+
* with either `genDie` or `genInternalInterrupt` because
43+
* we are forced to rethrow an `Outcome.Errored` using
44+
* `raiseError` in `Outcome#embed` which converts the
45+
* specific state into a typed error.
46+
*
47+
* While we consider both states to be `Outcome.Errored`,
48+
* they aren't really 'equivalent' even if we massage them
49+
* into having the same `Outcome`, because `handleErrorWith`
50+
* can't recover from these states.
51+
*
52+
* Now, we could make ZIO Throwable instances recover from
53+
* all errors via [[zio.Cause#squashTraceWith]], but
54+
* this would make Throwable instances contradict the
55+
* generic MonadError instance.
56+
* (Which I believe is acceptable, if confusing, as long
57+
* as the generic instances are moved to a separate `generic`
58+
* object.)
59+
*/
3860
def genDie(implicit arbThrowable: Arbitrary[Throwable]): Gen[UIO[Nothing]] = arbThrowable.arbitrary.map(IO.die(_))
39-
40-
def genInternalInterrupt: Gen[UIO[Nothing]] = ZIO.interrupt
61+
def genInternalInterrupt: Gen[UIO[Nothing]] = ZIO.interrupt
4162

4263
def genCancel[E, A: Arbitrary](implicit F: GenConcurrent[IO[E, _], ?]): Gen[IO[E, A]] =
4364
Arbitrary.arbitrary[A].map(F.canceled.as(_))
@@ -60,18 +81,22 @@ trait GenIOInteropCats {
6081
else
6182
Gen.oneOf(
6283
genSuccess[E, A],
84+
genFail[E, A],
85+
genCancel[E, A],
6386
genNever
6487
)
6588

66-
def genUIO[A: Arbitrary]: Gen[UIO[A]] =
89+
def genUIO[A: Arbitrary](implicit F: GenConcurrent[UIO, ?]): Gen[UIO[A]] =
6790
Gen.oneOf(genSuccess[Nothing, A], genIdentityTrans(genSuccess[Nothing, A]))
6891

6992
/**
7093
* Given a generator for `IO[E, A]`, produces a sized generator for `IO[E, A]` which represents a transformation,
7194
* by using some random combination of the methods `map`, `flatMap`, `mapError`, and any other method that does not change
7295
* the success/failure of the value, but may change the value itself.
7396
*/
74-
def genLikeTrans[E: Arbitrary: Cogen, A: Arbitrary: Cogen](gen: Gen[IO[E, A]]): Gen[IO[E, A]] = {
97+
def genLikeTrans[E: Arbitrary: Cogen, A: Arbitrary: Cogen](
98+
gen: Gen[IO[E, A]]
99+
)(implicit F: GenConcurrent[IO[E, _], ?]): Gen[IO[E, A]] = {
75100
val functions: IO[E, A] => Gen[IO[E, A]] = io =>
76101
Gen.oneOf(
77102
genOfFlatMaps[E, A](io)(genSuccess[E, A]),
@@ -87,7 +112,8 @@ trait GenIOInteropCats {
87112
* Given a generator for `IO[E, A]`, produces a sized generator for `IO[E, A]` which represents a transformation,
88113
* by using methods that can have no effect on the resulting value (e.g. `map(identity)`, `io.race(never)`, `io.par(io2).map(_._1)`).
89114
*/
90-
def genIdentityTrans[E, A: Arbitrary](gen: Gen[IO[E, A]]): Gen[IO[E, A]] = {
115+
def genIdentityTrans[E, A: Arbitrary](gen: Gen[IO[E, A]])(implicit F: GenConcurrent[IO[E, _], ?]): Gen[IO[E, A]] = {
116+
implicitly[Arbitrary[A]]
91117
val functions: IO[E, A] => Gen[IO[E, A]] = io =>
92118
Gen.oneOf(
93119
genOfIdentityFlatMaps[E, A](io),
@@ -131,9 +157,13 @@ trait GenIOInteropCats {
131157
private def genOfIdentityFlatMaps[E, A](io: IO[E, A]): Gen[IO[E, A]] =
132158
Gen.const(io.flatMap(a => IO.succeed(a)))
133159

134-
private def genOfRace[E, A](io: IO[E, A]): Gen[IO[E, A]] =
135-
Gen.const(io.interruptible.raceFirst(ZIO.never.interruptible))
160+
private def genOfRace[E, A](io: IO[E, A])(implicit F: GenConcurrent[IO[E, _], ?]): Gen[IO[E, A]] =
161+
// Gen.const(io.interruptible.raceFirst(ZIO.never.interruptible))
162+
Gen.const(F.race(io, ZIO.never).map(_.merge)) // we must use cats version for Outcome preservation in F.canceled
136163

137-
private def genOfParallel[E, A](io: IO[E, A])(gen: Gen[IO[E, A]]): Gen[IO[E, A]] =
138-
gen.map(parIo => io.interruptible.zipPar(parIo.interruptible).map(_._1))
164+
private def genOfParallel[E, A](io: IO[E, A])(
165+
gen: Gen[IO[E, A]]
166+
)(implicit F: GenConcurrent[IO[E, _], ?]): Gen[IO[E, A]] =
167+
// gen.map(parIo => io.interruptible.zipPar(parIo.interruptible).map(_._1))
168+
gen.map(parIO => F.both(io, parIO).map(_._1)) // we must use cats version for Outcome preservation in F.canceled
139169
}

zio-interop-cats-tests/shared/src/test/scala/zio/interop/ZioSpecBase.scala

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package zio.interop
22

3+
import cats.effect.kernel.Outcome
34
import org.scalacheck.{ Arbitrary, Cogen, Gen }
45
import zio.*
56
import zio.clock.Clock
67

78
private[interop] trait ZioSpecBase extends CatsSpecBase with ZioSpecBaseLowPriority with GenIOInteropCats {
89

9-
implicit def arbitraryUIO[A: Arbitrary]: Arbitrary[UIO[A]] =
10+
implicit def arbitraryUIO[A: Arbitrary]: Arbitrary[UIO[A]] = {
11+
import zio.interop.catz.generic.concurrentInstanceCause
1012
Arbitrary(genUIO[A])
13+
}
1114

1215
implicit def arbitraryURIO[R: Cogen, A: Arbitrary]: Arbitrary[URIO[R, A]] =
1316
Arbitrary(Arbitrary.arbitrary[R => UIO[A]].map(ZIO.environment[R].flatMap))
@@ -39,8 +42,13 @@ private[interop] trait ZioSpecBase extends CatsSpecBase with ZioSpecBaseLowPrior
3942
Arbitrary(self)
4043
}
4144

42-
implicit def cogenCause[E]: Cogen[Cause[E]] =
43-
Cogen(_.hashCode.toLong)
45+
implicit def cogenCause[E: Cogen]: Cogen[Cause[E]] =
46+
Cogen[Outcome[Option, Either[E, Int], Unit]].contramap { cause =>
47+
toOutcomeOtherFiber0[Option, E, Either[E, Int], Unit](true)(Option(_), Exit.Failure(cause))(
48+
(e, _) => Left(e),
49+
c => Right(c.hashCode())
50+
)
51+
}
4452
}
4553

4654
private[interop] trait ZioSpecBaseLowPriority { self: ZioSpecBase =>

zio-interop-cats/shared/src/main/scala/zio/interop/cats.scala

+7-2
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,9 @@ private abstract class ZioConcurrent[R, E, E1]
335335
)
336336
} yield res
337337

338-
override final def both[A, B](fa: F[A], fb: F[B]): F[(A, B)] =
339-
fa.interruptible zipPar fb.interruptible
338+
// delegate race & both to default implementations, because `raceFirst` & `zipPar` semantics do not match them
339+
override final def race[A, B](fa: F[A], fb: F[B]): F[Either[A, B]] = super.race(fa, fb)
340+
override final def both[A, B](fa: F[A], fb: F[B]): F[(A, B)] = super.both(fa, fb)
340341

341342
override final def guarantee[A](fa: F[A], fin: F[Unit]): F[A] =
342343
fa.ensuring(fin.orDieWith(toThrowableOrFiberFailure))
@@ -580,17 +581,21 @@ private abstract class ZioMonadErrorExit[R, E, E1] extends ZioMonadError[R, E, E
580581
private trait ZioMonadErrorExitThrowable[R]
581582
extends ZioMonadErrorExit[R, Throwable, Throwable]
582583
with ZioMonadErrorE[R, Throwable] {
584+
583585
override final protected def toOutcomeThisFiber[A](exit: Exit[Throwable, A]): UIO[Outcome[F, Throwable, A]] =
584586
toOutcomeThrowableThisFiber(exit)
587+
585588
protected final def toOutcomeOtherFiber[A](interruptedHandle: zio.Ref[Boolean])(
586589
exit: Exit[Throwable, A]
587590
): UIO[Outcome[F, Throwable, A]] =
588591
interruptedHandle.get.map(toOutcomeThrowableOtherFiber(_)(ZIO.succeedNow, exit))
589592
}
590593

591594
private trait ZioMonadErrorExitCause[R, E] extends ZioMonadErrorExit[R, E, Cause[E]] with ZioMonadErrorCause[R, E] {
595+
592596
override protected def toOutcomeThisFiber[A](exit: Exit[E, A]): UIO[Outcome[F, Cause[E], A]] =
593597
toOutcomeCauseThisFiber(exit)
598+
594599
protected final def toOutcomeOtherFiber[A](interruptedHandle: zio.Ref[Boolean])(
595600
exit: Exit[E, A]
596601
): UIO[Outcome[F, Cause[E], A]] =

0 commit comments

Comments
 (0)