final class TestControl[A] extends AnyRef
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.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
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 executeEmbed
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,
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 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 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(delay* 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(delay*.
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(delay*
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(delay* (or 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.
WARNING: Never use this runtime on programs which use the IO#evalOn method! The test runtime will detect this situation as an asynchronous deadlock.
- Source
- TestControl.scala
- See also
- Alphabetic
- By Inheritance
- TestControl
- AnyRef
- Any
- Hide All
- Show All
- Public
- Protected
Value Members
- final def !=(arg0: Any): Boolean
- Definition Classes
- AnyRef → Any
- final def ##: Int
- Definition Classes
- AnyRef → Any
- final def ==(arg0: Any): Boolean
- Definition Classes
- AnyRef → Any
- def advance(time: FiniteDuration): IO[Unit]
Advances the runtime clock by the specified amount (which must be positive).
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 advanceAndTick(time: FiniteDuration): IO[Unit]
A convenience effect which advances time by the specified amount and then ticks once.
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:
// will never print! val program = IO.sleep(100.millis) *> IO.println("Hello, World!") TestControl.execute(program).flatMap(_.advanceAndTick(1.second))
This is very subtle, but the problem is that time is advanced before the IO.sleep(delay* 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 to1.second + 100.millis
, since time was already advanced1.second
before it had a chance to submit. Of course, time has only been advanced by1.second
, thus thesleep
never completes and theprintln
cannot ever run.There are two possible solutions to this problem: either sequence tick first (before sequencing
advanceAndTick
) to ensure that thesleep
has a chance to schedule itself, or simply use tickAll if you do not need to run assertions between time windows.In most cases, tickFor will provide a more intuitive execution semantic.
- final def asInstanceOf[T0]: T0
- Definition Classes
- Any
- def clone(): AnyRef
- Attributes
- protected[lang]
- Definition Classes
- AnyRef
- Annotations
- @throws(classOf[java.lang.CloneNotSupportedException]) @native()
- final def eq(arg0: AnyRef): Boolean
- Definition Classes
- AnyRef
- def equals(arg0: AnyRef): Boolean
- Definition Classes
- AnyRef → Any
- def finalize(): Unit
- Attributes
- protected[lang]
- Definition Classes
- AnyRef
- Annotations
- @throws(classOf[java.lang.Throwable])
- final def getClass(): Class[_ <: AnyRef]
- Definition Classes
- AnyRef → Any
- Annotations
- @native()
- def hashCode(): Int
- Definition Classes
- AnyRef → Any
- Annotations
- @native()
- val isDeadlocked: IO[Boolean]
Produces
true
if the runtime has no remaining fibers, sleeping or otherwise, indicating an asynchronous deadlock has occurred.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). - final def isInstanceOf[T0]: Boolean
- Definition Classes
- Any
- final def ne(arg0: AnyRef): Boolean
- Definition Classes
- AnyRef
- val nextInterval: IO[FiniteDuration]
Produces the minimum time which must elapse for a fiber to become eligible for execution.
Produces the minimum time which must elapse for a fiber to become eligible for execution. If fibers are currently eligible for execution, or if the program is entirely deadlocked, the result will be
Duration.Zero
. - final def notify(): Unit
- Definition Classes
- AnyRef
- Annotations
- @native()
- final def notifyAll(): Unit
- Definition Classes
- AnyRef
- Annotations
- @native()
- val results: IO[Option[Outcome[Id, Throwable, A]]]
- def seed: String
Returns the base64-encoded seed which governs the random task interleaving during each tick.
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.
- final def synchronized[T0](arg0: => T0): T0
- Definition Classes
- AnyRef
- val tick: IO[Unit]
Executes all pending fibers in a random order, repeating on new tasks enqueued by those fibers until all pending fibers have been exhausted.
- val tickAll: IO[Unit]
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.
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 anyIO
which interacts with some external asynchronous scheduler (such as NIO) will be considered deadlocked for the purposes of this runtime.- See also
- def tickFor(time: FiniteDuration): IO[Unit]
Drives the runtime incrementally forward until all fibers have been executed, or until the specified
time
has elapsed.Drives the runtime incrementally forward until all fibers have been executed, or until the specified
time
has elapsed. The semantics of this function are very distinct from advance in that the runtime will tick for the minimum time necessary to reach the next batch of tasks within each interval, and then continue ticking as long as necessary to cumulatively reach the time limit (or the end of the program). This behavior can be seen in programs such as the following:val tick = IO.sleep(1.second) *> IO.realTime TestControl.execute((tick, tick).tupled) flatMap { control => for { _ <- control.tickFor(1.second + 500.millis) _ <- control.tickAll r <- control.results _ <- IO(assert(r == Some(Outcome.succeeded(1.second, 2.seconds)))) } yield () }
Notably, the first component of the results tuple here is
1.second
, meaning that the firstIO.realTime
evaluated after the clock had only advanced by1.second
. This is in contrast to what would have happened withcontrol.advanceAndTick(1.second + 500.millis)
, which would have caused the firstrealTime
to produce2500.millis
as a result, rather than the correct answer of1.second
. In other words, advanceAndTick is maximally aggressive on time advancement, whiletickFor
is maximally conservative and only ticks as much as necessary each time. - val tickOne: IO[Boolean]
Executes a single pending fiber and returns immediately.
Executes a single pending fiber and returns immediately. Does not advance time. Produces
false
if no fibers are pending. - def toString(): String
- Definition Classes
- AnyRef → Any
- final def wait(): Unit
- Definition Classes
- AnyRef
- Annotations
- @throws(classOf[java.lang.InterruptedException])
- final def wait(arg0: Long, arg1: Int): Unit
- Definition Classes
- AnyRef
- Annotations
- @throws(classOf[java.lang.InterruptedException])
- final def wait(arg0: Long): Unit
- Definition Classes
- AnyRef
- Annotations
- @throws(classOf[java.lang.InterruptedException]) @native()