-
Couldn't load subscription status.
- Fork 1.4k
Add interrupt deadline to 'timeout' TestAspect #1824
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this looks really good! A few minor comments but overall this is great. Thanks for taking the lead on identifying the issue and fixing it!
| import zio.{ clock, Cause, ZIO, ZManaged, ZSchedule } | ||
| import zio.duration.Duration | ||
| import zio.{ clock, Cause, Promise, ZIO, ZManaged, ZSchedule } | ||
| import zio.duration.{ Duration, _ } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor thing but I think you can just import everything since you're already doing a wildcard import.
| val sequential: TestAspectPoly = executionStrategy(ExecutionStrategy.Sequential) | ||
|
|
||
| /** | ||
| * An aspect that times out tests using the specified duration. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice documentation!
| def timeout(duration: Duration): TestAspect[Nothing, Live[Clock], Nothing, Any, Nothing, Any] = | ||
| def timeout( | ||
| duration: Duration, | ||
| interruptDuration: Duration = 10.seconds |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we should make this a little shorter? It is a trade-off because we want to give enough time to successfully interrupt if we can but we don't want to wait so long that the user just hits CTRL-C and thinks it is something wrong with ZIO instead of their code and misses out on the error message.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I agree. I think if users encounter warning, they will lookup what second parameter is for and act upon this.
| for { | ||
| p <- Promise.make[TestFailure[E], TestSuccess[S]] | ||
| _ <- test | ||
| .raceAttempt(Live.withLive(ZIO.fail(timeoutFailure))(_.delay(duration))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the failure isn't going to use any mock effects you could just do live around the whole thing to make this a little shorter, but again very minor
| .raceAttempt(Live.withLive(ZIO.fail(timeoutFailure))(_.delay(duration))) | ||
| .foldM(p.fail, p.succeed) | ||
| .fork | ||
| _ <- (Live.withLive(ZIO.unit)(_.delay(duration + interruptDuration)) *> p.fail(interruptionTimeoutFailure)).fork |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could just do live(ZIO.sleep(...)) *> p.fail here if you wanted. We should probably interrupt this fiber if the first fiber completes.
| isSubtype[T](anything) | ||
| ) | ||
|
|
||
| private def testExecutionFailedWith(spec: ZSpec[Live[Clock], Any, String, Any], pred: Throwable => Boolean) = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could implement this in terms of the forallTests combinator in TestUtils.scala. Maybe move this there as well so that others can use it in the future?
| failed(spec) | ||
| } | ||
|
|
||
| def timeoutMakesTestsFailAfterGivenDuration: Future[Boolean] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These tests are a little awkward to write because we would really like to use the MockClock here but we don't want to be using ZIO Test to test ZIO Test. I would shorten the timeout durations because we are using real time here and I don't think the actual amount of time matters since the effects will never terminate. You could probably just use 1 nanosecond here and 1 and 2 nanoseconds below.
|
|
||
| testExecutionFailedWith( | ||
| spec, | ||
| cause => cause.isInstanceOf[TestTimeoutException] && cause.getMessage() == "Timeout of 100 ms exceeded." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could use a pattern match here if you want to avoid the isInstanceOf check.
| .raceAttempt(Live.live(ZIO.fail(timeoutFailure).delay(duration))) | ||
| .foldM(p.fail, p.succeed) | ||
| .fork | ||
| _ <- (Live.live(ZIO.unit.delay(duration + interruptDuration)) *> p.fail(interruptionTimeoutFailure)).fork |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't we want to interrupt this fiber if the first fiber completes successfully? The Promise can only be completed once so it won't effect the results but we will have a lot of fibers sitting out there that aren't doing anything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do it!
| assertM(ZIO.never *> ZIO.unit, equalTo(())) | ||
| }: ZSpec[Live[Clock], Any, String, Any]) @@ timeout(1.millis) | ||
|
|
||
| failedWith(spec, cause => cause == TestTimeoutException("Timeout of 1 ms exceeded.")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we reduce this to nanoseconds now that we have the #1839 merged?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will reduce.
|
Now it interrupts test/timeout fibers but what I don't really like is that it spams output with fibers traces: and so on... |
|
Yes I definitely agree. Is it because the |
|
Why are only some of the tests failing? Is there some nondeterminism now? |
|
I don't know answers for any of these questions. I'll investigate on weekend. Thanks for hints! |
|
@LGLO Spent a little more time on this and I think there are a couple of things going on here. Regarding the tracing behavior, the same problem actually existed in the commit from before today, we just didn't notice, and it is not an issue with the test reporter. The issue is that in ZIO.succeed(1).raceAttempt(ZIO.fail("foo"))The effect will succeed but the fiber failure will also be reported to the console. So the solution to avoid this is to use The other issue is that when we have a for {
p <- Promise.make[TestFailure[E], TestSuccess[S]]
_ <- test.either
.race(Live.live(ZIO.succeed(Left(timeoutFailure)).delay(duration)))
.flatMap(_.fold(p.fail, p.succeed))
.fork
_ <- (Live.live(ZIO.unit.delay(duration + interruptDuration)) *> p.fail(interruptionTimeoutFailure)).fork
result <- p.await
} yield resultI thought that we could handle interrupting the second fiber if it is not needed by moving it before the first fiber in the for comprehension and cancelling it at the same time we fulfilled the promise, but at least in my local testing when I did that I got a warning about another resource leak because we are trying to cancel it after the test has already completed. So not sure we can do that part. |
|
@adamgfraser I got rid of warnings and fiber traces. This time |
|
This is getting complicated. Can we simplify by using test.either.raceWith(Live.live(ZIO.sleep(duration)))(
(exit, fiber) => ZIO.done(exit) <* fiber.interrupt,
(_, fiber) => fiber.interrupt.raceWith(Live.live(ZIO.sleep(interruptDuration)))(
(_, fiber) => ZIO.succeed(Left(timeoutFailure)) <* fiber.interrupt,
(_, _) => ZIO.succeed(Left(interruptionTimeoutFailure))
)
).absolveI think this will also address the flakiness and let us reduce the timeout back to 2 ns. |
|
@LGLO ZIO#timeout should automatically interrupt after the specified time elapses. What behavior did you observe that led to this re-implementation? |
|
@jdegoes Currently if testM("test") {
assertM(ZIO.never.uninterruptible, anything)
} @@ timeout(60.seconds)This is obviously a contrived example, but during development a user can accidentally write code involving such an effect and under the current behavior it could appear that ZIO Test is not working correctly. |
|
@adamgfraser Excellent point! I wonder if this (useful) functionality should be built into ZIO, e.g. Then we can use that operator here in the implementation of the |
|
@jdegoes Yes I think that is a great idea. How about: final def timeoutFork(d: Duration): ZIO[R with Clock, E, Either[Fiber[E, A], A]] =
raceWith(ZIO.sleep(d))(
(exit, fiber) => ZIO.done(exit).map(Right(_)) <* fiber.interrupt,
(_, fiber) => fiber.interrupt.flatMap(ZIO.done).fork.map(Left(_))
)If the effect is timed out we give the user back a reference to the fiber interrupting the effect so they can decide what further action to take (e.g. ignore it, wait a certain amount of time for it to succeed or else do something else). |
|
@adamgfraser - your implementation is far more elegant and I think we could go with it or wait for |
|
@LGLO I submitted #1856 to add Good thought checking the flakiness with a large number of repetitions. I'm having a hard time replicating locally using Live
.withLive(test)(_.either.timeoutFork(duration).flatMap {
case Left(fiber) =>
fiber.join.raceWith(ZIO.sleep(interruptDuration))(
(_, fiber) => ZIO.succeed(Left(timeoutFailure)) <* fiber.interrupt,
(_, _) => ZIO.succeed(Left(interruptionTimeoutFailure))
)
case Right(result) => ZIO.succeed(result)
})
.absolveI'm testing flakiness using |
|
With parameters 2.nanos and 1.nano I can observe flakiness. |
|
@LGLO Just submitted a PR to your branch to implement |
Implement TestAspect#timeout Via ZIO#timeoutFork
|
@ghostdogpr Could you take a look? |
|
@adamgfraser Thanks for your comments and PR! Now we need someone to review this effort. Could you remove your changes request? |
|
@LGLO Done. |
* Add interrupt deadline to 'timeout' TestAspect * Fixed imports. Using Live.live(...). Moved util funtion to TestUtil. * failedWith implemented with * Reduced timeouts to nanoseconds * Interrupt timeout threads * Low-level promise operations to avoid warnings. * Don't try to interrupt completed fiber * Anti-flakiness
Fixes #1764
I race test execution with timeout and also race it, using promise, with test timeout plus interruption timeout.
I'm not really proud of TestAspectSpec additions, any hints there?