typelevel / typelevel/cats-effect

Add `Fiber#joinOrCancel`

Open
#4,620 36 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

:mushroom: enhancement
Dominant language
Scala
Stars
2.2k
Forks
576
Avg merge
2d 11h
Merged PRs (30d)
18

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading the existing Fiber join and cancel APIs, the IOFiber implementation, and issue #4571 for the resource-leak context. Define how the default joinOrCancel behavior differs from IOFiber's atomic implementation, then verify that completion and cancellation races preserve the intended outcome semantics.

Written by the indexing model from the issue text.

Assessment

Tech stack
scala
Domain
api, backend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.