-
Notifications
You must be signed in to change notification settings - Fork 558
Description
val prg = IO.unit
.guarantee(IO(println("release 1 - enter")) *> Timer[IO].sleep(500.millis) *> IO(println("release 1 - exit")))
.guarantee(IO(println("release 2 - enter")) *> Timer[IO].sleep(500.millis) *> IO(println("release 2 - exit")))
prg.start(fiber => fiber.cancel *> fiber.join)
In case we leave this to run without cancellation, the output would be:
release 1 - enter
release 1 - exit
release 2 - enter
release 2 - exit
Cancellation on the other hand is concurrent and what happens right now is that the finalizers get triggered in order, but without sequencing forced, so in case of async finalizers (which we are forcing here by usage of sleep
), we can get a non-deterministic result, like this:
release 1 - enter
release 2 - enter
release 1 - exit
release 2 - exit
Or this:
release 1 - enter
release 2 - enter
release 2 - exit
release 1 - exit
This can cause problems for example in IOApp
. Due to our implementation, as a user reported, this doesn't work as one might expect:
object Uncancellable extends IOApp {
override def run(args: List[String]): IO[ExitCode] = {
val Timeout = 2.milli
val promise = Promise[Unit] // will never finish
val loooongIO = IO.fromFuture(IO(promise.future))
for {
_ <- IO(println("test uncancellable"))
_ <- loooongIO.guaranteeCase {
case Completed => IO(println("Completed"))
case Canceled => IO(println("Canceled: Before")) *> IO.sleep(Timeout) *> IO(println("Canceled: After"))
case Error(err) => IO(println(s"Error($err)"))
}
} yield {
ExitCode.Success
}
}
}
So when the user interrupts this app (via SIGINT
or SIGABORT
) that finalizer doesn't get a chance to run, because there's no back-pressuring between it and the app's shutdown hook. And that's because finalizers aren't sequenced on cancellation.
The questions are:
- should finalizers be sequenced on cancellation?
- if not, how should we fix the
IOApp
sample above?
- actually, should this sample be fixed or should it be considered normal behavior?
Why Aren't Finalizers Sequenced On Cancellation?
The builder for cancellable IOs, which sort of reflects the internal ADT, is this:
def cancelable[A](k: (Either[Throwable, A] => Unit) => IO[Unit]): IO[A]
However that IO[Unit]
in that function is a replacement for () => Unit
and isn't back-pressured on cancellation. The reason is two fold:
- cancellation happens in race conditions, once you cancel it means you no longer care about the result and freeing of resources soon enough is always a best effort anyway, therefore it's a "fire and forget" event
- in general back-pressuring on the result of a cancellation event is a really bad idea in network protocols, people having bad experiences with TCP's protocol for closing connections
- cancellation is an event that's concurrent with the run-loop processing of that task, which means that whatever you do in your cancellation logic or finalizers has to be synchronized somehow; so if you end up with ...
bracket(acquire1) { _ => bracket(acquire2)(use2)(release2) } (release1)
If release1
depends on release2
, then on cancellation you need some sort of synchronization anyway for the impure, side effectful parts being suspended, so it wouldn't be a stretch to suggest that such synchronization should also happen for sequencing the finalizers when needed.
Pro-Sequencing of Finalizers on Cancellation
The mental model users have is that of flatMap
so it makes sense for finalizers to be sequenced on cancellation as well.
@rossabaker built IOApp
and he wasn't aware that his IOApp
will not work for this use-case. And on reviewing his code, I also did not see a problem. So this means the current behavior can lead to unexpected behavior even when used by experienced developers.
It would also be consistent with how try/finally
behaves, even in the face of InterruptedException
, although that's not a very good argument to be honest
Anti-Sequencing of Finalizers on Cancellation
As far as I'm aware, we'd have the only implementation that does this.
- Scalaz 8 does not do it
- ReactiveX does not do it
- I don't know how Haskell does it, but it's probably not doing it either
The issues that come up are these ...
Case 1 — the inner release
blocks the execution of the outer release indefinitely and this can lead to unintended leakage, because in a race condition the logic won't wait and won't care if release1
is executed in order to proceed, this being the main problem with back-pressuring on an acknowledgement of interruption in network protocols, see arguments above:
bracket(acquire1) { _ =>
bracket(acquire2)(use2)( IO.never )
} (release1)
Case 2 — the inner release
triggers an error:
bracket(acquire1) { _ =>
bracket(acquire2)(use2)( IO.raiseError(e) )
} (release)
This isn't problematic for our implementation, but it is problematic for other implementations. Specifically the way Scalaz 8 implemented errors finalizers are not allowed to throw errors and when they do, they are caught by the Fiber's error handling mechanism. If you sequence finalizers, that's going to be a problem, because such errors in Scalaz are blowing up the Fiber's run-loop AFAIK.
So what this means is that we cannot add this as a law in Concurrent
, we cannot rely on it for polymorphic code.
The question is, what are we going to do about cats.effect.IO
? Should we make it behave like this, or should we fix IOApp
in some other way?
Just to make it clear, we can make IO
behave like that on finalization, the question being, do we want to?
I'm conflicted on the right answer, I'm leaning towards making it safe, but safety in this case is in the eyes of the beholder 🙂
/cc @rossabaker @jdegoes @gvolpe @iravid — with the pro and anti arguments brought above, I would appreciate feedback