Lossless cancelable and joinOrCancel#4641
Open
reardonj wants to merge 5 commits into
Open
Conversation
The sys.error call is a side-effect and should be suspended in IO
- add polling cancelable. This is needed to safely start the join in `joinOrCancel` without introducing a cancelation boundary that could drop an already started fiber - replace unsafe usages of `join.onCancel(cancel)` construct with `joinOrCancel` - replace fromCompletableFuture with an implementation that uses cancelable
In IO, cancelable can be implemented without hoisting the operation to a separate thread, by invoking the callback when cancelation is requested. Partly based on Arman's previous attempt in typelevel#3491 Co-authored-by: Arman Bilge <armanbilge@gmail.com>
IO.racePair has to be uncancelable because cancelable introduces a cancelation boundary when used unmasked
reardonj
force-pushed
the
4620-cancelable
branch
from
July 18, 2026 17:42
2596350 to
2a9e3b6
Compare
This was referenced Jul 18, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
There are a number of open bugs relating to data loss during races:
Fiber#joinOrCancel#4620Fundamentally, these all must occur because there is no way to do all of the following together:
In particular, the fiber that started the other fiber doesn't know it's getting canceled until it observes cancelation (ie.
onCancelruns). At this point it is no longer possible for the fiber to complete, it must cancel. So all it can do is terminate its child fibers and carry on1.I had previously tried to solve this problem with a new
onCancelRequestedcombinator (#4633 ), but that would break otherFs. This implementation instead follows @djspiewak's suggestion to base the solution on an @armanbilge'scancelablefix in #3491 which would givecancelabledifferent behavior inIO, but also leave a working (but not ideal) implementation for otherFs already.New
cancelableBehaviourIn
IO, cancelable can be implemented without hoisting the operation to a separate thread, by invoking the callback when cancelation is requested. Partly based on Arman's previous attempt in #3491This changes the behavior of
cancelableinIOvs otherFs. Specifically if this code is canceled:In
IO, the message "cancelable" will print first, while anFusing the defaultGenSpawnwill print "onCancel" first, as it'scancelableis implemented in terms ofonCancel.cancelabledoesn't describe any details of its semantics, so we're taking the liberty of choosing a different way to implement it 😸Changing the behavior this way is extremely useful! By invoking
cancelablefinalizers before the fiber observes cancelation, we are able to give the operation a chance to return a result before the fiber is canceled and becomes unable to return a result, but also try canceling the operation to make sure cancelation does actually happen if we can't complete.Changes to Use This Behaviour
Unsafe usages of
join.onCancel(cancel)are replaced with usages ofcancelableso that they can become lossless inIO. This does make these usages less efficient in otherFs, as we now start an extra fiber and join it. But in IO,joinOrCancelwill result in exactly one of getting outcome of the fiber or the fiber being canceled2.cancelableremains lossy by default, but this is unfixable without new semantics which would break compatibility.This change also required a new
cancelablethat accepts apoll, to avoid introducing a cancelation boundary while setting up thecancelablemachinery.The implementation of
fromCompletableFutureis also updated to usecancelableinstead of it's bespokecontimplementation. This implementation is no better by default, but will no longer lose data inIO.Implementation
IOFiber
cancelableinIOis now handled as a new primitiveIO.Cancelableclass. This operation introduces a second finalizer (referred to as acks to differentiation from the actual finalizers) stack toIOFiber. When the fiber receives a cancelation request, all acks are immediately run in parallel and any acks that get added after this point are also immediately started. This behavior is intended to drive the fiber towards cancelation as quickly as possible, with the expectation that acks are safe to run at any time, unlike finalizers, which clean up resources. The fiber waits for all acks to complete before running finalizers, since the acks could depend on a resource that will be disposed by a finalizer. If theIO.Cancelablewould come off the stack, the fiber will also wait on the ack to complete3IO.racePairis also updated to usecancelableinstead of async cancelation so it does not lose data during a race following @armanbilge's earlier implementation.polling
cancelableTo fully plug the race, I needed a new
cancelablethat accepted apollparameter. The existing problematic code (join.onCancel(cancel)) was typically wrapped in a poll so that the join could be canceled. Just changing this topoll(joinOrCancel)is insufficient, as there is now a cancelation boundary before the finalizer is installed, leading to possible leaks of the fiber. For the default implementation, we needed to be able to makecancelableunmask only after everything is set up, so apollneeds to be passed in. TheIOimplementation ignores the poll, since its handling ignores masking entirely.Footnotes
You can do a little better if you know the fiber is returning a resource, and clean up that resource, but you still can't get data back out. ↩
or non-termination, of course. ↩
The ack should already be completed, since the ack cancelable action should ↩