Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"client-sdk-android": patch
---

Keep dynacast-paused simulcast layers paused across publisher renegotiations. Previously a paused layer started to upload again after any renegotiation of the publisher connection. It stayed on until the next subscribed-quality change. Publishing a track, unpublishing one, a codec change, and an ICE restart all caused this.

The server's answer to a publisher offer no longer carries the RFC 8853 pause markers. Applying that answer re-enables every layer the client paused. If the subscribed set does not change, the server sends no subscribed-codec update, so the pause was never restated. Directly after it applies a publisher answer, the SDK now re-applies the last known subscribed codecs.

One frame per layer can still escape, because the encoders restart during the apply. This matches the JavaScript and Rust SDKs.
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,7 @@ internal constructor(
fun onUserPacket(packet: LivekitModels.UserPacket, kind: LivekitModels.DataPacket.Kind, encryptionType: LivekitModels.Encryption.Type)
fun onStreamStateUpdate(streamStates: List<LivekitRtc.StreamStateInfo>)
fun onSubscribedQualityUpdate(subscribedQualityUpdate: LivekitRtc.SubscribedQualityUpdate)
fun onRequestSubscribedCodecRefresh() {}
fun onSubscriptionPermissionUpdate(subscriptionPermissionUpdate: LivekitRtc.SubscriptionPermissionUpdate)
fun onSubscriptionError(subscriptionResponse: LivekitRtc.SubscriptionResponse)
suspend fun onSignalConnected(isResume: Boolean)
Expand Down Expand Up @@ -1251,7 +1252,7 @@ internal constructor(
coroutineScope.launch {
when (val outcome = publisher?.setRemoteDescription(sessionDescription, offerId).nullSafe()) {
is Either.Left -> {
// do nothing.
listener?.onRequestSubscribedCodecRefresh()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Sender refresh bypasses the RTC thread

On a publisher answer, onRequestSubscribedCodecRefresh accesses WebRTC sender parameters from the engine's I/O coroutine. This can race with RTC-thread negotiation and leave paused layers active.

Learn more

The publisher answer is applied by setRemoteDescription on the RTC thread, but the suspended caller resumes on the engine's I/O dispatcher. The new synchronous callback reaches setPublishingLayersForSender, which reads and writes RtpSender.parameters. Sender operations belong on the RTC thread according to the repository's WebRTC threading rule; otherwise they can overlap further negotiation work.

Example: An answer for an ICE restart arrives while another publisher negotiation starts. The refresh accesses the sender on the I/O worker while the other negotiation uses that sender on the RTC executor.

Recommended fix: Schedule the refresh's WebRTC sender operations through launchBlockingOnRTCThread or executeBlockingOnRTCThread with the room's RTC token. Keep the cached codec state synchronized with signaling updates if the callback is dispatched asynchronously.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}

is Either.Right -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1484,6 +1484,10 @@ constructor(
localParticipant.handleSubscribedQualityUpdate(subscribedQualityUpdate)
}

override fun onRequestSubscribedCodecRefresh() {
localParticipant.handleSubscribedCodecRefresh()
}

/**
* @suppress
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,16 @@ internal constructor(
}
}

internal fun handleSubscribedCodecRefresh() {
if (!dynacast) {
return
}

videoTrackPublications.forEach { (_, track) ->
(track as? LocalVideoTrack)?.refreshSubscribedCodecs()
}
}

private fun publishAdditionalCodecForTrack(track: LocalVideoTrack, codec: VideoCodec, options: VideoTrackPublishOptions) {
val existingPublication = trackPublications[track.sid] ?: run {
LKLog.w { "attempting to publish additional codec for non-published track?!" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,18 @@ constructor(
return newCodecs
}

/**
* Re-applies the most recently received subscribed codecs.
*
* Applying a publisher answer re-enables simulcast layers that dynacast had paused, and the
* server only sends a subscribed codec update when the set changes, so the pause is restated
* here instead.
*/
internal fun refreshSubscribedCodecs() {
val codecs = subscribedCodecs ?: return
setPublishingCodecs(codecs)
}
Comment on lines +440 to +443

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Quality-only pauses vanish after renegotiation

After a quality-only update, refreshSubscribedCodecs has no cached codecs to replay. The publisher answer re-enables paused layers, leaving them uploading until another quality update.

Learn more

A subscribed-quality update can contain a flat list of qualities without any subscribed codecs. handleSubscribedQualityUpdate applies that list directly with setPublishingLayers. The new replay only remembers subscribedCodecs, so these paused layers have no state to restore after a publisher answer re-enables them.

Example: A server sends an update for camera A with subscribedCodecs=[] and subscribedQualities=[HIGH disabled, LOW enabled]. The high layer pauses; an ICE-restart answer re-enables it, and refresh returns without pausing it again.

Recommended fix: Store the last flat quality update as well as the codec update and replay whichever form most recently controlled the primary sender. Clear both on unpublish or republish, and cover quality-only updates in the renegotiation test.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


internal fun addSimulcastTrack(codec: VideoCodec, encodings: List<RtpParameters.Encoding>): SimulcastTrackInfo? {
if (this.simulcastCodecs.containsKey(codec)) {
LKLog.w { "$codec already added, skipping." }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023-2025 LiveKit, Inc.
* Copyright 2023-2026 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -25,13 +25,16 @@ import org.mockito.kotlin.whenever
import java.util.UUID

object MockRtpSender {
fun create(id: String = "sender_id"): RtpSender {
fun create(
id: String = "sender_id",
encodings: List<RtpParameters.Encoding> = emptyList(),
): RtpSender {
var rtpParameters: RtpParameters = MockRtpParameters(
transactionId = UUID.randomUUID().toString(),
degradationPreference = null,
rtcp = MockRtpParameters.MockRtcp("", false),
headerExtensions = mutableListOf(),
encodings = mutableListOf(),
encodings = encodings.toMutableList(),
codecs = mutableListOf(),
)
return Mockito.mock(RtpSender::class.java).apply {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023-2025 LiveKit, Inc.
* Copyright 2023-2026 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -41,7 +41,7 @@ object MockRtpTransceiver {

when (direction) {
RtpTransceiverDirection.SEND_RECV, RtpTransceiverDirection.SEND_ONLY -> {
val sender = MockRtpSender.create(id = id)
val sender = MockRtpSender.create(id = id, encodings = init.sendEncodings)
Mockito.`when`(mock.sender)
.then { sender }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,96 @@ class LocalParticipantMockE2ETest : MockE2ETest() {
)
}

/**
* Disables the high layer for the track's publishing codec, leaving the lower layers enabled.
*/
private fun receiveSubscribedCodecUpdateDisablingHighLayer(trackSid: String) {
wsFactory.receiveMessage(
with(LivekitRtc.SignalResponse.newBuilder()) {
subscribedQualityUpdate = with(LivekitRtc.SubscribedQualityUpdate.newBuilder()) {
this.trackSid = trackSid
addAllSubscribedCodecs(
listOf(
with(SubscribedCodec.newBuilder()) {
codec = VideoCodec.VP8.codecName
addAllQualities(
listOf(
LivekitModels.VideoQuality.HIGH to false,
LivekitModels.VideoQuality.MEDIUM to true,
LivekitModels.VideoQuality.LOW to true,
).map { (quality, enabled) ->
SubscribedQuality.newBuilder()
.setQuality(quality)
.setEnabled(enabled)
.build()
},
)
build()
},
),
)
build()
}
build().toOkioByteString()
},
)
}

/**
* Answers any publisher offer, echoing back the offer id so the answer is not discarded as stale.
*/
private fun answerPublisherOffers() {
wsFactory.registerSignalRequestHandler { request ->
if (request.hasOffer()) {
wsFactory.receiveMessage(
with(LivekitRtc.SignalResponse.newBuilder()) {
answer = with(LivekitRtc.SessionDescription.newBuilder()) {
sdp = "remote_answer"
type = "answer"
id = request.offer.id
build()
}
build()
},
)
true
} else {
false
}
}
}

@Test
fun publisherAnswerRestatesPausedLayers() = runTest {
connect()
room.dynacast = true
answerPublisherOffers()

room.localParticipant.publishVideoTrack(track = createLocalTrack(width = 1280, height = 720))
testScheduler.advanceUntilIdle()

receiveSubscribedCodecUpdateDisablingHighLayer(
room.localParticipant.videoTrackPublications.first().first.sid,
)
testScheduler.advanceUntilIdle()

val encodings = getPublisherPeerConnection().transceivers.first().sender.parameters.encodings
val highEncoding = encodings.first { it.rid == "f" }
val lowEncoding = encodings.first { it.rid == "q" }
assertFalse(highEncoding.active)
assertTrue(lowEncoding.active)

// The SFU's answer no longer carries the RFC 8853 pause markers, so applying it re-enables
// the layer that dynacast paused.
highEncoding.active = true

getPublisherPeerConnection().observer?.onRenegotiationNeeded()
testScheduler.advanceUntilIdle()

assertFalse(highEncoding.active)
assertTrue(lowEncoding.active)
}

private fun createLocalTrack(
width: Int = 1280,
height: Int = 720,
Expand Down
Loading