typelevel / typelevel/cats-effect
`IO#syncStep` drops `uncancelable` and `onCancel` from the returned `IO`, so `Resource` release is skipped
Nobody has claimed this yet.
- Dominant language
- Scala
- Stars
- 2.2k
- Forks
- 576
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 18
Description
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in core/shared/src/main/scala/cats/effect/IO.scala at SyncStep.interpret, especially the Uncancelable and OnCancel cases around lines 2390-2398. Review the pending IOSuite tests described in #4686 and run the relevant syncStep and cancellation tests. Done means the reproduced Resource.use release and uncancelable cancellation cases retain their expected behavior after an async boundary.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- scala
- Domain
- backend-api-design
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100