From a2b877696a719c8878fa44769e3e9ece06b591ac Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Fri, 6 Nov 2026 18:12:42 -0500 Subject: [PATCH 1/2] Fix Async Cont get cancellation race Fixes a very narrow race condition when trying to `get` in an finalizer in a `Cont`. The implementation in IOFiber: 1. changed the cont state to waiting 2. concurrently, a running callback might check if the state is "waiting" (in `stateLoop`), see that it is, `loop()`, fail to resume, see the finalizing states disagree, and exit. The callback value is lost. 3. back in the fiber thread, it would update `wasFinalizing` on the cont state and wait for a callback that already terminated Now, `wasFinalizing` is set first. This ordering avoids the race. --- .../src/main/scala/cats/effect/IOFiber.scala | 18 ++++++++++------- .../src/test/scala/cats/effect/IOSuite.scala | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/core/shared/src/main/scala/cats/effect/IOFiber.scala b/core/shared/src/main/scala/cats/effect/IOFiber.scala index e34f3d6586..ca5ff352be 100644 --- a/core/shared/src/main/scala/cats/effect/IOFiber.scala +++ b/core/shared/src/main/scala/cats/effect/IOFiber.scala @@ -785,6 +785,17 @@ private final class IOFiber[A]( finalizers.push(fin) conts = ByteStack.push(conts, OnCancelK) + /* + * We set the finalizing check to the *suspension* point, which may + * be in a different finalizer scope than the cont itself. This must be + * written before publishing "waiting", or a racing `cb` could see a + * stale value from an earlier `get` and drop the wakeup. + * `wasFinalizing` is published by a volatile store on `suspended`, below. + */ + if (state.get() eq null) { + state.wasFinalizing = finalizing + } + if (state.compareAndSet(null, state.waiting)) { /* * `state` was "initial" (null), so `get` has arrived before the callback, @@ -792,13 +803,6 @@ private final class IOFiber[A]( * resume with the result once that's ready */ - /* - * we set the finalizing check to the *suspension* point, which may - * be in a different finalizer scope than the cont itself. - * `wasFinalizing` is published by a volatile store on `suspended`. - */ - state.wasFinalizing = finalizing - if (isStackTracing) { state.handle = monitor() finalizers.push(IO { diff --git a/tests/shared/src/test/scala/cats/effect/IOSuite.scala b/tests/shared/src/test/scala/cats/effect/IOSuite.scala index 4a4ecd5dd2..518eb6957e 100644 --- a/tests/shared/src/test/scala/cats/effect/IOSuite.scala +++ b/tests/shared/src/test/scala/cats/effect/IOSuite.scala @@ -1106,6 +1106,26 @@ class IOSuite extends BaseScalaCheckSuite with DisciplineSuite with IOPlatformSu } yield result } + // a second `get` in a finalizer must not miss a callback that races with cancelation + real("cont - second get in onCancel finalizer does not hang") { + val cont = IO.cont[Unit, Unit](new Cont[IO, Unit, Unit] { + def apply[G[_]](implicit G: MonadCancel[G, Throwable]) = { (resume, get, lift) => + G.uncancelable { poll => + lift(IO.cede.productR(IO(resume(Right(())))).start) *> + poll(get).onCancel(get) + } + } + }) + + val one = for { + fiber <- cont.start + _ <- IO.cede + _ <- fiber.cancel + } yield () + + one.parReplicateA_(if (!isJS) 8 else 1).replicateA_(50000) + } + ticked("async - allow for misordered nesting") { implicit ticker => var outerR = 0 var innerR = 0 From 9f5a48c5c33d566720ed55908e9f80e5498597c2 Mon Sep 17 00:00:00 2001 From: Justin Reardon Date: Fri, 6 Nov 2026 20:23:58 -0500 Subject: [PATCH 2/2] run build on macos-15 --- .github/workflows/ci.yml | 24 ++++++++++++------------ build.sbt | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed038581d3..7ea65c085a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - ubuntu-latest - ubuntu-22.04-arm - windows-2022 - - macos-14 + - macos-15 scala: [3.3.4, 2.12.20, 2.13.18] java: - temurin@8 @@ -64,18 +64,18 @@ jobs: - os: windows-2022 scala: 3.3.4 ci: ciJVM - - os: macos-14 + - os: macos-15 scala: 3.3.4 ci: ciJVM - os: windows-2022 scala: 2.12.20 ci: ciJVM - - os: macos-14 + - os: macos-15 scala: 2.12.20 ci: ciJVM - - os: macos-14 + - os: macos-15 java: temurin@8 - - os: macos-14 + - os: macos-15 java: temurin@11 - ci: ciFirefox scala: 3.3.4 @@ -97,7 +97,7 @@ jobs: ci: ciJS - os: windows-2022 ci: ciJS - - os: macos-14 + - os: macos-15 ci: ciJS - ci: ciFirefox java: temurin@11 @@ -111,7 +111,7 @@ jobs: ci: ciFirefox - os: windows-2022 ci: ciFirefox - - os: macos-14 + - os: macos-15 ci: ciFirefox - ci: ciChrome java: temurin@11 @@ -125,7 +125,7 @@ jobs: ci: ciChrome - os: windows-2022 ci: ciChrome - - os: macos-14 + - os: macos-15 ci: ciChrome - ci: ciNative java: temurin@11 @@ -138,7 +138,7 @@ jobs: os: windows-2022 - ci: ciNative java: temurin@11 - os: macos-14 + os: macos-15 - ci: ciNative java: temurin@17 os: ubuntu-latest @@ -156,7 +156,7 @@ jobs: os: windows-2022 - ci: ciNative java: temurin@21 - os: macos-14 + os: macos-15 - ci: ciNative java: graalvm@21 os: ubuntu-latest @@ -168,13 +168,13 @@ jobs: os: windows-2022 - ci: ciNative java: graalvm@21 - os: macos-14 + os: macos-15 - os: ubuntu-22.04-arm ci: ciNative scala: 2.12.20 - os: windows-2022 ci: ciNative - - os: macos-14 + - os: macos-15 ci: ciNative scala: 2.12.20 runs-on: ${{ matrix.os }} diff --git a/build.sbt b/build.sbt index b17cc1cd1b..f65f873594 100644 --- a/build.sbt +++ b/build.sbt @@ -112,7 +112,7 @@ ThisBuild / developers := List( val PrimaryOS = "ubuntu-latest" val ArmOS = "ubuntu-22.04-arm" val Windows = "windows-2022" -val MacOS = "macos-14" +val MacOS = "macos-15" val Scala212 = "2.12.20" val Scala213 = "2.13.18"