Repository: getkyo/kyo
Pull request #1193: Fix channel drain close race
Author: login: steinybot
Created: 2025-05-16T12:42:15Z | Merged: 2025-05-21T04:11:02Z

<!--
PRs require an approval from any of the core contributors, other than the PR author.

Include this header if applicable:
Fixes #issue1, #issue2, ...
-->

### Problem
<!--
Explain here the context, and why you're making this change. What is the problem you're trying to solve?
-->

Given:
- A channel.
- A producer that puts items into the channel.
- The produce closes the channel either:
  1) when it is empty via `close` or,
  2) using `closeAwaitEmpty`.
- A consumer that reads from the channel optimistically using `drain` and,
- if empty, the consumer uses `take` to block until there are new items or the channel is closed.

Then:
- There is a race condition where the consumer is in the middle of draining,
- the channel is not empty,
- the producer closes the channel,
- the consumer returns `Closed` rather than the items drained so far,
- and so items are effectively lost from the end of the channel.

### Solution
<!--
Describe your solution. Focus on helping reviewers understand your technical approach and implementation decisions.
-->

If draining encounters a `Failure[Closed]` and items have been drained, then returns those items.

### Notes
<!--
Add any important additional information as bullet points, such as:
- Implementation details reviewers should know about
- Open questions and concerns
- Limitations
-->

This is a little tricky to reproduce as it relies on timing. For whatever reason it was fairly reliable to reproduce when running all the gRPC tests.

I had asked about this and @fwbrasil had said:
> I'd say the behavior for the draining operations are expected since they just obtain the values but don't guarantee that new items won't arrive. The trouble is the closing since the pending backlog in this case is returned to the producer given it's the one to call close. The returned backlog to the producer should contain the missing message.

#1191 was an attempt to resolve this although unfortunately it does not (although it does make other parts of the above pattern much easier to implement).

With this change, it still doesn't guarantee that new items won't arrive. The guarantee is that if it wasn't closed when drain was first called (really when the underlying queue drain is first called) then it will always return a success.


Diff:
diff --git a/kyo-core/shared/src/main/scala/kyo/Channel.scala b/kyo-core/shared/src/main/scala/kyo/Channel.scala
index ee41adaf3..7cf818910 100644
--- a/kyo-core/shared/src/main/scala/kyo/Channel.scala
+++ b/kyo-core/shared/src/main/scala/kyo/Channel.scala
@@ -395,30 +395,54 @@ object Channel:
         final class ZeroCapacityUnsafe[A](using initFrame: Frame, allow: AllowUnsafe) extends BaseUnsafe[A]:
             val isClosed = AtomicBoolean.Unsafe.init(false)
 
-            private val closedResult                     = Result.fail(Closed("Channel", initFrame, "zero-capacity"))
-            def asResult[A](value: A): Result[Closed, A] = if isClosed.get() then closedResult else Result.succeed(value)
+            private val closedResult = Result.fail(Closed("Channel", initFrame, "zero-capacity"))
+
+            /** Succeeds with the value if the channel is still open, otherwise fails with a [[Closed]] error.
+              *
+              * @param value
+              *   The value to succeed with
+              * @return
+              *   The successful value or a [[Closed]] error
+              */
+            private def succeedIfOpen[B](value: B): Result[Closed, B] =
+                if isClosed.get() then closedResult else Result.succeed(value)
+
+            /** Succeeds with the value if it is non-empty or the channel is still open, otherwise fails with a [[Closed]] error.
+              *
+              * This is used in cases where the channel may be closed, but we still have a value to return. This typically occurs when the
+              * producer calls [[closeAwaitEmpty]] and the consumer calls [[drain]] or [[drainUpTo]] where the drain will close the channel
+              * once it has drained the last item, but we want the effect of the close occurring after the drain. This may also occur in a
+              * race condition where the producer checks if the channel is empty and closes it while the consumer is draining.
+              *
+              * @param value
+              *   The value to succeed with
+              * @return
+              *   The successful value or a [[Closed]] error
+              */
+            private def succeedIfNonEmptyOrOpen[B](value: Chunk[B]): Result[Closed, Chunk[B]] =
+                if value.nonEmpty then Result.succeed(value) else succeedIfOpen(value)
 
             def capacity = 0
 
-            def size()(using AllowUnsafe) = asResult(0)
+            def size()(using AllowUnsafe) = succeedIfOpen(0)
 
             def offer(value: A)(using AllowUnsafe) =
                 Maybe(takes.poll()) match
                     case Absent =>
-                        asResult(false)
+                        succeedIfOpen(false)
                     case Present(takePromise) =>
-                        if takePromise.complete(Result.succeed(value)) then asResult(true)
+                        if takePromise.complete(Result.succeed(value)) then succeedIfOpen(true)
                         else offer(value)
 
             def offerAll(values: Seq[A])(using AllowUnsafe): Result[Closed, Chunk[A]] =
                 @tailrec def loop(currentChunk: Chunk[A]): Result[Closed, Chunk[A]] =
                     currentChunk.headMaybe match
                         case Absent =>
-                            asResult(Chunk.empty)
+                            succeedIfOpen(Chunk.empty)
                         case Present(value) =>
                             Maybe(takes.poll()) match
                                 case Absent =>
-                                    asResult {
+                                    succeedIfOpen {
                                         currentChunk
                                     }
                                 case Present(takePromise) =>
@@ -429,7 +453,7 @@ object Channel:
             end offerAll
 
             def poll()(using AllowUnsafe) =
-                asResult {
+                succeedIfOpen {
                     Maybe(puts.poll()) match
                         case Absent =>
                             Absent
@@ -459,7 +483,7 @@ object Channel:
                         Maybe(puts.poll()) match
                             case Absent =>
                                 flush()
-                                asResult(current)
+                                succeedIfNonEmptyOrOpen(current)
                             case Present(Put.Value(value, promise)) =>
                                 discard(promise.complete(Result.unit))
                                 loop(current.appended(value), i - 1)
@@ -469,7 +493,7 @@ object Channel:
                                 if remaining.nonEmpty then
                                     discard(puts.offer(Put.Batch(remaining, promise)))
                                     flush()
-                                    asResult(current.concat(taken))
+                                    succeedIfNonEmptyOrOpen(current.concat(taken))
                                 else
                                     discard(promise.complete(Result.unit))
                                     loop(current.concat(taken), i - taken.length)
@@ -486,7 +510,7 @@ object Channel:
                 def loop(current: Chunk[A]): Result[Closed, Chunk[A]] =
                     Maybe(puts.poll()) match
                         case Absent =>
-                            asResult(current)
+                            succeedIfNonEmptyOrOpen(current)
                         case Present(Put.Value(value, promise)) =>
                             discard(promise.complete(Result.unit))
                             loop(current.appended(value))
@@ -509,8 +533,8 @@ object Channel:
             def closeAwaitEmpty()(using Frame, AllowUnsafe) =
                 Fiber.Unsafe.init(Result.succeed(close().isDefined))
 
-            def empty()(using AllowUnsafe)  = asResult(true)
-            def full()(using AllowUnsafe)   = asResult(true)
+            def empty()(using AllowUnsafe)  = succeedIfOpen(true)
+            def full()(using AllowUnsafe)   = succeedIfOpen(true)
             def closed()(using AllowUnsafe) = isClosed.get()
 
             @tailrec protected def flush(): Unit =
@@ -623,7 +647,8 @@ object Channel:
                                 else
                                     flush()
                                     loop(current.concat(c), i - c.length)
-                            case other => other
+                            case _ if current.nonEmpty => Result.Success(current)
+                            case other                 => other
                         end match
                     end if
                 end loop
@@ -641,7 +666,8 @@ object Channel:
                             else
                                 flush()
                                 loop(current.concat(c))
-                        case other => other
+                        case _ if current.nonEmpty => Result.Success(current)
+                        case other                 => other
                     end match
                 end loop
 
diff --git a/kyo-core/shared/src/test/scala/kyo/ChannelTest.scala b/kyo-core/shared/src/test/scala/kyo/ChannelTest.scala
index 5bf615b6c..c0234d73e 100644
--- a/kyo-core/shared/src/test/scala/kyo/ChannelTest.scala
+++ b/kyo-core/shared/src/test/scala/kyo/ChannelTest.scala
@@ -337,6 +337,15 @@ class ChannelTest extends Test:
                 yield assert(result == Chunk(1, 2, 3) && finalSize == 0)
             }
         }
+        "race with close" in run {
+            verifyRaceDrainWithClose(2, _.drain, _.close)
+        }
+        "race with closeAwaitEmpty" in run {
+            verifyRaceDrainWithClose(2, _.drain, _.closeAwaitEmpty)
+        }
+        "race with close and zero capacity" in run {
+            verifyRaceDrainWithClose(2, _.drain, _.close)
+        }
     }
     "drainUpTo" - {
         "zero or negative" in run {
@@ -408,6 +417,15 @@ class ChannelTest extends Test:
                 yield assert(result == Chunk(1, 2, 3) && finalSize == 0)
             }
         }
+        "race with close" in run {
+            verifyRaceDrainWithClose(2, _.drainUpTo(2), _.close)
+        }
+        "race with closeAwaitEmpty" in run {
+            verifyRaceDrainWithClose(2, _.drainUpTo(2), _.closeAwaitEmpty)
+        }
+        "race with close and zero capacity" in run {
+            verifyRaceDrainWithClose(0, _.drainUpTo(Int.MaxValue), _.close)
+        }
     }
     "close" - {
         "empty" in run {
@@ -1102,4 +1120,52 @@ class ChannelTest extends Test:
         }
     }
 
+    private def verifyRaceDrainWithClose(
+        capacity: Int,
+        drain: Channel[Int] => Any < (Abort[Closed] & IO),
+        close: Channel[Int] => (Any < Async)
+    ) =
+        for
+            c0  <- Channel.init[Int](capacity)
+            ref <- AtomicRef.init(c0)
+            // Create a fiber that repeatedly puts and item and then checks to see if the channel
+            // has been drained. If it has then it closes the channel and creates a new one.
+            producer <- Async.run {
+                Loop(()) { _ =>
+                    for
+                        c     <- ref.get
+                        _     <- c.put(1)
+                        empty <- c.empty
+                        _     <-
+                            // If it is empty then it could be that the consumer is in the middle of
+                            // draining. Attempt to close the channel right before the consumer
+                            // checks for more items.
+                            if empty then
+                                for
+                                    c2 <- Channel.init[Int](capacity)
+                                    _  <- ref.set(c2)
+                                    _  <- close(c)
+                                yield ()
+                            else Kyo.unit
+                            end if
+                    yield Loop.continue(())
+                }
+            }
+            // Create a fiber that repeatedly drains the channel if it is not closed or empty.
+            // If it is closed or empty (and is about to be closed) then repeat until the consumer
+            // creates a new channel.
+            result <- Abort.run {
+                Async.fill(100_000, concurrency = 1) {
+                    for
+                        c             <- ref.get
+                        closedOrEmpty <- Abort.recover[Closed](_ => true)(c.empty)
+                        _             <- if closedOrEmpty then Kyo.unit else drain(c)
+                    yield ()
+                }
+            }
+            _ <- producer.interrupt
+        yield assert(result.isSuccess)
+        end for
+    end verifyRaceDrainWithClose
+
 end ChannelTest


Reviews:
user: login: fwbrasil
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-17T04:46:10Z
user: login: fwbrasil
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-17T04:47:17Z
user: login: fwbrasil
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-17T04:48:07Z
user: login: steinybot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-17T06:20:17Z
user: login: steinybot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-17T06:20:58Z
user: login: steinybot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-17T06:24:48Z
user: login: steinybot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-17T06:27:06Z
user: login: steinybot
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-17T09:30:27Z
user: login: fwbrasil
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T04:07:33Z
user: login: fwbrasil
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T04:10:09Z

Review comments:
diff_hunk: @@ -395,8 +395,13 @@ object Channel:
         final class ZeroCapacityUnsafe[A](using initFrame: Frame, allow: AllowUnsafe) extends BaseUnsafe[A]:
             val isClosed = AtomicBoolean.Unsafe.init(false)
 
-            private val closedResult                     = Result.fail(Closed("Channel", initFrame, "zero-capacity"))
-            def asResult[A](value: A): Result[Closed, A] = if isClosed.get() then closedResult else Result.succeed(value)
+            private val closedResult = Result.fail(Closed("Channel", initFrame, "zero-capacity"))
+
+            private def asResult[A](value: A): Result[Closed, A] =
+                if isClosed.get() then closedResult else Result.succeed(value)
+
+            private def asOptimisticResult[A](value: Chunk[A]): Result[Closed, Chunk[A]] =
path: kyo-core/shared/src/main/scala/kyo/Channel.scala
user: login: fwbrasil
body: The name doesn't seem very informative, how about `asDrainResult`? Could you also add a comment explaining the edge case the method handles?
created_at: 2025-05-17T04:46:10Z
author_association: COLLABORATOR
diff_hunk: @@ -633,19 +639,20 @@ object Channel:
 
             def drain()(using AllowUnsafe) =
                 @tailrec
-                def loop(current: Chunk[A]): Result[Closed, Chunk[A]] =
+                def loop(current: Chunk[A], i: Int): Result[Closed, Chunk[A]] =
path: kyo-core/shared/src/main/scala/kyo/Channel.scala
user: login: fwbrasil
body: Is `i` unused?
created_at: 2025-05-17T04:47:17Z
author_association: COLLABORATOR
diff_hunk: @@ -1102,4 +1120,52 @@ class ChannelTest extends Test:
         }
     }
 
+    private def verifyRaceDrainWithClose(
path: kyo-core/shared/src/test/scala/kyo/ChannelTest.scala
user: login: fwbrasil
body: Good catch! I imagine it was a though one to debug  🙏
created_at: 2025-05-17T04:48:07Z
author_association: COLLABORATOR
diff_hunk: @@ -633,19 +639,20 @@ object Channel:
 
             def drain()(using AllowUnsafe) =
                 @tailrec
-                def loop(current: Chunk[A]): Result[Closed, Chunk[A]] =
+                def loop(current: Chunk[A], i: Int): Result[Closed, Chunk[A]] =
path: kyo-core/shared/src/main/scala/kyo/Channel.scala
user: login: steinybot
body: Woops, this was left in for verifying the tests. Removed.
created_at: 2025-05-17T06:20:17Z
author_association: COLLABORATOR
diff_hunk: @@ -395,8 +395,13 @@ object Channel:
         final class ZeroCapacityUnsafe[A](using initFrame: Frame, allow: AllowUnsafe) extends BaseUnsafe[A]:
             val isClosed = AtomicBoolean.Unsafe.init(false)
 
-            private val closedResult                     = Result.fail(Closed("Channel", initFrame, "zero-capacity"))
-            def asResult[A](value: A): Result[Closed, A] = if isClosed.get() then closedResult else Result.succeed(value)
+            private val closedResult = Result.fail(Closed("Channel", initFrame, "zero-capacity"))
+
+            private def asResult[A](value: A): Result[Closed, A] =
+                if isClosed.get() then closedResult else Result.succeed(value)
+
+            private def asOptimisticResult[A](value: Chunk[A]): Result[Closed, Chunk[A]] =
path: kyo-core/shared/src/main/scala/kyo/Channel.scala
user: login: steinybot
body: Sure thing. I went for a slightly different naming. Let me know what you think.
created_at: 2025-05-17T06:20:58Z
author_association: COLLABORATOR
diff_hunk: @@ -1102,4 +1120,52 @@ class ChannelTest extends Test:
         }
     }
 
+    private def verifyRaceDrainWithClose(
path: kyo-core/shared/src/test/scala/kyo/ChannelTest.scala
user: login: steinybot
body: Ha yeah, the closer I got to minimising the problem it started disappearing. Good ol' Heisenbug. It seems obvious in hindsight but I have been looking at that code for so long I failed to notice.
created_at: 2025-05-17T06:24:48Z
author_association: COLLABORATOR
diff_hunk: @@ -1102,4 +1120,52 @@ class ChannelTest extends Test:
         }
     }
 
+    private def verifyRaceDrainWithClose(
+        capacity: Int,
+        drain: Channel[Int] => Any < (Abort[Closed] & IO),
+        close: Channel[Int] => (Any < Async)
+    ) =
+        for
+            c0  <- Channel.init[Int](capacity)
+            ref <- AtomicRef.init(c0)
+            // Create a fiber that repeatedly puts and item and then checks to see if the channel
+            // has been drained. If it has then it closes the channel and creates a new one.
+            producer <- Async.run {
+                Loop(()) { _ =>
+                    for
+                        c     <- ref.get
+                        _     <- c.put(1)
+                        empty <- c.empty
+                        _     <-
+                            // If it is empty then it could be that the consumer is in the middle of
+                            // draining. Attempt to close the channel right before the consumer
+                            // checks for more items.
+                            if empty then
+                                for
+                                    c2 <- Channel.init[Int](capacity)
+                                    _  <- ref.set(c2)
+                                    _  <- close(c)
+                                yield ()
+                            else Kyo.unit
+                            end if
+                    yield Loop.continue(())
+                }
+            }
+            // Create a fiber that repeatedly drains the channel if it is not closed or empty.
+            // If it is closed or empty (and is about to be closed) then repeat until the consumer
+            // creates a new channel.
+            result <- Abort.run {
+                Async.fill(100_000, concurrency = 1) {
+                    for
+                        c             <- ref.get
+                        closedOrEmpty <- Abort.recover[Closed](_ => true)(c.empty)
+                        _             <- if closedOrEmpty then Kyo.unit else drain(c)
+                    yield ()
+                }
+            }
+            _ <- producer.interrupt
+        yield assert(result.isSuccess)
path: kyo-core/shared/src/test/scala/kyo/ChannelTest.scala
user: login: steinybot
body: This could be a bit better. It could actually check that the right number of elements were drained. I'd have to swap things around a bit. I should be able to simplify this a bit so long as it doesn't mess up the timings.
created_at: 2025-05-17T06:27:06Z
author_association: COLLABORATOR
diff_hunk: @@ -1102,4 +1120,52 @@ class ChannelTest extends Test:
         }
     }
 
+    private def verifyRaceDrainWithClose(
+        capacity: Int,
+        drain: Channel[Int] => Any < (Abort[Closed] & IO),
+        close: Channel[Int] => (Any < Async)
+    ) =
+        for
+            c0  <- Channel.init[Int](capacity)
+            ref <- AtomicRef.init(c0)
+            // Create a fiber that repeatedly puts and item and then checks to see if the channel
+            // has been drained. If it has then it closes the channel and creates a new one.
+            producer <- Async.run {
+                Loop(()) { _ =>
+                    for
+                        c     <- ref.get
+                        _     <- c.put(1)
+                        empty <- c.empty
+                        _     <-
+                            // If it is empty then it could be that the consumer is in the middle of
+                            // draining. Attempt to close the channel right before the consumer
+                            // checks for more items.
+                            if empty then
+                                for
+                                    c2 <- Channel.init[Int](capacity)
+                                    _  <- ref.set(c2)
+                                    _  <- close(c)
+                                yield ()
+                            else Kyo.unit
+                            end if
+                    yield Loop.continue(())
+                }
+            }
+            // Create a fiber that repeatedly drains the channel if it is not closed or empty.
+            // If it is closed or empty (and is about to be closed) then repeat until the consumer
+            // creates a new channel.
+            result <- Abort.run {
+                Async.fill(100_000, concurrency = 1) {
+                    for
+                        c             <- ref.get
+                        closedOrEmpty <- Abort.recover[Closed](_ => true)(c.empty)
+                        _             <- if closedOrEmpty then Kyo.unit else drain(c)
+                    yield ()
+                }
+            }
+            _ <- producer.interrupt
+        yield assert(result.isSuccess)
path: kyo-core/shared/src/test/scala/kyo/ChannelTest.scala
user: login: steinybot
body: I have a different version of these tests which are easier to read and test the actual counts but to get similar failure rates it runs about 5 times slower (~500ms on my M1): https://github.com/steinybot/kyo/pull/2/files

Not sure if it is worth it.
created_at: 2025-05-17T09:30:27Z
author_association: COLLABORATOR
diff_hunk: @@ -1102,4 +1120,52 @@ class ChannelTest extends Test:
         }
     }
 
+    private def verifyRaceDrainWithClose(
+        capacity: Int,
+        drain: Channel[Int] => Any < (Abort[Closed] & IO),
+        close: Channel[Int] => (Any < Async)
+    ) =
+        for
+            c0  <- Channel.init[Int](capacity)
+            ref <- AtomicRef.init(c0)
+            // Create a fiber that repeatedly puts and item and then checks to see if the channel
+            // has been drained. If it has then it closes the channel and creates a new one.
+            producer <- Async.run {
+                Loop(()) { _ =>
+                    for
+                        c     <- ref.get
+                        _     <- c.put(1)
+                        empty <- c.empty
+                        _     <-
+                            // If it is empty then it could be that the consumer is in the middle of
+                            // draining. Attempt to close the channel right before the consumer
+                            // checks for more items.
+                            if empty then
+                                for
+                                    c2 <- Channel.init[Int](capacity)
+                                    _  <- ref.set(c2)
+                                    _  <- close(c)
+                                yield ()
+                            else Kyo.unit
+                            end if
+                    yield Loop.continue(())
+                }
+            }
+            // Create a fiber that repeatedly drains the channel if it is not closed or empty.
+            // If it is closed or empty (and is about to be closed) then repeat until the consumer
+            // creates a new channel.
+            result <- Abort.run {
+                Async.fill(100_000, concurrency = 1) {
+                    for
+                        c             <- ref.get
+                        closedOrEmpty <- Abort.recover[Closed](_ => true)(c.empty)
+                        _             <- if closedOrEmpty then Kyo.unit else drain(c)
+                    yield ()
+                }
+            }
+            _ <- producer.interrupt
+        yield assert(result.isSuccess)
path: kyo-core/shared/src/test/scala/kyo/ChannelTest.scala
user: login: fwbrasil
body: I think having better coverage of an edge case is worth it! If we see it's impacting build times too much, we can follow up later 
created_at: 2025-05-21T04:07:33Z
author_association: COLLABORATOR
diff_hunk: @@ -1102,4 +1120,52 @@ class ChannelTest extends Test:
         }
     }
 
+    private def verifyRaceDrainWithClose(
+        capacity: Int,
+        drain: Channel[Int] => Any < (Abort[Closed] & IO),
+        close: Channel[Int] => (Any < Async)
+    ) =
+        for
+            c0  <- Channel.init[Int](capacity)
+            ref <- AtomicRef.init(c0)
+            // Create a fiber that repeatedly puts and item and then checks to see if the channel
+            // has been drained. If it has then it closes the channel and creates a new one.
+            producer <- Async.run {
+                Loop(()) { _ =>
+                    for
+                        c     <- ref.get
+                        _     <- c.put(1)
+                        empty <- c.empty
+                        _     <-
+                            // If it is empty then it could be that the consumer is in the middle of
+                            // draining. Attempt to close the channel right before the consumer
+                            // checks for more items.
+                            if empty then
+                                for
+                                    c2 <- Channel.init[Int](capacity)
+                                    _  <- ref.set(c2)
+                                    _  <- close(c)
+                                yield ()
+                            else Kyo.unit
+                            end if
+                    yield Loop.continue(())
+                }
+            }
+            // Create a fiber that repeatedly drains the channel if it is not closed or empty.
+            // If it is closed or empty (and is about to be closed) then repeat until the consumer
+            // creates a new channel.
+            result <- Abort.run {
+                Async.fill(100_000, concurrency = 1) {
+                    for
+                        c             <- ref.get
+                        closedOrEmpty <- Abort.recover[Closed](_ => true)(c.empty)
+                        _             <- if closedOrEmpty then Kyo.unit else drain(c)
+                    yield ()
+                }
+            }
+            _ <- producer.interrupt
+        yield assert(result.isSuccess)
path: kyo-core/shared/src/test/scala/kyo/ChannelTest.scala
user: login: fwbrasil
body: I'm merging the PR to stabilize the build but feel free to post follow ups 
created_at: 2025-05-21T04:10:09Z
author_association: COLLABORATOR

PR comments:
user: login: steinybot
created_at: 2025-05-16T12:50:45Z
body: Only one test failed here but it was the one with `closeAwaitEmpty`: https://github.com/getkyo/kyo/actions/runs/15068698744/job/42359374252#step:6:6695
author_association: COLLABORATOR
user: login: steinybot
created_at: 2025-05-16T13:03:43Z
body: This run was "better". 5 tests failed: https://github.com/getkyo/kyo/actions/runs/15068698744/job/42359899400
<img width="692" alt="Screenshot 2025-05-17 at 1 02 57 AM" src="https://github.com/user-attachments/assets/a6c7352e-1090-4702-837f-f6419e915bdb" />

author_association: COLLABORATOR
user: login: fwbrasil
created_at: 2025-05-17T04:44:01Z
body: @steinybot not sure I understood, are the tests still flaky?
author_association: COLLABORATOR
user: login: steinybot
created_at: 2025-05-17T06:29:28Z
body: > @steinybot not sure I understood, are the tests still flaky?

Ah sorry, no, they shouldn't be flaky. I first commited the tests to prove that they failed, then commited the fix. For some reason I thought that my comment would appear in the activity before the later commit.
author_association: COLLABORATOR