From e1a5679c706efead64676c9d45ca25dcafa52856 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Thu, 16 Jul 2026 22:02:27 -0400 Subject: [PATCH 01/10] Reduce MutexSuite iterations to fix flakey CI --- 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 d78638a268bacadf061282f3bf19cec4638c5fd1 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Thu, 16 Jul 2026 22:18:14 -0400 Subject: [PATCH 02/10] Suspend crash in IO The sys.error call is a side-effect and should be suspended in IO --- 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 c6304c402eaf6e117c729ff4cfc65132775c296e Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Fri, 17 Jul 2026 23:03:24 -0400 Subject: [PATCH 03/10] Replace unsafe joins with joinOrCancel - add polling cancelable. This is needed to safely start the join in `joinOrCancel` without introducing a cancelation boundary that could drop an already started fiber - replace unsafe usages of `join.onCancel(cancel)` construct with `joinOrCancel` - replace fromCompletableFuture with an implementation that uses cancelable --- .../cats/effect/kernel/AsyncPlatform.scala | 43 ++++++----------- .../main/scala/cats/effect/kernel/Fiber.scala | 13 +++++ .../cats/effect/kernel/GenConcurrent.scala | 21 ++++++--- .../scala/cats/effect/kernel/GenSpawn.scala | 47 +++++++++++++++++-- 4 files changed, 85 insertions(+), 39 deletions(-) diff --git a/kernel/jvm/src/main/scala/cats/effect/kernel/AsyncPlatform.scala b/kernel/jvm/src/main/scala/cats/effect/kernel/AsyncPlatform.scala index b0e6492770..2d5e0f18d3 100644 --- a/kernel/jvm/src/main/scala/cats/effect/kernel/AsyncPlatform.scala +++ b/kernel/jvm/src/main/scala/cats/effect/kernel/AsyncPlatform.scala @@ -38,36 +38,21 @@ private[kernel] trait AsyncPlatform[F[_]] extends Serializable { this: Async[F] * @param fut * The `java.util.concurrent.CompletableFuture` to suspend in `F[_]` */ - def fromCompletableFuture[A](fut: F[CompletableFuture[A]]): F[A] = cont { - new Cont[F, A, A] { - def apply[G[_]]( - implicit - G: MonadCancelThrow[G]): (Either[Throwable, A] => Unit, G[A], F ~> G) => G[A] = { - (resume, get, lift) => - G.uncancelable { poll => - G.flatMap(poll(lift(fut))) { cf => - val go = delay { - cf.handle[Unit] { - case (a, null) => resume(Right(a)) - case (_, t) => - resume(Left(t match { - case e: CompletionException if e.getCause ne null => e.getCause - case _ => t - })) - } - } - - val await = G.onCancel( - poll(get), - // if cannot cancel, fallback to get - G.ifM(lift(delay(cf.cancel(true))))(G.unit, G.void(get)) - ) - - G.productR(lift(go))(await) - } + def fromCompletableFuture[A](fut: F[CompletableFuture[A]]): F[A] = + uncancelable { poll => + flatMap(fut) { cf => + val wait = async_[A] { cb => + val _ = cf.handle[Unit] { + case (a, null) => cb(Right(a)) + case (_, t) => + cb(Left(t match { + case e: CompletionException if e.getCause ne null => e.getCause + case _ => t + })) } + } + + cancelable(poll, wait, void(delay(cf.cancel(true)))) } } - } - } 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..8d80163926 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,19 @@ 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 safer version of `join.onCancel(cancel)` for [[GenSpawn]] + * implementations where + * [[cats.effect.kernel.GenSpawn.cancelable[A](poll:cats\.effect\.kernel\.Poll[F],fa:F[A],fin:F[Unit]):* the polling cancelable]] + * has a data-loss safe implementation. + */ + def joinOrCancel(poll: Poll[F])(implicit F: GenSpawn[F, E]): F[Outcome[F, E, A]] = + F.cancelable(poll, F.uncancelable(_ => 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/GenConcurrent.scala b/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala index 024f640197..144f6516c0 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala @@ -77,16 +77,24 @@ trait GenConcurrent[F[_], E] extends GenSpawn[F, E] { val eval = go.start.flatMap { fiber => deferredFiber.complete(fiber) *> - poll(fiber.join.flatMap(_.embed(productR(canceled)(never)))) - .onCancel(unsubscribe(deferredFiber)) + poll( + fiber + .join + .flatMap(_.embed(productR(canceled)(never))) + .cancelable(unsubscribe(deferredFiber))) + } Evaluating(deferredFiber, 1) -> eval case (poll, Evaluating(fiber, subscribers)) => Evaluating(fiber, subscribers + 1) -> - poll(fiber.get.flatMap(_.join).flatMap(_.embed(productR(canceled)(never)))) - .onCancel(unsubscribe(fiber)) + poll( + fiber + .get + .flatMap(_.join) + .flatMap(_.embed(productR(canceled)(never))) + .cancelable(unsubscribe(fiber))) case (_, finished @ Finished(result)) => finished -> fromEither(result).flatten @@ -167,8 +175,9 @@ 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 <- cancelable( + poll, + result.get, for { canA <- start(fibA.cancel) canB <- start(fibB.cancel) 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..60ee55bc01 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala @@ -252,6 +252,13 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { * be equal to `never` (similar to [[race]]). Under normal circumstances, if `fa` * self-cancels, that cancelation will be propagated to the calling context. * + * @note + * The default implementation of `cancelable` ensures that `fa` is completed before + * cancelation continues, but cannot ensure that `fa` gets canceled before `fa` completes + * normally. When this race condition occurs, the result of `fa` is lost. Implementations of + * [[GenSpawn]] should override `cancelable` with an implementation that returns normally if + * `fa` wins the race between it and `fin`. + * * @param fa * the effect to be canceled * @param fin @@ -264,12 +271,44 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { def cancelable[A](fa: F[A], fin: F[Unit]): F[A] = uncancelable { poll => start(fa) flatMap { fiber => + // Note: cannot be replaced with joinOrCancel, as this is used to implement joinOrCancel poll(fiber.join) .onCancel(fin.guarantee(fiber.cancel)) .flatMap(_.embed(poll(canceled *> never))) } } + /** + * An override of [[cancelable[A](fa:F[A],fin:F[Unit]):* cancelable]] that can be safely used + * when `fa` and `fin` use a resource-like construct that must be used without allowing + * cancelation. + * + * @note + * The default implementation of `cancelable` ensures that `fa` is completed before + * cancelation continues, but cannot ensure that `fa` gets canceled before `fa` completes + * normally. When this race condition occurs, the result of `fa` is lost. Implementations of + * [[GenSpawn]] should override `cancelable` with an implementation that returns normally if + * `fa` wins the race between it and `fin`. + * + * @param poll + * the poller for the uncancelable context the cancelable finalizer is constructed in. + * @param fa + * the effect to be canceled + * @param fin + * an effect which orchestrates some external state which terminates `fa` + * @see + * [[uncancelable]] + * @see + * [[onCancel]] + */ + def cancelable[A](poll: Poll[F], fa: F[A], fin: F[Unit]): F[A] = + start(fa) flatMap { fiber => + // Note: cannot be replaced with joinOrCancel, as this is used to implement joinOrCancel. + poll(fiber.join) + .onCancel(fin.guarantee(fiber.cancel)) + .flatMap(_.embed(poll(canceled *> never))) + } + /** * A non-terminating effect that never completes, which causes a fiber to semantically block * indefinitely. This is the purely functional, asynchronous equivalent of an infinite while @@ -442,8 +481,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(poll)(this).tupleLeft(oc) + case Right((f, oc)) => f.joinOrCancel(poll)(this).tupleRight(oc) } } @@ -477,7 +516,7 @@ 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 { + f.joinOrCancel(poll)(this).flatMap { case Outcome.Succeeded(fb) => fa.product(fb) case Outcome.Errored(eb) => raiseError(eb) case Outcome.Canceled() => poll(canceled) *> never @@ -488,7 +527,7 @@ 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 { + f.joinOrCancel(poll)(this).flatMap { case Outcome.Succeeded(fa) => fa.product(fb) case Outcome.Errored(ea) => raiseError(ea) case Outcome.Canceled() => poll(canceled) *> never From ba3ec52f3aa7f3ea4805cdf7ef0c25dddedfa772 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Fri, 17 Jul 2026 23:17:53 -0400 Subject: [PATCH 04/10] make cancelable a primitive in IO In IO, cancelable can be implemented without hoisting the operation to a separate thread, by invoking the callback when cancelation is requested. Partly based on Arman's previous attempt in #3491 Co-authored-by: Arman Bilge --- .../scala/cats/effect/IOFiberConstants.scala | 1 + .../java/cats/effect/IOFiberConstants.java | 1 + .../src/main/scala/cats/effect/IO.scala | 12 +- .../src/main/scala/cats/effect/IOFiber.scala | 179 +++++++++++++----- .../src/test/scala/cats/effect/IOSuite.scala | 35 ++++ 5 files changed, 182 insertions(+), 46 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..c543e0e83e 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 CancelableK = 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..4872f55cf2 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 CancelableK = 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 41fb79db63..ac1cfcfb5c 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]] */ def cancelable(fin: IO[Unit]): IO[A] = - Spawn[IO].cancelable(this, fin) + IO.Cancelable(this, fin) def forceR[B](that: IO[B]): IO[B] = // cast is needed here to trick the compiler into avoiding the IO[Any] @@ -2059,6 +2059,12 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara def onCancel[A](ioa: IO[A], fin: IO[Unit]): IO[A] = ioa.onCancel(fin) + override def cancelable[A](poll: Poll[IO], ioa: IO[A], ack: IO[Unit]): IO[A] = + ioa.cancelable(ack) + + override def cancelable[A](ioa: IO[A], ack: IO[Unit]): IO[A] = + ioa.cancelable(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 +2334,10 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara def tag = 24 } + private[effect] final case class Cancelable[A](f: IO[A], ack: IO[Unit]) extends IO[A] { + def tag = 25 + } + // 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..daa6c4365e 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -103,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] = _ @@ -142,14 +146,14 @@ 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()) { /* ...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 => @@ -160,15 +164,17 @@ 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 */ + acknowledgeCancelation() suspend() /* allow someone else to take the runloop */ 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 } @@ -239,6 +245,7 @@ private final class IOFiber[A]( } } + acknowledgeCancelation() if (shouldFinalize()) { val fin = prepareFiberForCancelation(null) runLoop(fin, nextCancelation, nextAutoCede) @@ -531,6 +538,7 @@ private final class IOFiber[A]( /* Canceled */ case 10 => canceled = true + acknowledgeCancelation() if (isUnmasked()) { /* run finalizers immediately */ val fin = prepareFiberForCancelation(null) @@ -822,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()) { + if (canceled && (isUnmasked() || !startedAcks)) { /* * if we can re-acquire the run-loop, we can finalize, * otherwise somebody else acquired it and will eventually finalize. @@ -832,6 +840,7 @@ private final class IOFiber[A]( * finalisers. */ if (resume()) { + acknowledgeCancelation() if (shouldFinalize()) { val fin = prepareFiberForCancelation(null) runLoop(fin, nextCancelation, nextAutoCede) @@ -877,6 +886,7 @@ 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 */ + acknowledgeCancelation() val fin = prepareFiberForCancelation(null) runLoop(fin, nextCancelation, nextAutoCede) } @@ -908,46 +918,43 @@ private final class IOFiber[A]( case 18 => val cur = cur0.asInstanceOf[RacePair[Any, Any]] + 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 + ) + + val cancel = + for { + cancelA <- fiberA.cancel.start + cancelB <- fiberB.cancel.start + _ <- cancelA.join + _ <- cancelB.join + } yield () + val next = - IO.async[Either[(OutcomeIO[Any], FiberIO[Any]), (FiberIO[Any], OutcomeIO[Any])]] { + 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) - } - } + fiberA.setCallback(oc => cb(Right(Left((oc, fiberB))))) + fiberB.setCallback(oc => cb(Right(Right((fiberA, oc))))) + + scheduleFiber(ec, fiberA) + scheduleFiber(ec, fiberB) + }.cancelable(cancel) + .uncancelable runLoop(next, nextCancelation, nextAutoCede) @@ -1068,6 +1075,25 @@ private final class IOFiber[A]( /* ReadRT */ case 24 => runLoop(succeeded(runtime, 0), nextCancelation, nextAutoCede) + + /* Cancelable */ + case 25 => + val cur = cur0.asInstanceOf[Cancelable[Any]] + val ack = EvalOn(cur.ack, currentCtx) + + // 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, CancelableK) + } + + runLoop(cur.f, nextCancelation, nextAutoCede) + } } } @@ -1118,6 +1144,7 @@ private final class IOFiber[A]( conts = null objectState.invalidate() finalizers.invalidate() + acks.invalidate() currentCtx = null if (isStackTracing) { @@ -1130,7 +1157,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 @@ -1146,7 +1173,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. @@ -1163,6 +1191,39 @@ private final class IOFiber[A]( } } + private[this] def acknowledgeCancelation(): Unit = { + 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.flatMap(_.embed(IO.unit)) + } + /* * We should attempt finalization if all of the following are true: * 1) We own the runloop @@ -1175,6 +1236,9 @@ private final class IOFiber[A]( private[this] def isUnmasked(): Boolean = masks == 0 + private[this] def shouldAcknowledgeCancelation(): Boolean = + canceled && !finalizing && !startedAcks + /* * You should probably just read this as `suspended.compareAndSet(true, false)`. * This implementation has the same semantics as the above, except that it guarantees @@ -1265,6 +1329,14 @@ private final class IOFiber[A]( case 9 => // attemptK succeeded(Right(result), depth) + + case 10 => // cancelableSuccessK + if (startedAcks) { + acks.pop().as(result) + } else { + val _ = acks.pop() + succeeded(result, depth + 1) + } } private[this] def failed(error: Throwable, depth: Int): IO[Any] = { @@ -1327,6 +1399,14 @@ private final class IOFiber[A]( failed(error, depth + 1) case 9 => succeeded(Left(error), depth) // attemptK + + case 10 => // cancelableFailureK + if (startedAcks) { + acks.pop() >> failed(error, depth + 1) + } else { + val _ = acks.pop() + failed(error, depth + 1) + } } } @@ -1393,6 +1473,7 @@ private final class IOFiber[A]( objectState.init(16) finalizers.init(16) + acks.init(16) val io = resumeIO resumeIO = null @@ -1411,12 +1492,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) } @@ -1463,7 +1546,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() @@ -1513,6 +1600,7 @@ private final class IOFiber[A]( scheduleOnForeignEC(ec, this) IO.EndFiber } else { + acknowledgeCancelation() prepareFiberForCancelation(null) } } @@ -1531,6 +1619,7 @@ private final class IOFiber[A]( scheduleOnForeignEC(ec, this) IO.EndFiber } else { + acknowledgeCancelation() prepareFiberForCancelation(null) } } diff --git a/tests/shared/src/test/scala/cats/effect/IOSuite.scala b/tests/shared/src/test/scala/cats/effect/IOSuite.scala index 73dedf6129..56e16117ea 100644 --- a/tests/shared/src/test/scala/cats/effect/IOSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/IOSuite.scala @@ -1096,6 +1096,41 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu Outcome.succeeded[IO, Throwable, Int](IO.pure(42))) } + real("racePair completes when canceled") { + for { + started <- IO.deferred[Unit] + racingFiber <- IO + .racePair( + IO.deferred[Unit] + .flatMap(complete => + started.complete(()) *> complete.get.cancelable(complete.complete(()).void)) + .uncancelable, + IO.never.as(()) + ) + .start + _ <- started.get + _ <- racingFiber.cancel + outcome <- racingFiber.join + } yield assert(outcome.isSuccess, s"racing fiber was unable to complete, was $outcome") + } + + real("cancelable callback handled before inner onCancel") { + for { + started <- IO.deferred[Unit] + cancelable <- IO.deferred[Boolean] + callbacks <- { + started.complete(()) *> cancelable + .get + // with the default cancelable implementation, the onCancel would run first + .onCancel(cancelable.complete(false).void) + .cancelable(cancelable.complete(true).void) + }.uncancelable.start + _ <- started.get + _ <- callbacks.cancel + isCancelable <- cancelable.get + } yield assert(isCancelable, s"cancelable finalizer not called before cancelation observed") + } + real("async - race - immediately cancel inner race when outer unit") { for { start <- IO.monotonic From 2a9e3b67b45ea273a55441d7c6dba0a9bf81e7e2 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Sat, 18 Jul 2026 12:55:46 -0400 Subject: [PATCH 05/10] optimize IO.racePair IO.racePair has to be uncancelable because cancelable introduces a cancelation boundary when used unmasked --- .../src/main/scala/cats/effect/IOFiber.scala | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index daa6c4365e..d3c323a683 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -945,16 +945,29 @@ private final class IOFiber[A]( _ <- cancelB.join } yield () - val next = - IO.async_[Either[(OutcomeIO[Any], FiberIO[Any]), (FiberIO[Any], OutcomeIO[Any])]] { - cb => - fiberA.setCallback(oc => cb(Right(Left((oc, fiberB))))) - fiberB.setCallback(oc => cb(Right(Right((fiberA, oc))))) - - scheduleFiber(ec, fiberA) - scheduleFiber(ec, fiberB) - }.cancelable(cancel) - .uncancelable + type RacePairResult = + Either[(OutcomeIO[Any], FiberIO[Any]), (FiberIO[Any], OutcomeIO[Any])] + + val callback: ((Either[Throwable, RacePairResult] => Unit) => Unit) = cb => { + fiberA.setCallback(oc => cb(Right(Left((oc, fiberB))))) + fiberB.setCallback(oc => cb(Right(Right((fiberA, oc))))) + + scheduleFiber(ec, fiberA) + scheduleFiber(ec, fiberB) + } + + // inline and specialize `async_` so the `G.uncancelable` call be be removed, since + // the entire operation must be uncancelable. + val next = IO + .cont { + new Cont[IO, RacePairResult, RacePairResult] { + def apply[G[_]](implicit G: MonadCancel[G, Throwable]) = { + (resume, get, lift) => G.flatMap(lift(IO.delay(callback(resume))))(_ => get) + } + } + } + .cancelable(cancel) + .uncancelable runLoop(next, nextCancelation, nextAutoCede) From e9b89e54652fdcfeed72977f89e11fe7085c4918 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Thu, 30 Jul 2026 20:41:55 -0400 Subject: [PATCH 06/10] separate onCancelRequested from cancelable - cancelable needs to be on a separate fiber since it is for blocking operations which do not suspend the fiber, so we cannot start the acks - onCancelRequested becomes a synonym for onCancel by default. In IO, onCancelRequested masks. This lets the implementation keep the `poll(fa).onCancelRequested(ack)`, which will at least cancel in other Fs, but lose data, while working correctly in IO. - cancelable now uses onCancelRequested instead of onCancel so that cancelable operations get a chance to terminate. --- .../src/main/scala/cats/effect/IO.scala | 69 +++++++------------ .../src/main/scala/cats/effect/IOFiber.scala | 15 ++-- .../cats/effect/kernel/AsyncPlatform.scala | 2 +- .../main/scala/cats/effect/kernel/Fiber.scala | 2 +- .../cats/effect/kernel/GenConcurrent.scala | 22 +++--- .../scala/cats/effect/kernel/GenSpawn.scala | 34 ++++----- .../src/test/scala/cats/effect/IOSuite.scala | 46 +++++++++---- 7 files changed, 88 insertions(+), 102 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index ac1cfcfb5c..8a02e9538b 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -405,49 +405,17 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { IO.asyncForIO.backgroundOnExecutor(this, executor) /** - * Given an effect which might be [[uncancelable]] and a finalizer, produce an effect which - * can be canceled by running the finalizer. This combinator is useful for handling scenarios - * in which an effect is inherently uncancelable but may be canceled through setting some - * external state. A trivial example of this might be the following: + * Run the given finalizer when cancelation is requested. Unlike [[onCancel]], this will run + * before cancelation is observed, which may allow `fa` to complete before cancelation becomes + * effective. * - * {{{ - * val flag = new AtomicBoolean(false) - * val ioa = IO blocking { - * while (!flag.get()) { - * Thread.sleep(10) - * } - * } - * - * ioa.cancelable(IO.delay(flag.set(true))) - * }}} - * - * Without `cancelable`, effects constructed by `blocking`, `delay`, and similar are - * inherently uncancelable. Simply adding an `onCancel` to such effects is insufficient to - * resolve this, despite the fact that under *some* circumstances (such as the above), it is - * possible to enrich an otherwise-uncancelable effect with early termination. `cancelable` - * addresses this use-case. - * - * Note that there is no free lunch here. If an effect truly cannot be prematurely terminated, - * `cancelable` will not allow for cancelation. As an example, if you attempt to cancel - * `uncancelable(_ => never)`, the cancelation will hang forever (in other words, it will be - * itself equivalent to `never`). Applying `cancelable` will not change this in any way. Thus, - * attempting to cancel `cancelable(uncancelable(_ => never), unit)` will ''also'' hang - * forever. As in all cases, cancelation will only return when all finalizers have run and the - * fiber has fully terminated. - * - * If the `IO` self-cancels and the `cancelable` itself is uncancelable, the resulting fiber - * will be equal to `never` (similar to [[race]]). Under normal circumstances, if `IO` - * self-cancels, that cancelation will be propagated to the calling context. - * - * @param fin - * an effect which orchestrates some external state which terminates the `IO` - * @see - * [[uncancelable]] + * @param ack + * an effect which orchestrates some external state which terminates `fa` * @see * [[onCancel]] */ - def cancelable(fin: IO[Unit]): IO[A] = - IO.Cancelable(this, fin) + def onCancelRequested(ack: IO[Unit]): IO[A] = + IO.OnCancelRequested(this, ack) def forceR[B](that: IO[B]): IO[B] = // cast is needed here to trick the compiler into avoiding the IO[Any] @@ -2059,11 +2027,22 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara def onCancel[A](ioa: IO[A], fin: IO[Unit]): IO[A] = ioa.onCancel(fin) - override def cancelable[A](poll: Poll[IO], ioa: IO[A], ack: IO[Unit]): IO[A] = - ioa.cancelable(ack) - - override def cancelable[A](ioa: IO[A], ack: IO[Unit]): IO[A] = - ioa.cancelable(ack) + /** + * Run the given finalizer when cancelation is requested. Unlike [[onCancel]], this will run + * before cancelation is observed, which may allow `fa` to complete before cancelation + * becomes effective. + * + * @param ioa + * the effect to be canceled + * @param ack + * an effect which orchestrates some external state which terminates `fa` + * @see + * [[cancelable]] + * @see + * [[onCancel]] + */ + 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] = @@ -2334,7 +2313,7 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara def tag = 24 } - private[effect] final case class Cancelable[A](f: IO[A], ack: IO[Unit]) extends IO[A] { + private[effect] final case class OnCancelRequested[A](f: IO[A], ack: IO[Unit]) extends IO[A] { def tag = 25 } diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index d3c323a683..7ced49549f 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -958,16 +958,15 @@ private final class IOFiber[A]( // inline and specialize `async_` so the `G.uncancelable` call be be removed, since // the entire operation must be uncancelable. - val next = IO - .cont { + val next = + IO.cont { new Cont[IO, RacePairResult, RacePairResult] { def apply[G[_]](implicit G: MonadCancel[G, Throwable]) = { (resume, get, lift) => G.flatMap(lift(IO.delay(callback(resume))))(_ => get) } } - } - .cancelable(cancel) - .uncancelable + }.onCancelRequested(cancel) + .uncancelable runLoop(next, nextCancelation, nextAutoCede) @@ -1089,13 +1088,14 @@ private final class IOFiber[A]( case 24 => runLoop(succeeded(runtime, 0), nextCancelation, nextAutoCede) - /* Cancelable */ + /* OnCancelRequested */ case 25 => - val cur = cur0.asInstanceOf[Cancelable[Any]] + val cur = cur0.asInstanceOf[OnCancelRequested[Any]] val ack = EvalOn(cur.ack, currentCtx) // otherwise it is too late to request cancelation if (!finalizing) { + masks += 1 val push = if (startedAcks) runAcknowledgement(ack) // already started, run immediately @@ -1344,6 +1344,7 @@ private final class IOFiber[A]( succeeded(Right(result), depth) case 10 => // cancelableSuccessK + masks -= 1 if (startedAcks) { acks.pop().as(result) } else { diff --git a/kernel/jvm/src/main/scala/cats/effect/kernel/AsyncPlatform.scala b/kernel/jvm/src/main/scala/cats/effect/kernel/AsyncPlatform.scala index 2d5e0f18d3..1b28596731 100644 --- a/kernel/jvm/src/main/scala/cats/effect/kernel/AsyncPlatform.scala +++ b/kernel/jvm/src/main/scala/cats/effect/kernel/AsyncPlatform.scala @@ -52,7 +52,7 @@ private[kernel] trait AsyncPlatform[F[_]] extends Serializable { this: Async[F] } } - cancelable(poll, wait, void(delay(cf.cancel(true)))) + onCancelRequested(poll(wait), void(delay(cf.cancel(true)))) } } } 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 8d80163926..03c8048b16 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala @@ -64,7 +64,7 @@ trait Fiber[F[_], E, A] extends Serializable { * has a data-loss safe implementation. */ def joinOrCancel(poll: Poll[F])(implicit F: GenSpawn[F, E]): F[Outcome[F, E, A]] = - F.cancelable(poll, F.uncancelable(_ => join), cancel) + F.onCancelRequested(poll(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/GenConcurrent.scala b/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala index 144f6516c0..1cf7b79821 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala @@ -77,11 +77,9 @@ trait GenConcurrent[F[_], E] extends GenSpawn[F, E] { val eval = go.start.flatMap { fiber => deferredFiber.complete(fiber) *> - poll( - fiber - .join - .flatMap(_.embed(productR(canceled)(never))) - .cancelable(unsubscribe(deferredFiber))) + F.onCancelRequested( + poll(fiber.join.flatMap(_.embed(productR(canceled)(never)))), + unsubscribe(deferredFiber)) } @@ -89,12 +87,9 @@ trait GenConcurrent[F[_], E] extends GenSpawn[F, E] { case (poll, Evaluating(fiber, subscribers)) => Evaluating(fiber, subscribers + 1) -> - poll( - fiber - .get - .flatMap(_.join) - .flatMap(_.embed(productR(canceled)(never))) - .cancelable(unsubscribe(fiber))) + F.onCancelRequested( + poll(fiber.get.flatMap(_.join).flatMap(_.embed(productR(canceled)(never)))), + unsubscribe(fiber)) case (_, finished @ Finished(result)) => finished -> fromEither(result).flatten @@ -175,9 +170,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 <- cancelable( - poll, - result.get, + back <- onCancelRequested( + poll(result.get), for { canA <- start(fibA.cancel) canB <- start(fibB.cancel) 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 60ee55bc01..029263d542 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala @@ -272,42 +272,34 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { uncancelable { poll => start(fa) flatMap { fiber => // Note: cannot be replaced with joinOrCancel, as this is used to implement joinOrCancel - poll(fiber.join) - .onCancel(fin.guarantee(fiber.cancel)) + onCancelRequested(poll(fiber.join), fin.guarantee(fiber.cancel)) .flatMap(_.embed(poll(canceled *> never))) } } /** - * An override of [[cancelable[A](fa:F[A],fin:F[Unit]):* cancelable]] that can be safely used - * when `fa` and `fin` use a resource-like construct that must be used without allowing - * cancelation. + * Run the given finalizer when cancelation is requested. Unlike [[onCancel]], this may run + * before cancelation is observed, which may allow `fa` to complete before cancelation becomes + * effective. * * @note - * The default implementation of `cancelable` ensures that `fa` is completed before - * cancelation continues, but cannot ensure that `fa` gets canceled before `fa` completes - * normally. When this race condition occurs, the result of `fa` is lost. Implementations of - * [[GenSpawn]] should override `cancelable` with an implementation that returns normally if - * `fa` wins the race between it and `fin`. + * The default implementation of `onCancelRequested` is equivalent to `onCancel` ensures + * that `fin` is completed before cancelation continues, but cannot ensure that `fa` gets + * completes before the fiber is canceled. When this race condition occurs, the result of + * `fa` is lost. Implementations of [[GenSpawn]] should override `onCancelRequested` with an + * implementation that returns normally if `fa` wins the race between it and `fin`. * - * @param poll - * the poller for the uncancelable context the cancelable finalizer is constructed in. * @param fa * the effect to be canceled - * @param fin + * @param ack * an effect which orchestrates some external state which terminates `fa` * @see - * [[uncancelable]] + * [[cancelable]] * @see * [[onCancel]] */ - def cancelable[A](poll: Poll[F], fa: F[A], fin: F[Unit]): F[A] = - start(fa) flatMap { fiber => - // Note: cannot be replaced with joinOrCancel, as this is used to implement joinOrCancel. - poll(fiber.join) - .onCancel(fin.guarantee(fiber.cancel)) - .flatMap(_.embed(poll(canceled *> never))) - } + def onCancelRequested[A](fa: F[A], ack: F[Unit]): F[A] = + fa.onCancel(ack) /** * A non-terminating effect that never completes, which causes a fiber to semantically block diff --git a/tests/shared/src/test/scala/cats/effect/IOSuite.scala b/tests/shared/src/test/scala/cats/effect/IOSuite.scala index 56e16117ea..7f2fdeb46d 100644 --- a/tests/shared/src/test/scala/cats/effect/IOSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/IOSuite.scala @@ -1103,8 +1103,9 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu .racePair( IO.deferred[Unit] .flatMap(complete => - started.complete(()) *> complete.get.cancelable(complete.complete(()).void)) - .uncancelable, + IO.uncancelable(poll => + started.complete(()) *> poll(complete.get).onCancelRequested( + complete.complete(()).void))), IO.never.as(()) ) .start @@ -1114,21 +1115,39 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu } yield assert(outcome.isSuccess, s"racing fiber was unable to complete, was $outcome") } - real("cancelable callback handled before inner onCancel") { + real("onCancelRequested callback handled before inner onCancel") { for { started <- IO.deferred[Unit] cancelable <- IO.deferred[Boolean] - callbacks <- { - started.complete(()) *> cancelable - .get + callbacks <- IO.uncancelable { poll => + started.complete(()) *> poll(cancelable.get) // with the default cancelable implementation, the onCancel would run first .onCancel(cancelable.complete(false).void) - .cancelable(cancelable.complete(true).void) - }.uncancelable.start + .onCancelRequested(cancelable.complete(true).void) + }.start _ <- started.get _ <- callbacks.cancel isCancelable <- cancelable.get - } yield assert(isCancelable, s"cancelable finalizer not called before cancelation observed") + } yield assert( + isCancelable, + s"onCancelRequested finalizer not called before cancelation observed") + } + + real("cancelable - blocking can be canceled") { + for { + started <- IO.deferred[Unit] + await <- IO.delay(new java.util.concurrent.atomic.AtomicBoolean(true)) + callbacks <- { + started.complete(()) *> IO + .blocking { + while (await.get()) {} + } + .cancelable(IO.delay(await.set(false))) + }.start + _ <- started.get + _ <- callbacks.cancel + _ <- callbacks.join + } yield () } real("async - race - immediately cancel inner race when outer unit") { @@ -1259,11 +1278,12 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu assert(!failed) } - ticked("cancelation - support re-enablement via cancelable") { implicit ticker => + ticked("cancelation - support re-enablement via onCancelRequested") { implicit ticker => assertCompleteAs( IO.deferred[Unit].flatMap { gate => val test = IO.deferred[Unit] flatMap { latch => - (gate.complete(()) *> latch.get).uncancelable.cancelable(latch.complete(()).void) + IO.uncancelable(poll => + (gate.complete(()) *> poll(latch.get)).onCancelRequested(latch.complete(()).void)) } test.start.flatMap(gate.get *> _.cancel) @@ -1272,9 +1292,9 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu ) } - ticked("cancelation - cancelable waits for termination") { implicit ticker => + ticked("cancelation - onCancelRequested waits for termination") { implicit ticker => def test(fin: IO[Unit]) = { - val go = IO.never.uncancelable.cancelable(fin) + val go = IO.uncancelable(poll => poll(IO.never).onCancelRequested(fin)) go.start.flatMap(IO.sleep(1.second) *> _.cancel) } From 9203754a9ad40cef20dd2a03f44fc84eafc9089e Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Thu, 30 Jul 2026 22:48:57 -0400 Subject: [PATCH 07/10] GenConcurrent doesn't need onCancelRequested The current onCancelRequested doesn't work with it. It looks like it should be fixable without it, by waiting to transition to Unevaluated until the fiber completes. --- .../main/scala/cats/effect/kernel/GenConcurrent.scala | 10 ++++------ 1 file changed, 4 insertions(+), 6 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 1cf7b79821..2cadcf6c43 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala @@ -77,9 +77,8 @@ trait GenConcurrent[F[_], E] extends GenSpawn[F, E] { val eval = go.start.flatMap { fiber => deferredFiber.complete(fiber) *> - F.onCancelRequested( - poll(fiber.join.flatMap(_.embed(productR(canceled)(never)))), - unsubscribe(deferredFiber)) + poll(fiber.join.flatMap(_.embed(productR(canceled)(never)))) + .onCancel(unsubscribe(deferredFiber)) } @@ -87,9 +86,8 @@ trait GenConcurrent[F[_], E] extends GenSpawn[F, E] { case (poll, Evaluating(fiber, subscribers)) => Evaluating(fiber, subscribers + 1) -> - F.onCancelRequested( - poll(fiber.get.flatMap(_.join).flatMap(_.embed(productR(canceled)(never)))), - unsubscribe(fiber)) + poll(fiber.get.flatMap(_.join).flatMap(_.embed(productR(canceled)(never)))) + .onCancel(unsubscribe(fiber)) case (_, finished @ Finished(result)) => finished -> fromEither(result).flatten From 006483ed087e1ac92e384d4ab6d1ba55b5d32cfd Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Thu, 30 Jul 2026 23:15:37 -0400 Subject: [PATCH 08/10] return cancelable syntax for IO Needed to preserve bin-compat. --- .../src/main/scala/cats/effect/IO.scala | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala index 8a02e9538b..bd63d75f65 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -404,6 +404,51 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { executor: Executor): ResourceIO[IO[OutcomeIO[A @uncheckedVariance]]] = IO.asyncForIO.backgroundOnExecutor(this, executor) + /** + * Given an effect which might be [[uncancelable]] and a finalizer, produce an effect which + * can be canceled by running the finalizer. This combinator is useful for handling scenarios + * in which an effect is inherently uncancelable but may be canceled through setting some + * external state. A trivial example of this might be the following: + * + * {{{ + * val flag = new AtomicBoolean(false) + * val ioa = IO blocking { + * while (!flag.get()) { + * Thread.sleep(10) + * } + * } + * + * ioa.cancelable(IO.delay(flag.set(true))) + * }}} + * + * Without `cancelable`, effects constructed by `blocking`, `delay`, and similar are + * inherently uncancelable. Simply adding an `onCancel` to such effects is insufficient to + * resolve this, despite the fact that under *some* circumstances (such as the above), it is + * possible to enrich an otherwise-uncancelable effect with early termination. `cancelable` + * addresses this use-case. + * + * Note that there is no free lunch here. If an effect truly cannot be prematurely terminated, + * `cancelable` will not allow for cancelation. As an example, if you attempt to cancel + * `uncancelable(_ => never)`, the cancelation will hang forever (in other words, it will be + * itself equivalent to `never`). Applying `cancelable` will not change this in any way. Thus, + * attempting to cancel `cancelable(uncancelable(_ => never), unit)` will ''also'' hang + * forever. As in all cases, cancelation will only return when all finalizers have run and the + * fiber has fully terminated. + * + * If the `IO` self-cancels and the `cancelable` itself is uncancelable, the resulting fiber + * will be equal to `never` (similar to [[race]]). Under normal circumstances, if `IO` + * self-cancels, that cancelation will be propagated to the calling context. + * + * @param fin + * an effect which orchestrates some external state which terminates the `IO` + * @see + * [[uncancelable]] + * @see + * [[onCancel]] + */ + def cancelable(fin: IO[Unit]): IO[A] = + Spawn[IO].cancelable(this, fin) + /** * Run the given finalizer when cancelation is requested. Unlike [[onCancel]], this will run * before cancelation is observed, which may allow `fa` to complete before cancelation becomes From f7e220ac39e5be176cbc004a715de7f05e5bf6f3 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Fri, 31 Jul 2026 18:28:04 -0400 Subject: [PATCH 09/10] Code cleanup of onCancelRequested - fix up naming issues - restore cancelable tests - adjust scaladoc --- .../scala/cats/effect/IOFiberConstants.scala | 2 +- .../java/cats/effect/IOFiberConstants.java | 2 +- .../src/main/scala/cats/effect/IO.scala | 18 ++++++------- .../src/main/scala/cats/effect/IOFiber.scala | 6 ++--- .../cats/effect/kernel/GenConcurrent.scala | 1 - .../scala/cats/effect/kernel/GenSpawn.scala | 14 +++-------- .../src/test/scala/cats/effect/IOSuite.scala | 25 +++++++++++++++++++ 7 files changed, 42 insertions(+), 26 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 c543e0e83e..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,7 +34,7 @@ private object IOFiberConstants { final val UncancelableK = 7 final val UnmaskK = 8 final val AttemptK = 9 - final val CancelableK = 10 + 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 4872f55cf2..1eeb285449 100644 --- a/core/jvm/src/main/java/cats/effect/IOFiberConstants.java +++ b/core/jvm/src/main/java/cats/effect/IOFiberConstants.java @@ -36,7 +36,7 @@ final class IOFiberConstants { static final byte UncancelableK = 7; static final byte UnmaskK = 8; static final byte AttemptK = 9; - static final byte CancelableK = 10; + 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 bd63d75f65..82db0e28aa 100644 --- a/core/shared/src/main/scala/cats/effect/IO.scala +++ b/core/shared/src/main/scala/cats/effect/IO.scala @@ -450,9 +450,9 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] { Spawn[IO].cancelable(this, fin) /** - * Run the given finalizer when cancelation is requested. Unlike [[onCancel]], this will run - * before cancelation is observed, which may allow `fa` to complete before cancelation becomes - * effective. + * Run the given effect when cancelation is requested. Unlike [[onCancel]], this will run + * before cancelation is observed, on a separate fiber, and will always allow `fa` to complete + * before cancelation is observed. * * @param ack * an effect which orchestrates some external state which terminates `fa` @@ -2073,11 +2073,11 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara ioa.onCancel(fin) /** - * Run the given finalizer when cancelation is requested. Unlike [[onCancel]], this will run - * before cancelation is observed, which may allow `fa` to complete before cancelation - * becomes effective. + * Run the given effect when cancelation is requested. Unlike [[onCancel]], this will run + * before cancelation is observed, on a separate fiber, and will always allow `fa` to + * complete before cancelation is observed. * - * @param ioa + * @param fa * the effect to be canceled * @param ack * an effect which orchestrates some external state which terminates `fa` @@ -2086,8 +2086,8 @@ object IO extends IOCompanionPlatform with IOLowPriorityImplicits with TuplePara * @see * [[onCancel]] */ - override def onCancelRequested[A](ioa: IO[A], ack: IO[Unit]): IO[A] = - ioa.onCancelRequested(ack) + override def onCancelRequested[A](fa: IO[A], ack: IO[Unit]): IO[A] = + fa.onCancelRequested(ack) override def bracketFull[A, B](acquire: Poll[IO] => IO[A])(use: A => IO[B])( release: (A, OutcomeIO[B]) => IO[Unit]): IO[B] = diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index 7ced49549f..0050aa750e 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -1102,7 +1102,7 @@ private final class IOFiber[A]( else ack acks.push(push) - conts = ByteStack.push(conts, CancelableK) + conts = ByteStack.push(conts, OnCancelRequestedK) } runLoop(cur.f, nextCancelation, nextAutoCede) @@ -1343,7 +1343,7 @@ private final class IOFiber[A]( case 9 => // attemptK succeeded(Right(result), depth) - case 10 => // cancelableSuccessK + case 10 => // onCancelRequestedSuccessK masks -= 1 if (startedAcks) { acks.pop().as(result) @@ -1414,7 +1414,7 @@ private final class IOFiber[A]( case 9 => succeeded(Left(error), depth) // attemptK - case 10 => // cancelableFailureK + case 10 => // onCancelRequestedFailureK if (startedAcks) { acks.pop() >> failed(error, depth + 1) } else { 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 2cadcf6c43..7c56c3f38a 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenConcurrent.scala @@ -79,7 +79,6 @@ trait GenConcurrent[F[_], E] extends GenSpawn[F, E] { deferredFiber.complete(fiber) *> poll(fiber.join.flatMap(_.embed(productR(canceled)(never)))) .onCancel(unsubscribe(deferredFiber)) - } Evaluating(deferredFiber, 1) -> eval 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 029263d542..6a65ba2f91 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala @@ -252,13 +252,6 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { * be equal to `never` (similar to [[race]]). Under normal circumstances, if `fa` * self-cancels, that cancelation will be propagated to the calling context. * - * @note - * The default implementation of `cancelable` ensures that `fa` is completed before - * cancelation continues, but cannot ensure that `fa` gets canceled before `fa` completes - * normally. When this race condition occurs, the result of `fa` is lost. Implementations of - * [[GenSpawn]] should override `cancelable` with an implementation that returns normally if - * `fa` wins the race between it and `fin`. - * * @param fa * the effect to be canceled * @param fin @@ -271,23 +264,22 @@ trait GenSpawn[F[_], E] extends MonadCancel[F, E] with Unique[F] { def cancelable[A](fa: F[A], fin: F[Unit]): F[A] = uncancelable { poll => start(fa) flatMap { fiber => - // Note: cannot be replaced with joinOrCancel, as this is used to implement joinOrCancel onCancelRequested(poll(fiber.join), fin.guarantee(fiber.cancel)) .flatMap(_.embed(poll(canceled *> never))) } } /** - * Run the given finalizer when cancelation is requested. Unlike [[onCancel]], this may run + * Run the given effect when cancelation is requested. Unlike [[onCancel]], this may run * before cancelation is observed, which may allow `fa` to complete before cancelation becomes * effective. * * @note * The default implementation of `onCancelRequested` is equivalent to `onCancel` ensures - * that `fin` is completed before cancelation continues, but cannot ensure that `fa` gets + * that `ack` is completed before cancelation continues, but cannot ensure that `fa` gets * completes before the fiber is canceled. When this race condition occurs, the result of * `fa` is lost. Implementations of [[GenSpawn]] should override `onCancelRequested` with an - * implementation that returns normally if `fa` wins the race between it and `fin`. + * implementation that returns normally if `fa` wins the race between it and `ack`. * * @param fa * the effect to be canceled diff --git a/tests/shared/src/test/scala/cats/effect/IOSuite.scala b/tests/shared/src/test/scala/cats/effect/IOSuite.scala index 7f2fdeb46d..156e4eb839 100644 --- a/tests/shared/src/test/scala/cats/effect/IOSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/IOSuite.scala @@ -1278,6 +1278,31 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu assert(!failed) } + ticked("cancelation - support re-enablement via cancelable") { implicit ticker => + assertCompleteAs( + IO.deferred[Unit].flatMap { gate => + val test = IO.deferred[Unit] flatMap { latch => + IO.uncancelable(poll => + (gate.complete(()) *> poll(latch.get)).cancelable(latch.complete(()).void)) + } + + test.start.flatMap(gate.get *> _.cancel) + }, + () + ) + } + + ticked("cancelation - cancelable waits for termination") { implicit ticker => + def test(fin: IO[Unit]) = { + val go = IO.never.uncancelable.cancelable(fin) + go.start.flatMap(IO.sleep(1.second) *> _.cancel) + } + + assertNonTerminate(test(IO.unit)) + assertNonTerminate(test(IO.raiseError(new Exception))) + assertNonTerminate(test(IO.canceled)) + } + ticked("cancelation - support re-enablement via onCancelRequested") { implicit ticker => assertCompleteAs( IO.deferred[Unit].flatMap { gate => From 7600193f35915f2dfda1f9ebe82f864683c96b8f Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Fri, 31 Jul 2026 18:51:34 -0400 Subject: [PATCH 10/10] Fix scaladoc --- kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala | 3 +-- 1 file changed, 1 insertion(+), 2 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 03c8048b16..b6c8764991 100644 --- a/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala +++ b/kernel/shared/src/main/scala/cats/effect/kernel/Fiber.scala @@ -59,8 +59,7 @@ trait Fiber[F[_], E, A] extends Serializable { * * @note * This method provides a safer version of `join.onCancel(cancel)` for [[GenSpawn]] - * implementations where - * [[cats.effect.kernel.GenSpawn.cancelable[A](poll:cats\.effect\.kernel\.Poll[F],fa:F[A],fin:F[Unit]):* the polling cancelable]] + * implementations where [[cats.effect.kernel.GenSpawn.onCancelRequested onCancelRequested]] * has a data-loss safe implementation. */ def joinOrCancel(poll: Poll[F])(implicit F: GenSpawn[F, E]): F[Outcome[F, E, A]] =