Skip to content

Maybe force sequencing of finalizers, on cancelation #267

@alexandru

Description

@alexandru
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:

  1. should finalizers be sequenced on cancellation?
  2. 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:

  1. 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
  1. 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.

  1. Scalaz 8 does not do it
  2. ReactiveX does not do it
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions