typelevel / typelevel/cats-effect

`IO#syncStep` drops `uncancelable` and `onCancel` from the returned `IO`, so `Resource` release is skipped

Open
#4,687 2 comments 0 reactions 0 assignees View on GitHub

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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.