From d1003df0f326de1a0837f3ee6d71383fba71f64f Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Thu, 9 Jul 2026 22:07:31 -0400 Subject: [PATCH 01/26] PoC onCancelRequested Basic implementation of onCancelRequested for IO. This version uses existing IO combinators instead of inlining the entire implementation in IOFiber. It also runs the `ack` asynchronously as that was easier to implement. Also adds a `Fiber.joinOrCancel` implementation based on this new method. --- .../src/main/scala/cats/effect/IO.scala | 20 +++++++ .../src/main/scala/cats/effect/IOFiber.scala | 56 +++++++++++++++++++ .../main/scala/cats/effect/kernel/Fiber.scala | 11 ++++ .../cats/effect/kernel/MonadCancel.scala | 46 +++++++++++++++ .../src/test/scala/cats/effect/IOSuite.scala | 47 ++++++++++++++++ 5 files changed, 180 insertions(+) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index 41fb79db63..777406cd6d 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -590,6 +590,15 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { def onCancel(fin: IO[Unit]): IO[A] = IO.OnCancel(this, fin) + def onCancelRequested(ack: IO[Unit]): IO[A] = { + val complete = IO.PopCancelRequested.flatMap { + case Some(fiber) => fiber.join.void + case None => IO.unit + } + + IO.uncancelable(poll => IO.PushCancelRequested(ack) *> poll(this).guarantee(complete)) + } + @deprecated("Use onError with PartialFunction argument", "3.6.0") private[effect] def onError(f: Throwable => IO[Unit]): IO[A] = { val pf: PartialFunction[Throwable, IO[Unit]] = { case t => f(t).reportError } @@ -2059,6 +2068,9 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara def onCancel[A](ioa: IO[A], fin: IO[Unit]): IO[A] = ioa.onCancel(fin) + override def onCancelRequested[A](ioa: IO[A], ack: IO[Unit]): IO[A] = + ioa.onCancelRequested(ack) + override def bracketFull[A, B](acquire: Poll[IO] => IO[A])(use: A => IO[B])( release: (A, OutcomeIO[B]) => IO[Unit]): IO[B] = IO.bracketFull(acquire)(use)(release) @@ -2328,6 +2340,14 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara def tag = 24 } + private[effect] final case class PushCancelRequested(ack: IO[Unit]) extends IO[Unit] { + def tag = 25 + } + + private[effect] case object PopCancelRequested extends IO[Option[FiberIO[Unit]]] { + def tag = 26 + } + // INTERNAL, only created by the runloop itself as the terminal state of several operations private[effect] case object EndFiber extends IO[Nothing] { def tag = -1 diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index e34f3d6586..154e9fc96a 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -86,6 +86,7 @@ private final class IOFiber[A]( private[this] var currentCtx: ExecutionContext = startEC private[this] val objectState: ArrayStack[AnyRef] = ArrayStack() private[this] val finalizers: ArrayStack[IO[Unit]] = ArrayStack() + private[this] val acknowledgers: ArrayStack[IO[Unit]] = ArrayStack() private[this] val callbacks: CallbackStack[OutcomeIO[A]] = CallbackStack.of(cb) private[this] var resumeTag: Byte = ExecR private[this] var resumeIO: IO[Any] = startIO @@ -103,6 +104,9 @@ private final class IOFiber[A]( private[this] var masks: Int = 0 private[this] var finalizing: Boolean = false + // The running cancelation acknowledgement, if any + private[this] var runningAcknowledgement: FiberIO[Unit] = null + @volatile private[this] var outcome: OutcomeIO[A] = _ @@ -146,6 +150,7 @@ private final class IOFiber[A]( /* check to see if the target fiber is suspended */ if (resume()) { + acknowledgeCancelation() /* ...it was! was it masked? */ if (isUnmasked()) { /* ...nope! take over the target fiber's runloop and run the finalizers */ @@ -239,6 +244,7 @@ private final class IOFiber[A]( } } + acknowledgeCancelation() if (shouldFinalize()) { val fin = prepareFiberForCancelation(null) runLoop(fin, nextCancelation, nextAutoCede) @@ -531,6 +537,7 @@ private final class IOFiber[A]( /* Canceled */ case 10 => canceled = true + acknowledgeCancelation() if (isUnmasked()) { /* run finalizers immediately */ val fin = prepareFiberForCancelation(null) @@ -832,6 +839,7 @@ private final class IOFiber[A]( * finalisers. */ if (resume()) { + acknowledgeCancelation() if (shouldFinalize()) { val fin = prepareFiberForCancelation(null) runLoop(fin, nextCancelation, nextAutoCede) @@ -877,6 +885,8 @@ private final class IOFiber[A]( * we were canceled, but `cancel` cannot run the finalisers * because the runloop was not suspended, so we have to run them */ + // TODO is this the right position? These conditions are odd + acknowledgeCancelation() val fin = prepareFiberForCancelation(null) runLoop(fin, nextCancelation, nextAutoCede) } @@ -1068,6 +1078,27 @@ private final class IOFiber[A]( /* ReadRT */ case 24 => runLoop(succeeded(runtime, 0), nextCancelation, nextAutoCede) + + /* OnCancelRequested */ + case 25 => + val cur = cur0.asInstanceOf[PushCancelRequested] + + acknowledgers.push(EvalOn(cur.ack, currentCtx)) + // println(s"pushed onto acknowledgers: length = ${acknowledgers.unsafeIndex()}") + + runLoop(IO.unit, nextCancelation, nextAutoCede) + + case 26 => + val _ = acknowledgers.pop() + + if (runningAcknowledgement == null) + runLoop(IO.none, nextCancelation, nextAutoCede) + else { + val ack = runningAcknowledgement + runningAcknowledgement = null + runLoop(IO.some(ack), nextCancelation, nextAutoCede) + } + } } } @@ -1118,6 +1149,7 @@ private final class IOFiber[A]( conts = null objectState.invalidate() finalizers.invalidate() + acknowledgers.invalidate() currentCtx = null if (isStackTracing) { @@ -1163,6 +1195,25 @@ private final class IOFiber[A]( } } + private[this] def acknowledgeCancelation(): Unit = { + if (canceled && runningAcknowledgement == null && !acknowledgers.isEmpty()) { + // println(s"$this: starting cancelation acknowledgement") + val acknowledgement = acknowledgers.peek() + val ec = currentCtx + val rt = runtime + + val runningAcknowledgement = new IOFiber[Any]( + localState, + null, + acknowledgement, + ec, + rt + ) + + scheduleFiber(ec, runningAcknowledgement) + } + } + /* * We should attempt finalization if all of the following are true: * 1) We own the runloop @@ -1393,6 +1444,7 @@ private final class IOFiber[A]( objectState.init(16) finalizers.init(16) + acknowledgers.init(16) val io = resumeIO resumeIO = null @@ -1411,12 +1463,14 @@ private final class IOFiber[A]( } private[this] def asyncContinueCanceledR(): Unit = { + acknowledgeCancelation() val fin = prepareFiberForCancelation(null) runLoop(fin, runtime.cancelationCheckThreshold, runtime.autoYieldThreshold) } private[this] def asyncContinueCanceledWithFinalizerR(): Unit = { val cb = objectState.pop().asInstanceOf[Either[Throwable, Unit] => Unit] + acknowledgeCancelation() val fin = prepareFiberForCancelation(cb) runLoop(fin, runtime.cancelationCheckThreshold, runtime.autoYieldThreshold) } @@ -1507,6 +1561,7 @@ private final class IOFiber[A]( val ec = objectState.pop().asInstanceOf[ExecutionContext] currentCtx = ec + acknowledgeCancelation() if (!shouldFinalize()) { resumeTag = AsyncContinueSuccessfulR objectState.push(result.asInstanceOf[AnyRef]) @@ -1525,6 +1580,7 @@ private final class IOFiber[A]( val ec = objectState.pop().asInstanceOf[ExecutionContext] currentCtx = ec + acknowledgeCancelation() if (!shouldFinalize()) { resumeTag = AsyncContinueFailedR objectState.push(t) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala index 1588a18ee7..8470a75479 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala @@ -53,6 +53,17 @@ trait Fiber[F[_], E, A] extends Serializable { */ def join: F[Outcome[F, E, A]] + /** + * Awaits the completion of the fiber bound to this [[Fiber]] and returns its [[Outcome]] once + * it completes and cancels the fiber if cancelation is requested. + * + * @note + * This method provides a data-race free version of `join.onCancel(cancel)`, which can lose + * data if the fiber completes after cancelation is observed. + */ + def joinOrCancel(implicit F: MonadCancel[F, E]): F[Outcome[F, E, A]] = + F.onCancelRequested(join, cancel) + /** * Awaits the completion of the bound fiber and returns its result once it completes. * diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala b/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala index fbe6dc312f..22dae65989 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala @@ -335,6 +335,20 @@ trait MonadCancel[F[_], E] extends MonadError[F, E] { */ def onCancel[A](fa: F[A], fin: F[Unit]): F[A] + /** + * Registers an acknowledgement that is invoked asynchronously if cancelation is requested + * during the evaluation of `fa`. If the evaluation of `fa` completes without encountering a + * cancelation, the acknowledgement is unregistered before proceeding. If `ack` was invoked, + * further execution will be blocked until it completes. If cancelation is observed, the `ack` + * will be awaited on before cancelation completes. + * + * If asynchronous cancelation is not supported by `F`, this function equivalent to `fa` + */ + def onCancelRequested[A](fa: F[A], ack: F[Unit]): F[A] = { + val _ = ack + fa + } + /** * Specifies an effect that is always invoked after evaluation of `fa` completes, regardless * of the outcome. @@ -648,6 +662,9 @@ object MonadCancel { def onCancel[A](fa: OptionT[F, A], fin: OptionT[F, Unit]): OptionT[F, A] = OptionT(F.onCancel(fa.value, fin.value.void)) + override def onCancelRequested[A](fa: OptionT[F, A], ack: OptionT[F, Unit]): OptionT[F, A] = + OptionT(F.onCancelRequested(fa.value, ack.value.void)) + def forceR[A, B](fa: OptionT[F, A])(fb: OptionT[F, B]): OptionT[F, B] = OptionT( F.forceR(fa.value)(fb.value) @@ -702,6 +719,11 @@ object MonadCancel { def onCancel[A](fa: EitherT[F, E0, A], fin: EitherT[F, E0, Unit]): EitherT[F, E0, A] = EitherT(F.onCancel(fa.value, fin.value.void)) + override def onCancelRequested[A]( + fa: EitherT[F, E0, A], + ack: EitherT[F, E0, Unit]): EitherT[F, E0, A] = + EitherT(F.onCancelRequested(fa.value, ack.value.void)) + def forceR[A, B](fa: EitherT[F, E0, A])(fb: EitherT[F, E0, B]): EitherT[F, E0, B] = EitherT( F.forceR(fa.value)(fb.value) @@ -756,6 +778,9 @@ object MonadCancel { def onCancel[A](fa: IorT[F, L, A], fin: IorT[F, L, Unit]): IorT[F, L, A] = IorT(F.onCancel(fa.value, fin.value.void)) + override def onCancelRequested[A](fa: IorT[F, L, A], ack: IorT[F, L, Unit]): IorT[F, L, A] = + IorT(F.onCancelRequested(fa.value, ack.value.void)) + def forceR[A, B](fa: IorT[F, L, A])(fb: IorT[F, L, B]): IorT[F, L, B] = IorT( F.forceR(fa.value)(fb.value) @@ -810,6 +835,11 @@ object MonadCancel { def onCancel[A](fa: Kleisli[F, R, A], fin: Kleisli[F, R, Unit]): Kleisli[F, R, A] = Kleisli { r => F.onCancel(fa.run(r), fin.run(r)) } + override def onCancelRequested[A]( + fa: Kleisli[F, R, A], + ack: Kleisli[F, R, Unit]): Kleisli[F, R, A] = + Kleisli { r => F.onCancelRequested(fa.run(r), ack.run(r)) } + def forceR[A, B](fa: Kleisli[F, R, A])(fb: Kleisli[F, R, B]): Kleisli[F, R, B] = Kleisli(r => F.forceR(fa.run(r))(fb.run(r))) @@ -864,6 +894,11 @@ object MonadCancel { def onCancel[A](fa: WriterT[F, L, A], fin: WriterT[F, L, Unit]): WriterT[F, L, A] = WriterT(F.onCancel(fa.run, fin.value.void)) + override def onCancelRequested[A]( + fa: WriterT[F, L, A], + ack: WriterT[F, L, Unit]): WriterT[F, L, A] = + WriterT(F.onCancelRequested(fa.run, ack.value.void)) + def forceR[A, B](fa: WriterT[F, L, A])(fb: WriterT[F, L, B]): WriterT[F, L, B] = WriterT( F.forceR(fa.run)(fb.run) @@ -926,6 +961,11 @@ object MonadCancel { def onCancel[A](fa: StateT[F, S, A], fin: StateT[F, S, Unit]): StateT[F, S, A] = StateT[F, S, A](s => F.onCancel(fa.run(s), fin.runA(s))) + override def onCancelRequested[A]( + fa: StateT[F, S, A], + ack: StateT[F, S, Unit]): StateT[F, S, A] = + StateT[F, S, A](s => F.onCancelRequested(fa.run(s), ack.runA(s))) + def uncancelable[A](body: Poll[StateT[F, S, *]] => StateT[F, S, A]): StateT[F, S, A] = StateT[F, S, A] { s => F uncancelable { poll => @@ -991,6 +1031,12 @@ object MonadCancel { fin: ReaderWriterStateT[F, E0, L, S, Unit]): ReaderWriterStateT[F, E0, L, S, A] = ReaderWriterStateT[F, E0, L, S, A]((e, s) => F.onCancel(fa.run(e, s), fin.runA(e, s))) + override def onCancelRequested[A]( + fa: ReaderWriterStateT[F, E0, L, S, A], + ack: ReaderWriterStateT[F, E0, L, S, Unit]): ReaderWriterStateT[F, E0, L, S, A] = + ReaderWriterStateT[F, E0, L, S, A]((e, s) => + F.onCancelRequested(fa.run(e, s), ack.runA(e, s))) + def uncancelable[A]( body: Poll[ReaderWriterStateT[F, E0, L, S, *]] => ReaderWriterStateT[F, E0, L, S, A]) : ReaderWriterStateT[F, E0, L, S, A] = diff --git a/tests/shared/src/test/scala/cats/effect/IOSuite.scala b/tests/shared/src/test/scala/cats/effect/IOSuite.scala index 73dedf6129..eb8cff5cab 100644 --- a/tests/shared/src/test/scala/cats/effect/IOSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/IOSuite.scala @@ -2209,6 +2209,53 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu } yield () } + real("onCancelRequested - not invoked when not canceled") { + for { + requested <- Deferred[IO, Unit] + _ <- IO.unit.onCancelRequested(requested.complete(()).void) + _ <- requested + .complete(()) + .flatMap(completed => IO(assert(completed, "onCancelRequested executed"))) + } yield () + } + + real("onCancelRequested - invoked when canceled") { + for { + requested <- Deferred[IO, Unit] + fiber <- IO.never.onCancelRequested(requested.complete(()).void).start + _ <- fiber.cancel + _ <- requested.get + } yield () + } + + real("onCancelRequested - invoked when canceled while masked") { + for { + requested <- Deferred[IO, Unit] + fiber <- requested.get.onCancelRequested(requested.complete(()).void).uncancelable.start + _ <- fiber.cancel + } yield () + } + + real("joinOrCancel - gets result") { + for { + requested <- Deferred[IO, Boolean] + ioa <- requested + .get + .onCancelRequested(requested.complete(true).void) + .onCancel(requested.complete(false).void) + .uncancelable + .start + fiber <- ioa + .joinOrCancel + .flatMap(_.fold(IO.pure(false), _ => IO.pure(false), identity)) + .uncancelable + .start + _ <- fiber.cancel + fiberResult <- fiber.join.flatMap(_.fold(IO.pure(false), _ => IO.pure(false), identity)) + requestedResult <- requested.get + } yield assert(fiberResult == requestedResult) + } + property("serialize") { forAll { (io: IO[Int]) => serializable(io) }( implicitly, From ba270db0844bff7c327a41377da4284534967630 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Thu, 9 Jul 2026 23:08:14 -0400 Subject: [PATCH 02/26] Fix flakey tests A fiber can be canceled before it starts. Use a Deferred to make sure we have registered the onCancelRequested callback before cancelling the fiber --- tests/shared/src/test/scala/cats/effect/IOSuite.scala | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/shared/src/test/scala/cats/effect/IOSuite.scala b/tests/shared/src/test/scala/cats/effect/IOSuite.scala index eb8cff5cab..1d64e4c452 100644 --- a/tests/shared/src/test/scala/cats/effect/IOSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/IOSuite.scala @@ -2221,8 +2221,10 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu real("onCancelRequested - invoked when canceled") { for { + ready <- Deferred[IO, Unit] requested <- Deferred[IO, Unit] - fiber <- IO.never.onCancelRequested(requested.complete(()).void).start + fiber <- (ready.complete(()) *> IO.never).onCancelRequested(requested.complete(()).void).start + _ <- ready.get _ <- fiber.cancel _ <- requested.get } yield () @@ -2230,8 +2232,10 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu real("onCancelRequested - invoked when canceled while masked") { for { + ready <- Deferred[IO, Unit] requested <- Deferred[IO, Unit] - fiber <- requested.get.onCancelRequested(requested.complete(()).void).uncancelable.start + fiber <- (ready.complete(()) *> requested.get).onCancelRequested(requested.complete(()).void).uncancelable.start + _ <- ready.get _ <- fiber.cancel } yield () } From 8d8d784f70032734ab2d9ca33cdc44ef506739aa Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Thu, 9 Jul 2026 23:18:10 -0400 Subject: [PATCH 03/26] Formatting --- tests/shared/src/test/scala/cats/effect/IOSuite.scala | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/shared/src/test/scala/cats/effect/IOSuite.scala b/tests/shared/src/test/scala/cats/effect/IOSuite.scala index 1d64e4c452..5dac28f008 100644 --- a/tests/shared/src/test/scala/cats/effect/IOSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/IOSuite.scala @@ -2223,7 +2223,9 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu for { ready <- Deferred[IO, Unit] requested <- Deferred[IO, Unit] - fiber <- (ready.complete(()) *> IO.never).onCancelRequested(requested.complete(()).void).start + fiber <- (ready.complete(()) *> IO.never) + .onCancelRequested(requested.complete(()).void) + .start _ <- ready.get _ <- fiber.cancel _ <- requested.get @@ -2234,7 +2236,10 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu for { ready <- Deferred[IO, Unit] requested <- Deferred[IO, Unit] - fiber <- (ready.complete(()) *> requested.get).onCancelRequested(requested.complete(()).void).uncancelable.start + fiber <- (ready.complete(()) *> requested.get) + .onCancelRequested(requested.complete(()).void) + .uncancelable + .start _ <- ready.get _ <- fiber.cancel } yield () From 73f9c6cb453e4f83319d124bbf3977505df0c617 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Thu, 9 Jul 2026 23:33:06 -0400 Subject: [PATCH 04/26] ensure another test has callback registered --- tests/shared/src/test/scala/cats/effect/IOSuite.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/shared/src/test/scala/cats/effect/IOSuite.scala b/tests/shared/src/test/scala/cats/effect/IOSuite.scala index 5dac28f008..cf38ce280f 100644 --- a/tests/shared/src/test/scala/cats/effect/IOSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/IOSuite.scala @@ -2247,9 +2247,9 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu real("joinOrCancel - gets result") { for { + ready <- Deferred[IO, Unit] requested <- Deferred[IO, Boolean] - ioa <- requested - .get + ioa <- (ready.complete(()) *> requested.get) .onCancelRequested(requested.complete(true).void) .onCancel(requested.complete(false).void) .uncancelable @@ -2259,6 +2259,7 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu .flatMap(_.fold(IO.pure(false), _ => IO.pure(false), identity)) .uncancelable .start + _ <- ready.get _ <- fiber.cancel fiberResult <- fiber.join.flatMap(_.fold(IO.pure(false), _ => IO.pure(false), identity)) requestedResult <- requested.get From 370cb9ab68a4e2329aa0c1ef977f5f83e9e4c3a3 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Thu, 9 Jul 2026 23:47:37 -0400 Subject: [PATCH 05/26] ensure joinOrCancel test fiber actually starts --- .../src/test/scala/cats/effect/IOSuite.scala | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/shared/src/test/scala/cats/effect/IOSuite.scala b/tests/shared/src/test/scala/cats/effect/IOSuite.scala index cf38ce280f..28b35833be 100644 --- a/tests/shared/src/test/scala/cats/effect/IOSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/IOSuite.scala @@ -2247,19 +2247,19 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu real("joinOrCancel - gets result") { for { - ready <- Deferred[IO, Unit] + ioaReady <- Deferred[IO, Unit] + fiberReady <- Deferred[IO, Unit] requested <- Deferred[IO, Boolean] - ioa <- (ready.complete(()) *> requested.get) + ioa <- (ioaReady.complete(()) *> requested.get) .onCancelRequested(requested.complete(true).void) .onCancel(requested.complete(false).void) .uncancelable .start - fiber <- ioa + fiber <- (fiberReady.complete(()) *> ioa .joinOrCancel - .flatMap(_.fold(IO.pure(false), _ => IO.pure(false), identity)) - .uncancelable - .start - _ <- ready.get + .flatMap(_.fold(IO.pure(false), _ => IO.pure(false), identity))).uncancelable.start + _ <- ioaReady.get + _ <- fiberReady.get _ <- fiber.cancel fiberResult <- fiber.join.flatMap(_.fold(IO.pure(false), _ => IO.pure(false), identity)) requestedResult <- requested.get From 5a72dc27a624f2e5faea5e81697d354546142b15 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Fri, 10 Jul 2026 10:20:38 -0400 Subject: [PATCH 06/26] fix comment --- kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala index 8470a75479..808b661f20 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala @@ -58,7 +58,7 @@ trait Fiber[F[_], E, A] extends Serializable { * it completes and cancels the fiber if cancelation is requested. * * @note - * This method provides a data-race free version of `join.onCancel(cancel)`, which can lose + * This method provides a race free version of `join.onCancel(cancel)`, which can lose * data if the fiber completes after cancelation is observed. */ def joinOrCancel(implicit F: MonadCancel[F, E]): F[Outcome[F, E, A]] = From 376f9cb60379b3d651b846b5dc590d30685ca908 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Fri, 10 Jul 2026 12:30:17 -0400 Subject: [PATCH 07/26] safe racePair in GenConcurrent --- .../src/main/scala/cats/effect/kernel/GenConcurrent.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala b/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala index 024f640197..428fecf478 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala @@ -159,7 +159,7 @@ trait GenConcurrent[F[_], E] extends GenSpawn[F, E] { : F[Either[(Outcome[F, E, A], Fiber[F, E, B]), (Fiber[F, E, A], Outcome[F, E, B])]] = { implicit val F: GenConcurrent[F, E] = this - uncancelable { poll => + uncancelable { _ => for { result <- deferred[Either[Outcome[F, E, A], Outcome[F, E, B]]] @@ -167,8 +167,8 @@ trait GenConcurrent[F[_], E] extends GenSpawn[F, E] { fibA <- start(guaranteeCase(fa)(oc => result.complete(Left(oc)).void)) fibB <- start(guaranteeCase(fb)(oc => result.complete(Right(oc)).void)) - back <- onCancel( - poll(result.get), + back <- onCancelRequested( + result.get, for { canA <- start(fibA.cancel) canB <- start(fibB.cancel) From 65f92ea628cfd51d0ce8cc527caac1b512f6e2be Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sat, 11 Jul 2026 07:16:18 -0400 Subject: [PATCH 08/26] Add onCancelRequested syntax --- .../scala/cats/effect/kernel/syntax/MonadCancelSyntax.scala | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/syntax/MonadCancelSyntax.scala b/kernel/shared/src/main/scala/cats/effect/kernel/syntax/MonadCancelSyntax.scala index 946aedd0fc..bc524ce305 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/syntax/MonadCancelSyntax.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/syntax/MonadCancelSyntax.scala @@ -48,6 +48,9 @@ final class MonadCancelOps_[F[_], A] private[syntax] (private val wrapped: F[A]) def onCancel(fin: F[Unit])(implicit F: MonadCancel[F, ?]): F[A] = F.onCancel(wrapped, fin) + def onCancelRequested(ack: F[Unit])(implicit F: MonadCancel[F, ?]): F[A] = + F.onCancelRequested(wrapped, ack) + def guarantee(fin: F[Unit])(implicit F: MonadCancel[F, ?]): F[A] = F.guarantee(wrapped, fin) From cc0f31923dc3ce4ae3483a1b6430a59260441ab1 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sat, 11 Jul 2026 07:16:40 -0400 Subject: [PATCH 09/26] warn against cancelable --- core/shared/src/main/scala/cats/effect/IO.scala | 3 +++ kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala | 3 +++ 2 files changed, 6 insertions(+) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index 777406cd6d..dd184485d3 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -445,6 +445,9 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { * [[uncancelable]] * @see * [[onCancel]] + * @note + * this method can lose data if `fa` completes before `fin` can stop it. See + * [[onCancelRequested]] for a safer alternative. */ def cancelable(fin: IO[Unit]): IO[A] = Spawn[IO].cancelable(this, fin) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala index bfbdb55687..e430052ec9 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala @@ -260,6 +260,9 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { * [[uncancelable]] * @see * [[onCancel]] + * @note + * this method can lose data if `fa` completes before `fin` can stop it. See + * [[onCancelRequested]] for a safer alternative. */ def cancelable[A](fa: F[A], fin: F[Unit]): F[A] = uncancelable { poll => From ce2eaf5c079d43d288d95709c6fd3022198c7c42 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sat, 11 Jul 2026 08:15:54 -0400 Subject: [PATCH 10/26] Add methods for async cancelation --- .../src/main/scala/cats/effect/IO.scala | 94 ++++++++++++++++ .../main/scala/cats/effect/kernel/Async.scala | 100 +++++++++++++++++- .../scala/cats/effect/laws/AsyncLaws.scala | 35 ++++++ .../scala/cats/effect/kernel/AsyncSuite.scala | 46 ++++++++ 4 files changed, 272 insertions(+), 3 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index dd184485d3..699f7c3636 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -1379,6 +1379,46 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara IOCont(body, Tracing.calculateTracingEvent(k)) } + /** + * Suspends an asynchronous side effect with optional immediate result in `IO`. + * + * The given function `k` will be invoked during evaluation of the `IO` to: + * - check if result is already available; + * - "schedule" the asynchronous callback, where the callback of type `Either[Throwable, A] + * \=> Unit` is the parameter passed to that function. Only the ''first'' invocation of + * the callback will be effective! All subsequent invocations will be silently dropped. + * + * The process of registering the callback itself is suspended in `IO` (the outer `IO` of + * `IO[Either[Option[IO[Unit]], A]]`). + * + * The effect returns `Either[Option[IO[Unit]], A]` where: + * - right side `A` is an immediate result of computation (callback invocation will be + * dropped); + * - left side `Option[IO[Unit]]` is an optional acknowledgement to be run in the event that + * cancelation of the fiber running `asyncCheckAttemptCancelableAsync(k)` is requested. + * + * Note that `asyncCheckAttemptCancelableAsync` is uncancelable during its registration. + * + * @see + * [[async]] for a simplified variant without an option for immediate result + */ + def asyncCheckAttemptCancelableAsync[A]( + k: (Either[Throwable, A] => Unit) => IO[Either[Option[IO[Unit]], A]]): IO[A] = { + val body = new Cont[IO, A, A] { + def apply[G[_]](implicit G: MonadCancel[G, Throwable]) = { (resume, get, lift) => + G.uncancelable { _ => + lift(k(resume)) flatMap { + case Right(a) => G.pure(a) + case Left(Some(fin)) => G.onCancelRequested(get, lift(fin)) + case Left(None) => get + } + } + } + } + + IOCont(body, Tracing.calculateTracingEvent(k)) + } + /** * Suspends an asynchronous side effect in `IO`. * @@ -1442,6 +1482,50 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara IOCont(body, Tracing.calculateTracingEvent(k)) } + /** + * Suspends an asynchronous side effect in `IO`. + * + * The given function `k` will be invoked during evaluation of the `IO` to "schedule" the + * asynchronous callback, where the callback of type `Either[Throwable, A] => Unit` is the + * parameter passed to that function. Only the ''first'' invocation of the callback will be + * effective! All subsequent invocations will be silently dropped. + * + * The process of registering the callback itself is suspended in `IO` (the outer `IO` of + * `IO[Option[IO[Unit]]]`). + * + * The effect returns `Option[IO[Unit]]` which is an optional cancelation acknowledgement to + * be run in the event that cancelation of the fiber running `asyncCancelableAsync(k)` is + * requested. + * + * @note + * `asyncCancelableAsync` is always uncancelable during its registration. The created effect + * will be uncancelable during its execution if the registration callback provides no + * cancelation acknowledgement (i.e. evaluates to `None`). If you need the created task to + * be cancelable, return a acknowledgement effect upon the registration. In a rare case when + * there's nothing to finalize, you can return `Some(IO.unit)` for that. + * + * @see + * [[async_]] for a simplified uncancelable variant + * @see + * [[asyncCheckAttemptCancelableAsync]] for more generic version providing an optional + * immediate result of computation + */ + def asyncCancelableAsync[A]( + k: (Either[Throwable, A] => Unit) => IO[Option[IO[Unit]]]): IO[A] = { + val body = new Cont[IO, A, A] { + def apply[G[_]](implicit G: MonadCancel[G, Throwable]) = { (resume, get, lift) => + G.uncancelable { _ => + lift(k(resume)) flatMap { + case Some(fin) => G.onCancelRequested(get, lift(fin)) + case None => get + } + } + } + } + + IOCont(body, Tracing.calculateTracingEvent(k)) + } + /** * Suspends an asynchronous side effect in `IO`. * @@ -1777,10 +1861,20 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara /** * Like [[fromFuture]], but is cancelable via the provided finalizer. + * + * @see + * [[onCancelRequested]] for a safer alternative. This method can lose data if the future + * completes before the finalizaer can stop it. */ def fromFutureCancelable[A](fut: IO[(Future[A], IO[Unit])]): IO[A] = asyncForIO.fromFutureCancelable(fut) + /** + * Like [[fromFuture]], but is cancelable asynchronously. + */ + def fromFutureCancelableAsync[A](fut: IO[(Future[A], IO[Unit])]): IO[A] = + asyncForIO.fromFutureCancelableAsync(fut) + /** * Run two IO tasks concurrently, and return the first to finish, either in success or error. * The loser of the race is canceled. diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala index f9e20e1c4e..6782d5d324 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala @@ -105,6 +105,51 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { cont(body) } + /** + * Suspends an asynchronous side effect with optional immediate result in `F` and asynchronous + * cancelation. + * + * The given function `k` will be invoked during evaluation of `F` to: + * - check if result is already available; + * - "schedule" the asynchronous callback, where the callback of type `Either[Throwable, A] + * \=> Unit` is the parameter passed to that function. Only the ''first'' invocation of + * the callback will be effective! All subsequent invocations will be silently dropped. + * + * The process of registering the callback itself is suspended in `F` (the outer `F` of + * `F[Either[Option[F[Unit]], A]]`). + * + * The effect returns `Either[Option[F[Unit]], A]` where: + * - right side `A` is an immediate result of computation (callback invocation will be + * dropped); + * - left side `Option[F[Unit]]` is an optional acknowledgement to be run in the event that + * cancelation of the fiber running `asyncCheckAttemptCancelableAsync(k)` is requested. + * + * Also, note that `asyncCheckAttemptCancelableAsync` is uncancelable during its registration. + * + * @see + * [[async]] for a simplified variant without an option for immediate result + * @see + * [[async_]] for a simplified variant without an option for immediate result or cancelation + * support + */ + def asyncCheckAttemptCancelableAsync[A]( + k: (Either[Throwable, A] => Unit) => F[Either[Option[F[Unit]], A]] + ): F[A] = { + val body = new Cont[F, A, A] { + def apply[G[_]](implicit G: MonadCancel[G, Throwable]) = { (resume, get, lift) => + G.uncancelable { _ => + lift(k(resume)) flatMap { + case Right(a) => G.pure(a) + case Left(Some(fin)) => G.onCancelRequested(get, lift(fin)) + case Left(None) => get + } + } + } + } + + cont(body) + } + /** * Suspends an asynchronous side effect in `F`. * @@ -135,6 +180,36 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { def async[A](k: (Either[Throwable, A] => Unit) => F[Option[F[Unit]]]): F[A] = asyncCheckAttempt[A](cb => map(k(cb))(Left(_))) + /** + * Suspends an asynchronous side effect in `F` with asynchronous cancelation. + * + * The given function `k` will be invoked during evaluation of the `F` to "schedule" the + * asynchronous callback, where the callback of type `Either[Throwable, A] => Unit` is the + * parameter passed to that function. Only the ''first'' invocation of the callback will be + * effective! All subsequent invocations will be silently dropped. + * + * The process of registering the callback itself is suspended in `F` (the outer `F` of + * `F[Option[F[Unit]]]`). + * + * The effect returns `Option[F[Unit]]` which is an optional acknowledgement to be run in the + * event that the cancelation of the fiber running `asyncCancelableAsync(k)` is requested. + * + * @note + * `asyncCancelableAsync` is always uncancelable during its registration. The created effect + * will be uncancelable during its execution if the registration callback provides no + * finalizer (i.e. evaluates to `None`). If you need the created task to be cancelable, + * return a finalizer effect upon the registration. In a rare case when there's nothing to + * finalize, you can return `Some(F.unit)` for that. + * + * @see + * [[async_]] for a simplified variant without cancelation support + * @see + * [[asyncCheckAttemptCancelableAsync]] for more generic version with option of providing + * immediate result of computation + */ + def asyncCancelableAsync[A](k: (Either[Throwable, A] => Unit) => F[Option[F[Unit]]]): F[A] = + asyncCheckAttemptCancelableAsync[A](cb => map(k(cb))(Left(_))) + /** * Suspends an asynchronous side effect in `F`. * @@ -151,10 +226,11 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { * asyncronous effect to be cancelable, consider using `async` instead. * * @see - * [[async]] for more generic version providing a finalizer + * [[[Async!.async[A](asyncCancelation:Boolean)(k:*]]] for more generic version providing a + * finalizer * @see - * [[asyncCheckAttempt]] for more generic version with option of providing immediate result - * of computation and finalizer + * [[[Async!.asyncCheckAttempt[A](asyncCancelation:Boolean)(k:*]]] for more generic version + * with option of providing immediate result of computation and finalizer */ def async_[A](k: (Either[Throwable, A] => Unit) => Unit): F[A] = async[A](cb => as(delay(k(cb)), None)) @@ -272,6 +348,10 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { /** * Like [[fromFuture]], but is cancelable via the provided finalizer. + * + * @see + * [[onCancelRequested]] for a safer alternative. This method can lose data if the future + * completes before the finalizaer can stop it. */ def fromFutureCancelable[A](futCancel: F[(Future[A], F[Unit])]): F[A] = flatMap(executionContext) { implicit ec => @@ -285,6 +365,20 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { } } + /** + * Like [[fromFuture]], but is cancelable via asynchronous cancelation. + */ + def fromFutureCancelableAsync[A](futCancel: F[(Future[A], F[Unit])]): F[A] = + flatMap(executionContext) { implicit ec => + uncancelable { poll => + flatMap(poll(futCancel)) { + case (fut, fin) => + asyncCancelableAsync[A](cb => + as(delay(fut.onComplete(t => cb(t.toEither))), Some(fin))) + } + } + } + /** * Translates this `F[A]` into a `G` value which, when evaluated, runs the original `F` to its * completion, the `limit` number of stages, or until the first stage that cannot be expressed diff --git a/laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala b/laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala index 0c0319e081..bf8a0d40af 100644 --- a/laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala +++ b/laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala @@ -31,35 +31,70 @@ trait AsyncLaws[F[_]] extends GenTemporalLaws[F, Throwable] with SyncLaws[F] { (F.asyncCheckAttempt[A](_ => F.pure(Right(a))) <* F.unit) <-> (F.pure(a)) // format: on + // format: off + def asyncCheckAttemptCancelableAsyncImmediateIsPure[A](a: A) = + (F.asyncCheckAttemptCancelableAsync[A](_ => F.pure(Right(a))) <* F.unit) <-> (F.pure(a)) + // format: on + // format: off def asyncCheckAttemptSuspendedRightIsAsyncRight[A](a: A, fu: F[Unit]) = (F.asyncCheckAttempt[A](k => F.delay(k(Right(a))) >> fu.as(Left(None))) <* F.unit) <-> (F.async[A](k => F.delay(k(Right(a))) >> fu.as(None)) <* F.unit) // format: on + // format: off + def asyncCheckAttemptCancelableAsyncSuspendedRightIsAsyncRight[A](a: A, fu: F[Unit]) = + (F.asyncCheckAttemptCancelableAsync[A](k => F.delay(k(Right(a))) >> fu.as(Left(None))) <* F.unit) <-> (F.async[A](k => F.delay(k(Right(a))) >> fu.as(None)) <* F.unit) + // format: on + // format: off def asyncCheckAttemptSuspendedLeftIsAsyncLeft[A](e: Throwable, fu: F[Unit]) = (F.asyncCheckAttempt[A](k => F.delay(k(Left(e))) >> fu.as(Left(None))) <* F.unit) <-> (F.async[A](k => F.delay(k(Left(e))) >> fu.as(None)) <* F.unit) // format: on + // format: off + def asyncCheckAttemptCancelableAsyncSuspendedLeftIsAsyncLeft[A](e: Throwable, fu: F[Unit]) = + (F.asyncCheckAttemptCancelableAsync[A](k => F.delay(k(Left(e))) >> fu.as(Left(None))) <* F.unit) <-> (F.async[A](k => F.delay(k(Left(e))) >> fu.as(None)) <* F.unit) + // format: on + // format: off def asyncRightIsUncancelableSequencedPure[A](a: A, fu: F[Unit]) = (F.async[A](k => F.delay(k(Right(a))) >> fu.as(None)) <* F.unit) <-> (F.uncancelable(_ => fu) >> F.pure(a)) // format: on + // format: off + def asyncCancelableAsyncRightIsUncancelableSequencedPure[A](a: A, fu: F[Unit]) = + (F.asyncCancelableAsync[A](k => F.delay(k(Right(a))) >> fu.as(None)) <* F.unit) <-> (F.uncancelable(_ => fu) >> F.pure(a)) + // format: on + // format: off def asyncLeftIsUncancelableSequencedRaiseError[A](e: Throwable, fu: F[Unit]) = (F.async[A](k => F.delay(k(Left(e))) >> fu.as(None)) <* F.unit) <-> (F.uncancelable(_ => fu) >> F.raiseError(e)) // format: on + // format: off + def asyncCancelableAsyncLeftIsUncancelableSequencedRaiseError[A](e: Throwable, fu: F[Unit]) = + (F.asyncCancelableAsync[A](k => F.delay(k(Left(e))) >> fu.as(None)) <* F.unit) <-> (F.uncancelable(_ => fu) >> F.raiseError(e)) + // format: on + def asyncRepeatedCallbackIgnored[A](a: A) = F.async[A](k => F.delay(k(Right(a))) >> F.delay(k(Right(a))).as(None)) <-> F.pure(a) + def asyncCancelableAsyncRepeatedCallbackIgnored[A](a: A) = + F.asyncCancelableAsync[A](k => F.delay(k(Right(a))) >> F.delay(k(Right(a))).as(None)) <-> F.pure(a) + def asyncCancelTokenIsUnsequencedOnCompletion[A](a: A, fu: F[Unit]) = F.async[A](k => F.delay(k(Right(a))) >> F.pure(Some(fu))) <-> F.pure(a) + def asyncCancelableAsyncCancelTokenIsUnsequencedOnCompletion[A](a: A, fu: F[Unit]) = + F.asyncCancelableAsync[A](k => F.delay(k(Right(a))) >> F.pure(Some(fu))) <-> F.pure(a) + def asyncCancelTokenIsUnsequencedOnError[A](e: Throwable, fu: F[Unit]) = F.async[A](k => F.delay(k(Left(e))) >> F.pure(Some(fu))) <-> F.raiseError(e) + def asyncCancelableAsyncCancelTokenIsUnsequencedOnError[A](e: Throwable, fu: F[Unit]) = + F.asyncCancelableAsync[A](k => F.delay(k(Left(e))) >> F.pure(Some(fu))) <-> F.raiseError(e) + + def neverIsDerivedFromAsync[A] = F.never[A] <-> F.async[A](_ => F.pure(Some(F.unit))) diff --git a/tests/shared/src/test/scala/cats/effect/kernel/AsyncSuite.scala b/tests/shared/src/test/scala/cats/effect/kernel/AsyncSuite.scala index e1e982346b..c5c7af7d3b 100644 --- a/tests/shared/src/test/scala/cats/effect/kernel/AsyncSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/kernel/AsyncSuite.scala @@ -33,6 +33,7 @@ import scala.concurrent.duration._ import java.util.concurrent.atomic.AtomicBoolean import munit.DisciplineSuite +import scala.util.Failure class AsyncSuite extends BaseSuite with DisciplineSuite { @@ -111,6 +112,51 @@ class AsyncSuite extends BaseSuite with DisciplineSuite { .map(r => assert(r.forall(identity(_)))) } + real("fromFutureCancelableAsync should cancel on fiber cancelation") { + val smallDelay: IO[Unit] = IO.sleep(10.millis) + def mkf() = Promise[Unit]() + + val go = for { + canceled <- IO(new AtomicBoolean) + fiber <- IO.fromFutureCancelableAsync { + IO(mkf()).map(f => + f.future -> IO { + canceled.set(true); f.complete(Failure(new InterruptedException())) + }.void) + }.start + _ <- smallDelay + _ <- fiber.cancel + res <- IO(assert(canceled.get())) + } yield res + + TestControl.executeEmbed(go, IORuntimeConfig(1, 2)).replicateA_(1000) + + } + + real("fromFutureCancelableAsync should backpressure on cancelation") { + // a non-cancelable, never-completing Future + def mkf() = Promise[Unit]().future + + val go = for { + started <- IO(new AtomicBoolean) + fiber <- IO.fromFutureCancelableAsync { + IO { + started.set(true) + mkf() + }.map(f => f -> IO.never) + }.start + _ <- IO.cede.whileM_(IO(!started.get)) + _ <- fiber.cancel + } yield () + + TestControl + .executeEmbed(go, IORuntimeConfig(1, 2)) + .as(false) + .recover { case _: TestControl.NonTerminationException => true } + .replicateA(1000) + .map(r => assert(r.forall(identity(_)))) + } + final class AsyncIO[A](val io: IO[A]) implicit def asyncForAsyncIO: Async[AsyncIO] = new Async[AsyncIO] From aa4793ac22af9906a44be482127e969e4e0247be Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sat, 11 Jul 2026 13:18:38 -0400 Subject: [PATCH 11/26] Cancelation cannot be acknowledged after observed You cannot acknowledge cancelation after finalization has started. Which makes perfect sense in hindsight. This can happen if you register an `onCancelRequested` handler inside a `onCancel` handler, which is utterly uncancelable, which seems silly, but happened after I changed `Async.both` to use `joinOrCancel` This comes up in `Resource.both`, which uses `F.both` to run finalizers, which the `Async` implementation of uses the `join.onCancel(cancel)` construct. Changing `both` to use `joinOrCancel` means the implementation observed cancelation inside `onCancel`, at which point we are obviously never canceling anything. `both` is fundamentally lossy, but `joinOrCancel` will at least eliminate edge cases where the user might be able to observe that both sides obviously completed, but it still got canceled. The fix is a simple addition to the `acknowledgeCancelation` checks. --- .../src/main/scala/cats/effect/IOFiber.scala | 78 +++++++++---------- .../scala/cats/effect/kernel/GenSpawn.scala | 10 ++- .../cats/effect/kernel/MonadCancel.scala | 3 +- 3 files changed, 47 insertions(+), 44 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index 154e9fc96a..f0ec5671ac 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -919,44 +919,44 @@ private final class IOFiber[A]( val cur = cur0.asInstanceOf[RacePair[Any, Any]] val next = - IO.async[Either[(OutcomeIO[Any], FiberIO[Any]), (FiberIO[Any], OutcomeIO[Any])]] { - cb => - IO { - val ec = currentCtx - val rt = runtime - - val fiberA = new IOFiber[Any]( - localState, - null, - cur.ioa, - ec, - rt - ) - - val fiberB = new IOFiber[Any]( - localState, - null, - cur.iob, - ec, - rt - ) - - fiberA.setCallback(oc => cb(Right(Left((oc, fiberB))))) - fiberB.setCallback(oc => cb(Right(Right((fiberA, oc))))) - - scheduleFiber(ec, fiberA) - scheduleFiber(ec, fiberB) - - val cancel = - for { - cancelA <- fiberA.cancel.start - cancelB <- fiberB.cancel.start - _ <- cancelA.join - _ <- cancelB.join - } yield () - - Some(cancel) - } + IO.asyncCancelableAsync[ + Either[(OutcomeIO[Any], FiberIO[Any]), (FiberIO[Any], OutcomeIO[Any])]] { cb => + IO { + val ec = currentCtx + val rt = runtime + + val fiberA = new IOFiber[Any]( + localState, + null, + cur.ioa, + ec, + rt + ) + + val fiberB = new IOFiber[Any]( + localState, + null, + cur.iob, + ec, + rt + ) + + fiberA.setCallback(oc => cb(Right(Left((oc, fiberB))))) + fiberB.setCallback(oc => cb(Right(Right((fiberA, oc))))) + + scheduleFiber(ec, fiberA) + scheduleFiber(ec, fiberB) + + val cancel = + for { + cancelA <- fiberA.cancel.start + cancelB <- fiberB.cancel.start + _ <- cancelA.join + _ <- cancelB.join + } yield () + + Some(cancel) + } } runLoop(next, nextCancelation, nextAutoCede) @@ -1196,7 +1196,7 @@ private final class IOFiber[A]( } private[this] def acknowledgeCancelation(): Unit = { - if (canceled && runningAcknowledgement == null && !acknowledgers.isEmpty()) { + if (canceled && !finalizing && runningAcknowledgement == null && !acknowledgers.isEmpty()) { // println(s"$this: starting cancelation acknowledgement") val acknowledgement = acknowledgers.peek() val ec = currentCtx diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala index e430052ec9..304385372e 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala @@ -445,8 +445,8 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { def bothOutcome[A, B](fa: F[A], fb: F[B]): F[(Outcome[F, E, A], Outcome[F, E, B])] = uncancelable { poll => poll(racePair(fa, fb)).flatMap { - case Left((oc, f)) => poll(f.join).onCancel(f.cancel).tupleLeft(oc) - case Right((f, oc)) => poll(f.join).onCancel(f.cancel).tupleRight(oc) + case Left((oc, f)) => f.joinOrCancel.tupleLeft(oc) + case Right((f, oc)) => f.joinOrCancel.tupleRight(oc) } } @@ -480,7 +480,8 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { case Left((oc, f)) => oc match { case Outcome.Succeeded(fa) => - poll(f.join).onCancel(f.cancel).flatMap { + // We can only cancel `f` if cancelation is observed, otherwise, the canceled case below never returns + f.joinOrCancel.flatMap { case Outcome.Succeeded(fb) => fa.product(fb) case Outcome.Errored(eb) => raiseError(eb) case Outcome.Canceled() => poll(canceled) *> never @@ -491,7 +492,8 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { case Right((f, oc)) => oc match { case Outcome.Succeeded(fb) => - poll(f.join).onCancel(f.cancel).flatMap { + // We can only cancel `f` if cancelation is observed, otherwise, the canceled case below never returns + f.joinOrCancel.flatMap { case Outcome.Succeeded(fa) => fa.product(fb) case Outcome.Errored(ea) => raiseError(ea) case Outcome.Canceled() => poll(canceled) *> never diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala b/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala index 22dae65989..11baf967e9 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala @@ -340,7 +340,8 @@ trait MonadCancel[F[_], E] extends MonadError[F, E] { * during the evaluation of `fa`. If the evaluation of `fa` completes without encountering a * cancelation, the acknowledgement is unregistered before proceeding. If `ack` was invoked, * further execution will be blocked until it completes. If cancelation is observed, the `ack` - * will be awaited on before cancelation completes. + * will be awaited on before cancelation completes. Once cancelation has been observed, this + * method has no effect. * * If asynchronous cancelation is not supported by `F`, this function equivalent to `fa` */ From 5425aba17c5e1b8da5485686854428eb6a6ff93c Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sat, 11 Jul 2026 13:27:30 -0400 Subject: [PATCH 12/26] Fix formatting --- kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala | 4 ++-- laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala index 808b661f20..34182a5d64 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala @@ -58,8 +58,8 @@ trait Fiber[F[_], E, A] extends Serializable { * it completes and cancels the fiber if cancelation is requested. * * @note - * This method provides a race free version of `join.onCancel(cancel)`, which can lose - * data if the fiber completes after cancelation is observed. + * This method provides a race free version of `join.onCancel(cancel)`, which can lose data + * if the fiber completes after cancelation is observed. */ def joinOrCancel(implicit F: MonadCancel[F, E]): F[Outcome[F, E, A]] = F.onCancelRequested(join, cancel) diff --git a/laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala b/laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala index bf8a0d40af..73f17745d3 100644 --- a/laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala +++ b/laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala @@ -79,8 +79,11 @@ trait AsyncLaws[F[_]] extends GenTemporalLaws[F, Throwable] with SyncLaws[F] { def asyncRepeatedCallbackIgnored[A](a: A) = F.async[A](k => F.delay(k(Right(a))) >> F.delay(k(Right(a))).as(None)) <-> F.pure(a) + // format: off def asyncCancelableAsyncRepeatedCallbackIgnored[A](a: A) = - F.asyncCancelableAsync[A](k => F.delay(k(Right(a))) >> F.delay(k(Right(a))).as(None)) <-> F.pure(a) + F.asyncCancelableAsync[A](k => F.delay(k(Right(a))) >> F.delay(k(Right(a))).as(None)) <-> F + .pure(a) + // format: on def asyncCancelTokenIsUnsequencedOnCompletion[A](a: A, fu: F[Unit]) = F.async[A](k => F.delay(k(Right(a))) >> F.pure(Some(fu))) <-> F.pure(a) @@ -94,7 +97,6 @@ trait AsyncLaws[F[_]] extends GenTemporalLaws[F, Throwable] with SyncLaws[F] { def asyncCancelableAsyncCancelTokenIsUnsequencedOnError[A](e: Throwable, fu: F[Unit]) = F.asyncCancelableAsync[A](k => F.delay(k(Left(e))) >> F.pure(Some(fu))) <-> F.raiseError(e) - def neverIsDerivedFromAsync[A] = F.never[A] <-> F.async[A](_ => F.pure(Some(F.unit))) From 9c9a63c0468b76e2703cec2449c432d35feb5b1c Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sat, 11 Jul 2026 16:16:38 -0400 Subject: [PATCH 13/26] Report errors during `ack` execution --- core/shared/src/main/scala/cats/effect/IO.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index 699f7c3636..f8667b667c 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -599,7 +599,8 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { case None => IO.unit } - IO.uncancelable(poll => IO.PushCancelRequested(ack) *> poll(this).guarantee(complete)) + IO.uncancelable(poll => + IO.PushCancelRequested(ack.reportError) *> poll(this).guarantee(complete)) } @deprecated("Use onError with PartialFunction argument", "3.6.0") From eea70c7e32dc798ed907a4faf4fe4ab59fc41942 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sat, 11 Jul 2026 16:28:32 -0400 Subject: [PATCH 14/26] Use `par` for the new async methods `par` is the prefix used elsewhere in CE to indicate when actions can happen concurrently, and `async` has a specific meaning: the async FFI. --- .../src/main/scala/cats/effect/IO.scala | 18 +++++------ .../src/main/scala/cats/effect/IOFiber.scala | 2 +- .../main/scala/cats/effect/kernel/Async.scala | 20 ++++++------ .../scala/cats/effect/laws/AsyncLaws.scala | 32 +++++++++---------- .../scala/cats/effect/kernel/AsyncSuite.scala | 8 ++--- 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index f8667b667c..0615b24619 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -1396,14 +1396,14 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara * - right side `A` is an immediate result of computation (callback invocation will be * dropped); * - left side `Option[IO[Unit]]` is an optional acknowledgement to be run in the event that - * cancelation of the fiber running `asyncCheckAttemptCancelableAsync(k)` is requested. + * cancelation of the fiber running `asyncCheckAttemptParCancelable(k)` is requested. * - * Note that `asyncCheckAttemptCancelableAsync` is uncancelable during its registration. + * Note that `asyncCheckAttemptParCancelable` is uncancelable during its registration. * * @see * [[async]] for a simplified variant without an option for immediate result */ - def asyncCheckAttemptCancelableAsync[A]( + def asyncCheckAttemptParCancelable[A]( k: (Either[Throwable, A] => Unit) => IO[Either[Option[IO[Unit]], A]]): IO[A] = { val body = new Cont[IO, A, A] { def apply[G[_]](implicit G: MonadCancel[G, Throwable]) = { (resume, get, lift) => @@ -1495,11 +1495,11 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara * `IO[Option[IO[Unit]]]`). * * The effect returns `Option[IO[Unit]]` which is an optional cancelation acknowledgement to - * be run in the event that cancelation of the fiber running `asyncCancelableAsync(k)` is + * be run in the event that cancelation of the fiber running `asyncParCancelable(k)` is * requested. * * @note - * `asyncCancelableAsync` is always uncancelable during its registration. The created effect + * `asyncParCancelable` is always uncancelable during its registration. The created effect * will be uncancelable during its execution if the registration callback provides no * cancelation acknowledgement (i.e. evaluates to `None`). If you need the created task to * be cancelable, return a acknowledgement effect upon the registration. In a rare case when @@ -1508,10 +1508,10 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara * @see * [[async_]] for a simplified uncancelable variant * @see - * [[asyncCheckAttemptCancelableAsync]] for more generic version providing an optional + * [[asyncCheckAttemptParCancelable]] for more generic version providing an optional * immediate result of computation */ - def asyncCancelableAsync[A]( + def asyncParCancelable[A]( k: (Either[Throwable, A] => Unit) => IO[Option[IO[Unit]]]): IO[A] = { val body = new Cont[IO, A, A] { def apply[G[_]](implicit G: MonadCancel[G, Throwable]) = { (resume, get, lift) => @@ -1873,8 +1873,8 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara /** * Like [[fromFuture]], but is cancelable asynchronously. */ - def fromFutureCancelableAsync[A](fut: IO[(Future[A], IO[Unit])]): IO[A] = - asyncForIO.fromFutureCancelableAsync(fut) + def fromFutureParCancelable[A](fut: IO[(Future[A], IO[Unit])]): IO[A] = + asyncForIO.fromFutureParCancelable(fut) /** * Run two IO tasks concurrently, and return the first to finish, either in success or error. diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index f0ec5671ac..6b73b5d327 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -919,7 +919,7 @@ private final class IOFiber[A]( val cur = cur0.asInstanceOf[RacePair[Any, Any]] val next = - IO.asyncCancelableAsync[ + IO.asyncParCancelable[ Either[(OutcomeIO[Any], FiberIO[Any]), (FiberIO[Any], OutcomeIO[Any])]] { cb => IO { val ec = currentCtx diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala index 6782d5d324..777866f6c5 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala @@ -122,9 +122,9 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { * - right side `A` is an immediate result of computation (callback invocation will be * dropped); * - left side `Option[F[Unit]]` is an optional acknowledgement to be run in the event that - * cancelation of the fiber running `asyncCheckAttemptCancelableAsync(k)` is requested. + * cancelation of the fiber running `asyncCheckAttemptParCancelable(k)` is requested. * - * Also, note that `asyncCheckAttemptCancelableAsync` is uncancelable during its registration. + * Also, note that `asyncCheckAttemptParCancelable` is uncancelable during its registration. * * @see * [[async]] for a simplified variant without an option for immediate result @@ -132,7 +132,7 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { * [[async_]] for a simplified variant without an option for immediate result or cancelation * support */ - def asyncCheckAttemptCancelableAsync[A]( + def asyncCheckAttemptParCancelable[A]( k: (Either[Throwable, A] => Unit) => F[Either[Option[F[Unit]], A]] ): F[A] = { val body = new Cont[F, A, A] { @@ -192,10 +192,10 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { * `F[Option[F[Unit]]]`). * * The effect returns `Option[F[Unit]]` which is an optional acknowledgement to be run in the - * event that the cancelation of the fiber running `asyncCancelableAsync(k)` is requested. + * event that the cancelation of the fiber running `asyncParCancelable(k)` is requested. * * @note - * `asyncCancelableAsync` is always uncancelable during its registration. The created effect + * `asyncParCancelable` is always uncancelable during its registration. The created effect * will be uncancelable during its execution if the registration callback provides no * finalizer (i.e. evaluates to `None`). If you need the created task to be cancelable, * return a finalizer effect upon the registration. In a rare case when there's nothing to @@ -204,11 +204,11 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { * @see * [[async_]] for a simplified variant without cancelation support * @see - * [[asyncCheckAttemptCancelableAsync]] for more generic version with option of providing + * [[asyncCheckAttemptParCancelable]] for more generic version with option of providing * immediate result of computation */ - def asyncCancelableAsync[A](k: (Either[Throwable, A] => Unit) => F[Option[F[Unit]]]): F[A] = - asyncCheckAttemptCancelableAsync[A](cb => map(k(cb))(Left(_))) + def asyncParCancelable[A](k: (Either[Throwable, A] => Unit) => F[Option[F[Unit]]]): F[A] = + asyncCheckAttemptParCancelable[A](cb => map(k(cb))(Left(_))) /** * Suspends an asynchronous side effect in `F`. @@ -368,12 +368,12 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { /** * Like [[fromFuture]], but is cancelable via asynchronous cancelation. */ - def fromFutureCancelableAsync[A](futCancel: F[(Future[A], F[Unit])]): F[A] = + def fromFutureParCancelable[A](futCancel: F[(Future[A], F[Unit])]): F[A] = flatMap(executionContext) { implicit ec => uncancelable { poll => flatMap(poll(futCancel)) { case (fut, fin) => - asyncCancelableAsync[A](cb => + asyncParCancelable[A](cb => as(delay(fut.onComplete(t => cb(t.toEither))), Some(fin))) } } diff --git a/laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala b/laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala index 73f17745d3..c58e39b762 100644 --- a/laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala +++ b/laws/shared/src/main/scala/cats/effect/laws/AsyncLaws.scala @@ -32,8 +32,8 @@ trait AsyncLaws[F[_]] extends GenTemporalLaws[F, Throwable] with SyncLaws[F] { // format: on // format: off - def asyncCheckAttemptCancelableAsyncImmediateIsPure[A](a: A) = - (F.asyncCheckAttemptCancelableAsync[A](_ => F.pure(Right(a))) <* F.unit) <-> (F.pure(a)) + def asyncCheckAttemptParCancelableImmediateIsPure[A](a: A) = + (F.asyncCheckAttemptParCancelable[A](_ => F.pure(Right(a))) <* F.unit) <-> (F.pure(a)) // format: on // format: off @@ -42,8 +42,8 @@ trait AsyncLaws[F[_]] extends GenTemporalLaws[F, Throwable] with SyncLaws[F] { // format: on // format: off - def asyncCheckAttemptCancelableAsyncSuspendedRightIsAsyncRight[A](a: A, fu: F[Unit]) = - (F.asyncCheckAttemptCancelableAsync[A](k => F.delay(k(Right(a))) >> fu.as(Left(None))) <* F.unit) <-> (F.async[A](k => F.delay(k(Right(a))) >> fu.as(None)) <* F.unit) + def asyncCheckAttemptParCancelableSuspendedRightIsAsyncRight[A](a: A, fu: F[Unit]) = + (F.asyncCheckAttemptParCancelable[A](k => F.delay(k(Right(a))) >> fu.as(Left(None))) <* F.unit) <-> (F.async[A](k => F.delay(k(Right(a))) >> fu.as(None)) <* F.unit) // format: on // format: off @@ -52,8 +52,8 @@ trait AsyncLaws[F[_]] extends GenTemporalLaws[F, Throwable] with SyncLaws[F] { // format: on // format: off - def asyncCheckAttemptCancelableAsyncSuspendedLeftIsAsyncLeft[A](e: Throwable, fu: F[Unit]) = - (F.asyncCheckAttemptCancelableAsync[A](k => F.delay(k(Left(e))) >> fu.as(Left(None))) <* F.unit) <-> (F.async[A](k => F.delay(k(Left(e))) >> fu.as(None)) <* F.unit) + def asyncCheckAttemptParCancelableSuspendedLeftIsAsyncLeft[A](e: Throwable, fu: F[Unit]) = + (F.asyncCheckAttemptParCancelable[A](k => F.delay(k(Left(e))) >> fu.as(Left(None))) <* F.unit) <-> (F.async[A](k => F.delay(k(Left(e))) >> fu.as(None)) <* F.unit) // format: on // format: off @@ -62,8 +62,8 @@ trait AsyncLaws[F[_]] extends GenTemporalLaws[F, Throwable] with SyncLaws[F] { // format: on // format: off - def asyncCancelableAsyncRightIsUncancelableSequencedPure[A](a: A, fu: F[Unit]) = - (F.asyncCancelableAsync[A](k => F.delay(k(Right(a))) >> fu.as(None)) <* F.unit) <-> (F.uncancelable(_ => fu) >> F.pure(a)) + def asyncParCancelableRightIsUncancelableSequencedPure[A](a: A, fu: F[Unit]) = + (F.asyncParCancelable[A](k => F.delay(k(Right(a))) >> fu.as(None)) <* F.unit) <-> (F.uncancelable(_ => fu) >> F.pure(a)) // format: on // format: off @@ -72,30 +72,30 @@ trait AsyncLaws[F[_]] extends GenTemporalLaws[F, Throwable] with SyncLaws[F] { // format: on // format: off - def asyncCancelableAsyncLeftIsUncancelableSequencedRaiseError[A](e: Throwable, fu: F[Unit]) = - (F.asyncCancelableAsync[A](k => F.delay(k(Left(e))) >> fu.as(None)) <* F.unit) <-> (F.uncancelable(_ => fu) >> F.raiseError(e)) + def asyncParCancelableLeftIsUncancelableSequencedRaiseError[A](e: Throwable, fu: F[Unit]) = + (F.asyncParCancelable[A](k => F.delay(k(Left(e))) >> fu.as(None)) <* F.unit) <-> (F.uncancelable(_ => fu) >> F.raiseError(e)) // format: on def asyncRepeatedCallbackIgnored[A](a: A) = F.async[A](k => F.delay(k(Right(a))) >> F.delay(k(Right(a))).as(None)) <-> F.pure(a) // format: off - def asyncCancelableAsyncRepeatedCallbackIgnored[A](a: A) = - F.asyncCancelableAsync[A](k => F.delay(k(Right(a))) >> F.delay(k(Right(a))).as(None)) <-> F + def asyncParCancelableRepeatedCallbackIgnored[A](a: A) = + F.asyncParCancelable[A](k => F.delay(k(Right(a))) >> F.delay(k(Right(a))).as(None)) <-> F .pure(a) // format: on def asyncCancelTokenIsUnsequencedOnCompletion[A](a: A, fu: F[Unit]) = F.async[A](k => F.delay(k(Right(a))) >> F.pure(Some(fu))) <-> F.pure(a) - def asyncCancelableAsyncCancelTokenIsUnsequencedOnCompletion[A](a: A, fu: F[Unit]) = - F.asyncCancelableAsync[A](k => F.delay(k(Right(a))) >> F.pure(Some(fu))) <-> F.pure(a) + def asyncParCancelableCancelTokenIsUnsequencedOnCompletion[A](a: A, fu: F[Unit]) = + F.asyncParCancelable[A](k => F.delay(k(Right(a))) >> F.pure(Some(fu))) <-> F.pure(a) def asyncCancelTokenIsUnsequencedOnError[A](e: Throwable, fu: F[Unit]) = F.async[A](k => F.delay(k(Left(e))) >> F.pure(Some(fu))) <-> F.raiseError(e) - def asyncCancelableAsyncCancelTokenIsUnsequencedOnError[A](e: Throwable, fu: F[Unit]) = - F.asyncCancelableAsync[A](k => F.delay(k(Left(e))) >> F.pure(Some(fu))) <-> F.raiseError(e) + def asyncParCancelableCancelTokenIsUnsequencedOnError[A](e: Throwable, fu: F[Unit]) = + F.asyncParCancelable[A](k => F.delay(k(Left(e))) >> F.pure(Some(fu))) <-> F.raiseError(e) def neverIsDerivedFromAsync[A] = F.never[A] <-> F.async[A](_ => F.pure(Some(F.unit))) diff --git a/tests/shared/src/test/scala/cats/effect/kernel/AsyncSuite.scala b/tests/shared/src/test/scala/cats/effect/kernel/AsyncSuite.scala index c5c7af7d3b..15158b071c 100644 --- a/tests/shared/src/test/scala/cats/effect/kernel/AsyncSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/kernel/AsyncSuite.scala @@ -112,13 +112,13 @@ class AsyncSuite extends BaseSuite with DisciplineSuite { .map(r => assert(r.forall(identity(_)))) } - real("fromFutureCancelableAsync should cancel on fiber cancelation") { + real("fromFutureParCancelable should cancel on fiber cancelation") { val smallDelay: IO[Unit] = IO.sleep(10.millis) def mkf() = Promise[Unit]() val go = for { canceled <- IO(new AtomicBoolean) - fiber <- IO.fromFutureCancelableAsync { + fiber <- IO.fromFutureParCancelable { IO(mkf()).map(f => f.future -> IO { canceled.set(true); f.complete(Failure(new InterruptedException())) @@ -133,13 +133,13 @@ class AsyncSuite extends BaseSuite with DisciplineSuite { } - real("fromFutureCancelableAsync should backpressure on cancelation") { + real("fromFutureParCancelable should backpressure on cancelation") { // a non-cancelable, never-completing Future def mkf() = Promise[Unit]().future val go = for { started <- IO(new AtomicBoolean) - fiber <- IO.fromFutureCancelableAsync { + fiber <- IO.fromFutureParCancelable { IO { started.set(true) mkf() From d6f08e7207b1482fb5ce48a9950b967fb6aae0b6 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sat, 11 Jul 2026 17:30:31 -0400 Subject: [PATCH 15/26] Make sure cancelation is only requested once --- .../src/main/scala/cats/effect/IOFiber.scala | 29 +++++++------------ .../src/test/scala/cats/effect/IOSuite.scala | 18 ++++++++++++ .../scala/cats/effect/kernel/AsyncSuite.scala | 2 +- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index 6b73b5d327..bd040556f5 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -86,7 +86,7 @@ private final class IOFiber[A]( private[this] var currentCtx: ExecutionContext = startEC private[this] val objectState: ArrayStack[AnyRef] = ArrayStack() private[this] val finalizers: ArrayStack[IO[Unit]] = ArrayStack() - private[this] val acknowledgers: ArrayStack[IO[Unit]] = ArrayStack() + private[this] val acknowledgers: ArrayStack[Either[IO[Unit], IOFiber[Unit]]] = ArrayStack() private[this] val callbacks: CallbackStack[OutcomeIO[A]] = CallbackStack.of(cb) private[this] var resumeTag: Byte = ExecR private[this] var resumeIO: IO[Any] = startIO @@ -104,9 +104,6 @@ private final class IOFiber[A]( private[this] var masks: Int = 0 private[this] var finalizing: Boolean = false - // The running cancelation acknowledgement, if any - private[this] var runningAcknowledgement: FiberIO[Unit] = null - @volatile private[this] var outcome: OutcomeIO[A] = _ @@ -1083,22 +1080,14 @@ private final class IOFiber[A]( case 25 => val cur = cur0.asInstanceOf[PushCancelRequested] - acknowledgers.push(EvalOn(cur.ack, currentCtx)) + acknowledgers.push(Left(EvalOn(cur.ack, currentCtx))) // println(s"pushed onto acknowledgers: length = ${acknowledgers.unsafeIndex()}") runLoop(IO.unit, nextCancelation, nextAutoCede) case 26 => - val _ = acknowledgers.pop() - - if (runningAcknowledgement == null) - runLoop(IO.none, nextCancelation, nextAutoCede) - else { - val ack = runningAcknowledgement - runningAcknowledgement = null - runLoop(IO.some(ack), nextCancelation, nextAutoCede) - } - + val ackCompletion = IO.pure(acknowledgers.pop().toOption) + runLoop(ackCompletion, nextCancelation, nextAutoCede) } } } @@ -1196,13 +1185,14 @@ private final class IOFiber[A]( } private[this] def acknowledgeCancelation(): Unit = { - if (canceled && !finalizing && runningAcknowledgement == null && !acknowledgers.isEmpty()) { - // println(s"$this: starting cancelation acknowledgement") - val acknowledgement = acknowledgers.peek() + if (canceled && !finalizing && !acknowledgers.isEmpty() && acknowledgers.peek().isLeft) { + val acknowledgement = + acknowledgers.pop().asInstanceOf[Left[IO[Unit], IOFiber[Unit]]].value + // println(s"$this: starting cancelation acknowledgement in thread ${Thread.currentThread()}; total ${acknowledgers.unsafeIndex()}") val ec = currentCtx val rt = runtime - val runningAcknowledgement = new IOFiber[Any]( + val runningAcknowledgement = new IOFiber[Unit]( localState, null, acknowledgement, @@ -1211,6 +1201,7 @@ private final class IOFiber[A]( ) scheduleFiber(ec, runningAcknowledgement) + acknowledgers.push(Right(runningAcknowledgement)) } } diff --git a/tests/shared/src/test/scala/cats/effect/IOSuite.scala b/tests/shared/src/test/scala/cats/effect/IOSuite.scala index 28b35833be..50b0c6dfc7 100644 --- a/tests/shared/src/test/scala/cats/effect/IOSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/IOSuite.scala @@ -2232,6 +2232,24 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu } yield () } + real("onCancelRequested - invoked once per ack when canceled") { + for { + ready <- Deferred[IO, Unit] + requests <- Ref.of[IO, Int](0) + canceled <- Deferred[IO, Unit] + done <- Deferred[IO, Unit] + fiber <- (ready.complete(()) *> canceled.get.void) + .onCancelRequested(requests.update(_ + 1) *> canceled.complete(()).void) + .onCancelRequested(requests.update(_ + 1) *> done.complete(()).void) + .uncancelable + .start + _ <- ready.get + _ <- fiber.cancel + _ <- done.get + times <- requests.get + } yield assertEquals(times, 2) + } + real("onCancelRequested - invoked when canceled while masked") { for { ready <- Deferred[IO, Unit] diff --git a/tests/shared/src/test/scala/cats/effect/kernel/AsyncSuite.scala b/tests/shared/src/test/scala/cats/effect/kernel/AsyncSuite.scala index 15158b071c..e563fcce45 100644 --- a/tests/shared/src/test/scala/cats/effect/kernel/AsyncSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/kernel/AsyncSuite.scala @@ -29,11 +29,11 @@ import org.scalacheck.Arbitrary.arbitrary import scala.concurrent.{ExecutionContext, Promise} import scala.concurrent.duration._ +import scala.util.Failure import java.util.concurrent.atomic.AtomicBoolean import munit.DisciplineSuite -import scala.util.Failure class AsyncSuite extends BaseSuite with DisciplineSuite { From 13753cd71dd545a816daf67e2ab4edd29a18dae5 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sat, 11 Jul 2026 19:21:36 -0400 Subject: [PATCH 16/26] fix scaladoc --- core/shared/src/main/scala/cats/effect/IO.scala | 4 ++-- kernel/shared/src/main/scala/cats/effect/kernel/Async.scala | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index 0615b24619..80f9fd6f1b 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -447,7 +447,7 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { * [[onCancel]] * @note * this method can lose data if `fa` completes before `fin` can stop it. See - * [[onCancelRequested]] for a safer alternative. + * [[IO!.onCancelRequested]] for a safer alternative. */ def cancelable(fin: IO[Unit]): IO[A] = Spawn[IO].cancelable(this, fin) @@ -1864,7 +1864,7 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara * Like [[fromFuture]], but is cancelable via the provided finalizer. * * @see - * [[onCancelRequested]] for a safer alternative. This method can lose data if the future + * [[IO!.onCancelRequested]] for a safer alternative. This method can lose data if the future * completes before the finalizaer can stop it. */ def fromFutureCancelable[A](fut: IO[(Future[A], IO[Unit])]): IO[A] = diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala index 777866f6c5..9c513808a8 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala @@ -226,10 +226,10 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { * asyncronous effect to be cancelable, consider using `async` instead. * * @see - * [[[Async!.async[A](asyncCancelation:Boolean)(k:*]]] for more generic version providing a + * [[Async!.async]] for more generic version providing a * finalizer * @see - * [[[Async!.asyncCheckAttempt[A](asyncCancelation:Boolean)(k:*]]] for more generic version + * [[Async!.asyncCheckAttempt]] for more generic version * with option of providing immediate result of computation and finalizer */ def async_[A](k: (Either[Throwable, A] => Unit) => Unit): F[A] = From a548d64e0995b15a162a49e4af5fec95931f18f9 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sat, 11 Jul 2026 19:22:06 -0400 Subject: [PATCH 17/26] fix formatting --- core/shared/src/main/scala/cats/effect/IO.scala | 4 ++-- .../shared/src/main/scala/cats/effect/kernel/Async.scala | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index 80f9fd6f1b..1bc4468629 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -1864,8 +1864,8 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara * Like [[fromFuture]], but is cancelable via the provided finalizer. * * @see - * [[IO!.onCancelRequested]] for a safer alternative. This method can lose data if the future - * completes before the finalizaer can stop it. + * [[IO!.onCancelRequested]] for a safer alternative. This method can lose data if the + * future completes before the finalizaer can stop it. */ def fromFutureCancelable[A](fut: IO[(Future[A], IO[Unit])]): IO[A] = asyncForIO.fromFutureCancelable(fut) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala b/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala index 9c513808a8..f147af9c50 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Async.scala @@ -226,11 +226,10 @@ trait Async[F[_]] extends AsyncPlatform[F] with Sync[F] with Temporal[F] { * asyncronous effect to be cancelable, consider using `async` instead. * * @see - * [[Async!.async]] for more generic version providing a - * finalizer + * [[Async!.async]] for more generic version providing a finalizer * @see - * [[Async!.asyncCheckAttempt]] for more generic version - * with option of providing immediate result of computation and finalizer + * [[Async!.asyncCheckAttempt]] for more generic version with option of providing immediate + * result of computation and finalizer */ def async_[A](k: (Either[Throwable, A] => Unit) => Unit): F[A] = async[A](cb => as(delay(k(cb)), None)) From ae55ae652fa47f041793b1b12cc0a807fd4c9bfc Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sat, 11 Jul 2026 21:03:19 -0400 Subject: [PATCH 18/26] Fix cancelation race with IOCont.Get Same race condition as cancelation after suspending to await the callback, if suspended then needs to ack we need to try resuming as well. --- .../src/main/scala/cats/effect/IOFiber.scala | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index bd040556f5..da10dcc066 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -143,7 +143,7 @@ private final class IOFiber[A]( private[this] var _cancel: IO[Unit] = IO uncancelable { _ => canceled = true - // println(s"${name}: attempting cancelation") + // println(s"${this}: attempting cancelation") /* check to see if the target fiber is suspended */ if (resume()) { @@ -151,7 +151,7 @@ private final class IOFiber[A]( /* ...it was! was it masked? */ if (isUnmasked()) { /* ...nope! take over the target fiber's runloop and run the finalizers */ - // println(s"<$name> running cancelation (finalizers.length = ${finalizers.unsafeIndex()})") + // println(s"$this: running cancelation (finalizers.length = ${finalizers.unsafeIndex()})") /* if we have async finalizers, runLoop may return early */ IO.async_[Unit] { fin => @@ -162,6 +162,7 @@ private final class IOFiber[A]( scheduleFiber(ec, this) } } else { + // println(s"$this: masked, it will cancel) /* * it was masked, so we need to wait for it to finish whatever * it was doing and cancel itself @@ -170,7 +171,7 @@ private final class IOFiber[A]( join.void } } else { - // println(s"${name}: had to join") + // println(s"$this: had to join") /* it's already being run somewhere; await the finalizers */ join.void } @@ -826,7 +827,7 @@ private final class IOFiber[A]( * race condition check: we may have been canceled * after setting the state but before we suspended */ - if (shouldFinalize()) { + if (shouldFinalize() || shouldAcknowledgeCancelation()) { /* * if we can re-acquire the run-loop, we can finalize, * otherwise somebody else acquired it and will eventually finalize. @@ -1185,7 +1186,7 @@ private final class IOFiber[A]( } private[this] def acknowledgeCancelation(): Unit = { - if (canceled && !finalizing && !acknowledgers.isEmpty() && acknowledgers.peek().isLeft) { + if (shouldAcknowledgeCancelation() && acknowledgers.peek().isLeft) { val acknowledgement = acknowledgers.pop().asInstanceOf[Left[IO[Unit], IOFiber[Unit]]].value // println(s"$this: starting cancelation acknowledgement in thread ${Thread.currentThread()}; total ${acknowledgers.unsafeIndex()}") @@ -1217,6 +1218,9 @@ private final class IOFiber[A]( private[this] def isUnmasked(): Boolean = masks == 0 + private[this] def shouldAcknowledgeCancelation(): Boolean = + canceled && !finalizing && !acknowledgers.isEmpty() + /* * You should probably just read this as `suspended.compareAndSet(true, false)`. * This implementation has the same semantics as the above, except that it guarantees From 11b15e768812430bd834f474a3df51ad048f729c Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sun, 12 Jul 2026 00:04:52 -0400 Subject: [PATCH 19/26] Wrap impure side effect in IOApp The change in raceOutcome behavior causes this to actually return a result when the fiber is canceled now, and the flatMap is evaluated to the next IO before cancelation actually takes effect, triggering a crash from the sys.error side effect. Wrapping it so it is properly suspended then ignored on cancelation. --- core/js/src/main/scala/cats/effect/IOApp.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/js/src/main/scala/cats/effect/IOApp.scala b/core/js/src/main/scala/cats/effect/IOApp.scala index 8133b54fee..1bd9235c59 100644 --- a/core/js/src/main/scala/cats/effect/IOApp.scala +++ b/core/js/src/main/scala/cats/effect/IOApp.scala @@ -270,7 +270,7 @@ trait IOApp { case Left(Outcome.Errored(t)) => IO.raiseError(t) case Left(Outcome.Succeeded(code)) => code case Right(Outcome.Errored(t)) => IO.raiseError(t) - case Right(_) => sys.error("impossible") + case Right(_) => IO.delay(sys.error("impossible")) } .unsafeRunFiber( hardExit(cancelCode), From fba194db9acc901d54f390d62ca5b13a6ef9374c Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sun, 12 Jul 2026 08:03:54 -0400 Subject: [PATCH 20/26] Remove uncancelable check when registering cancel requested If the fiber is canceled at this point, the ack isn't getting popped anyways. --- core/shared/src/main/scala/cats/effect/IO.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index 1bc4468629..c7bb5f8440 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -599,8 +599,7 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { case None => IO.unit } - IO.uncancelable(poll => - IO.PushCancelRequested(ack.reportError) *> poll(this).guarantee(complete)) + IO.PushCancelRequested(ack.reportError) *> this.guarantee(complete) } @deprecated("Use onError with PartialFunction argument", "3.6.0") From 7a90b9c79b02e3ae8397a0ed1b998829469e7479 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sun, 12 Jul 2026 10:37:55 -0400 Subject: [PATCH 21/26] polls no longer needed while racing --- core/shared/src/main/scala/cats/effect/IO.scala | 2 +- .../src/main/scala/cats/effect/kernel/GenSpawn.scala | 12 ++++++------ .../main/scala/cats/effect/kernel/GenTemporal.scala | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index c7bb5f8440..d37330a6c2 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -862,7 +862,7 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { def timeoutTo[A2 >: A](duration: Duration, fallback: IO[A2]): IO[A2] = { handleDuration[IO[A2]](duration, this) { finiteDuration => IO.uncancelable { poll => - poll(racePair(IO.sleep(finiteDuration))) flatMap { + racePair(IO.sleep(finiteDuration)) flatMap { case Left((oc, f)) => f.cancel *> oc.embed(poll(IO.canceled) *> IO.never) case Right((f, _)) => f.cancel *> f.join.flatMap { oc => oc.fold(fallback, IO.raiseError, identity) } diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala index 304385372e..9ee1cfd56c 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala @@ -371,8 +371,8 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { * [[race]] for a simpler variant that returns the successful outcome. */ def raceOutcome[A, B](fa: F[A], fb: F[B]): F[Either[Outcome[F, E, A], Outcome[F, E, B]]] = - uncancelable { poll => - poll(racePair(fa, fb)).flatMap { + uncancelable { _ => + racePair(fa, fb).flatMap { case Left((oc, f)) => f.cancel.as(Left(oc)) case Right((f, oc)) => f.cancel.as(Right(oc)) } @@ -403,7 +403,7 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { */ def race[A, B](fa: F[A], fb: F[B]): F[Either[A, B]] = uncancelable { poll => - poll(racePair(fa, fb)).flatMap { + racePair(fa, fb).flatMap { case Left((oc, f)) => oc match { case Outcome.Succeeded(fa) => f.cancel *> fa.map(Left(_)) @@ -443,8 +443,8 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { * [[both]] for a simpler variant that returns the results of both fibers. */ def bothOutcome[A, B](fa: F[A], fb: F[B]): F[(Outcome[F, E, A], Outcome[F, E, B])] = - uncancelable { poll => - poll(racePair(fa, fb)).flatMap { + uncancelable { _ => + racePair(fa, fb).flatMap { case Left((oc, f)) => f.joinOrCancel.tupleLeft(oc) case Right((f, oc)) => f.joinOrCancel.tupleRight(oc) } @@ -476,7 +476,7 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { */ def both[A, B](fa: F[A], fb: F[B]): F[(A, B)] = uncancelable { poll => - poll(racePair(fa, fb)).flatMap { + racePair(fa, fb).flatMap { case Left((oc, f)) => oc match { case Outcome.Succeeded(fa) => diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/GenTemporal.scala b/kernel/shared/src/main/scala/cats/effect/kernel/GenTemporal.scala index 9527bc5992..26fd498e84 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenTemporal.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenTemporal.scala @@ -100,7 +100,7 @@ trait GenTemporal[F[_], E] extends GenConcurrent[F, E] with Clock[F] { uncancelable { poll => implicit val F: GenTemporal[F, E] = this - poll(racePair(fa, sleep(duration))) flatMap { + racePair(fa, sleep(duration)) flatMap { case Left((oc, f)) => f.cancel *> oc.embed(poll(F.canceled) *> F.never) case Right((f, _)) => f.cancel *> f.join.flatMap { oc => oc.embed(fallback) } } @@ -137,7 +137,7 @@ trait GenTemporal[F[_], E] extends GenConcurrent[F, E] with Clock[F] { uncancelable { poll => implicit val F: GenTemporal[F, E] = this - poll(racePair(fa, sleep(duration))) flatMap { + racePair(fa, sleep(duration)) flatMap { case Left((oc, f)) => f.cancel *> oc.embed(poll(F.canceled) *> F.never) case Right((f, _)) => f.cancel *> f.join.flatMap { oc => @@ -178,7 +178,7 @@ trait GenTemporal[F[_], E] extends GenConcurrent[F, E] with Clock[F] { uncancelable { poll => implicit val F: GenTemporal[F, E] = this - poll(racePair(fa, sleep(duration))) flatMap { + racePair(fa, sleep(duration)) flatMap { case Left((oc, f)) => poll(f.cancel *> oc.embed(poll(F.canceled) *> F.never)) From 856b551ea41f11d360bf5a49779eb950b3500a0e Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sun, 12 Jul 2026 09:04:14 -0400 Subject: [PATCH 22/26] start all acks --- .../src/main/scala/cats/effect/IO.scala | 9 +- .../src/main/scala/cats/effect/IOFiber.scala | 84 ++++++++++++------- .../cats/effect/kernel/MonadCancel.scala | 6 +- 3 files changed, 59 insertions(+), 40 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index d37330a6c2..2e880b558b 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -594,12 +594,7 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { IO.OnCancel(this, fin) def onCancelRequested(ack: IO[Unit]): IO[A] = { - val complete = IO.PopCancelRequested.flatMap { - case Some(fiber) => fiber.join.void - case None => IO.unit - } - - IO.PushCancelRequested(ack.reportError) *> this.guarantee(complete) + IO.PushCancelRequested(ack) *> this.guarantee(IO.PopCancelRequested) } @deprecated("Use onError with PartialFunction argument", "3.6.0") @@ -2441,7 +2436,7 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara def tag = 25 } - private[effect] case object PopCancelRequested extends IO[Option[FiberIO[Unit]]] { + private[effect] case object PopCancelRequested extends IO[Unit] { def tag = 26 } diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index da10dcc066..4436b5de57 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -86,7 +86,6 @@ private final class IOFiber[A]( private[this] var currentCtx: ExecutionContext = startEC private[this] val objectState: ArrayStack[AnyRef] = ArrayStack() private[this] val finalizers: ArrayStack[IO[Unit]] = ArrayStack() - private[this] val acknowledgers: ArrayStack[Either[IO[Unit], IOFiber[Unit]]] = ArrayStack() private[this] val callbacks: CallbackStack[OutcomeIO[A]] = CallbackStack.of(cb) private[this] var resumeTag: Byte = ExecR private[this] var resumeIO: IO[Any] = startIO @@ -104,6 +103,10 @@ private final class IOFiber[A]( private[this] var masks: Int = 0 private[this] var finalizing: Boolean = false + // async cancelation handling + private[this] val acks: ArrayStack[IO[Unit]] = ArrayStack() + private[this] var startedAcks: Boolean = false + @volatile private[this] var outcome: OutcomeIO[A] = _ @@ -147,7 +150,6 @@ private final class IOFiber[A]( /* check to see if the target fiber is suspended */ if (resume()) { - acknowledgeCancelation() /* ...it was! was it masked? */ if (isUnmasked()) { /* ...nope! take over the target fiber's runloop and run the finalizers */ @@ -167,6 +169,7 @@ private final class IOFiber[A]( * it was masked, so we need to wait for it to finish whatever * it was doing and cancel itself */ + acknowledgeCancelation() suspend() /* allow someone else to take the runloop */ join.void } @@ -827,7 +830,7 @@ private final class IOFiber[A]( * race condition check: we may have been canceled * after setting the state but before we suspended */ - if (shouldFinalize() || shouldAcknowledgeCancelation()) { + if (canceled && (isUnmasked() || !startedAcks)) { /* * if we can re-acquire the run-loop, we can finalize, * otherwise somebody else acquired it and will eventually finalize. @@ -883,7 +886,6 @@ private final class IOFiber[A]( * we were canceled, but `cancel` cannot run the finalisers * because the runloop was not suspended, so we have to run them */ - // TODO is this the right position? These conditions are odd acknowledgeCancelation() val fin = prepareFiberForCancelation(null) runLoop(fin, nextCancelation, nextAutoCede) @@ -1080,14 +1082,22 @@ private final class IOFiber[A]( /* OnCancelRequested */ case 25 => val cur = cur0.asInstanceOf[PushCancelRequested] - - acknowledgers.push(Left(EvalOn(cur.ack, currentCtx))) - // println(s"pushed onto acknowledgers: length = ${acknowledgers.unsafeIndex()}") - + val ack = EvalOn(cur.ack, currentCtx) + val push = + if (finalizing) + IO.unit + else if (startedAcks) + runAcknowledgement(ack) + else + ack + + acks.push(push) runLoop(IO.unit, nextCancelation, nextAutoCede) case 26 => - val ackCompletion = IO.pure(acknowledgers.pop().toOption) + val ack = acks.pop() + val ackCompletion = if (startedAcks) ack else IO.unit + runLoop(ackCompletion, nextCancelation, nextAutoCede) } } @@ -1139,7 +1149,7 @@ private final class IOFiber[A]( conts = null objectState.invalidate() finalizers.invalidate() - acknowledgers.invalidate() + acks.invalidate() currentCtx = null if (isStackTracing) { @@ -1186,26 +1196,38 @@ private final class IOFiber[A]( } private[this] def acknowledgeCancelation(): Unit = { - if (shouldAcknowledgeCancelation() && acknowledgers.peek().isLeft) { - val acknowledgement = - acknowledgers.pop().asInstanceOf[Left[IO[Unit], IOFiber[Unit]]].value - // println(s"$this: starting cancelation acknowledgement in thread ${Thread.currentThread()}; total ${acknowledgers.unsafeIndex()}") - val ec = currentCtx - val rt = runtime - - val runningAcknowledgement = new IOFiber[Unit]( - localState, - null, - acknowledgement, - ec, - rt - ) - - scheduleFiber(ec, runningAcknowledgement) - acknowledgers.push(Right(runningAcknowledgement)) + if (shouldAcknowledgeCancelation()) { + startedAcks = true + + // Replace all of the pending acks with running acks unsafely to minimize allocations + var i = acks.unsafeIndex() + val ackBuffer = acks.unsafeBuffer() + + while (i > 0) { + i -= 1 + val acknowledgement = ackBuffer(i).asInstanceOf[IO[Unit]] + ackBuffer(i) = runAcknowledgement(acknowledgement) + } + } } + private[this] def runAcknowledgement(acknowledgement: IO[Unit]): IO[Unit] = { + // println(s"$this: starting cancelation acknowledgement in thread ${Thread.currentThread()}") + val ec = currentCtx + val rt = runtime + + val runningAcknowledgement = new IOFiber[Unit]( + localState, + null, + acknowledgement, + ec, + rt + ) + scheduleFiber(ec, runningAcknowledgement) + runningAcknowledgement.join.void + } + /* * We should attempt finalization if all of the following are true: * 1) We own the runloop @@ -1219,7 +1241,7 @@ private final class IOFiber[A]( masks == 0 private[this] def shouldAcknowledgeCancelation(): Boolean = - canceled && !finalizing && !acknowledgers.isEmpty() + canceled && !finalizing && !startedAcks /* * You should probably just read this as `suspended.compareAndSet(true, false)`. @@ -1439,7 +1461,7 @@ private final class IOFiber[A]( objectState.init(16) finalizers.init(16) - acknowledgers.init(16) + acks.init(16) val io = resumeIO resumeIO = null @@ -1556,13 +1578,13 @@ private final class IOFiber[A]( val ec = objectState.pop().asInstanceOf[ExecutionContext] currentCtx = ec - acknowledgeCancelation() if (!shouldFinalize()) { resumeTag = AsyncContinueSuccessfulR objectState.push(result.asInstanceOf[AnyRef]) scheduleOnForeignEC(ec, this) IO.EndFiber } else { + acknowledgeCancelation() prepareFiberForCancelation(null) } } @@ -1575,13 +1597,13 @@ private final class IOFiber[A]( val ec = objectState.pop().asInstanceOf[ExecutionContext] currentCtx = ec - acknowledgeCancelation() if (!shouldFinalize()) { resumeTag = AsyncContinueFailedR objectState.push(t) scheduleOnForeignEC(ec, this) IO.EndFiber } else { + acknowledgeCancelation() prepareFiberForCancelation(null) } } diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala b/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala index 11baf967e9..926980eecf 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala @@ -341,9 +341,11 @@ trait MonadCancel[F[_], E] extends MonadError[F, E] { * cancelation, the acknowledgement is unregistered before proceeding. If `ack` was invoked, * further execution will be blocked until it completes. If cancelation is observed, the `ack` * will be awaited on before cancelation completes. Once cancelation has been observed, this - * method has no effect. + * method has no effect. When cancelation is requested, all registered acknowledgements will + * be started concurrently. * - * If asynchronous cancelation is not supported by `F`, this function equivalent to `fa` + * @note + * If asynchronous cancelation is not supported by `F`, this function equivalent to `fa`. */ def onCancelRequested[A](fa: F[A], ack: F[Unit]): F[A] = { val _ = ack From 90cb044b6058c8150b3a3fdc9d176b346d821dd9 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sun, 12 Jul 2026 19:06:54 -0400 Subject: [PATCH 23/26] Reduce iterations because CI is slow This failed on one build for no particular reason, which I suspect is a CI issue and not an issue with the changes to race --- tests/shared/src/test/scala/cats/effect/std/MutexSuite.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shared/src/test/scala/cats/effect/std/MutexSuite.scala b/tests/shared/src/test/scala/cats/effect/std/MutexSuite.scala index c742d0b910..93d0adcd54 100644 --- a/tests/shared/src/test/scala/cats/effect/std/MutexSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/std/MutexSuite.scala @@ -155,7 +155,7 @@ final class MutexSuite extends BaseSuite with DetectPlatform { m.lock.use_ } - tsk.replicateA_(if (isJVM) 3000 else 5) + tsk.replicateA_(if (isJVM) 1000 else 5) } p.mustEqual(()) From ab12734d6073656910e07950f222b2d18d82d440 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sun, 12 Jul 2026 20:35:17 -0400 Subject: [PATCH 24/26] Optimizations - Don't spawn an extra fiber to cancel - poll when the first side of race cancels --- core/shared/src/main/scala/cats/effect/IOFiber.scala | 11 +++-------- .../main/scala/cats/effect/kernel/GenConcurrent.scala | 9 ++------- .../src/main/scala/cats/effect/kernel/GenSpawn.scala | 4 ++-- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index 4436b5de57..edf2c0265c 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -948,12 +948,7 @@ private final class IOFiber[A]( scheduleFiber(ec, fiberB) val cancel = - for { - cancelA <- fiberA.cancel.start - cancelB <- fiberB.cancel.start - _ <- cancelA.join - _ <- cancelB.join - } yield () + fiberA.cancel.start.flatMap(cancelA => fiberB.cancel *> cancelA.join.void) Some(cancel) } @@ -1085,9 +1080,9 @@ private final class IOFiber[A]( val ack = EvalOn(cur.ack, currentCtx) val push = if (finalizing) - IO.unit + IO.unit // it is too late to cancel else if (startedAcks) - runAcknowledgement(ack) + runAcknowledgement(ack) // already started, run immediately else ack diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala b/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala index 428fecf478..563fcf92c3 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala @@ -169,13 +169,8 @@ trait GenConcurrent[F[_], E] extends GenSpawn[F, E] { back <- onCancelRequested( result.get, - for { - canA <- start(fibA.cancel) - canB <- start(fibB.cancel) - - _ <- canA.join - _ <- canB.join - } yield ()) + start(fibA.cancel).flatMap(canA => fibB.cancel *> canA.join.void) + ) } yield back match { case Left(oc) => Left((oc, fibB)) case Right(oc) => Right((fibA, oc)) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala index 9ee1cfd56c..68a6d02f5a 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala @@ -409,7 +409,7 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { case Outcome.Succeeded(fa) => f.cancel *> fa.map(Left(_)) case Outcome.Errored(ea) => f.cancel *> raiseError(ea) case Outcome.Canceled() => - f.cancel *> f.join flatMap { + f.cancel *> poll(f.join) flatMap { case Outcome.Succeeded(fb) => fb.map(Right(_)) case Outcome.Errored(eb) => raiseError(eb) case Outcome.Canceled() => poll(canceled) *> never @@ -420,7 +420,7 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { case Outcome.Succeeded(fb) => f.cancel *> fb.map(Right(_)) case Outcome.Errored(eb) => f.cancel *> raiseError(eb) case Outcome.Canceled() => - f.cancel *> f.join flatMap { + f.cancel *> poll(f.join) flatMap { case Outcome.Succeeded(fa) => fa.map(Left(_)) case Outcome.Errored(ea) => raiseError(ea) case Outcome.Canceled() => poll(canceled) *> never From ba2a4dee5706b3f6ba26618f6e59abebffef973c Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Mon, 13 Jul 2026 20:15:32 -0400 Subject: [PATCH 25/26] use single IO primitive on onCancelRequested --- .../scala/cats/effect/IOFiberConstants.scala | 1 + .../java/cats/effect/IOFiberConstants.java | 1 + .../src/main/scala/cats/effect/IO.scala | 8 +-- .../src/main/scala/cats/effect/IOFiber.scala | 60 +++++++++++++------ 4 files changed, 46 insertions(+), 24 deletions(-) diff --git a/core/js-native/src/main/scala/cats/effect/IOFiberConstants.scala b/core/js-native/src/main/scala/cats/effect/IOFiberConstants.scala index b93f462ef3..796171d90a 100644 --- a/core/js-native/src/main/scala/cats/effect/IOFiberConstants.scala +++ b/core/js-native/src/main/scala/cats/effect/IOFiberConstants.scala @@ -34,6 +34,7 @@ private object IOFiberConstants { final val UncancelableK = 7 final val UnmaskK = 8 final val AttemptK = 9 + final val OnCancelRequestedK = 10 // resume ids final val ExecR = 0 diff --git a/core/jvm/src/main/java/cats/effect/IOFiberConstants.java b/core/jvm/src/main/java/cats/effect/IOFiberConstants.java index c4310aea05..1eeb285449 100644 --- a/core/jvm/src/main/java/cats/effect/IOFiberConstants.java +++ b/core/jvm/src/main/java/cats/effect/IOFiberConstants.java @@ -36,6 +36,7 @@ final class IOFiberConstants { static final byte UncancelableK = 7; static final byte UnmaskK = 8; static final byte AttemptK = 9; + static final byte OnCancelRequestedK = 10; // resume ids static final byte ExecR = 0; diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index 2e880b558b..5e25e72208 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -594,7 +594,7 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { IO.OnCancel(this, fin) def onCancelRequested(ack: IO[Unit]): IO[A] = { - IO.PushCancelRequested(ack) *> this.guarantee(IO.PopCancelRequested) + IO.OnCancelRequested(this, ack) } @deprecated("Use onError with PartialFunction argument", "3.6.0") @@ -2432,14 +2432,10 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara def tag = 24 } - private[effect] final case class PushCancelRequested(ack: IO[Unit]) extends IO[Unit] { + private[effect] final case class OnCancelRequested[A](f: IO[A], ack: IO[Unit]) extends IO[A] { def tag = 25 } - private[effect] case object PopCancelRequested extends IO[Unit] { - def tag = 26 - } - // INTERNAL, only created by the runloop itself as the terminal state of several operations private[effect] case object EndFiber extends IO[Nothing] { def tag = -1 diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index edf2c0265c..03fba20271 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -1076,24 +1076,27 @@ private final class IOFiber[A]( /* OnCancelRequested */ case 25 => - val cur = cur0.asInstanceOf[PushCancelRequested] + val cur = cur0.asInstanceOf[OnCancelRequested[Any]] val ack = EvalOn(cur.ack, currentCtx) - val push = - if (finalizing) - IO.unit // it is too late to cancel - else if (startedAcks) - runAcknowledgement(ack) // already started, run immediately - else - ack - - acks.push(push) - runLoop(IO.unit, nextCancelation, nextAutoCede) + // otherwise it is too late to request cancelation + if (!finalizing) { + val push = + if (startedAcks) + runAcknowledgement(ack) // already started, run immediately + else + ack + acks.push(push) + conts = ByteStack.push(conts, OnCancelRequestedK) + } + + runLoop(cur.f, nextCancelation, nextAutoCede) case 26 => - val ack = acks.pop() - val ackCompletion = if (startedAcks) ack else IO.unit + if (!startedAcks) { + val _ = acks.pop() + } - runLoop(ackCompletion, nextCancelation, nextAutoCede) + runLoop(IO.unit, nextCancelation, nextAutoCede) } } } @@ -1157,7 +1160,7 @@ private final class IOFiber[A]( * because cancelation has been triggered. */ private[this] def prepareFiberForCancelation(cb: Either[Throwable, Unit] => Unit): IO[Any] = { - if (!finalizers.isEmpty()) { + if (!finalizers.isEmpty() || !acks.isEmpty()) { if (!finalizing) { // Do not nuke the fiber execution state repeatedly. finalizing = true @@ -1173,7 +1176,8 @@ private final class IOFiber[A]( } // Return the first finalizer for execution. - finalizers.pop() + if (!acks.isEmpty()) acks.pop() + else finalizers.pop() } else { // There are no finalizers to execute. @@ -1220,7 +1224,7 @@ private final class IOFiber[A]( rt ) scheduleFiber(ec, runningAcknowledgement) - runningAcknowledgement.join.void + runningAcknowledgement.join.flatMap(_.embed(IO.unit)) } /* @@ -1328,6 +1332,14 @@ private final class IOFiber[A]( case 9 => // attemptK succeeded(Right(result), depth) + + case 10 => // onCancelRequestedSuccessK + if (startedAcks) { + acks.pop().as(result) + } else { + val _ = acks.pop() + succeeded(result, depth + 1) + } } private[this] def failed(error: Throwable, depth: Int): IO[Any] = { @@ -1390,6 +1402,14 @@ private final class IOFiber[A]( failed(error, depth + 1) case 9 => succeeded(Left(error), depth) // attemptK + + case 10 => // onCancelRequestedFailureK + if (startedAcks) { + acks.pop() >> failed(error, depth + 1) + } else { + val _ = acks.pop() + failed(error, depth + 1) + } } } @@ -1529,7 +1549,11 @@ private final class IOFiber[A]( /* Implementations of continuations */ private[this] def cancelationLoopSuccessK(): IO[Any] = { - if (!finalizers.isEmpty()) { + if (!acks.isEmpty()) { + // There are still remaining finalizers to execute. Continue. + conts = ByteStack.push(conts, CancelationLoopK) + acks.pop() + } else if (!finalizers.isEmpty()) { // There are still remaining finalizers to execute. Continue. conts = ByteStack.push(conts, CancelationLoopK) finalizers.pop() From d3a353facf9588d8b0a3c0dd287144b27278094a Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Mon, 13 Jul 2026 22:20:16 -0400 Subject: [PATCH 26/26] Tweak documentation on onCancelRequested --- .../scala/cats/effect/kernel/MonadCancel.scala | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala b/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala index 926980eecf..b826461edf 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/MonadCancel.scala @@ -336,13 +336,16 @@ trait MonadCancel[F[_], E] extends MonadError[F, E] { def onCancel[A](fa: F[A], fin: F[Unit]): F[A] /** - * Registers an acknowledgement that is invoked asynchronously if cancelation is requested - * during the evaluation of `fa`. If the evaluation of `fa` completes without encountering a - * cancelation, the acknowledgement is unregistered before proceeding. If `ack` was invoked, - * further execution will be blocked until it completes. If cancelation is observed, the `ack` - * will be awaited on before cancelation completes. Once cancelation has been observed, this - * method has no effect. When cancelation is requested, all registered acknowledgements will - * be started concurrently. + * Registers a cancelation acknowledgment that is invoked if cancelation is requested. + * + * If the evaluation of `fa` completes without the cancelation being requested, the + * acknowledgement is unregistered before proceeding. If a cancelation request is observed + * while `fa` is running, then `ack` is started concurrently. + * + * After `ack` has been started, if `fa` completes, the fiber is blocked until the cancelation + * request completes. If the fiber is canceled before `fa` completes, then the fiber will + * block on the completion of `ack` before running finalizers. After finalizers have started, + * [[onCancelRequested]] has no effect. * * @note * If asynchronous cancelation is not supported by `F`, this function equivalent to `fa`.