When IO#syncStep stops at an async boundary, the IO it returns in Left has lost the uncancelable and onCancel it was wrapped in. Canceling it skips finalizers and interrupts masked regions. The visible symptom is that Resource.use no longer releases.
Reproduced on cats-effect 3.7.1, Scala 2.13.18, JVM.
Reproduction
//> using scala 2.13.18
//> using dep org.typelevel::cats-effect:3.7.1
import cats.effect._, unsafe.implicits.global
import scala.concurrent.duration._, scala.util.Try
object Repro extends App {
var released = false
val effect = Resource.make(IO.unit)(_ => IO { released = true }).use(_ => IO.never)
val result = Try {
effect.syncStep(Int.MaxValue).unsafeRunSync() match {
case Right(a) => a
case Left(rest) => rest.timeout(50.millis).unsafeRunSync()
}
}
println(s"result = $result, released = $released")
// result = Failure(java.util.concurrent.TimeoutException: 50 milliseconds), released = false
}
Running effect.timeout(50.millis).unsafeRunSync() directly, without the syncStep, prints released = true.
The timeout is not the point. I opened #4686 with three pending IOSuite tests: a Resource.use canceled from inside and from another fiber, both skipping the release, and an IO.uncancelable whose returned IO no longer blocks a cancel.
Cause
SyncStep.interpret walks the IO node by node until it reaches one it cannot run in G. When G's root cancel scope is Uncancelable, it does not stop at Uncancelable and OnCancel nodes but walks into them (IO.scala#L2390-L2398):
// walk inside: unwrap the region, replace Poll with identity, keep going
case IO.Uncancelable(body, _) if G.rootCancelScope == CancelScope.Uncancelable =>
val ioa = body(new Poll[IO] { def apply[C](ioc: IO[C]): IO[C] = ioc })
interpret(ioa, limit, stepsUntilDefer)
// walk past: keep the effect, drop the finalizer
case IO.OnCancel(ioa, _) if G.rootCancelScope == CancelScope.Uncancelable =>
interpret(ioa, limit, stepsUntilDefer)
// stop: return the node as is
case _ =>
G.pure(Left(io))
This is fine as long as the walk stays inside G: an uncancelable G cannot actually be canceled, so the two wrappers make no difference there. It stops being fine when the walk hits an async boundary. The inner IO is then returned as Left and the wrappers are not put back, so it runs on a cancelable runtime again without its mask and without its finalizer. Resource.use is built from exactly these two, which is why its release is what gets dropped.
The two cases came in with #3064 and #3065 (v3.3.14, v3.4.0), to let a Resource.allocated with a synchronous acquire be stepped through. The shortcut was noticed in review (discussion).
Nothing caught it because IO#syncStep always uses SyncIO, which is uncancelable, while AsyncLaws.syncStepIdentity runs with G = F = IO, which is cancelable, so the laws never reach these branches. The IOSuite tests for the two cases only check that the prefix completes.
When
IO#syncStepstops at an async boundary, theIOit returns inLefthas lost theuncancelableandonCancelit was wrapped in. Canceling it skips finalizers and interrupts masked regions. The visible symptom is thatResource.useno longer releases.Reproduced on cats-effect 3.7.1, Scala 2.13.18, JVM.
Reproduction
Running
effect.timeout(50.millis).unsafeRunSync()directly, without thesyncStep, printsreleased = true.The timeout is not the point. I opened #4686 with three pending
IOSuitetests: aResource.usecanceled from inside and from another fiber, both skipping the release, and anIO.uncancelablewhose returnedIOno longer blocks a cancel.Cause
SyncStep.interpretwalks theIOnode by node until it reaches one it cannot run inG. WhenG's root cancel scope isUncancelable, it does not stop atUncancelableandOnCancelnodes but walks into them (IO.scala#L2390-L2398):This is fine as long as the walk stays inside
G: an uncancelableGcannot actually be canceled, so the two wrappers make no difference there. It stops being fine when the walk hits an async boundary. The innerIOis then returned asLeftand the wrappers are not put back, so it runs on a cancelable runtime again without its mask and without its finalizer.Resource.useis built from exactly these two, which is why its release is what gets dropped.The two cases came in with #3064 and #3065 (v3.3.14, v3.4.0), to let a
Resource.allocatedwith a synchronous acquire be stepped through. The shortcut was noticed in review (discussion).Nothing caught it because
IO#syncStepalways usesSyncIO, which is uncancelable, whileAsyncLaws.syncStepIdentityruns withG = F = IO, which is cancelable, so the laws never reach these branches. TheIOSuitetests for the two cases only check that the prefix completes.