From 6a8128847b86c049475fd847d6487ba3899df8f9 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Sun, 29 Aug 2021 17:03:57 -0600 Subject: [PATCH 01/28] Completely reworked TestContext API --- build.sbt | 2 +- .../effect/kernel/testkit/TestContext.scala | 104 +++++++++--------- .../cats/effect/testkit/TestInstances.scala | 2 +- .../src/test/scala/cats/effect/IOSpec.scala | 40 +++---- .../test/scala/cats/effect/MemoizeSpec.scala | 18 +-- .../test/scala/cats/effect/ResourceSpec.scala | 69 +++++++----- .../cats/effect/std/BackpressureSpec.scala | 2 +- 7 files changed, 125 insertions(+), 112 deletions(-) diff --git a/build.sbt b/build.sbt index c0641dd646..0585580bd3 100644 --- a/build.sbt +++ b/build.sbt @@ -27,7 +27,7 @@ import org.scalajs.jsenv.selenium.SeleniumJSEnv import JSEnv._ -ThisBuild / baseVersion := "3.2" +ThisBuild / baseVersion := "3.3" ThisBuild / organization := "org.typelevel" ThisBuild / organizationName := "Typelevel" diff --git a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala index 805e654334..4bb7a98b64 100644 --- a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala +++ b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala @@ -17,6 +17,7 @@ package cats.effect.kernel package testkit +import scala.annotation.tailrec import scala.collection.immutable.SortedSet import scala.concurrent.ExecutionContext import scala.concurrent.duration._ @@ -157,6 +158,27 @@ final class TestContext private () extends ExecutionContext { self => def state: State = synchronized(stateRef) + /** + * Returns the current interval between "now" and the earliest scheduled + * task. If there are tasks which will run immediately, this will return + * `Duration.Zero`. Passing this value to [[tick]] will guarantee + * minimum time-oriented progress on the task queue (e.g. `tick(nextInterval())`). + */ + def nextInterval(): FiniteDuration = { + val s = state + s.tasks.min.runsAt - s.clock + } + + def advance(time: FiniteDuration): Unit = + synchronized { + stateRef = stateRef.copy(clock = stateRef.clock + time) + } + + def advanceAndTick(time: FiniteDuration): Unit = { + advance(time) + tick() + } + /** * Executes just one tick, one task, from the internal queue, useful * for testing that a some runnable will definitely be executed next. @@ -190,66 +212,51 @@ final class TestContext private () extends ExecutionContext { self => try head.task.run() catch { case NonFatal(ex) => reportFailure(ex) } true + case None => false } } - /** - * Triggers execution by going through the queue of scheduled tasks and - * executing them all, until no tasks remain in the queue to execute. - * - * Order of execution isn't guaranteed, the queued `Runnable`s are - * being shuffled in order to simulate the needed nondeterminism - * that happens with multi-threading. - * - * {{{ - * implicit val ec = TestContext() - * - * val f = Future(1 + 1).flatMap(_ + 1) - * // Execution is momentarily suspended in TestContext - * assert(f.value == None) - * - * // Simulating async execution: - * ec.tick() - * assert(f.value, Some(Success(2))) - * }}} - * - * @param time is an optional parameter for simulating time passing; - */ - def tick(time: FiniteDuration = Duration.Zero): Unit = { - val targetTime = this.stateRef.clock + time - var hasTasks = true + @tailrec + def tick(): Unit = + if (tickOne()) { + tick() + } - while (hasTasks) synchronized { - val current = this.stateRef + private[testkit] def tick(time: FiniteDuration): Unit = { + advance(time) + tick() + } - extractOneTask(current, targetTime) match { - case Some((head, rest)) => - stateRef = current.copy(clock = head.runsAt, tasks = rest) - // execute task - try head.task.run() - catch { - case ex if NonFatal(ex) => - reportFailure(ex) - } + private[testkit] def tick$default$1(): FiniteDuration = Duration.Zero - case None => - stateRef = current.copy(clock = targetTime) - hasTasks = false - } + /** + * Repeatedly runs `tick(nextInterval())` until all work has completed. + * This is useful for emulating the quantized passage of time. For any + * discrete tick, the scheduler will randomly pick from all eligible tasks + * until the only remaining work is delayed. At that point, the scheduler + * will then advance the minimum delay (to the next time interval) and + * the process repeats. + * + * This is intuitively equivalent to "running to completion". + */ + @tailrec + def tickAll(): Unit = { + tick() + if (!stateRef.tasks.isEmpty) { + advance(nextInterval()) + tickAll() } } - def tickAll(time: FiniteDuration = Duration.Zero): Unit = { - tick(time) - - // some of our tasks may have enqueued more tasks - if (!this.stateRef.tasks.isEmpty) { - tickAll(time) - } + private[testkit] def tickAll(time: FiniteDuration): Unit = { + val _ = time + tickAll() } + private[testkit] def tickAll$default$1(): FiniteDuration = Duration.Zero + def schedule(delay: FiniteDuration, r: Runnable): () => Unit = synchronized { val current: State = stateRef @@ -307,9 +314,6 @@ object TestContext { clock: FiniteDuration, tasks: SortedSet[Task], lastReportedFailure: Option[Throwable]) { - assert( - !tasks.headOption.exists(_.runsAt < clock), - "The runsAt for any task must never be in the past") /** * Returns a new state with the runnable scheduled for execution. diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestInstances.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestInstances.scala index f33eafe174..fbf1bf181f 100644 --- a/testkit/shared/src/main/scala/cats/effect/testkit/TestInstances.scala +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestInstances.scala @@ -191,7 +191,7 @@ trait TestInstances extends ParallelFGenerators with OutcomeGenerators with Sync .unsafeRunAsyncOutcome { oc => results = oc.mapK(someK) }(unsafe .IORuntime(ticker.ctx, ticker.ctx, scheduler, () => (), unsafe.IORuntimeConfig())) - ticker.ctx.tickAll(1.second) + ticker.ctx.tickAll() /*println("====================================") println(s"completed ioa with $results") diff --git a/tests/shared/src/test/scala/cats/effect/IOSpec.scala b/tests/shared/src/test/scala/cats/effect/IOSpec.scala index ea7ceab028..41b19497d1 100644 --- a/tests/shared/src/test/scala/cats/effect/IOSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/IOSpec.scala @@ -279,7 +279,7 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { val ioa = for { f <- IO.executionContext.start.evalOn(ec) - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) oc <- f.join } yield oc @@ -293,7 +293,7 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { "cancel an already canceled fiber" in ticked { implicit ticker => val test = for { f <- IO.canceled.start - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) _ <- f.cancel } yield () @@ -332,13 +332,13 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { val test = for { fiber <- async.start - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) _ <- IO(cb(Right(42))) - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) _ <- IO(cb(Right(43))) - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) _ <- IO(cb(Left(TestException))) - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) value <- fiber.joinWithNever } yield value @@ -455,9 +455,9 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { "propagate cancelation" in ticked { implicit ticker => (for { fiber <- IO.both(IO.never, IO.never).void.start - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) _ <- fiber.cancel - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) oc <- fiber.join } yield oc) must completeAs(Outcome.canceled[IO, Throwable, Unit]) } @@ -468,9 +468,9 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { r <- Ref[IO].of(false) fiber <- IO.both(IO.never.onCancel(l.set(true)), IO.never.onCancel(r.set(true))).start - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) _ <- fiber.cancel - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) l2 <- l.get r2 <- r.get } yield (l2 -> r2)) must completeAs(true -> true) @@ -544,9 +544,9 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { r <- Ref.of[IO, Boolean](false) fiber <- IO.race(IO.never.onCancel(l.set(true)), IO.never.onCancel(r.set(true))).start - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) _ <- fiber.cancel - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) l2 <- l.get r2 <- r.get } yield (l2 -> r2)) must completeAs(true -> true) @@ -624,7 +624,7 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { val ioa = for { f <- (IO.never: IO[Unit]).start ec <- IO.executionContext - _ <- IO(ec.asInstanceOf[TestContext].tickAll()) + _ <- IO(ec.asInstanceOf[TestContext].tick()) _ <- f.cancel oc <- f.join } yield oc @@ -640,7 +640,7 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { val ioa = for { f <- target.start - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) _ <- f.cancel } yield () @@ -658,7 +658,7 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { val ioa = for { f <- target.start - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) _ <- f.cancel } yield () @@ -720,7 +720,7 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { val test = for { f <- body.onCancel(IO(results ::= 2)).onCancel(IO(results ::= 1)).start - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) _ <- f.cancel back <- IO(results) } yield back @@ -927,11 +927,11 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { val test = for { f <- subject.start - _ <- IO(ticker.ctx.tickAll()) // schedule everything + _ <- IO(ticker.ctx.tick()) // schedule everything _ <- f.cancel.start - _ <- IO(ticker.ctx.tickAll()) // get inside the finalizer suspension + _ <- IO(ticker.ctx.tick()) // get inside the finalizer suspension _ <- IO(cb(Right(()))) - _ <- IO(ticker.ctx.tickAll()) // show that the finalizer didn't explode + _ <- IO(ticker.ctx.tick()) // show that the finalizer didn't explode } yield () test must completeAs(()) // ...but not throw an exception @@ -948,7 +948,7 @@ class IOSpec extends BaseSpec with Discipline with IOPlatformSpecification { IO.pure(Some(fin)) } - val test = target.start flatMap { f => IO(ticker.ctx.tickAll()) *> f.cancel } + val test = target.start flatMap { f => IO(ticker.ctx.tick()) *> f.cancel } test must completeAs(()) success must beTrue diff --git a/tests/shared/src/test/scala/cats/effect/MemoizeSpec.scala b/tests/shared/src/test/scala/cats/effect/MemoizeSpec.scala index 09eed0f8eb..bbc293d7c5 100644 --- a/tests/shared/src/test/scala/cats/effect/MemoizeSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/MemoizeSpec.scala @@ -40,7 +40,7 @@ class MemoizeSpec extends BaseSpec with Discipline { } yield v val result = op.unsafeToFuture() - ticker.ctx.tickAll() + ticker.ctx.tick() result.value mustEqual Some(Success(0)) } @@ -60,7 +60,7 @@ class MemoizeSpec extends BaseSpec with Discipline { } yield (x, y, v) val result = op.unsafeToFuture() - ticker.ctx.tickAll() + ticker.ctx.tick() result.value mustEqual Some(Success((1, 1, 1))) } @@ -76,12 +76,12 @@ class MemoizeSpec extends BaseSpec with Discipline { memoized <- Concurrent[IO].memoize(action) _ <- memoized.start x <- memoized - _ <- IO(ticker.ctx.tickAll()) + _ <- IO(ticker.ctx.tick()) v <- ref.get } yield x -> v val result = op.unsafeToFuture() - ticker.ctx.tickAll() + ticker.ctx.tick() result.value mustEqual Some(Success((1, 1))) } @@ -104,7 +104,7 @@ class MemoizeSpec extends BaseSpec with Discipline { } yield res val result = op.unsafeToFuture() - ticker.ctx.tickAll(500.millis) + ticker.ctx.tickAll() result.value mustEqual Some(Success(false)) } @@ -126,7 +126,7 @@ class MemoizeSpec extends BaseSpec with Discipline { } yield res val result = op.unsafeToFuture() - ticker.ctx.tickAll(600.millis) + ticker.ctx.tickAll() result.value mustEqual Some(Success(false)) } @@ -148,7 +148,7 @@ class MemoizeSpec extends BaseSpec with Discipline { } yield res val result = op.unsafeToFuture() - ticker.ctx.tickAll(600.millis) + ticker.ctx.tickAll() result.value mustEqual Some(Success(false)) } @@ -169,7 +169,7 @@ class MemoizeSpec extends BaseSpec with Discipline { } yield v1 -> v2 val result = op.unsafeToFuture() - ticker.ctx.tickAll(500.millis) + ticker.ctx.tickAll() result.value mustEqual Some(Success((2, 1))) } @@ -190,7 +190,7 @@ class MemoizeSpec extends BaseSpec with Discipline { } yield v val result = op.unsafeToFuture() - ticker.ctx.tickAll(500.millis) + ticker.ctx.tickAll() result.value mustEqual Some(Success(true)) } diff --git a/tests/shared/src/test/scala/cats/effect/ResourceSpec.scala b/tests/shared/src/test/scala/cats/effect/ResourceSpec.scala index b055a50562..3a68a71d5d 100644 --- a/tests/shared/src/test/scala/cats/effect/ResourceSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/ResourceSpec.scala @@ -469,7 +469,8 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 1 second: // both resources have allocated (concurrency, serially it would happen after 2 seconds) // resources are still open during `use` (correctness) - ticker.ctx.tick(1.second) + ticker.ctx.tick() + ticker.ctx.advanceAndTick(1.second) leftAllocated must beTrue rightAllocated must beTrue leftReleasing must beFalse @@ -477,7 +478,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 2 seconds: // both resources have started cleanup (correctness) - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) leftReleasing must beTrue rightReleasing must beTrue leftReleased must beFalse @@ -485,7 +486,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 3 seconds: // both resources have terminated cleanup (concurrency, serially it would happen after 4 seconds) - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) leftReleased must beTrue rightReleased must beTrue } @@ -518,7 +519,8 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 1 second: // both resources have allocated (concurrency, serially it would happen after 2 seconds) // resources are still open during `flatMap` (correctness) - ticker.ctx.tick(1.second) + ticker.ctx.tick() + ticker.ctx.advanceAndTick(1.second) leftAllocated must beTrue rightAllocated must beTrue leftReleasing must beFalse @@ -526,7 +528,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 2 seconds: // both resources have started cleanup (interruption, or rhs would start releasing after 3 seconds) - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) leftReleasing must beTrue rightReleasing must beTrue leftReleased must beFalse @@ -534,7 +536,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 3 seconds: // both resources have terminated cleanup (concurrency, serially it would happen after 4 seconds) - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) leftReleased must beTrue rightReleased must beTrue } @@ -565,7 +567,8 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 1 second: // rhs has partially allocated, lhs executing - ticker.ctx.tick(1.second) + ticker.ctx.tick() + ticker.ctx.advanceAndTick(1.second) leftAllocated must beFalse rightAllocated must beTrue rightErrored must beFalse @@ -574,7 +577,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 2 seconds: // rhs has failed, release blocked since lhs is in uninterruptible allocation - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) leftAllocated must beFalse rightAllocated must beTrue rightErrored must beTrue @@ -584,7 +587,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 3 seconds: // lhs completes allocation (concurrency, serially it would happen after 4 seconds) // both resources have started cleanup (correctness, error propagates to both sides) - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) leftAllocated must beTrue leftReleasing must beTrue rightReleasing must beTrue @@ -593,7 +596,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 4 seconds: // both resource have terminated cleanup (concurrency, serially it would happen after 5 seconds) - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) leftReleased must beTrue rightReleased must beTrue } @@ -679,7 +682,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { (Resource.eval(IO.canceled)).uncancelable.onCancel(Resource.eval(IO { fired = true })) test.use_.unsafeToFuture() - ticker.ctx.tickAll() + ticker.ctx.tick() fired must beFalse } @@ -699,11 +702,12 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { (outerInit *> async *> waitR).use_.unsafeToFuture() - ticker.ctx.tick(1.second) + ticker.ctx.tick() + ticker.ctx.advanceAndTick(1.second) innerClosed must beFalse outerClosed must beFalse - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) innerClosed must beTrue outerClosed must beTrue } @@ -720,11 +724,12 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { target.use_.unsafeToFuture() - ticker.ctx.tick(1.second) + ticker.ctx.tick() + ticker.ctx.advanceAndTick(1.second) leftClosed must beTrue rightClosed must beFalse - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) rightClosed must beTrue } } @@ -790,25 +795,26 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { target.use_.unsafeToFuture() - ticker.ctx.tick(50.millis) + ticker.ctx.tick() + ticker.ctx.advanceAndTick(50.millis) winnerClosed must beFalse loserClosed must beFalse completed must beFalse results must beNull - ticker.ctx.tick(50.millis) + ticker.ctx.advanceAndTick(50.millis) winnerClosed must beFalse loserClosed must beTrue completed must beFalse results must beLeft("winner") - ticker.ctx.tick(50.millis) + ticker.ctx.advanceAndTick(50.millis) winnerClosed must beFalse loserClosed must beTrue completed must beFalse results must beLeft("winner") - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) winnerClosed must beTrue completed must beTrue } @@ -820,7 +826,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { val target = waitR.start *> waitR *> Resource.eval(IO { completed = true }) target.use_.unsafeToFuture() - ticker.ctx.tick(1.second) + ticker.ctx.tickAll() completed must beTrue } @@ -843,12 +849,12 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { val target = Resource.make(wait)(_ => IO(i += 1)).start *> finish target.use_.unsafeToFuture() - ticker.ctx.tick(50.millis) + ticker.ctx.advanceAndTick(50.millis) completed must beTrue i mustEqual 0 - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) i mustEqual 1 } @@ -860,10 +866,11 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { target.use_.unsafeToFuture() - ticker.ctx.tick(1.second) + ticker.ctx.tick() + ticker.ctx.advanceAndTick(1.second) i mustEqual 1 - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) i mustEqual 1 } @@ -880,14 +887,15 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { target.use_.unsafeToFuture() - ticker.ctx.tick(100.millis) + ticker.ctx.tick() + ticker.ctx.advanceAndTick(100.millis) i mustEqual 0 - ticker.ctx.tick(900.millis) + ticker.ctx.advanceAndTick(900.millis) i mustEqual 0 completed must beFalse - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) i mustEqual 1 completed must beTrue } @@ -917,7 +925,8 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 1 second: // both resources have allocated (concurrency, serially it would happen after 2 seconds) // resources are still open during `use` (correctness) - ticker.ctx.tick(1.second) + ticker.ctx.tick() + ticker.ctx.advanceAndTick(1.second) leftAllocated must beTrue rightAllocated must beTrue leftReleasing must beFalse @@ -925,7 +934,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 2 seconds: // both resources have started cleanup (correctness) - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) leftReleasing must beTrue rightReleasing must beTrue leftReleased must beFalse @@ -933,7 +942,7 @@ class ResourceSpec extends BaseSpec with ScalaCheck with Discipline { // after 3 seconds: // both resources have terminated cleanup (concurrency, serially it would happen after 4 seconds) - ticker.ctx.tick(1.second) + ticker.ctx.advanceAndTick(1.second) leftReleased must beTrue rightReleased must beTrue } diff --git a/tests/shared/src/test/scala/cats/effect/std/BackpressureSpec.scala b/tests/shared/src/test/scala/cats/effect/std/BackpressureSpec.scala index ec4be251be..f9e1bbad32 100644 --- a/tests/shared/src/test/scala/cats/effect/std/BackpressureSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/std/BackpressureSpec.scala @@ -21,7 +21,7 @@ import cats.syntax.all._ import scala.concurrent.duration._ -class BackpressureTests extends BaseSpec { +class BackpressureSpec extends BaseSpec { "Backpressure" should { "Lossy Strategy should return IO[None] when no permits are available" in ticked { From 7c143dd05ead06e373eabd1ada2b125e23a3c226 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Sun, 29 Aug 2021 22:39:01 -0600 Subject: [PATCH 02/28] Added `TestControl` for creating and controlling a mock runtime --- .../effect/kernel/testkit/TestContext.scala | 3 +- .../cats/effect/testkit/TestControl.scala | 247 ++++++++++++++++++ 2 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala diff --git a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala index 4bb7a98b64..3eee1c9703 100644 --- a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala +++ b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala @@ -166,11 +166,12 @@ final class TestContext private () extends ExecutionContext { self => */ def nextInterval(): FiniteDuration = { val s = state - s.tasks.min.runsAt - s.clock + (s.tasks.min.runsAt - s.clock).max(Duration.Zero) } def advance(time: FiniteDuration): Unit = synchronized { + require(time > Duration.Zero) stateRef = stateRef.copy(clock = stateRef.clock + time) } diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala new file mode 100644 index 0000000000..cb4d216f3d --- /dev/null +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala @@ -0,0 +1,247 @@ +/* + * Copyright 2020-2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cats.effect +package testkit + +import cats.effect.kernel.testkit.TestContext +import cats.effect.unsafe.{IORuntime, IORuntimeConfig, Scheduler} + +import scala.concurrent.duration.FiniteDuration + +/** + * Implements a fully functional single-threaded runtime for [[cats.effect.IO]]. + * When using the [[runtime]] provided by this type, `IO` programs will be + * executed on a single JVM thread, ''similar'' to how they would behave if the + * production runtime were configured to use a single worker thread regardless + * of underlying physical thread count. Calling one of the `unsafeRun` methods + * on an `IO` will submit it to the runtime for execution, but nothing will + * actually evaluate until one of the ''tick'' methods on this class are called. + * If the desired behavior is to simply run the `IO` fully to completion within + * the mock environment, respecting monotonic time, then [[tickAll]] is likely + * the desired method. + * + * Where things ''differ'' from the production runtime is in two critical areas. + * + * First, whenever multiple fibers are outstanding and ready to be resumed, the + * `TestControl` runtime will ''randomly'' choose between them, rather than taking + * them in a first-in, first-out fashion as the default production runtime will. + * This semantic is intended to simulate different scheduling interleavings, + * ensuring that race conditions are not accidentally masked by deterministic + * execution order. + * + * Second, within the context of the `TestControl`, ''time'' is very carefully + * and artificially controlled. In a sense, this runtime behaves as if it is + * executing on a single CPU which performs all operations infinitely fast. + * Any fibers which are immediately available for execution will be executed + * until no further fibers are available to run (assuming the use of `tickAll`). + * Through this entire process, the current clock (which is exposed to the + * program via [[IO.realTime]] and [[IO.monotonic]]) will remain fixed at the + * very beginning, meaning that no time is considered to have elapsed as a + * consequence of ''compute''. + * + * Note that the above means that it is relatively easy to create a deadlock + * on this runtime with a program which would not deadlock on either the JVM + * or JavaScript: + * + * {{{ + * // do not do this! + * IO.cede.foreverM.timeout(10.millis) + * }}} + * + * The above program spawns a fiber which yields forever, setting a timeout + * for 10 milliseconds which is ''intended'' to bring the loop to a halt. + * However, because the immediate task queue will never be empty, the test + * runtime will never advance time, meaning that the 10 milliseconds will + * never elapse and the timeout will not be hit. This will manifest as the + * [[tick]] and [[tickAll]] functions simply running forever and not returning + * if called. [[tickOne]] is safe to call on the above program, but it will + * always return `true`. + * + * In order to advance time, you must use the [[advance]] method to move the + * clock forward by a specified offset (which must be greater than 0). If you + * use the `tickAll` method, the clock will be automatically advanced by the + * minimum amount necessary to reach the next pending task. For example, if + * the program contains an [[IO.sleep]] for `500.millis`, and there are no + * shorter sleeps, then time will need to be advanced by 500 milliseconds in + * order to make that fiber eligible for execution. + * + * At this point, the process repeats until all tasks are exhausted. If the + * program has reached a concluding value or exception, then it will be + * produced from the `unsafeRun` method which scheduled the `IO` on the + * runtime (pro tip: do ''not'' use `unsafeRunSync` with this runtime, since + * it will always result in immediate deadlock). If the program does ''not'' + * produce a result but also has no further work to perform (such as a + * program like [[IO.never]]), then `tickAll` will return but no result will + * have been produced by the `unsafeRun`. If this happens, [[isDeadlocked]] + * will return `true` and the program is in a "hung" state. This same situation + * on the production runtime would have manifested as an asynchronous + * deadlock. + * + * You should ''never'' use this runtime in a production code path. It is + * strictly meant for testing purposes, particularly testing programs that + * involve time functions and [[IO.sleep]]. + * + * Due to the semantics of this runtime, time will behave entirely consistently + * with a plausible production execution environment provided that you ''never'' + * observe time via side-effects, and exclusively through the [[IO.realTime]], + * [[IO.monotonic]], and [[IO.sleep]] functions (and other functions built on + * top of these). From the perspective of these functions, all computation is + * infinitely fast, and the only effect which advances time is [[IO.sleep]] + * (or if something external, such as the test harness, calls the [[advance]] + * method). However, an effect such as `IO(System.currentTimeMillis())` will + * "see through" the illusion, since the system clock is unaffected by this + * runtime. This is one reason why it is important to always and exclusively + * rely on `realTime` and `monotonic`, either directly on `IO` or via the + * typeclass abstractions. + * + * WARNING: ''Never'' use this runtime on programs which use the [[IO#evalOn]] + * method! The test runtime will detect this situation as an asynchronous + * deadlock. + * + * @see [[cats.effect.unsafe.IORuntime]] + * @see [[cats.effect.kernel.Clock]] + * @see [[tickAll]] + */ +final class TestControl private (config: IORuntimeConfig) { + + private[this] val ctx = TestContext() + + /** + * An [[IORuntime]] which is controlled by the side-effecting + * methods on this class. + * + * @see [[tickAll]] + */ + val runtime: IORuntime = IORuntime( + ctx, + ctx, + new Scheduler { + def sleep(delay: FiniteDuration, task: Runnable): Runnable = { + val cancel = ctx.schedule(delay, task) + () => cancel() + } + + def nowMillis() = + ctx.now().toMillis + + def monotonicNanos() = + ctx.now().toNanos + }, + () => (), + config) + + /** + * Returns the minimum time which must elapse for a fiber to become + * eligible for execution. If fibers are currently eligible for + * execution, the result will be `Duration.Zero`. + */ + def nextInterval(): FiniteDuration = + ctx.nextInterval() + + /** + * Advances the runtime clock by the specified amount (which must + * be positive). Does not execute any fibers, though may result in + * some previously-sleeping fibers to become pending and eligible + * for execution in the next [[tick]]. + */ + def advance(time: FiniteDuration): Unit = + ctx.advance(time) + + /** + * A convenience method which advances time by the specified amount + * and then ticks once. Note that this method is very subtle and + * will often ''not'' do what you think it should. For example: + * + * {{{ + * // will never print! + * val program = IO.sleep(100.millis) *> IO.println("Hello, World!") + * + * val control = TestControl() + * program.unsafeRunAndForget()(control.runtime) + * + * control.advanceAndTick(1.second) + * }}} + * + * This is very subtle, but the problem is that time is advanced + * ''before'' the [[IO.sleep]] even has a chance to get scheduled! + * This means that when `sleep` is finally submitted to the runtime, + * it is scheduled for the time offset equal to `1.second + 100.millis`, + * since time was already advanced `1.second` before it had a chance + * to submit. Of course, time has only been advanced by `1.second`, + * thus the `sleep` never completes and the `println` cannot ever run. + * + * There are two possible solutions to this problem: either call [[tick]] + * ''first'' (before calling `advanceAndTick`) to ensure that the `sleep` + * has a chance to schedule itself, or simply use [[tickAll]] if you + * do not need to run assertions between time windows. + * + * @see [[advance]] + * @see [[tick]] + */ + def advanceAndTick(time: FiniteDuration): Unit = + ctx.advanceAndTick(time) + + /** + * Executes a single pending fiber and returns immediately. Does not + * advance time. Returns `false` if no fibers are pending. + */ + def tickOne(): Boolean = + ctx.tickOne() + + /** + * Executes all pending fibers in a random order, repeating on new tasks + * enqueued by those fibers until all pending fibers have been exhausted. + * Does not result in the advancement of time. + * + * @see [[advance]] + * @see [[tickAll]] + */ + def tick(): Unit = + ctx.tick() + + /** + * Drives the runtime until all fibers have been executed, then advances + * time until the next fiber becomes available (if relevant), and repeats + * until no further fibers are scheduled. Analogous to, though critically + * not the same as, running an [[IO]]] on a single-threaded production + * runtime. + * + * This function will terminate for `IO`s which deadlock ''asynchronously'', + * but any program which runs in a loop without fully suspending will cause + * this function to run indefinitely. Also note that any `IO` which interacts + * with some external asynchronous scheduler (such as NIO) will be considered + * deadlocked for the purposes of this runtime. + * + * @see [[tick]] + */ + def tickAll(): Unit = + ctx.tickAll() + + /** + * Returns `true` if the runtime has no remaining fibers, sleeping or otherwise, + * indicating an asynchronous deadlock has occurred. Or rather, ''either'' an + * asynchronous deadlock, or some interaction with an external asynchronous + * scheduler (such as another thread pool). + */ + def isDeadlocked(): Boolean = + ctx.state.tasks.isEmpty +} + +object TestControl { + def apply(config: IORuntimeConfig = IORuntimeConfig()): TestControl = + new TestControl(config) +} From 125ad7e09ea972ad1eec0973266622c8722005e2 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Sun, 29 Aug 2021 22:56:25 -0600 Subject: [PATCH 03/28] Added support for reading (and configuring) the source of randomness in `TestContext` --- .../effect/kernel/testkit/TestContext.scala | 46 ++++++++++++++++--- .../cats/effect/testkit/TestControl.scala | 24 ++++++++-- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala index 3eee1c9703..e4eb009339 100644 --- a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala +++ b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala @@ -18,12 +18,16 @@ package cats.effect.kernel package testkit import scala.annotation.tailrec + import scala.collection.immutable.SortedSet import scala.concurrent.ExecutionContext import scala.concurrent.duration._ + import scala.util.Random import scala.util.control.NonFatal +import java.util.Base64 + /** * A `scala.concurrent.ExecutionContext` implementation and a provider * of `cats.effect.Timer` instances, that can simulate async boundaries @@ -123,8 +127,10 @@ import scala.util.control.NonFatal * assert(f.value == Some(Success(2)) * }}} */ -final class TestContext private () extends ExecutionContext { self => - import TestContext.{State, Task} +final class TestContext private (_seed: Long) extends ExecutionContext { self => + import TestContext.{Encoder, State, Task} + + private[this] val random = new Random(_seed) private[this] var stateRef = State( lastID = 0, @@ -206,7 +212,7 @@ final class TestContext private () extends ExecutionContext { self => val current = stateRef // extracting one task by taking the immediate tasks - extractOneTask(current, current.clock) match { + extractOneTask(current, current.clock, random) match { case Some((head, rest)) => stateRef = current.copy(tasks = rest) // execute task @@ -274,15 +280,19 @@ final class TestContext private () extends ExecutionContext { self => def now(): FiniteDuration = stateRef.clock + def seed: String = + new String(Encoder.encode(_seed.toString.getBytes)) + private def extractOneTask( current: State, - clock: FiniteDuration): Option[(Task, SortedSet[Task])] = + clock: FiniteDuration, + random: Random): Option[(Task, SortedSet[Task])] = current.tasks.headOption.filter(_.runsAt <= clock) match { case Some(value) => val firstTick = value.runsAt val forExecution = { val arr = current.tasks.iterator.takeWhile(_.runsAt == firstTick).take(10).toArray - arr(Random.nextInt(arr.length)) + arr(random.nextInt(arr.length)) } val remaining = current.tasks - forExecution @@ -300,11 +310,33 @@ final class TestContext private () extends ExecutionContext { self => object TestContext { + private val Decoder = Base64.getDecoder() + private val Encoder = Base64.getEncoder() + /** - * Builder for [[TestContext]] instances. + * Builder for [[TestContext]] instances. Utilizes a random seed, + * which may be obtained from the [[TestContext#seed]] method. */ def apply(): TestContext = - new TestContext + new TestContext(Random.nextLong()) + + /** + * Constructs a new [[TestContext]] using the given seed, which + * must be encoded as base64. Assuming this seed was produced by + * another `TestContext`, running the same program against the + * new context will result in the exact same task interleaving + * as happened in the previous context, provided that the same + * tasks are interleaved. Note that subtle differences between + * different runs of identical programs are possible, + * particularly if one program auto-`cede`s in a different place + * than the other one. This is an excellent and reliable mechanism + * for small, tightly-controlled programs with entirely deterministic + * side-effects, and a completely useless mechanism for anything + * where the scheduler ticks see different task lists despite + * identical configuration. + */ + def apply(seed: String): TestContext = + new TestContext(new String(Decoder.decode(seed)).toLong) /** * Used internally by [[TestContext]], represents the internal diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala index cb4d216f3d..afc41ff640 100644 --- a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala @@ -116,9 +116,13 @@ import scala.concurrent.duration.FiniteDuration * @see [[cats.effect.kernel.Clock]] * @see [[tickAll]] */ -final class TestControl private (config: IORuntimeConfig) { +final class TestControl private (config: IORuntimeConfig, _seed: Option[String]) { - private[this] val ctx = TestContext() + private[this] val ctx = + _seed match { + case Some(seed) => TestContext(seed) + case None => TestContext() + } /** * An [[IORuntime]] which is controlled by the side-effecting @@ -142,7 +146,8 @@ final class TestControl private (config: IORuntimeConfig) { ctx.now().toNanos }, () => (), - config) + config + ) /** * Returns the minimum time which must elapse for a fiber to become @@ -239,9 +244,18 @@ final class TestControl private (config: IORuntimeConfig) { */ def isDeadlocked(): Boolean = ctx.state.tasks.isEmpty + + /** + * Produces the base64-encoded seed which governs the random task interleaving + * during each [[tick]]. This is useful for reproducing test failures which + * came about due to some unexpected (though clearly plausible) execution order. + */ + def seed: String = ctx.seed } object TestControl { - def apply(config: IORuntimeConfig = IORuntimeConfig()): TestControl = - new TestControl(config) + def apply( + config: IORuntimeConfig = IORuntimeConfig(), + seed: Option[String] = None): TestControl = + new TestControl(config, seed) } From c902d9e944ff2d9f6ca3a737dfa030aa4dce099f Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Sun, 29 Aug 2021 23:11:29 -0600 Subject: [PATCH 04/28] Removed unnecessary import --- .../shared/src/main/scala/cats/effect/testkit/TestControl.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala index afc41ff640..9437b57f96 100644 --- a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala @@ -17,7 +17,6 @@ package cats.effect package testkit -import cats.effect.kernel.testkit.TestContext import cats.effect.unsafe.{IORuntime, IORuntimeConfig, Scheduler} import scala.concurrent.duration.FiniteDuration From 579f4683fda4eb85cba0374f245ef096865a3d52 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Sun, 29 Aug 2021 23:12:56 -0600 Subject: [PATCH 05/28] Added mima exclusion for new `TestContext` constructor --- build.sbt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index 0585580bd3..d2bf070867 100644 --- a/build.sbt +++ b/build.sbt @@ -275,8 +275,10 @@ lazy val kernelTestkit = crossProject(JSPlatform, JVMPlatform) libraryDependencies ++= Seq( "org.typelevel" %%% "cats-free" % CatsVersion, "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion, - "org.typelevel" %%% "coop" % CoopVersion) - ) + "org.typelevel" %%% "coop" % CoopVersion), + + mimaBinaryIssueFilters ++= Seq( + ProblemFilters.exclude[DirectMissingMethodProblem]("cats.effect.kernel.testkit.TestContext.this"))) /** * The laws which constrain the abstractions. This is split from kernel to avoid From 0a62cbdef344341f221a34eeceabfe9c5af60bdc Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Mon, 30 Aug 2021 08:43:39 -0600 Subject: [PATCH 06/28] Fixed scaladoc reference --- .../src/main/scala/cats/effect/testkit/TestControl.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala index 9437b57f96..93a4eb6594 100644 --- a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala @@ -124,8 +124,8 @@ final class TestControl private (config: IORuntimeConfig, _seed: Option[String]) } /** - * An [[IORuntime]] which is controlled by the side-effecting - * methods on this class. + * An [[cats.effect.unsafe.IORuntime]] which is controlled by the + * side-effecting methods on this class. * * @see [[tickAll]] */ From fe9502fd04523096bc3432c1b1e9320851007f4c Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Mon, 30 Aug 2021 08:54:46 -0600 Subject: [PATCH 07/28] Adjusted bincompat shim to be closer to the same semantics --- .../src/main/scala/cats/effect/kernel/testkit/TestContext.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala index e4eb009339..538c557b13 100644 --- a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala +++ b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala @@ -232,8 +232,8 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => } private[testkit] def tick(time: FiniteDuration): Unit = { - advance(time) tick() + advanceAndTick(time) } private[testkit] def tick$default$1(): FiniteDuration = Duration.Zero From fc65cc22f8823f12b6df2e97d5b367ba18d40885 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Mon, 30 Aug 2021 08:57:32 -0600 Subject: [PATCH 08/28] Made everything blocking and stuff --- .../effect/kernel/testkit/TestContext.scala | 76 +++++++++++-------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala index 538c557b13..82f0315693 100644 --- a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala +++ b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala @@ -20,7 +20,7 @@ package testkit import scala.annotation.tailrec import scala.collection.immutable.SortedSet -import scala.concurrent.ExecutionContext +import scala.concurrent.{blocking, ExecutionContext} import scala.concurrent.duration._ import scala.util.Random @@ -145,16 +145,20 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => * for execution. */ def execute(r: Runnable): Unit = - synchronized { - stateRef = stateRef.execute(r) + blocking { + synchronized { + stateRef = stateRef.execute(r) + } } /** * Inherited from `ExecutionContext`, reports uncaught errors. */ def reportFailure(cause: Throwable): Unit = - synchronized { - stateRef = stateRef.copy(lastReportedFailure = Some(cause)) + blocking { + synchronized { + stateRef = stateRef.copy(lastReportedFailure = Some(cause)) + } } /** @@ -162,7 +166,7 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => * that certain execution conditions have been met. */ def state: State = - synchronized(stateRef) + blocking(synchronized(stateRef)) /** * Returns the current interval between "now" and the earliest scheduled @@ -175,11 +179,15 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => (s.tasks.min.runsAt - s.clock).max(Duration.Zero) } - def advance(time: FiniteDuration): Unit = - synchronized { - require(time > Duration.Zero) - stateRef = stateRef.copy(clock = stateRef.clock + time) + def advance(time: FiniteDuration): Unit = { + require(time > Duration.Zero) + + blocking { + synchronized { + stateRef = stateRef.copy(clock = stateRef.clock + time) + } } + } def advanceAndTick(time: FiniteDuration): Unit = { advance(time) @@ -208,20 +216,22 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => * was executed, or `false` otherwise */ def tickOne(): Boolean = - synchronized { - val current = stateRef - - // extracting one task by taking the immediate tasks - extractOneTask(current, current.clock, random) match { - case Some((head, rest)) => - stateRef = current.copy(tasks = rest) - // execute task - try head.task.run() - catch { case NonFatal(ex) => reportFailure(ex) } - true - - case None => - false + blocking { + synchronized { + val current = stateRef + + // extracting one task by taking the immediate tasks + extractOneTask(current, current.clock, random) match { + case Some((head, rest)) => + stateRef = current.copy(tasks = rest) + // execute task + try head.task.run() + catch { case NonFatal(ex) => reportFailure(ex) } + true + + case None => + false + } } } @@ -265,11 +275,13 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => private[testkit] def tickAll$default$1(): FiniteDuration = Duration.Zero def schedule(delay: FiniteDuration, r: Runnable): () => Unit = - synchronized { - val current: State = stateRef - val (cancelable, newState) = current.scheduleOnce(delay, r, cancelTask) - stateRef = newState - cancelable + blocking { + synchronized { + val current: State = stateRef + val (cancelable, newState) = current.scheduleOnce(delay, r, cancelTask) + stateRef = newState + cancelable + } } def derive(): ExecutionContext = @@ -303,8 +315,10 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => } private def cancelTask(t: Task): Unit = - synchronized { - stateRef = stateRef.copy(tasks = stateRef.tasks - t) + blocking { + synchronized { + stateRef = stateRef.copy(tasks = stateRef.tasks - t) + } } } From 9168ec35585f485e67f054b411f2a9c8bac4ccb3 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Mon, 30 Aug 2021 13:04:08 -0600 Subject: [PATCH 09/28] Removed negative epoch since a) it was confusing, and b) we no longer spin as aggressively --- .../main/scala/cats/effect/kernel/testkit/TestContext.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala index 82f0315693..8f18e06a20 100644 --- a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala +++ b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala @@ -134,8 +134,7 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => private[this] var stateRef = State( lastID = 0, - // our epoch is negative! this is just to give us an extra 263 years of space for Prop shrinking to play - clock = (Long.MinValue + 1).nanos, + clock = Duration.Zero, tasks = SortedSet.empty[Task], lastReportedFailure = None ) From 851ce1256c921df19e9c3395fa398d6e0326199f Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Wed, 8 Sep 2021 10:47:21 -0600 Subject: [PATCH 10/28] Get a new ticker for each test iteration to avoid flakiness --- .../jvm/src/test/scala/cats/effect/ParasiticECSpec.scala | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/jvm/src/test/scala/cats/effect/ParasiticECSpec.scala b/tests/jvm/src/test/scala/cats/effect/ParasiticECSpec.scala index dfc4a39651..9bd605e46d 100644 --- a/tests/jvm/src/test/scala/cats/effect/ParasiticECSpec.scala +++ b/tests/jvm/src/test/scala/cats/effect/ParasiticECSpec.scala @@ -28,10 +28,12 @@ class ParasiticECSpec extends BaseSpec with TestInstances { "IO monad" should { "evaluate fibers correctly in presence of a parasitic execution context" in real { - implicit val ticker = Ticker() + val test = { + implicit val ticker = Ticker() - val test = IO(implicitly[Arbitrary[IO[Int]]].arbitrary.sample.get).flatMap { io => - IO.delay(io.eqv(io)) + IO(implicitly[Arbitrary[IO[Int]]].arbitrary.sample.get).flatMap { io => + IO.delay(io.eqv(io)) + } } val iterations = 15000 From 360dff3733a50f9c29a735203c583c9afca52fb5 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Wed, 8 Sep 2021 16:34:05 -0600 Subject: [PATCH 11/28] Removed `blocking` from `TestContext` --- .../effect/kernel/testkit/TestContext.scala | 68 ++++++++----------- 1 file changed, 28 insertions(+), 40 deletions(-) diff --git a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala index 8f18e06a20..acee3de05f 100644 --- a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala +++ b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala @@ -20,7 +20,7 @@ package testkit import scala.annotation.tailrec import scala.collection.immutable.SortedSet -import scala.concurrent.{blocking, ExecutionContext} +import scala.concurrent.ExecutionContext import scala.concurrent.duration._ import scala.util.Random @@ -144,20 +144,16 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => * for execution. */ def execute(r: Runnable): Unit = - blocking { - synchronized { - stateRef = stateRef.execute(r) - } + synchronized { + stateRef = stateRef.execute(r) } /** * Inherited from `ExecutionContext`, reports uncaught errors. */ def reportFailure(cause: Throwable): Unit = - blocking { - synchronized { - stateRef = stateRef.copy(lastReportedFailure = Some(cause)) - } + synchronized { + stateRef = stateRef.copy(lastReportedFailure = Some(cause)) } /** @@ -181,10 +177,8 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => def advance(time: FiniteDuration): Unit = { require(time > Duration.Zero) - blocking { - synchronized { - stateRef = stateRef.copy(clock = stateRef.clock + time) - } + synchronized { + stateRef = stateRef.copy(clock = stateRef.clock + time) } } @@ -215,22 +209,20 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => * was executed, or `false` otherwise */ def tickOne(): Boolean = - blocking { - synchronized { - val current = stateRef - - // extracting one task by taking the immediate tasks - extractOneTask(current, current.clock, random) match { - case Some((head, rest)) => - stateRef = current.copy(tasks = rest) - // execute task - try head.task.run() - catch { case NonFatal(ex) => reportFailure(ex) } - true - - case None => - false - } + synchronized { + val current = stateRef + + // extracting one task by taking the immediate tasks + extractOneTask(current, current.clock, random) match { + case Some((head, rest)) => + stateRef = current.copy(tasks = rest) + // execute task + try head.task.run() + catch { case NonFatal(ex) => reportFailure(ex) } + true + + case None => + false } } @@ -274,13 +266,11 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => private[testkit] def tickAll$default$1(): FiniteDuration = Duration.Zero def schedule(delay: FiniteDuration, r: Runnable): () => Unit = - blocking { - synchronized { - val current: State = stateRef - val (cancelable, newState) = current.scheduleOnce(delay, r, cancelTask) - stateRef = newState - cancelable - } + synchronized { + val current: State = stateRef + val (cancelable, newState) = current.scheduleOnce(delay, r, cancelTask) + stateRef = newState + cancelable } def derive(): ExecutionContext = @@ -314,10 +304,8 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => } private def cancelTask(t: Task): Unit = - blocking { - synchronized { - stateRef = stateRef.copy(tasks = stateRef.tasks - t) - } + synchronized { + stateRef = stateRef.copy(tasks = stateRef.tasks - t) } } From e4945f431d20a072fb1a258f4713e12c4969643d Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Thu, 9 Sep 2021 07:49:07 -0600 Subject: [PATCH 12/28] Fixed silly oversight --- .../src/main/scala/cats/effect/kernel/testkit/TestContext.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala index 469a7b3d36..c5b2ee51d7 100644 --- a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala +++ b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala @@ -151,7 +151,7 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => * conditions have been met. */ def state: State = - blocking(synchronized(stateRef)) + synchronized(stateRef) /** * Returns the current interval between "now" and the earliest scheduled task. If there are From a5d05b24ee06e3434212954bbe873231f31d7693 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Thu, 9 Sep 2021 08:28:57 -0600 Subject: [PATCH 13/28] Added `TestControl.execute` and `executeFully` --- .../cats/effect/testkit/TestControl.scala | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala index a9f822d7f3..5e811860aa 100644 --- a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala @@ -239,8 +239,54 @@ final class TestControl private (config: IORuntimeConfig, _seed: Option[String]) } object TestControl { + def apply( config: IORuntimeConfig = IORuntimeConfig(), seed: Option[String] = None): TestControl = new TestControl(config, seed) + + /** + * Executes a given [[IO]] under fully mocked runtime control. This is a convenience method + * wrapping the process of creating a new `TestControl` runtime, then using it to evaluate the + * given `IO`, with the `body` in control of the actual ticking process. This method is very + * useful when writing assertions about program state ''between'' clock ticks, and even more + * so when time must be explicitly advanced by set increments. If your assertions are entirely + * intrinsic (within the program) and the test is such that time should advance in an + * automatic fashion, the [[executeFully]] method may be a more convenient option. + * + * The `TestControl` parameter of the `body` provides control over the mock runtime which is + * executing the program. The second parameter produces the ''results'' of the program, if the + * program has completed. If the program has not yet completed, this function will return + * `None`. + */ + def execute[A, B]( + program: IO[A], + config: IORuntimeConfig = IORuntimeConfig(), + seed: Option[String] = None)( + body: (TestControl, () => Option[Either[Throwable, A]]) => B): B = { + + val control = TestControl(config = config, seed = seed) + val f = program.unsafeToFuture()(control.runtime) + body(control, () => f.value.map(_.toEither)) + } + + /** + * Executes an [[IO]] under fully mocked runtime control, returning the final results. This is + * very similar to calling `unsafeRunSync` on the program, except that the scheduler will use + * a mocked and quantized notion of time, all while executing on a singleton worker thread. + * This can cause some programs to deadlock which would otherwise complete normally, but it + * also allows programs which involve [[IO.sleep]] s of any length to complete almost + * instantly with correct semantics. + * + * @return + * `None` if `program` does not complete, otherwise `Some` of the results. + */ + def executeFully[A]( + program: IO[A], + config: IORuntimeConfig = IORuntimeConfig(), + seed: Option[String] = None): Option[Either[Throwable, A]] = + execute(program, config = config, seed = seed) { (control, result) => + control.tickAll() + result() + } } From aba30dfafcc211988d549604896a2fa6c7fe51ec Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Thu, 9 Sep 2021 09:56:07 -0600 Subject: [PATCH 14/28] Added tests and infix syntax for `TestControl` --- build.sbt | 5 +- .../src/main/scala/cats/effect/IO.scala | 13 +++ .../effect/testkit/syntax/AllSyntax.scala | 19 ++++ .../testkit/syntax/TestControlSyntax.scala | 38 +++++++ .../cats/effect/testkit/syntax/package.scala | 22 +++++ .../cats/effect/testkit/SyntaxSpec.scala | 45 +++++++++ .../cats/effect/testkit/TestControlSpec.scala | 98 +++++++++++++++++++ 7 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 testkit/shared/src/main/scala/cats/effect/testkit/syntax/AllSyntax.scala create mode 100644 testkit/shared/src/main/scala/cats/effect/testkit/syntax/TestControlSyntax.scala create mode 100644 testkit/shared/src/main/scala/cats/effect/testkit/syntax/package.scala create mode 100644 testkit/shared/src/test/scala/cats/effect/testkit/SyntaxSpec.scala create mode 100644 testkit/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala diff --git a/build.sbt b/build.sbt index ff387acc29..186de5395b 100644 --- a/build.sbt +++ b/build.sbt @@ -355,7 +355,10 @@ lazy val testkit = crossProject(JSPlatform, JVMPlatform) .dependsOn(core, kernelTestkit) .settings( name := "cats-effect-testkit", - libraryDependencies ++= Seq("org.scalacheck" %%% "scalacheck" % ScalaCheckVersion)) + libraryDependencies ++= Seq( + "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion, + ("org.specs2" %%% "specs2-core" % Specs2Version % Test) + .cross(CrossVersion.for3Use2_13))) /** * Unit tests for the core project, utilizing the support provided by testkit. diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index e2eece2d23..7fb81f2722 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -545,6 +545,19 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { def redeemWith[B](recover: Throwable => IO[B], bind: A => IO[B]): IO[B] = attempt.flatMap(_.fold(recover, bind)) + def replicateA(n: Int): IO[List[A]] = + if (n <= 0) + IO.pure(Nil) + else + flatMap(a => replicateA(n - 1).map(a :: _)) + + // TODO PR to cats + def replicateA_(n: Int): IO[Unit] = + if (n <= 0) + IO.unit + else + flatMap(_ => replicateA_(n - 1)) + /** * Returns an IO that will delay the execution of the source by the given duration. */ diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/syntax/AllSyntax.scala b/testkit/shared/src/main/scala/cats/effect/testkit/syntax/AllSyntax.scala new file mode 100644 index 0000000000..342ee0cc7b --- /dev/null +++ b/testkit/shared/src/main/scala/cats/effect/testkit/syntax/AllSyntax.scala @@ -0,0 +1,19 @@ +/* + * Copyright 2020-2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cats.effect.testkit.syntax + +trait AllSyntax extends TestControlSyntax diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/syntax/TestControlSyntax.scala b/testkit/shared/src/main/scala/cats/effect/testkit/syntax/TestControlSyntax.scala new file mode 100644 index 0000000000..d98f56b44c --- /dev/null +++ b/testkit/shared/src/main/scala/cats/effect/testkit/syntax/TestControlSyntax.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2020-2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cats.effect +package testkit +package syntax + +import cats.effect.unsafe.IORuntimeConfig + +trait TestControlSyntax { + implicit def testControlOps[A](wrapped: IO[A]): TestControlOps[A] = + new TestControlOps(wrapped) +} + +final class TestControlOps[A] private[syntax] (private val wrapped: IO[A]) extends AnyVal { + + def execute[B](config: IORuntimeConfig = IORuntimeConfig(), seed: Option[String] = None)( + body: (TestControl, () => Option[Either[Throwable, A]]) => B): B = + TestControl.execute(wrapped, config = config, seed = seed)(body) + + def executeFully( + config: IORuntimeConfig = IORuntimeConfig(), + seed: Option[String] = None): Option[Either[Throwable, A]] = + TestControl.executeFully(wrapped, config = config, seed = seed) +} diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/syntax/package.scala b/testkit/shared/src/main/scala/cats/effect/testkit/syntax/package.scala new file mode 100644 index 0000000000..370c597b8a --- /dev/null +++ b/testkit/shared/src/main/scala/cats/effect/testkit/syntax/package.scala @@ -0,0 +1,22 @@ +/* + * Copyright 2020-2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cats.effect.testkit + +package object syntax { + object all extends AllSyntax + object testControl extends TestControlSyntax +} diff --git a/testkit/shared/src/test/scala/cats/effect/testkit/SyntaxSpec.scala b/testkit/shared/src/test/scala/cats/effect/testkit/SyntaxSpec.scala new file mode 100644 index 0000000000..5193f252fd --- /dev/null +++ b/testkit/shared/src/test/scala/cats/effect/testkit/SyntaxSpec.scala @@ -0,0 +1,45 @@ +/* + * Copyright 2020-2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cats.effect +package testkit + +import org.specs2.mutable.Specification + +class SyntaxSpec extends Specification { + + "testkit syntax" >> ok + + def testControlSyntax = { + import syntax.testControl._ + + val program: IO[Int] = IO.pure(42) + + program.execute() { (control, results) => + val c: TestControl = control + val r: () => Option[Either[Throwable, Int]] = results + + val _ = { + val _ = c + r + } + + () + } + + program.executeFully(): Option[Either[Throwable, Int]] + } +} diff --git a/testkit/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala b/testkit/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala new file mode 100644 index 0000000000..01846301ce --- /dev/null +++ b/testkit/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala @@ -0,0 +1,98 @@ +/* + * Copyright 2020-2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cats.effect +package testkit + +import org.specs2.mutable.Specification + +import scala.concurrent.duration._ + +class TestControlSpec extends Specification { + + val simple = IO.unit + + val ceded = IO.cede.replicateA(10) *> IO.unit + + val longSleeps = for { + first <- IO.monotonic + _ <- IO.sleep(1.hour) + second <- IO.monotonic + _ <- IO.race(IO.sleep(1.day), IO.sleep(1.day + 1.nanosecond)) + third <- IO.monotonic + } yield (first.toCoarsest, second.toCoarsest, third.toCoarsest) + + val deadlock = IO.never + + "execute" should { + "run a simple IO" in { + TestControl.execute(simple) { (control, results) => + results() must beNone + control.tick() + results() must beSome(beRight(())) + } + } + + "run a ceded IO in a single tick" in { + TestControl.execute(simple) { (control, results) => + results() must beNone + control.tick() + results() must beSome(beRight(())) + } + } + + "run an IO with long sleeps" in { + TestControl.execute(longSleeps) { (control, results) => + results() must beNone + + control.tick() + results() must beNone + control.nextInterval() mustEqual 1.hour + + control.advanceAndTick(1.hour) + results() must beNone + control.nextInterval() mustEqual 1.day + + control.advanceAndTick(1.day) + results() must beSome(beRight((0.nanoseconds, 1.hour, 25.hours))) + } + } + + "detect a deadlock" in { + TestControl.execute(deadlock) { (control, results) => + results() must beNone + control.tick() + control.isDeadlocked() must beTrue + results() must beNone + } + } + } + + "executeFully" should { + "run a simple IO" in { + TestControl.executeFully(simple) must beSome(beRight(())) + } + + "run an IO with long sleeps" in { + TestControl.executeFully(longSleeps) must beSome( + beRight((0.nanoseconds, 1.hour, 25.hours))) + } + + "detect a deadlock" in { + TestControl.executeFully(deadlock) must beNone + } + } +} From 48193beb407b29a0f904407f220ec694582d98a2 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Thu, 9 Sep 2021 10:36:07 -0600 Subject: [PATCH 15/28] Added macrotask-executor exclusions via specs2 --- build.sbt | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/build.sbt b/build.sbt index 186de5395b..0624fe5948 100644 --- a/build.sbt +++ b/build.sbt @@ -219,16 +219,22 @@ lazy val kernel = crossProject(JSPlatform, JVMPlatform) .in(file("kernel")) .settings( name := "cats-effect-kernel", - libraryDependencies ++= Seq( - ("org.specs2" %%% "specs2-core" % Specs2Version % Test).cross(CrossVersion.for3Use2_13), - "org.typelevel" %%% "cats-core" % CatsVersion) - ) - .jsSettings(Compile / doc / sources := { - if (isDotty.value) - Seq() - else - (Compile / doc / sources).value - }) + libraryDependencies += "org.typelevel" %%% "cats-core" % CatsVersion, + libraryDependencies += { + if (isDotty.value) + ("org.specs2" %%% "specs2-core" % Specs2Version % Test) + .cross(CrossVersion.for3Use2_13) + .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1") + else + "org.specs2" %%% "specs2-core" % Specs2Version % Test + }) + .jsSettings( + Compile / doc / sources := { + if (isDotty.value) + Seq() + else + (Compile / doc / sources).value + }) /** * Reference implementations (including a pure ConcurrentBracket), generic ScalaCheck @@ -355,10 +361,15 @@ lazy val testkit = crossProject(JSPlatform, JVMPlatform) .dependsOn(core, kernelTestkit) .settings( name := "cats-effect-testkit", - libraryDependencies ++= Seq( - "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion, - ("org.specs2" %%% "specs2-core" % Specs2Version % Test) - .cross(CrossVersion.for3Use2_13))) + libraryDependencies += "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion, + libraryDependencies += { + if (isDotty.value) + ("org.specs2" %%% "specs2-core" % Specs2Version % Test) + .cross(CrossVersion.for3Use2_13) + .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1") + else + "org.specs2" %%% "specs2-core" % Specs2Version % Test + }) /** * Unit tests for the core project, utilizing the support provided by testkit. @@ -392,6 +403,7 @@ lazy val std = crossProject(JSPlatform, JVMPlatform) if (isDotty.value) ("org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test) .cross(CrossVersion.for3Use2_13) + .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1") .exclude("org.scalacheck", "scalacheck_2.13") .exclude("org.scalacheck", "scalacheck_sjs1_2.13") else From 7e86798aadba66c5582c20d85aa05a4ebe0e5e3c Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Thu, 9 Sep 2021 10:40:35 -0600 Subject: [PATCH 16/28] Fixed excludes hopefully --- build.sbt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.sbt b/build.sbt index 0624fe5948..dc438aaa5a 100644 --- a/build.sbt +++ b/build.sbt @@ -224,7 +224,7 @@ lazy val kernel = crossProject(JSPlatform, JVMPlatform) if (isDotty.value) ("org.specs2" %%% "specs2-core" % Specs2Version % Test) .cross(CrossVersion.for3Use2_13) - .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1") + .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1_2.13") else "org.specs2" %%% "specs2-core" % Specs2Version % Test }) @@ -366,7 +366,7 @@ lazy val testkit = crossProject(JSPlatform, JVMPlatform) if (isDotty.value) ("org.specs2" %%% "specs2-core" % Specs2Version % Test) .cross(CrossVersion.for3Use2_13) - .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1") + .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1_2.13") else "org.specs2" %%% "specs2-core" % Specs2Version % Test }) @@ -403,7 +403,7 @@ lazy val std = crossProject(JSPlatform, JVMPlatform) if (isDotty.value) ("org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test) .cross(CrossVersion.for3Use2_13) - .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1") + .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1_2.13") .exclude("org.scalacheck", "scalacheck_2.13") .exclude("org.scalacheck", "scalacheck_sjs1_2.13") else From baf65a40ee8115faa6c7177c41be1f1b66c4481e Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Thu, 9 Sep 2021 11:06:59 -0600 Subject: [PATCH 17/28] Removed unneeded syntax import --- example/js/src/main/scala/cats/effect/example/Example.scala | 2 -- 1 file changed, 2 deletions(-) diff --git a/example/js/src/main/scala/cats/effect/example/Example.scala b/example/js/src/main/scala/cats/effect/example/Example.scala index ecb408b096..55e84637e4 100644 --- a/example/js/src/main/scala/cats/effect/example/Example.scala +++ b/example/js/src/main/scala/cats/effect/example/Example.scala @@ -17,8 +17,6 @@ package cats.effect package example -import cats.syntax.all._ - object Example extends IOApp { def run(args: List[String]): IO[ExitCode] = From 7eed1d3bfba78b960df2d7a88874f2d38a49735e Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Thu, 9 Sep 2021 11:15:42 -0600 Subject: [PATCH 18/28] =?UTF-8?q?Sigh=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.sbt | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/build.sbt b/build.sbt index dc438aaa5a..42014ded56 100644 --- a/build.sbt +++ b/build.sbt @@ -219,16 +219,24 @@ lazy val kernel = crossProject(JSPlatform, JVMPlatform) .in(file("kernel")) .settings( name := "cats-effect-kernel", - libraryDependencies += "org.typelevel" %%% "cats-core" % CatsVersion, + libraryDependencies += "org.typelevel" %%% "cats-core" % CatsVersion) + .jvmSettings( libraryDependencies += { if (isDotty.value) ("org.specs2" %%% "specs2-core" % Specs2Version % Test) .cross(CrossVersion.for3Use2_13) - .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1_2.13") else "org.specs2" %%% "specs2-core" % Specs2Version % Test }) .jsSettings( + libraryDependencies += { + if (isDotty.value) + ("org.specs2" %%% "specs2-core" % Specs2Version % Test) + .cross(CrossVersion.for3Use2_13) + .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1_2.13") + else + "org.specs2" %%% "specs2-core" % Specs2Version % Test + }, Compile / doc / sources := { if (isDotty.value) Seq() @@ -361,7 +369,16 @@ lazy val testkit = crossProject(JSPlatform, JVMPlatform) .dependsOn(core, kernelTestkit) .settings( name := "cats-effect-testkit", - libraryDependencies += "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion, + libraryDependencies += "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion) + .jvmSettings( + libraryDependencies += { + if (isDotty.value) + ("org.specs2" %%% "specs2-core" % Specs2Version % Test) + .cross(CrossVersion.for3Use2_13) + else + "org.specs2" %%% "specs2-core" % Specs2Version % Test + }) + .jsSettings( libraryDependencies += { if (isDotty.value) ("org.specs2" %%% "specs2-core" % Specs2Version % Test) @@ -399,6 +416,18 @@ lazy val std = crossProject(JSPlatform, JVMPlatform) .dependsOn(kernel) .settings( name := "cats-effect-std", + libraryDependencies += "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion % Test) + .jvmSettings( + libraryDependencies += { + if (isDotty.value) + ("org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test) + .cross(CrossVersion.for3Use2_13) + .exclude("org.scalacheck", "scalacheck_2.13") + .exclude("org.scalacheck", "scalacheck_sjs1_2.13") + else + "org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test + }) + .jsSettings( libraryDependencies += { if (isDotty.value) ("org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test) @@ -408,9 +437,7 @@ lazy val std = crossProject(JSPlatform, JVMPlatform) .exclude("org.scalacheck", "scalacheck_sjs1_2.13") else "org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test - }, - libraryDependencies += "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion % Test - ) + }) /** * A trivial pair of trivial example apps primarily used to show that IOApp works as a practical From ae5339660dcb7029ccab6667d2e5250b325ca959 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Thu, 9 Sep 2021 11:20:25 -0600 Subject: [PATCH 19/28] Added `replicateA` override --- core/shared/src/main/scala/cats/effect/IO.scala | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index 7fb81f2722..34160f631d 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -1463,6 +1463,9 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits { override def productR[A, B](left: IO[A])(right: IO[B]): IO[B] = left.productR(right) + override def replicateA[A](n: Int, fa: IO[A]): IO[List[A]] = + fa.replicateA(n) + def start[A](fa: IO[A]): IO[FiberIO[A]] = fa.start From eec868028fb37782d2c3e14858482655865c2bcf Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Thu, 9 Sep 2021 11:28:16 -0600 Subject: [PATCH 20/28] Face, meet keyboard --- build.sbt | 99 +++++++++++++++++++++++++++---------------------------- 1 file changed, 48 insertions(+), 51 deletions(-) diff --git a/build.sbt b/build.sbt index 42014ded56..bdb23dee81 100644 --- a/build.sbt +++ b/build.sbt @@ -161,6 +161,8 @@ val ScalaCheckVersion = "1.15.4" val DisciplineVersion = "1.1.6" val CoopVersion = "1.1.1" +val MacrotaskExecutorVersion = "0.1.0" + replaceCommandAlias("ci", CI.AllCIs.map(_.toString).mkString) addCommandAlias(CI.JVM.command, CI.JVM.toString) @@ -220,14 +222,12 @@ lazy val kernel = crossProject(JSPlatform, JVMPlatform) .settings( name := "cats-effect-kernel", libraryDependencies += "org.typelevel" %%% "cats-core" % CatsVersion) - .jvmSettings( - libraryDependencies += { - if (isDotty.value) - ("org.specs2" %%% "specs2-core" % Specs2Version % Test) - .cross(CrossVersion.for3Use2_13) - else - "org.specs2" %%% "specs2-core" % Specs2Version % Test - }) + .jvmSettings(libraryDependencies += { + if (isDotty.value) + ("org.specs2" %%% "specs2-core" % Specs2Version % Test).cross(CrossVersion.for3Use2_13) + else + "org.specs2" %%% "specs2-core" % Specs2Version % Test + }) .jsSettings( libraryDependencies += { if (isDotty.value) @@ -237,12 +237,14 @@ lazy val kernel = crossProject(JSPlatform, JVMPlatform) else "org.specs2" %%% "specs2-core" % Specs2Version % Test }, + libraryDependencies += "org.scala-js" %%% "scala-js-macrotask-executor" % MacrotaskExecutorVersion % Test, Compile / doc / sources := { if (isDotty.value) Seq() else (Compile / doc / sources).value - }) + } + ) /** * Reference implementations (including a pure ConcurrentBracket), generic ScalaCheck @@ -257,9 +259,10 @@ lazy val kernelTestkit = crossProject(JSPlatform, JVMPlatform) "org.typelevel" %%% "cats-free" % CatsVersion, "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion, "org.typelevel" %%% "coop" % CoopVersion), - mimaBinaryIssueFilters ++= Seq( - ProblemFilters.exclude[DirectMissingMethodProblem]("cats.effect.kernel.testkit.TestContext.this"))) + ProblemFilters.exclude[DirectMissingMethodProblem]( + "cats.effect.kernel.testkit.TestContext.this")) + ) /** * The laws which constrain the abstractions. This is split from kernel to avoid jar file and @@ -357,8 +360,7 @@ lazy val core = crossProject(JSPlatform, JVMPlatform) javacOptions ++= Seq("-source", "1.8", "-target", "1.8") ) .jsSettings( - libraryDependencies += "org.scala-js" %%% "scala-js-macrotask-executor" % "0.1.0" - ) + libraryDependencies += "org.scala-js" %%% "scala-js-macrotask-executor" % MacrotaskExecutorVersion) /** * Test support for the core project, providing various helpful instances like ScalaCheck @@ -370,23 +372,20 @@ lazy val testkit = crossProject(JSPlatform, JVMPlatform) .settings( name := "cats-effect-testkit", libraryDependencies += "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion) - .jvmSettings( - libraryDependencies += { - if (isDotty.value) - ("org.specs2" %%% "specs2-core" % Specs2Version % Test) - .cross(CrossVersion.for3Use2_13) - else - "org.specs2" %%% "specs2-core" % Specs2Version % Test - }) - .jsSettings( - libraryDependencies += { - if (isDotty.value) - ("org.specs2" %%% "specs2-core" % Specs2Version % Test) - .cross(CrossVersion.for3Use2_13) - .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1_2.13") - else - "org.specs2" %%% "specs2-core" % Specs2Version % Test - }) + .jvmSettings(libraryDependencies += { + if (isDotty.value) + ("org.specs2" %%% "specs2-core" % Specs2Version % Test).cross(CrossVersion.for3Use2_13) + else + "org.specs2" %%% "specs2-core" % Specs2Version % Test + }) + .jsSettings(libraryDependencies += { + if (isDotty.value) + ("org.specs2" %%% "specs2-core" % Specs2Version % Test) + .cross(CrossVersion.for3Use2_13) + .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1_2.13") + else + "org.specs2" %%% "specs2-core" % Specs2Version % Test + }) /** * Unit tests for the core project, utilizing the support provided by testkit. @@ -417,27 +416,25 @@ lazy val std = crossProject(JSPlatform, JVMPlatform) .settings( name := "cats-effect-std", libraryDependencies += "org.scalacheck" %%% "scalacheck" % ScalaCheckVersion % Test) - .jvmSettings( - libraryDependencies += { - if (isDotty.value) - ("org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test) - .cross(CrossVersion.for3Use2_13) - .exclude("org.scalacheck", "scalacheck_2.13") - .exclude("org.scalacheck", "scalacheck_sjs1_2.13") - else - "org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test - }) - .jsSettings( - libraryDependencies += { - if (isDotty.value) - ("org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test) - .cross(CrossVersion.for3Use2_13) - .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1_2.13") - .exclude("org.scalacheck", "scalacheck_2.13") - .exclude("org.scalacheck", "scalacheck_sjs1_2.13") - else - "org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test - }) + .jvmSettings(libraryDependencies += { + if (isDotty.value) + ("org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test) + .cross(CrossVersion.for3Use2_13) + .exclude("org.scalacheck", "scalacheck_2.13") + .exclude("org.scalacheck", "scalacheck_sjs1_2.13") + else + "org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test + }) + .jsSettings(libraryDependencies += { + if (isDotty.value) + ("org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test) + .cross(CrossVersion.for3Use2_13) + .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1_2.13") + .exclude("org.scalacheck", "scalacheck_2.13") + .exclude("org.scalacheck", "scalacheck_sjs1_2.13") + else + "org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test + }) /** * A trivial pair of trivial example apps primarily used to show that IOApp works as a practical From c458766397d9cc9b636e05583b926d54ea86bdf4 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Thu, 9 Sep 2021 11:54:14 -0600 Subject: [PATCH 21/28] More... dependencies... --- build.sbt | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/build.sbt b/build.sbt index bdb23dee81..6a7fdc5780 100644 --- a/build.sbt +++ b/build.sbt @@ -425,16 +425,18 @@ lazy val std = crossProject(JSPlatform, JVMPlatform) else "org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test }) - .jsSettings(libraryDependencies += { - if (isDotty.value) - ("org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test) - .cross(CrossVersion.for3Use2_13) - .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1_2.13") - .exclude("org.scalacheck", "scalacheck_2.13") - .exclude("org.scalacheck", "scalacheck_sjs1_2.13") - else - "org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test - }) + .jsSettings( + libraryDependencies += { + if (isDotty.value) + ("org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test) + .cross(CrossVersion.for3Use2_13) + .exclude("org.scala-js", "scala-js-macrotask-executor_sjs1_2.13") + .exclude("org.scalacheck", "scalacheck_2.13") + .exclude("org.scalacheck", "scalacheck_sjs1_2.13") + else + "org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test + }, + libraryDependencies += "org.scala-js" %%% "scala-js-macrotask-executor" % MacrotaskExecutorVersion % Test) /** * A trivial pair of trivial example apps primarily used to show that IOApp works as a practical From 81e0685232baa7c8516bbc957a77dcfe528b85f0 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Fri, 10 Sep 2021 09:50:29 -0600 Subject: [PATCH 22/28] Refactored `TestControl` API to be pure --- .../cats/effect/testkit/TestControl.scala | 286 +++++++++++------- .../effect/testkit/syntax/AllSyntax.scala | 19 -- .../testkit/syntax/TestControlSyntax.scala | 38 --- .../cats/effect/testkit/syntax/package.scala | 22 -- .../cats/effect/testkit/SyntaxSpec.scala | 45 --- .../cats/effect/testkit/TestControlSpec.scala | 98 ------ .../cats/effect/testkit/TestControlSpec.scala | 131 ++++++++ 7 files changed, 304 insertions(+), 335 deletions(-) delete mode 100644 testkit/shared/src/main/scala/cats/effect/testkit/syntax/AllSyntax.scala delete mode 100644 testkit/shared/src/main/scala/cats/effect/testkit/syntax/TestControlSyntax.scala delete mode 100644 testkit/shared/src/main/scala/cats/effect/testkit/syntax/package.scala delete mode 100644 testkit/shared/src/test/scala/cats/effect/testkit/SyntaxSpec.scala delete mode 100644 testkit/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala create mode 100644 tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala index 5e811860aa..ea407ad00c 100644 --- a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala @@ -19,19 +19,35 @@ package testkit import cats.effect.unsafe.{IORuntime, IORuntimeConfig, Scheduler} +import scala.concurrent.Future import scala.concurrent.duration.FiniteDuration /** - * Implements a fully functional single-threaded runtime for [[cats.effect.IO]]. When using the - * [[runtime]] provided by this type, `IO` programs will be executed on a single JVM thread, - * ''similar'' to how they would behave if the production runtime were configured to use a - * single worker thread regardless of underlying physical thread count. Calling one of the - * `unsafeRun` methods on an `IO` will submit it to the runtime for execution, but nothing will - * actually evaluate until one of the ''tick'' methods on this class are called. If the desired - * behavior is to simply run the `IO` fully to completion within the mock environment, - * respecting monotonic time, then [[tickAll]] is likely the desired method. + * Implements a fully functional single-threaded runtime for a [[cats.effect.IO]] program. When + * using this control system, `IO` programs will be executed on a single JVM thread, ''similar'' + * to how they would behave if the production runtime were configured to use a single worker + * thread regardless of underlying physical thread count. The results of the underlying `IO` + * will be produced by the [[results]] effect when ready, but nothing will actually evaluate + * until one of the ''tick'' effects on this class are sequenced. If the desired behavior is to + * simply run the `IO` fully to completion within the mock environment, respecting monotonic + * time, then [[tickAll]] is likely the desired effect (or, alternatively, + * [[TestControl.executeFully]]). * - * Where things ''differ'' from the production runtime is in two critical areas. + * In other words, `TestControl` is sort of like a "handle" to the runtime internals within the + * context of a specific `IO`'s execution. It makes it possible for users to manipulate and + * observe the execution of the `IO` under test from an external vantage point. It is important + * to understand that the ''outer'' `IO`s (e.g. those returned by the [[tick]] or [[results]] + * methods) are ''not'' running under the test control environment, and instead they are meant + * to be run by some outer runtime. Interactions between the outer runtime and the inner runtime + * (potentially via mechanisms like [[cats.effect.std.Queue]] or + * [[cats.effect.kernel.Deferred]]) are quite tricky and should only be done with extreme care. + * The likely outcome in such scenarios is that the `TestControl` runtime will detect the inner + * `IO` as being deadlocked whenever it is actually waiting on the external runtime. This could + * result in strange effects such as [[tickAll]] or `executeFully` terminating early. Do not + * construct such scenarios unless you're very confident you understand the implications of what + * you're doing. + * + * Where things ''differ'' from a single-threaded production runtime is in two critical areas. * * First, whenever multiple fibers are outstanding and ready to be resumed, the `TestControl` * runtime will ''randomly'' choose between them, rather than taking them in a first-in, @@ -59,11 +75,11 @@ import scala.concurrent.duration.FiniteDuration * which is ''intended'' to bring the loop to a halt. However, because the immediate task queue * will never be empty, the test runtime will never advance time, meaning that the 10 * milliseconds will never elapse and the timeout will not be hit. This will manifest as the - * [[tick]] and [[tickAll]] functions simply running forever and not returning if called. - * [[tickOne]] is safe to call on the above program, but it will always return `true`. + * [[tick]] and [[tickAll]] effects simply running forever and not returning if called. + * [[tickOne]] is safe to call on the above program, but it will always produce `true`. * - * In order to advance time, you must use the [[advance]] method to move the clock forward by a - * specified offset (which must be greater than 0). If you use the `tickAll` method, the clock + * In order to advance time, you must use the [[advance]] effect to move the clock forward by a + * specified offset (which must be greater than 0). If you use the `tickAll` effect, the clock * will be automatically advanced by the minimum amount necessary to reach the next pending * task. For example, if the program contains an [[IO.sleep]] for `500.millis`, and there are no * shorter sleeps, then time will need to be advanced by 500 milliseconds in order to make that @@ -86,8 +102,8 @@ import scala.concurrent.duration.FiniteDuration * and exclusively through the [[IO.realTime]], [[IO.monotonic]], and [[IO.sleep]] functions * (and other functions built on top of these). From the perspective of these functions, all * computation is infinitely fast, and the only effect which advances time is [[IO.sleep]] (or - * if something external, such as the test harness, calls the [[advance]] method). However, an - * effect such as `IO(System.currentTimeMillis())` will "see through" the illusion, since the + * if something external, such as the test harness, sequences the [[advance]] effect). However, + * an effect such as `IO(System.currentTimeMillis())` will "see through" the illusion, since the * system clock is unaffected by this runtime. This is one reason why it is important to always * and exclusively rely on `realTime` and `monotonic`, either directly on `IO` or via the * typeclass abstractions. @@ -96,63 +112,32 @@ import scala.concurrent.duration.FiniteDuration * runtime will detect this situation as an asynchronous deadlock. * * @see - * [[cats.effect.unsafe.IORuntime]] - * @see * [[cats.effect.kernel.Clock]] * @see * [[tickAll]] */ -final class TestControl private (config: IORuntimeConfig, _seed: Option[String]) { - - private[this] val ctx = - _seed match { - case Some(seed) => TestContext(seed) - case None => TestContext() - } - - /** - * An [[cats.effect.unsafe.IORuntime]] which is controlled by the side-effecting methods on - * this class. - * - * @see - * [[tickAll]] - */ - val runtime: IORuntime = IORuntime( - ctx, - ctx, - new Scheduler { - def sleep(delay: FiniteDuration, task: Runnable): Runnable = { - val cancel = ctx.schedule(delay, task) - () => cancel() - } - - def nowMillis() = - ctx.now().toMillis +final class TestControl[+A] private (ctx: TestContext, _results: Future[A]) { - def monotonicNanos() = - ctx.now().toNanos - }, - () => (), - config - ) + val results: IO[Option[Either[Throwable, A]]] = + IO(_results.value.map(_.toEither)) /** - * Returns the minimum time which must elapse for a fiber to become eligible for execution. If - * fibers are currently eligible for execution, the result will be `Duration.Zero`. + * Produces the minimum time which must elapse for a fiber to become eligible for execution. + * If fibers are currently eligible for execution, the result will be `Duration.Zero`. */ - def nextInterval(): FiniteDuration = - ctx.nextInterval() + val nextInterval: IO[FiniteDuration] = + IO(ctx.nextInterval()) /** * Advances the runtime clock by the specified amount (which must be positive). Does not * execute any fibers, though may result in some previously-sleeping fibers to become pending * and eligible for execution in the next [[tick]]. */ - def advance(time: FiniteDuration): Unit = - ctx.advance(time) + def advance(time: FiniteDuration): IO[Unit] = + IO(ctx.advance(time)) /** - * A convenience method which advances time by the specified amount and then ticks once. Note + * A convenience effect which advances time by the specified amount and then ticks once. Note * that this method is very subtle and will often ''not'' do what you think it should. For * example: * @@ -160,10 +145,7 @@ final class TestControl private (config: IORuntimeConfig, _seed: Option[String]) * // will never print! * val program = IO.sleep(100.millis) *> IO.println("Hello, World!") * - * val control = TestControl() - * program.unsafeRunAndForget()(control.runtime) - * - * control.advanceAndTick(1.second) + * TestControl.execute(program).flatMap(_.advanceAndTick(1.second)) * }}} * * This is very subtle, but the problem is that time is advanced ''before'' the [[IO.sleep]] @@ -173,24 +155,25 @@ final class TestControl private (config: IORuntimeConfig, _seed: Option[String]) * only been advanced by `1.second`, thus the `sleep` never completes and the `println` cannot * ever run. * - * There are two possible solutions to this problem: either call [[tick]] ''first'' (before - * calling `advanceAndTick`) to ensure that the `sleep` has a chance to schedule itself, or - * simply use [[tickAll]] if you do not need to run assertions between time windows. + * There are two possible solutions to this problem: either sequence [[tick]] ''first'' + * (before sequencing `advanceAndTick`) to ensure that the `sleep` has a chance to schedule + * itself, or simply use [[tickAll]] if you do not need to run assertions between time + * windows. * * @see * [[advance]] * @see * [[tick]] */ - def advanceAndTick(time: FiniteDuration): Unit = - ctx.advanceAndTick(time) + def advanceAndTick(time: FiniteDuration): IO[Unit] = + IO(ctx.advanceAndTick(time)) /** - * Executes a single pending fiber and returns immediately. Does not advance time. Returns + * Executes a single pending fiber and returns immediately. Does not advance time. Produces * `false` if no fibers are pending. */ - def tickOne(): Boolean = - ctx.tickOne() + val tickOne: IO[Boolean] = + IO(ctx.tickOne()) /** * Executes all pending fibers in a random order, repeating on new tasks enqueued by those @@ -202,13 +185,13 @@ final class TestControl private (config: IORuntimeConfig, _seed: Option[String]) * @see * [[tickAll]] */ - def tick(): Unit = - ctx.tick() + val tick: IO[Unit] = + IO(ctx.tick()) /** * Drives the runtime until all fibers have been executed, then advances time until the next * fiber becomes available (if relevant), and repeats until no further fibers are scheduled. - * Analogous to, though critically not the same as, running an [[IO]] ] on a single-threaded + * Analogous to, though critically not the same as, running an [[IO]] on a single-threaded * production runtime. * * This function will terminate for `IO`s which deadlock ''asynchronously'', but any program @@ -219,19 +202,19 @@ final class TestControl private (config: IORuntimeConfig, _seed: Option[String]) * @see * [[tick]] */ - def tickAll(): Unit = - ctx.tickAll() + val tickAll: IO[Unit] = + IO(ctx.tickAll()) /** - * Returns `true` if the runtime has no remaining fibers, sleeping or otherwise, indicating an - * asynchronous deadlock has occurred. Or rather, ''either'' an asynchronous deadlock, or some - * interaction with an external asynchronous scheduler (such as another thread pool). + * Produces `true` if the runtime has no remaining fibers, sleeping or otherwise, indicating + * an asynchronous deadlock has occurred. Or rather, ''either'' an asynchronous deadlock, or + * some interaction with an external asynchronous scheduler (such as another thread pool). */ - def isDeadlocked(): Boolean = - ctx.state.tasks.isEmpty + val isDeadlocked: IO[Boolean] = + IO(ctx.state.tasks.isEmpty) /** - * Produces the base64-encoded seed which governs the random task interleaving during each + * Returns the base64-encoded seed which governs the random task interleaving during each * [[tick]]. This is useful for reproducing test failures which came about due to some * unexpected (though clearly plausible) execution order. */ @@ -240,53 +223,130 @@ final class TestControl private (config: IORuntimeConfig, _seed: Option[String]) object TestControl { - def apply( - config: IORuntimeConfig = IORuntimeConfig(), - seed: Option[String] = None): TestControl = - new TestControl(config, seed) - /** - * Executes a given [[IO]] under fully mocked runtime control. This is a convenience method - * wrapping the process of creating a new `TestControl` runtime, then using it to evaluate the - * given `IO`, with the `body` in control of the actual ticking process. This method is very - * useful when writing assertions about program state ''between'' clock ticks, and even more - * so when time must be explicitly advanced by set increments. If your assertions are entirely - * intrinsic (within the program) and the test is such that time should advance in an - * automatic fashion, the [[executeFully]] method may be a more convenient option. + * Executes a given [[IO]] under fully mocked runtime control. Produces a `TestControl` which + * can be used to manipulate the mocked runtime and retrieve the results. Note that the outer + * `IO` (and the `IO`s produced by the `TestControl`) do ''not'' evaluate under mocked runtime + * control and must be evaluated by some external harness, usually some test framework + * integration. + * + * A simple example (returns an `IO` which must, itself, be run) using MUnit assertion syntax: + * + * {{{ + * val program = for { + * first <- IO.realTime // IO.monotonic also works + * _ <- IO.println("it is currently " + first) + * + * _ <- IO.sleep(100.milis) + * second <- IO.realTime + * _ <- IO.println("we slept and now it is " + second) + * + * _ <- IO.sleep(1.hour).timeout(1.minute) + * third <- IO.realTime + * _ <- IO.println("we slept a second time and now it is " + third) + * } yield () * - * The `TestControl` parameter of the `body` provides control over the mock runtime which is - * executing the program. The second parameter produces the ''results'' of the program, if the - * program has completed. If the program has not yet completed, this function will return - * `None`. + * TestControl.execute(program) flatMap { control => + * for { + * first <- control.results + * _ <- IO(assert(first == None)) // we haven't finished yet + * + * _ <- control.tick + * // at this point, the "it is currently ..." line will have printed + * + * next1 <- control.nextInterval + * _ <- IO(assert(next1 == 100.millis)) + * + * _ <- control.advance(100.millis) + * // nothing has happened yet! + * _ <- control.tick + * // now the "we slept and now it is ..." line will have printed + * + * second <- control.results + * _ <- IO(assert(second == None)) // we're still not done yet + * + * next2 <- control.nextInterval + * _ <- IO(assert(next2 == 1.minute)) // we need to wait one minute for our next task, since we will hit the timeout + * + * _ <- control.advance(15.seconds) + * _ <- control.tick + * // nothing happens! + * + * next3 <- control.nextInterval + * _ <- IO(assert(next3 == 45.seconds)) // haven't gone far enough to hit the timeout + * + * _ <- control.advanceAndTick(45.seconds) + * // at this point, nothing will print because we hit the timeout exception! + * + * third <- control.results + * + * _ <- IO { + * assert(third.isDefined) + * assert(third.get.isLeft) // an exception, not a value! + * assert(third.get.left.get.isInstanceOf[TimeoutException]) + * } + * } yield () + * } + * }}} + * + * The above will run to completion within milliseconds. + * + * If your assertions are entirely intrinsic (within the program) and the test is such that + * time should advance in an automatic fashion, [[executeFully]] may be a more convenient + * option. */ - def execute[A, B]( + def execute[A]( program: IO[A], config: IORuntimeConfig = IORuntimeConfig(), - seed: Option[String] = None)( - body: (TestControl, () => Option[Either[Throwable, A]]) => B): B = { + seed: Option[String] = None): IO[TestControl[A]] = + IO { + val ctx = seed match { + case Some(seed) => TestContext(seed) + case None => TestContext() + } + + val runtime: IORuntime = IORuntime( + ctx, + ctx, + new Scheduler { + def sleep(delay: FiniteDuration, task: Runnable): Runnable = { + val cancel = ctx.schedule(delay, task) + () => cancel() + } - val control = TestControl(config = config, seed = seed) - val f = program.unsafeToFuture()(control.runtime) - body(control, () => f.value.map(_.toEither)) - } + def nowMillis() = + ctx.now().toMillis + + def monotonicNanos() = + ctx.now().toNanos + }, + () => (), + config + ) + + val results = program.unsafeToFuture()(runtime) + new TestControl(ctx, results) + } /** * Executes an [[IO]] under fully mocked runtime control, returning the final results. This is - * very similar to calling `unsafeRunSync` on the program, except that the scheduler will use - * a mocked and quantized notion of time, all while executing on a singleton worker thread. - * This can cause some programs to deadlock which would otherwise complete normally, but it - * also allows programs which involve [[IO.sleep]] s of any length to complete almost - * instantly with correct semantics. + * very similar to calling `unsafeRunSync` on the program and wrapping it in an `IO`, except + * that the scheduler will use a mocked and quantized notion of time, all while executing on a + * singleton worker thread. This can cause some programs to deadlock which would otherwise + * complete normally, but it also allows programs which involve [[IO.sleep]] s of any length + * to complete almost instantly with correct semantics. + * + * Note that any program which involves an [[IO.async]] that waits for some external thread + * (including [[IO.evalOn]]) will be detected as a deadlock and will result in the + * `executeFully` effect immediately producing `None`. * * @return - * `None` if `program` does not complete, otherwise `Some` of the results. + * An `IO` which produces `None` if `program` does not complete, otherwise `Some` of the + * results. */ def executeFully[A]( program: IO[A], config: IORuntimeConfig = IORuntimeConfig(), - seed: Option[String] = None): Option[Either[Throwable, A]] = - execute(program, config = config, seed = seed) { (control, result) => - control.tickAll() - result() - } + seed: Option[String] = None): IO[Option[Either[Throwable, A]]] = + execute(program, config = config, seed = seed).flatMap(c => c.tickAll *> c.results) } diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/syntax/AllSyntax.scala b/testkit/shared/src/main/scala/cats/effect/testkit/syntax/AllSyntax.scala deleted file mode 100644 index 342ee0cc7b..0000000000 --- a/testkit/shared/src/main/scala/cats/effect/testkit/syntax/AllSyntax.scala +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2020-2021 Typelevel - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package cats.effect.testkit.syntax - -trait AllSyntax extends TestControlSyntax diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/syntax/TestControlSyntax.scala b/testkit/shared/src/main/scala/cats/effect/testkit/syntax/TestControlSyntax.scala deleted file mode 100644 index d98f56b44c..0000000000 --- a/testkit/shared/src/main/scala/cats/effect/testkit/syntax/TestControlSyntax.scala +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2020-2021 Typelevel - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package cats.effect -package testkit -package syntax - -import cats.effect.unsafe.IORuntimeConfig - -trait TestControlSyntax { - implicit def testControlOps[A](wrapped: IO[A]): TestControlOps[A] = - new TestControlOps(wrapped) -} - -final class TestControlOps[A] private[syntax] (private val wrapped: IO[A]) extends AnyVal { - - def execute[B](config: IORuntimeConfig = IORuntimeConfig(), seed: Option[String] = None)( - body: (TestControl, () => Option[Either[Throwable, A]]) => B): B = - TestControl.execute(wrapped, config = config, seed = seed)(body) - - def executeFully( - config: IORuntimeConfig = IORuntimeConfig(), - seed: Option[String] = None): Option[Either[Throwable, A]] = - TestControl.executeFully(wrapped, config = config, seed = seed) -} diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/syntax/package.scala b/testkit/shared/src/main/scala/cats/effect/testkit/syntax/package.scala deleted file mode 100644 index 370c597b8a..0000000000 --- a/testkit/shared/src/main/scala/cats/effect/testkit/syntax/package.scala +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2020-2021 Typelevel - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package cats.effect.testkit - -package object syntax { - object all extends AllSyntax - object testControl extends TestControlSyntax -} diff --git a/testkit/shared/src/test/scala/cats/effect/testkit/SyntaxSpec.scala b/testkit/shared/src/test/scala/cats/effect/testkit/SyntaxSpec.scala deleted file mode 100644 index 5193f252fd..0000000000 --- a/testkit/shared/src/test/scala/cats/effect/testkit/SyntaxSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2020-2021 Typelevel - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package cats.effect -package testkit - -import org.specs2.mutable.Specification - -class SyntaxSpec extends Specification { - - "testkit syntax" >> ok - - def testControlSyntax = { - import syntax.testControl._ - - val program: IO[Int] = IO.pure(42) - - program.execute() { (control, results) => - val c: TestControl = control - val r: () => Option[Either[Throwable, Int]] = results - - val _ = { - val _ = c - r - } - - () - } - - program.executeFully(): Option[Either[Throwable, Int]] - } -} diff --git a/testkit/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala b/testkit/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala deleted file mode 100644 index 01846301ce..0000000000 --- a/testkit/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2020-2021 Typelevel - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package cats.effect -package testkit - -import org.specs2.mutable.Specification - -import scala.concurrent.duration._ - -class TestControlSpec extends Specification { - - val simple = IO.unit - - val ceded = IO.cede.replicateA(10) *> IO.unit - - val longSleeps = for { - first <- IO.monotonic - _ <- IO.sleep(1.hour) - second <- IO.monotonic - _ <- IO.race(IO.sleep(1.day), IO.sleep(1.day + 1.nanosecond)) - third <- IO.monotonic - } yield (first.toCoarsest, second.toCoarsest, third.toCoarsest) - - val deadlock = IO.never - - "execute" should { - "run a simple IO" in { - TestControl.execute(simple) { (control, results) => - results() must beNone - control.tick() - results() must beSome(beRight(())) - } - } - - "run a ceded IO in a single tick" in { - TestControl.execute(simple) { (control, results) => - results() must beNone - control.tick() - results() must beSome(beRight(())) - } - } - - "run an IO with long sleeps" in { - TestControl.execute(longSleeps) { (control, results) => - results() must beNone - - control.tick() - results() must beNone - control.nextInterval() mustEqual 1.hour - - control.advanceAndTick(1.hour) - results() must beNone - control.nextInterval() mustEqual 1.day - - control.advanceAndTick(1.day) - results() must beSome(beRight((0.nanoseconds, 1.hour, 25.hours))) - } - } - - "detect a deadlock" in { - TestControl.execute(deadlock) { (control, results) => - results() must beNone - control.tick() - control.isDeadlocked() must beTrue - results() must beNone - } - } - } - - "executeFully" should { - "run a simple IO" in { - TestControl.executeFully(simple) must beSome(beRight(())) - } - - "run an IO with long sleeps" in { - TestControl.executeFully(longSleeps) must beSome( - beRight((0.nanoseconds, 1.hour, 25.hours))) - } - - "detect a deadlock" in { - TestControl.executeFully(deadlock) must beNone - } - } -} diff --git a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala new file mode 100644 index 0000000000..deec318709 --- /dev/null +++ b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala @@ -0,0 +1,131 @@ +/* + * Copyright 2020-2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cats.effect +package testkit + +import scala.concurrent.duration._ + +class TestControlSpec extends BaseSpec { + + val simple = IO.unit + + val ceded = IO.cede.replicateA(10) *> IO.unit + + val longSleeps = for { + first <- IO.monotonic + _ <- IO.sleep(1.hour) + second <- IO.monotonic + _ <- IO.race(IO.sleep(1.day), IO.sleep(1.day + 1.nanosecond)) + third <- IO.monotonic + } yield (first.toCoarsest, second.toCoarsest, third.toCoarsest) + + val deadlock = IO.never + + "execute" should { + "run a simple IO" in real { + TestControl.execute(simple) flatMap { control => + for { + r1 <- control.results + _ <- IO(r1 must beNone) + + _ <- control.tick + + r2 <- control.results + _ <- IO(r2 must beSome(beRight(()))) + } yield ok + } + } + + "run a ceded IO in a single tick" in real { + TestControl.execute(simple) flatMap { control => + for { + r1 <- control.results + _ <- IO(r1 must beNone) + + _ <- control.tick + + r2 <- control.results + _ <- IO(r2 must beSome(beRight(()))) + } yield ok + } + } + + "run an IO with long sleeps" in real { + TestControl.execute(longSleeps) flatMap { control => + for { + r1 <- control.results + _ <- IO(r1 must beNone) + + _ <- control.tick + r2 <- control.results + _ <- IO(r2 must beNone) + + int1 <- control.nextInterval + _ <- IO(int1 mustEqual 1.hour) + + _ <- control.advanceAndTick(1.hour) + r3 <- control.results + _ <- IO(r3 must beNone) + + int2 <- control.nextInterval + _ <- IO(int2 mustEqual 1.day) + + _ <- control.advanceAndTick(1.day) + + r4 <- control.results + _ <- IO(r4 must beSome(beRight((0.nanoseconds, 1.hour, 25.hours)))) + } yield ok + } + } + + "detect a deadlock" in real { + TestControl.execute(deadlock) flatMap { control => + for { + r1 <- control.results + _ <- IO(r1 must beNone) + + _ <- control.tick + id <- control.isDeadlocked + _ <- IO(id must beTrue) + + r2 <- control.results + _ <- IO(r2 must beNone) + } yield ok + } + } + } + + "executeFully" should { + "run a simple IO" in real { + TestControl.executeFully(simple) flatMap { r => + IO(r must beSome(beRight(()))) + } + } + + "run an IO with long sleeps" in real { + TestControl.executeFully(longSleeps) flatMap { r => + IO(r must beSome(beRight((0.nanoseconds, 1.hour, 25.hours)))) + } + } + + "detect a deadlock" in real { + TestControl.executeFully(deadlock) flatMap { r => + IO(r must beNone) + } + } + } +} From 6c48c923f76c8f5a373de812363d8b6f17aa2724 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Fri, 10 Sep 2021 09:53:50 -0600 Subject: [PATCH 23/28] Ensure blocking tasks get appropriately marked --- .../scala/cats/effect/kernel/testkit/TestContext.scala | 8 ++++++++ .../src/main/scala/cats/effect/testkit/TestControl.scala | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala index c5b2ee51d7..c7256a9209 100644 --- a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala +++ b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala @@ -268,6 +268,14 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => def reportFailure(cause: Throwable): Unit = self.reportFailure(cause) } + def deriveBlocking(): ExecutionContext = + new ExecutionContext { + import scala.concurrent.blocking + + def execute(runnable: Runnable): Unit = blocking(self.execute(runnable)) + def reportFailure(cause: Throwable): Unit = self.reportFailure(cause) + } + def now(): FiniteDuration = stateRef.clock def seed: String = diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala index ea407ad00c..a0e8c1eaee 100644 --- a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala @@ -307,7 +307,7 @@ object TestControl { val runtime: IORuntime = IORuntime( ctx, - ctx, + ctx.deriveBlocking(), new Scheduler { def sleep(delay: FiniteDuration, task: Runnable): Runnable = { val cancel = ctx.schedule(delay, task) From b15d57b17bf37d53c7983110092370ede661b8e5 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Fri, 10 Sep 2021 09:57:29 -0600 Subject: [PATCH 24/28] Adjusted scaladoc on `TestContext` to clarify its low-level nature --- build.sbt | 3 +- .../effect/kernel/testkit/TestContext.scala | 82 +++---------------- .../cats/effect/testkit/TestControlSpec.scala | 8 +- 3 files changed, 14 insertions(+), 79 deletions(-) diff --git a/build.sbt b/build.sbt index 6a7fdc5780..e969da85b7 100644 --- a/build.sbt +++ b/build.sbt @@ -436,7 +436,8 @@ lazy val std = crossProject(JSPlatform, JVMPlatform) else "org.specs2" %%% "specs2-scalacheck" % Specs2Version % Test }, - libraryDependencies += "org.scala-js" %%% "scala-js-macrotask-executor" % MacrotaskExecutorVersion % Test) + libraryDependencies += "org.scala-js" %%% "scala-js-macrotask-executor" % MacrotaskExecutorVersion % Test + ) /** * A trivial pair of trivial example apps primarily used to show that IOApp works as a practical diff --git a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala index c7256a9209..81d9edaf99 100644 --- a/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala +++ b/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/TestContext.scala @@ -29,10 +29,13 @@ import scala.util.control.NonFatal import java.util.Base64 /** - * A `scala.concurrent.ExecutionContext` implementation and a provider of `cats.effect.Timer` - * instances, that can simulate async boundaries and time passage, useful for testing purposes. + * A [[scala.concurrent.ExecutionContext]] implementation that can simulate async boundaries and + * time passage, useful for law testing purposes. This is intended primarily for datatype + * implementors. Most end-users will be better served by the `cats.effect.testkit.TestControl` + * utility, rather than using `TestContext` directly. * * Usage for simulating an `ExecutionContext`): + * * {{{ * implicit val ec = TestContext() * @@ -55,68 +58,6 @@ import java.util.Base64 * assert(ec.state.tasks.isEmpty) * assert(ec.state.lastReportedFailure == None) * }}} - * - * Our `TestContext` can also simulate time passage, as we are able to builds a - * `cats.effect.Timer` instance for any data type that has a `LiftIO` instance: - * - * {{{ - * val ctx = TestContext() - * - * val timer: Timer[IO] = ctx.timer[IO] - * }}} - * - * We can now simulate actual time: - * - * {{{ - * val io = timer.sleep(10.seconds) *> IO(1 + 1) - * val f = io.unsafeToFuture() - * - * // This invariant holds true, because our IO is async - * assert(f.value == None) - * - * // Not yet completed, because this does not simulate time passing: - * ctx.tick() - * assert(f.value == None) - * - * // Simulating time passing: - * ctx.tick(10.seconds) - * assert(f.value == Some(Success(2)) - * }}} - * - * Simulating time makes this pretty useful for testing race conditions: - * - * {{{ - * val never = IO.async[Int](_ => {}) - * val timeoutError = new TimeoutException - * val timeout = timer.sleep(10.seconds) *> IO.raiseError[Int](timeoutError) - * - * val pair = (never, timeout).parMapN(_ + _) - * - * // Not yet - * ctx.tick() - * assert(f.value == None) - * // Not yet - * ctx.tick(5.seconds) - * assert(f.value == None) - * - * // Good to go: - * ctx.tick(5.seconds) - * assert(f.value, Some(Failure(timeoutError))) - * }}} - * - * @define timerExample - * {{{ val ctx = TestContext() // Building a Timer[IO] from this: implicit val timer: - * Timer[IO] = ctx.timer[IO] - * - * // Can now simulate time val io = timer.sleep(10.seconds) *> IO(1 + 1) val f = - * io.unsafeToFuture() - * - * // This invariant holds true, because our IO is async assert(f.value == None) - * - * // Not yet completed, because this does not simulate time passing: ctx.tick() assert(f.value - * == None) - * - * // Simulating time passing: ctx.tick(10.seconds) assert(f.value == Some(Success(2)) }}} */ final class TestContext private (_seed: Long) extends ExecutionContext { self => import TestContext.{Encoder, State, Task} @@ -130,17 +71,11 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => lastReportedFailure = None ) - /** - * Inherited from `ExecutionContext`, schedules a runnable for execution. - */ def execute(r: Runnable): Unit = synchronized { stateRef = stateRef.execute(r) } - /** - * Inherited from `ExecutionContext`, reports uncaught errors. - */ def reportFailure(cause: Throwable): Unit = synchronized { stateRef = stateRef.copy(lastReportedFailure = Some(cause)) @@ -150,8 +85,7 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => * Returns the internal state of the `TestContext`, useful for testing that certain execution * conditions have been met. */ - def state: State = - synchronized(stateRef) + def state: State = synchronized(stateRef) /** * Returns the current interval between "now" and the earliest scheduled task. If there are @@ -268,6 +202,10 @@ final class TestContext private (_seed: Long) extends ExecutionContext { self => def reportFailure(cause: Throwable): Unit = self.reportFailure(cause) } + /** + * Derives a new `ExecutionContext` which delegates to `this`, but wrapping all tasks in + * [[scala.concurrent.blocking]]. + */ def deriveBlocking(): ExecutionContext = new ExecutionContext { import scala.concurrent.blocking diff --git a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala index deec318709..72fc2b9b05 100644 --- a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala @@ -111,9 +111,7 @@ class TestControlSpec extends BaseSpec { "executeFully" should { "run a simple IO" in real { - TestControl.executeFully(simple) flatMap { r => - IO(r must beSome(beRight(()))) - } + TestControl.executeFully(simple) flatMap { r => IO(r must beSome(beRight(()))) } } "run an IO with long sleeps" in real { @@ -123,9 +121,7 @@ class TestControlSpec extends BaseSpec { } "detect a deadlock" in real { - TestControl.executeFully(deadlock) flatMap { r => - IO(r must beNone) - } + TestControl.executeFully(deadlock) flatMap { r => IO(r must beNone) } } } } From d227caa9f8eddc169cc8845194ecb76bc537c6ae Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Sun, 12 Sep 2021 10:17:10 -0600 Subject: [PATCH 25/28] Replaced "unknown" with dots to mirror `IO`'s Show --- kernel/shared/src/main/scala/cats/effect/kernel/Outcome.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Outcome.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Outcome.scala index d4c497b864..e335f1b6da 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Outcome.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Outcome.scala @@ -95,7 +95,7 @@ private[kernel] trait LowPriorityImplicits { Show show { case Canceled() => "Canceled" case Errored(left) => s"Errored(${left.show})" - case Succeeded(_) => s"Succeeded()" + case Succeeded(_) => s"Succeeded(...)" } implicit def eq[F[_], E: Eq, A](implicit FA: Eq[F[A]]): Eq[Outcome[F, E, A]] = From 154f2d150b1bd666fe9bfdbdfded9cbd6235e687 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Sun, 12 Sep 2021 10:30:59 -0600 Subject: [PATCH 26/28] Revised API to work in terms of `Outcome` rather than `Either` --- .../cats/effect/testkit/TestControl.scala | 20 ++++++---- .../cats/effect/testkit/TestControlSpec.scala | 37 ++++++++++++++++--- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala index a0e8c1eaee..483997d8e5 100644 --- a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala @@ -17,11 +17,13 @@ package cats.effect package testkit +import cats.Id import cats.effect.unsafe.{IORuntime, IORuntimeConfig, Scheduler} -import scala.concurrent.Future import scala.concurrent.duration.FiniteDuration +import java.util.concurrent.atomic.AtomicReference + /** * Implements a fully functional single-threaded runtime for a [[cats.effect.IO]] program. When * using this control system, `IO` programs will be executed on a single JVM thread, ''similar'' @@ -116,10 +118,11 @@ import scala.concurrent.duration.FiniteDuration * @see * [[tickAll]] */ -final class TestControl[+A] private (ctx: TestContext, _results: Future[A]) { +final class TestControl[A] private ( + ctx: TestContext, + _results: AtomicReference[Option[Outcome[Id, Throwable, A]]]) { - val results: IO[Option[Either[Throwable, A]]] = - IO(_results.value.map(_.toEither)) + val results: IO[Option[Outcome[Id, Throwable, A]]] = IO(_results.get) /** * Produces the minimum time which must elapse for a fiber to become eligible for execution. @@ -282,8 +285,8 @@ object TestControl { * * _ <- IO { * assert(third.isDefined) - * assert(third.get.isLeft) // an exception, not a value! - * assert(third.get.left.get.isInstanceOf[TimeoutException]) + * assert(third.get.isError) // an exception, not a value! + * assert(third.get.fold(false, _.isInstanceOf[TimeoutException], _ => false)) * } * } yield () * } @@ -324,7 +327,8 @@ object TestControl { config ) - val results = program.unsafeToFuture()(runtime) + val results = new AtomicReference[Option[Outcome[Id, Throwable, A]]](None) + program.unsafeRunAsyncOutcome(oc => results.set(Some(oc)))(runtime) new TestControl(ctx, results) } @@ -347,6 +351,6 @@ object TestControl { def executeFully[A]( program: IO[A], config: IORuntimeConfig = IORuntimeConfig(), - seed: Option[String] = None): IO[Option[Either[Throwable, A]]] = + seed: Option[String] = None): IO[Option[Outcome[Id, Throwable, A]]] = execute(program, config = config, seed = seed).flatMap(c => c.tickAll *> c.results) } diff --git a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala index 72fc2b9b05..07def8eb56 100644 --- a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala @@ -17,6 +17,10 @@ package cats.effect package testkit +import cats.Id + +import org.specs2.matcher.Matcher + import scala.concurrent.duration._ class TestControlSpec extends BaseSpec { @@ -33,7 +37,7 @@ class TestControlSpec extends BaseSpec { third <- IO.monotonic } yield (first.toCoarsest, second.toCoarsest, third.toCoarsest) - val deadlock = IO.never + val deadlock: IO[Unit] = IO.never "execute" should { "run a simple IO" in real { @@ -45,7 +49,7 @@ class TestControlSpec extends BaseSpec { _ <- control.tick r2 <- control.results - _ <- IO(r2 must beSome(beRight(()))) + _ <- IO(r2 must beSome(beSucceeded(()))) } yield ok } } @@ -59,7 +63,7 @@ class TestControlSpec extends BaseSpec { _ <- control.tick r2 <- control.results - _ <- IO(r2 must beSome(beRight(()))) + _ <- IO(r2 must beSome(beSucceeded(()))) } yield ok } } @@ -87,7 +91,7 @@ class TestControlSpec extends BaseSpec { _ <- control.advanceAndTick(1.day) r4 <- control.results - _ <- IO(r4 must beSome(beRight((0.nanoseconds, 1.hour, 25.hours)))) + _ <- IO(r4 must beSome(beSucceeded((0.nanoseconds, 1.hour, 25.hours)))) } yield ok } } @@ -111,17 +115,38 @@ class TestControlSpec extends BaseSpec { "executeFully" should { "run a simple IO" in real { - TestControl.executeFully(simple) flatMap { r => IO(r must beSome(beRight(()))) } + TestControl.executeFully(simple) flatMap { r => IO(r must beSome(beSucceeded(()))) } } "run an IO with long sleeps" in real { TestControl.executeFully(longSleeps) flatMap { r => - IO(r must beSome(beRight((0.nanoseconds, 1.hour, 25.hours)))) + IO(r must beSome(beSucceeded((0.nanoseconds, 1.hour, 25.hours)))) } } "detect a deadlock" in real { TestControl.executeFully(deadlock) flatMap { r => IO(r must beNone) } } + + "run an IO which produces an error" in real { + case object TestException extends RuntimeException + + TestControl.executeFully(IO.raiseError[Unit](TestException)) flatMap { r => + IO(r must beSome(beErrored[Unit](TestException))) + } + } + + "run an IO which self-cancels" in real { + TestControl.executeFully(IO.canceled) flatMap { r => IO(r must beSome(beCanceled[Unit])) } + } } + + private def beSucceeded[A](value: A): Matcher[Outcome[Id, Throwable, A]] = + (_: Outcome[Id, Throwable, A]) == Outcome.succeeded[Id, Throwable, A](value) + + private def beErrored[A](t: Throwable): Matcher[Outcome[Id, Throwable, A]] = + (_: Outcome[Id, Throwable, A]) == Outcome.errored[Id, Throwable, A](t) + + private def beCanceled[A]: Matcher[Outcome[Id, Throwable, A]] = + (_: Outcome[Id, Throwable, A]) == Outcome.canceled[Id, Throwable, A] } From 4e6597ec799bd9c962f1d623c541f0bcbcb7a4d9 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Sun, 12 Sep 2021 16:54:51 -0600 Subject: [PATCH 27/28] Replaced `executeFully` with `executeEmbed` --- .../cats/effect/testkit/TestControl.scala | 28 +++++++++++++------ .../cats/effect/testkit/TestControlSpec.scala | 27 +++++++++--------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala index 483997d8e5..a67dce63d3 100644 --- a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala @@ -17,9 +17,11 @@ package cats.effect package testkit -import cats.Id +import cats.{~>, Id} import cats.effect.unsafe.{IORuntime, IORuntimeConfig, Scheduler} +import cats.syntax.all._ +import scala.concurrent.CancellationException import scala.concurrent.duration.FiniteDuration import java.util.concurrent.atomic.AtomicReference @@ -33,7 +35,7 @@ import java.util.concurrent.atomic.AtomicReference * until one of the ''tick'' effects on this class are sequenced. If the desired behavior is to * simply run the `IO` fully to completion within the mock environment, respecting monotonic * time, then [[tickAll]] is likely the desired effect (or, alternatively, - * [[TestControl.executeFully]]). + * [[TestControl.executeEmbed]]). * * In other words, `TestControl` is sort of like a "handle" to the runtime internals within the * context of a specific `IO`'s execution. It makes it possible for users to manipulate and @@ -45,7 +47,7 @@ import java.util.concurrent.atomic.AtomicReference * [[cats.effect.kernel.Deferred]]) are quite tricky and should only be done with extreme care. * The likely outcome in such scenarios is that the `TestControl` runtime will detect the inner * `IO` as being deadlocked whenever it is actually waiting on the external runtime. This could - * result in strange effects such as [[tickAll]] or `executeFully` terminating early. Do not + * result in strange effects such as [[tickAll]] or `executeEmbed` terminating early. Do not * construct such scenarios unless you're very confident you understand the implications of what * you're doing. * @@ -295,7 +297,7 @@ object TestControl { * The above will run to completion within milliseconds. * * If your assertions are entirely intrinsic (within the program) and the test is such that - * time should advance in an automatic fashion, [[executeFully]] may be a more convenient + * time should advance in an automatic fashion, [[executeEmbed]] may be a more convenient * option. */ def execute[A]( @@ -342,15 +344,23 @@ object TestControl { * * Note that any program which involves an [[IO.async]] that waits for some external thread * (including [[IO.evalOn]]) will be detected as a deadlock and will result in the - * `executeFully` effect immediately producing `None`. + * `executeEmbed` effect immediately producing `None`. * * @return * An `IO` which produces `None` if `program` does not complete, otherwise `Some` of the - * results. + * results. If the program is canceled, a [[scala.concurrent.CancellationException]] is + * raised. */ - def executeFully[A]( + def executeEmbed[A]( program: IO[A], config: IORuntimeConfig = IORuntimeConfig(), - seed: Option[String] = None): IO[Option[Outcome[Id, Throwable, A]]] = - execute(program, config = config, seed = seed).flatMap(c => c.tickAll *> c.results) + seed: Option[String] = None): IO[Option[A]] = + execute(program, config = config, seed = seed) flatMap { c => + val nt = new (Id ~> IO) { def apply[E](e: E) = IO.pure(e) } + + val onCancel = IO.defer(IO.raiseError(new CancellationException)) + val embedded = c.results.flatMap(_.traverse(_.mapK(nt).embed(onCancel))) + + c.tickAll *> embedded + } } diff --git a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala index 07def8eb56..a6171cf090 100644 --- a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala @@ -21,6 +21,7 @@ import cats.Id import org.specs2.matcher.Matcher +import scala.concurrent.CancellationException import scala.concurrent.duration._ class TestControlSpec extends BaseSpec { @@ -115,38 +116,38 @@ class TestControlSpec extends BaseSpec { "executeFully" should { "run a simple IO" in real { - TestControl.executeFully(simple) flatMap { r => IO(r must beSome(beSucceeded(()))) } + TestControl.executeEmbed(simple) flatMap { r => + IO(r must beSome(())) + } } "run an IO with long sleeps" in real { - TestControl.executeFully(longSleeps) flatMap { r => - IO(r must beSome(beSucceeded((0.nanoseconds, 1.hour, 25.hours)))) + TestControl.executeEmbed(longSleeps) flatMap { r => + IO(r must beSome((0.nanoseconds, 1.hour, 25.hours))) } } "detect a deadlock" in real { - TestControl.executeFully(deadlock) flatMap { r => IO(r must beNone) } + TestControl.executeEmbed(deadlock) flatMap { r => + IO(r must beNone) + } } "run an IO which produces an error" in real { case object TestException extends RuntimeException - TestControl.executeFully(IO.raiseError[Unit](TestException)) flatMap { r => - IO(r must beSome(beErrored[Unit](TestException))) + TestControl.executeEmbed(IO.raiseError[Unit](TestException)).attempt flatMap { r => + IO(r must beLeft(TestException: Throwable)) } } "run an IO which self-cancels" in real { - TestControl.executeFully(IO.canceled) flatMap { r => IO(r must beSome(beCanceled[Unit])) } + TestControl.executeEmbed(IO.canceled).attempt flatMap { r => + IO(r must beLike { case Left(_: CancellationException) => ok }) + } } } private def beSucceeded[A](value: A): Matcher[Outcome[Id, Throwable, A]] = (_: Outcome[Id, Throwable, A]) == Outcome.succeeded[Id, Throwable, A](value) - - private def beErrored[A](t: Throwable): Matcher[Outcome[Id, Throwable, A]] = - (_: Outcome[Id, Throwable, A]) == Outcome.errored[Id, Throwable, A](t) - - private def beCanceled[A]: Matcher[Outcome[Id, Throwable, A]] = - (_: Outcome[Id, Throwable, A]) == Outcome.canceled[Id, Throwable, A] } From 2022ef90609cf611faf5db47eb0177136ac4c490 Mon Sep 17 00:00:00 2001 From: Daniel Spiewak Date: Thu, 16 Sep 2021 10:03:26 -0600 Subject: [PATCH 28/28] Adjusted the signature of `executeEmbed` to produce an `IO[A]` --- .../cats/effect/testkit/TestControl.scala | 27 +++++++++++++------ .../cats/effect/testkit/TestControlSpec.scala | 16 ++++++----- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala index a67dce63d3..483c68ebee 100644 --- a/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala +++ b/testkit/shared/src/main/scala/cats/effect/testkit/TestControl.scala @@ -19,7 +19,6 @@ package testkit import cats.{~>, Id} import cats.effect.unsafe.{IORuntime, IORuntimeConfig, Scheduler} -import cats.syntax.all._ import scala.concurrent.CancellationException import scala.concurrent.duration.FiniteDuration @@ -344,23 +343,35 @@ object TestControl { * * Note that any program which involves an [[IO.async]] that waits for some external thread * (including [[IO.evalOn]]) will be detected as a deadlock and will result in the - * `executeEmbed` effect immediately producing `None`. + * `executeEmbed` effect immediately producing a [[NonTerminationException]]. * * @return - * An `IO` which produces `None` if `program` does not complete, otherwise `Some` of the - * results. If the program is canceled, a [[scala.concurrent.CancellationException]] is - * raised. + * An `IO` which runs the given program under a mocked runtime, producing the result or an + * error if the program runs to completion. If the program is canceled, a + * [[scala.concurrent.CancellationException]] will be raised within the `IO`. If the program + * fails to terminate with either a result or an error, a [[NonTerminationException]] will + * be raised. */ def executeEmbed[A]( program: IO[A], config: IORuntimeConfig = IORuntimeConfig(), - seed: Option[String] = None): IO[Option[A]] = + seed: Option[String] = None): IO[A] = execute(program, config = config, seed = seed) flatMap { c => val nt = new (Id ~> IO) { def apply[E](e: E) = IO.pure(e) } - val onCancel = IO.defer(IO.raiseError(new CancellationException)) - val embedded = c.results.flatMap(_.traverse(_.mapK(nt).embed(onCancel))) + val onCancel = IO.defer(IO.raiseError(new CancellationException())) + val onNever = IO.raiseError(new NonTerminationException()) + val embedded = c.results.flatMap(_.map(_.mapK(nt).embed(onCancel)).getOrElse(onNever)) c.tickAll *> embedded } + + final class NonTerminationException + extends RuntimeException( + "Program under test failed produce a result (either a value or an error) and has no further " + + "actions to take, likely indicating an asynchronous deadlock. This may also indicate some " + + "interaction with an external thread, potentially via IO.async or IO#evalOn. If this is the " + + "case, then it is likely you cannot use TestControl to correctly evaluate this program, and " + + "you should either use the production IORuntime (ideally via some integration with your " + + "testing framework), or attempt to refactor the program into smaller, more testable components.") } diff --git a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala index a6171cf090..6723d3e75c 100644 --- a/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala +++ b/tests/shared/src/test/scala/cats/effect/testkit/TestControlSpec.scala @@ -116,20 +116,20 @@ class TestControlSpec extends BaseSpec { "executeFully" should { "run a simple IO" in real { - TestControl.executeEmbed(simple) flatMap { r => - IO(r must beSome(())) - } + TestControl.executeEmbed(simple) flatMap { r => IO(r mustEqual (())) } } "run an IO with long sleeps" in real { TestControl.executeEmbed(longSleeps) flatMap { r => - IO(r must beSome((0.nanoseconds, 1.hour, 25.hours))) + IO(r mustEqual ((0.nanoseconds, 1.hour, 25.hours))) } } "detect a deadlock" in real { - TestControl.executeEmbed(deadlock) flatMap { r => - IO(r must beNone) + TestControl.executeEmbed(deadlock).attempt flatMap { r => + IO { + r must beLike { case Left(_: TestControl.NonTerminationException) => ok } + } } } @@ -143,7 +143,9 @@ class TestControlSpec extends BaseSpec { "run an IO which self-cancels" in real { TestControl.executeEmbed(IO.canceled).attempt flatMap { r => - IO(r must beLike { case Left(_: CancellationException) => ok }) + IO { + r must beLike { case Left(_: CancellationException) => ok } + } } } }