Skip to content

Commit 616ec1c

Browse files
fix(core): pause mute SFU sync during reconnect and migration
Keep recording local mute bits while the session is torn down, and flush UpdateMuteStates only after the active SFU is ready. Also clear TrackKeyedJobs in cleanup. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fa2cf2a commit 616ec1c

3 files changed

Lines changed: 152 additions & 0 deletions

File tree

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ import stream.video.sfu.signal.UpdateSubscriptionsResponse
173173
import java.io.InterruptedIOException
174174
import java.net.SocketTimeoutException
175175
import java.util.Collections
176+
import java.util.concurrent.atomic.AtomicBoolean
176177
import java.util.concurrent.atomic.AtomicInteger
177178
/**
178179
* Keeps track of which track is being rendered at what resolution.
@@ -328,6 +329,13 @@ public class RtcSession internal constructor(
328329
},
329330
) {
330331
private val muteStateSyncJobs = TrackKeyedJobs()
332+
333+
/**
334+
* When false, [setMuteState] still records the latest desired mute bits locally but does
335+
* not POST [UpdateMuteStatesRequest] — used while this session's SFU is being torn down
336+
* for reconnect/migration so a media collector cannot target the old connection.
337+
*/
338+
private val muteSyncEnabled = AtomicBoolean(true)
331339
private val oneBasedSessionCounter = sessionCounter + 1
332340

333341
/**
@@ -1210,6 +1218,7 @@ public class RtcSession internal constructor(
12101218

12111219
private suspend fun connectRtc() {
12121220
logger.d { "[connectRtc] #sfu; #track; no args" }
1221+
resumeMuteSync()
12131222
// step 6 - onNegotiationNeeded will trigger and complete the setup using SetPublisherRequest
12141223
publisher.value?.let {
12151224
listenToMediaChanges()
@@ -1304,6 +1313,8 @@ public class RtcSession internal constructor(
13041313
}
13051314

13061315
mediaScope.cancel()
1316+
muteSyncEnabled.set(false)
1317+
muteStateSyncJobs.cancelAll()
13071318

13081319
// cleanup all non-local tracks
13091320
supervisorJob.cancel()
@@ -1349,15 +1360,30 @@ public class RtcSession internal constructor(
13491360
*
13501361
* The local mute map is updated with [MutableStateFlow.update] so concurrent collectors
13511362
* cannot lose another track's bit via a stale read–copy–write.
1363+
*
1364+
* During reconnect/migration, [cancelActiveWork] pauses SFU sync so collectors can keep
1365+
* recording the latest desired bits without posting to a stale connection. Sync resumes
1366+
* from [connectRtc] once the active SFU is ready.
13521367
*/
13531368
private fun setMuteState(isEnabled: Boolean, trackType: TrackType) {
13541369
logger.d { "[setPublishState] #sfu; $trackType isEnabled: $isEnabled" }
13551370

13561371
muteState.update { it + (trackType to isEnabled) }
1372+
syncMuteStateToSfu(trackType, isEnabled)
1373+
}
1374+
1375+
private fun syncMuteStateToSfu(trackType: TrackType, isEnabled: Boolean) {
1376+
if (!muteSyncEnabled.get()) {
1377+
logger.d {
1378+
"[syncMuteStateToSfu] deferred until SFU is ready; $trackType isEnabled: $isEnabled"
1379+
}
1380+
return
1381+
}
13571382

13581383
val currentSfu = sfuUrl
13591384
// Coalesce retries for this track only. Other tracks keep their in-flight RPCs.
13601385
muteStateSyncJobs.launch(coroutineScope, trackType) {
1386+
if (!muteSyncEnabled.get()) return@launch
13611387
flow {
13621388
val request = UpdateMuteStatesRequest(
13631389
session_id = sessionId,
@@ -1372,6 +1398,7 @@ public class RtcSession internal constructor(
13721398
emit(response)
13731399
}.flowOn(DispatcherProvider.IO).retryWhen { cause, attempt ->
13741400
if (cause is SessionFatalException) return@retryWhen false
1401+
if (!muteSyncEnabled.get()) return@retryWhen false
13751402
val sameValue = muteState.value[trackType] == isEnabled
13761403
val sameSfu = currentSfu == sfuUrl
13771404
val isPermanent = isPermanentError(cause)
@@ -1386,6 +1413,14 @@ public class RtcSession internal constructor(
13861413
}
13871414
}
13881415

1416+
private fun resumeMuteSync() {
1417+
if (!muteSyncEnabled.compareAndSet(false, true)) return
1418+
logger.d { "[resumeMuteSync] flushing latest mute state to the active SFU" }
1419+
muteState.value.forEach { (trackType, isEnabled) ->
1420+
syncMuteStateToSfu(trackType, isEnabled)
1421+
}
1422+
}
1423+
13891424
private fun isPermanentError(cause: Throwable): Boolean {
13901425
return false
13911426
}
@@ -2121,6 +2156,7 @@ public class RtcSession internal constructor(
21212156
if (cancelEventJob) eventJob?.cancel()
21222157
iceMonitoringJob?.cancel()
21232158
iceMonitoringJob = null
2159+
muteSyncEnabled.set(false)
21242160
muteStateSyncJobs.cancelAll()
21252161
participantsMonitoringJob?.cancel()
21262162
participantsMonitoringJob = null

stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/TrackKeyedJobsTest.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,34 @@ class TrackKeyedJobsTest {
7979
scope.coroutineContext[Job]?.cancel()
8080
}
8181
}
82+
83+
@Test
84+
fun `cancelAll cancels every track job and clears the map`() = runTest {
85+
val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job())
86+
val jobs = TrackKeyedJobs()
87+
val audioHold = CompletableDeferred<Unit>()
88+
val videoHold = CompletableDeferred<Unit>()
89+
val audioDone = AtomicBoolean(false)
90+
val videoDone = AtomicBoolean(false)
91+
92+
try {
93+
jobs.launch(scope, TrackType.TRACK_TYPE_AUDIO) {
94+
audioHold.await()
95+
audioDone.set(true)
96+
}
97+
jobs.launch(scope, TrackType.TRACK_TYPE_VIDEO) {
98+
videoHold.await()
99+
videoDone.set(true)
100+
}
101+
102+
jobs.cancelAll()
103+
audioHold.complete(Unit)
104+
videoHold.complete(Unit)
105+
106+
assertThat(audioDone.get()).isFalse()
107+
assertThat(videoDone.get()).isFalse()
108+
} finally {
109+
scope.coroutineContext[Job]?.cancel()
110+
}
111+
}
82112
}

stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/RtcSessionTest2.kt

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import stream.video.sfu.models.WebsocketReconnectStrategy
8080
import stream.video.sfu.signal.StartNoiseCancellationRequest
8181
import stream.video.sfu.signal.StopNoiseCancellationRequest
8282
import java.io.InterruptedIOException
83+
import java.util.concurrent.atomic.AtomicBoolean
8384

8485
class RtcSessionTest2 {
8586

@@ -926,6 +927,53 @@ class RtcSessionTest2 {
926927
}
927928
}
928929

930+
@Test
931+
fun `mute collectors during migration keep local state and do not call the old SFU`() =
932+
runTest(testDispatcher) {
933+
val signalService = mockk<SignalServerService>(relaxed = true)
934+
ownCapabilitiesFlow.value = listOf(OwnCapability.SendAudio)
935+
val (rtcSession, publisherMock) = muteSyncSession(signalService)
936+
rtcSession.publisher.value = publisherMock
937+
val audioTrack = mockk<org.webrtc.AudioTrack>(relaxed = true)
938+
coEvery {
939+
publisherMock.publishStream(any(), TrackType.TRACK_TYPE_AUDIO)
940+
} returns audioTrack
941+
942+
rtcSession.enterMigration()
943+
rtcSession.createAndPublishAudioTrack()
944+
testScheduler.advanceUntilIdle()
945+
946+
assertEquals(true, rtcSession.muteState.value[TrackType.TRACK_TYPE_AUDIO])
947+
coVerify(exactly = 0) { signalService.updateMuteStates(any()) }
948+
}
949+
950+
@Test
951+
fun `mute sync resumes and flushes local state once the SFU is ready`() = runTest(
952+
testDispatcher,
953+
) {
954+
val signalService = mockk<SignalServerService>(relaxed = true)
955+
ownCapabilitiesFlow.value = listOf(OwnCapability.SendAudio)
956+
val (rtcSession, publisherMock) = muteSyncSession(signalService)
957+
rtcSession.publisher.value = publisherMock
958+
val audioTrack = mockk<org.webrtc.AudioTrack>(relaxed = true)
959+
coEvery {
960+
publisherMock.publishStream(any(), TrackType.TRACK_TYPE_AUDIO)
961+
} returns audioTrack
962+
963+
rtcSession.enterMigration()
964+
rtcSession.createAndPublishAudioTrack()
965+
testScheduler.advanceUntilIdle()
966+
coVerify(exactly = 0) { signalService.updateMuteStates(any()) }
967+
assertEquals(false, muteSyncEnabled(rtcSession).get())
968+
969+
RtcSession::class.java.getDeclaredMethod("resumeMuteSync").apply {
970+
isAccessible = true
971+
invoke(rtcSession)
972+
}
973+
974+
assertEquals(true, muteSyncEnabled(rtcSession).get())
975+
}
976+
929977
@Test
930978
fun `stopNoiseCancellation sends the request to the SFU with the session id`() = runTest {
931979
// Given
@@ -943,6 +991,44 @@ class RtcSessionTest2 {
943991
}
944992
}
945993

994+
private fun muteSyncEnabled(rtcSession: RtcSession): AtomicBoolean {
995+
val field = RtcSession::class.java.getDeclaredField("muteSyncEnabled")
996+
field.isAccessible = true
997+
return field.get(rtcSession) as AtomicBoolean
998+
}
999+
1000+
private fun muteSyncSession(
1001+
signalService: SignalServerService,
1002+
): Pair<RtcSession, Publisher> {
1003+
val mockModule = mockk<SfuConnectionModule>(relaxed = true) {
1004+
every { api } returns signalService
1005+
}
1006+
val rtcSession = spyk(
1007+
RtcSession(
1008+
client = mockStreamVideo,
1009+
powerManager = mockPowerManager,
1010+
call = mockCall,
1011+
sessionManager = CallSessionManager(),
1012+
sessionId = "session-id",
1013+
apiKey = "api-key",
1014+
lifecycle = mockLifecycle,
1015+
sfuUrl = "https://test-sfu.stream.com",
1016+
sfuWsUrl = "wss://test-sfu.stream.com",
1017+
sfuToken = "fake-sfu-token",
1018+
sfuName = "test-sfu-edge",
1019+
clientImpl = mockVideoClient,
1020+
coroutineScope = testScope,
1021+
rtcSessionScope = testScope,
1022+
remoteIceServers = emptyList(),
1023+
sfuConnectionModuleProvider = { mockModule },
1024+
sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(),
1025+
),
1026+
recordPrivateCalls = true,
1027+
)
1028+
val publisherMock = mockk<Publisher>(relaxed = true)
1029+
return rtcSession to publisherMock
1030+
}
1031+
9461032
private fun noiseCancellationSession(
9471033
signalService: SignalServerService,
9481034
): Pair<RtcSession, SfuConnectionModule> {

0 commit comments

Comments
 (0)