Skip to content

Add Fiber#joinOrCancel #4620

Description

@djspiewak

I think this may be the solution to all the resource leaks around nested fibers. For example, #4571

Here's the intuition:

ioa.start
  .flatMap { inner => 
    inner.join.onCancel(inner.cancel)
  }
  .start.flatMap { outer => 
    outer.cancel *> outer.join
  }

In this spaghetti, the really problematic bit is inner.join.onCancel(inner.cancel). This is problematic because there's a defiance of plain intent here in the semantics of cancelation. Specifically, what can happen is the inner.join can be canceled, which results in inner.cancel, but when the inner is canceled it may have already completed. If that happens, the value is lost regardless of what else we do, because inner.join is already yeeted and outer is committed to its cancelation. This pattern isn't universal when we use fibers, but it's fairly common. It's fundamental to racePair (and thus, timeout), and we also see it pop up in Resource quite a bit.

If this were all happening within a single fiber, the semantics of uncancelable would have saved us, since the inner.join expression would have either completed (in which case we have the value, so the onCancel doesn't run but the continuing flatMap does) or would have been canceled (in which case its finalizers would have cascaded), with none of this liminal half-and-half nonsense. joinOrCancel allows us to approximate this behavior across the fiber boundary.

The default implementation is obvious:

def joinOrCancel(implicit F: MonadCancel[F, _]): F[Outcome[F, E, A]] =
  join.onCancel(cancel)

IOFiber would override this to implement the same thing but atomically, matching the semantics of the analogous non-nested-fiber construction. For external observers, this is essentially semantic-preserving because the difference between the atomic and non-atomic version is unobservable up to race condition (so, same argument as our functor law breakage), with the only difference being that some races are now off the table.

Another much trickier option is we could detect join.onCancel and make it behave differently, but I think that's more brittle and not worth it (also requires the run loop pattern match to do something weird).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions