Skip to content

Lossless cancelation - #4710

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

reardonj wants to merge 19 commits into
typelevel:series/3.7.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, #3474 and related issues.

This solution avoids adding a completely different cancelation mechanism by breaking the fundamental principles of cancelation, by letting us 'continue' instead of unconditionally canceling, allowing graceful cleanup:

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

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

I've also included Poll syntax to capture the idiom, so we can just write poll.fiberGracefully(f). This is essentially joinOrCancel (#4620)

Implementation

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

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 onCancelGraceful or onCancel is hit. In either case, the handler is then executed. Then in the onCancelGraceful 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 onCancelGraceful'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 onCancelGraceful still requires poll, this design avoids the weirdness 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 onCancel 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. Since cancelation plays out by unwinding the stack, we really can't run finalizers asynchronously.

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.
  • rebase onto series/3.x. This PR introduces new methods so it needs to be 3.8.0

AI Use

Some of this code was generated by Copilot, especially test updates.

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
reardonj force-pushed the 4620-onCancelContinueWith branch 2 times, most recently from 6bb7389 to cfd23db Compare November 4, 2026 04:49
@reardonj
reardonj changed the base branch from series/3.x to series/3.7.x November 5, 2026 16:21
@reardonj
reardonj force-pushed the 4620-onCancelContinueWith branch from 3e9db9a to a56d797 Compare November 5, 2026 16:21
reardonj and others added 14 commits November 5, 2026 11:31
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
"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.
Still getting flakey CI. Not able to reproduce locally, so I don't think I'm seeing a real issue.
@reardonj
reardonj force-pushed the 4620-onCancelContinueWith branch from a56d797 to 9ac7ec7 Compare November 5, 2026 16:40
@reardonj
reardonj marked this pull request as ready for review November 5, 2026 17:56
Add an explicit test to ensure that the take.timeout.timeout issue is resolved by the racePair fix.
Since finalizing can now go back and forth, we can't just use a finalizing flag. Instead, we just track if the cont still has a get pending.
Now that ContState doesn't need to care about cancelation, we can replace the finalizing flag with mask tracking. Also fixes a bug where error handling after unmasking took precedence over cancelation.
@reardonj
reardonj force-pushed the 4620-onCancelContinueWith branch from f85904a to f313ce8 Compare November 10, 2026 03:13

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