test(nests): close FRAME1 delivery race in subscribeSpeaker_survives_session_swap

The test emitted FRAME1 into first.frames and immediately called
first.fail(...), trusting that FRAME1 would propagate through the pump
to the wrapper's frames before the session swap. emit() is
non-suspending — it only enqueues FRAME1 into the pump's slot. On
slower hosts (observed on macOS CI) the orchestrator's reconnect path
can flip activeListener to the second session before the pump's
collect lambda runs, at which point collectLatest cancels
pump-iteration-1 mid-resume and FRAME1 is dropped.  The consumer's
take(2) then only ever sees FRAME2 and the async's withTimeout fires
after 5 s.

Add a consumerProgress StateFlow that the consumer's collector bumps
on each frame, and wait for it to reach 1 before failing the listener.
Same shape as the existing consumerSubscribed gate that closed the
"emit before consumer subscribes" race.
This commit is contained in:
Claude
2026-05-05 18:06:56 +00:00
parent c0262abe59
commit bd681a0a5f
@@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
@@ -189,6 +190,18 @@ class ReconnectingNestsListenerTest {
// before the test thread races into the first emit.
val consumerSubscribed = CompletableDeferred<Unit>()
// Counter for frames the consumer has actually observed.
// Needed to break the FRAME1-delivery race: emit() into
// first.frames is non-suspending and just enqueues into
// the pump's slot. If we trigger a session swap before
// the pump's collect lambda runs (`frames.emit(it)` to
// the wrapper), collectLatest cancels iteration 1 mid-
// resume and FRAME1 is lost — consumer ends with 1/2
// frames and the async's withTimeout fires. The
// collector is single-coroutine so a plain StateFlow
// update is safe; the test thread reads it via .first.
val consumerProgress = MutableStateFlow(0)
// The wrapper backs handle.objects with a SharedFlow
// (frames.asSharedFlow); the cast lets us use
// SharedFlow.onSubscription, which fires AFTER the
@@ -201,6 +214,7 @@ class ReconnectingNestsListenerTest {
objectsAsShared
.onSubscription { consumerSubscribed.complete(Unit) }
.take(2)
.onEach { consumerProgress.value += 1 }
.toList()
}
}
@@ -220,6 +234,19 @@ class ReconnectingNestsListenerTest {
first.frames.emit(frame(byteArrayOf(0x01)))
// Wait for FRAME1 to traverse pump → wrapper.frames →
// consumer collector. Without this sync the next
// `first.fail(...)` can race ahead and trigger
// collectLatest cancellation of pump-iteration-1 while
// FRAME1 is still queued in first.frames; the cancel
// interrupts the pump's resume before its lambda runs
// and FRAME1 never reaches the wrapper. Consumer then
// observes only FRAME2 (1 frame ≠ take(2)) and the
// async's withTimeout fires after 5 s.
withTimeout(5_000L) {
consumerProgress.first { it >= 1 }
}
// Force a reconnect: fail the first listener, the
// orchestrator opens the next one.
first.fail("scripted-disconnect")