Skip to content

Commit f588241

Browse files
committed
[kyo-reactive-streams] fix flaky hang in publisher-interruption test
PublisherToSubscriberTest "publisher's interuption should end all subscribed parties" intermittently timed out (a 2m hang) on CI. latchPub only proves publisher.subscribe was called for each subscriber; onSubscribe is delivered asynchronously by the publisher's channel-consumer fiber. Interrupting the publisher before a subscriber is established orphans it: it stays Uninitialized, its emit-loop await blocks on a promise that only onSubscribe (or its own scope teardown) can complete, and its run fiber hangs until the leaf timeout. A prior change removed the settle sleep that had masked this, exposing the race. Fix by ordering the interruption strictly after establishment: StreamSubscriber now completes a dedicated `subscribed` promise when it leaves Uninitialized, and exposes `awaitSubscribed`; the test awaits all four subscribers before interrupting. The test then exercises real propagation to established parties (no restored sleep, no manual subscriber interrupts) and no longer races setup. Validated: the interruption leaf passes 15/15 loops on the JVM and on JS/Native.
1 parent 4353a74 commit f588241

2 files changed

Lines changed: 21 additions & 0 deletions

File tree

kyo-reactive-streams/shared/src/main/scala/kyo/interop/flow/StreamSubscriber.scala

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ final private[kyo] class StreamSubscriber[V](
2323
UpstreamState.Uninitialized -> Maybe.empty[Fiber.Promise.Unsafe[Unit, Any]]
2424
)
2525

26+
// Completed the first time the subscriber leaves Uninitialized (onSubscribe delivered, i.e. the
27+
// subscription is established). Lets a caller await establishment without touching the consumption
28+
// `await` above, whose promise slot is owned by the emit loop.
29+
private val subscribed = Fiber.Promise.Unsafe.init[Unit, Any]()
30+
2631
private inline def throwIfNull[A](b: A): Unit = if isNull(b) then throw new NullPointerException()
2732

2833
override def onSubscribe(subscription: Subscription): Unit =
@@ -33,6 +38,7 @@ final private[kyo] class StreamSubscriber[V](
3338
case (UpstreamState.Uninitialized, maybePromise) =>
3439
val nextState = UpstreamState.WaitForRequest(subscription, Chunk.empty, 0) -> Absent
3540
if state.compareAndSet(curState, nextState) then
41+
subscribed.completeUnitDiscard()
3642
maybePromise.foreach(_.completeUnitDiscard())
3743
else
3844
handleSubscribe()
@@ -156,6 +162,12 @@ final private[kyo] class StreamSubscriber[V](
156162
Sync.defer(handleAwait())
157163
end await
158164

165+
// Resolves once onSubscribe has been delivered (the subscription is established). Used to order a
166+
// downstream action (e.g. a publisher interruption) strictly after every subscriber is subscribed,
167+
// so it cannot race subscription setup and orphan a still-Uninitialized subscriber.
168+
private[interop] def awaitSubscribed(using Frame): Unit < Async =
169+
subscribed.safe.use(_ => ())
170+
159171
private[interop] def request(using Frame): Long < Sync =
160172
@tailrec def handleRequest(): Long < Sync =
161173
val curState = state.get()

kyo-reactive-streams/shared/src/test/scala/kyo/interop/flow/PublisherToSubscriberTest.scala

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,15 @@ abstract private class PublisherToSubscriberTest extends kyo.test.Test[Any]:
195195
.andThen(latchPub.release).andThen(Async.never)
196196
)))
197197
_ <- latchPub.await
198+
// latchPub only proves publisher.subscribe was called for each subscriber; onSubscribe
199+
// is delivered asynchronously by the publisher's channel-consumer fiber. Interrupting
200+
// before a subscriber is established would orphan it (it never receives onSubscribe or a
201+
// terminal signal), and its run fiber would hang. Wait until every subscriber is actually
202+
// subscribed, so this tests propagation to established parties rather than racing setup.
203+
_ <- subscriber1.awaitSubscribed
204+
_ <- subscriber2.awaitSubscribed
205+
_ <- subscriber3.awaitSubscribed
206+
_ <- subscriber4.awaitSubscribed
198207
_ <- publisherFiber.interrupt.unit
199208
// Interrupting the publisher fiber closes its scope, which must propagate
200209
// cancellation to every subscriber; their run fibers then complete on their own.

0 commit comments

Comments
 (0)