Skip to content

Lossless cancelation - #4710

Draft
reardonj wants to merge 9 commits into
typelevel:series/3.xfrom
reardonj:4620-onCancelContinueWith
Draft

reardonj wants to merge 9 commits into
typelevel:series/3.xfrom
reardonj:4620-onCancelContinueWith

Conversation

@reardonj

@reardonj reardonj commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

This PR provides an alternate solution to #4641 to fix #4571 , #4489 and related issues.

This solution avoids adding a completely different cancelation mechanism by breaking the fundamental principles of cancelation, by letting us 'continue':

def onCancelContinueWith[A](fa: F[A], continue: F[A]): F[A] 

onCancelContinueWith works like onCancel except:

  1. if it errors, the fiber errors
  2. the fiber continues with the value it returns instead of functioning as finalizer.

This let's us write idioms like:

poll(f.join).onCancelContinueWith(f.cancel *> f.join)

To express that if the fiber is canceled, another fiber, f, should also be canceled, but still needs to be joined to the current fiber.

Implementation

onCancelContinueWith is defined in MonadCancel, with a default implementation that degrades to onCancel. This results in fairly clean fallback behavior (no worse than in CE 3.7.x) for implementations that don't define their own onCancelContinueWith.

This required some reworking of how cancelation works. There is no longer a separate stack of finalizers in IOFiber1. Instead, the stack is unwound until an onCancelContinueWith or onCancel is hit. In either case, the handler is then executed. Then in the onCancelContinueWith case, the fiber continues executing instead of continuing cancelation. However, the fiber is still canceled! So we'll immediately continue unwinding unless cancellation is masked where onCancelContinueWith's handler was run. If cancellation is masked, the fiber will continue running until unmasked, then start unwinding again.

I've also written async* equivalents that complete on cancelation and made use of them in a few places.

Compared to #4641

Since onCancelContinueWith still requires poll, this design avoids the pitfall of #4641 where onCancelRequested didn't actually need poll, but it was needed to make things work with the bad default onCancelRequested.

This design is less powerful, as the stack has to get unwound to the handler before continuing, so state that was set up in the poll is lost before the handler goes off. This means usages tend to be a bit repetitive (eg. the join example above), where both the poll body and handler have similar content.

On the upside, laws for the new method are fairly obvious, as they mostly follow the cancel laws.

What is Not Fixed

  • This closes the cancelation gap in cancelable, but does not avoid the separate fiber. If anything, this probably pushes us further away from removing the fiber by removing the separate finalizer stack
  • This PR does not add joinOrCancel as I can't see a way to do it without passing in a poll, which feels rather ugly. Instead I have added some methods to attach one fiber to another, so their cancelations are ordered properly.

TODO

  • I haven't implemented this in PureConc, as PureConc not running finalizers #3430 makes this kind of hard
  • I'm open to naming suggestions. I considered using 'delay' or 'defer' instead of 'continue' for brevity, but those are already CE terms.

Footnotes

  1. As a bonus, under -XX:+UseCompactObjectHeaders / JVM 27, this fixes Can we push to make IOFiber fit in 64 bytes? #3584 by pushing the object size to 64 bytes! ↩

reardonj and others added 4 commits November 3, 2026 20:13
The sys.error call is a side-effect and should be suspended in IO
- add onCancelContinueWith that can temporarily revert cancelation to MonadCancel and IO
- replace cancelation stack with full unwinding to support continuing from cancelation
- add equivalent methods for Async
- use onCancelContinueWith to make racePair and other combinators cancelation-safe
@reardonj
reardonj force-pushed the 4620-onCancelContinueWith branch from ce37681 to 6bb7389 Compare November 4, 2026 04:36
@reardonj
reardonj force-pushed the 4620-onCancelContinueWith branch from 6bb7389 to cfd23db Compare November 4, 2026 04:49
"parTraverseN - interrupt on errors" failed in CI on: macos-14, 2.13.18, temurin@17, ciJVM

Local running of the parTraverse benchmarks don't show an significant change in speed between branch and series/3.x, so I'm chalking this up to flakey CI and reducing repetitions.
PureConc really needs async cancelation to be implemented to get that to work.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Queue.take.timeout(...) loses elements on cancelation Can we push to make IOFiber fit in 64 bytes?

1 participant