Describe the bug
When a track is published with a backupCodec (e.g. H265 primary + VP8 backup, simulcast = true), a duplicate/late SubscribedQualityUpdate from the server can re-enter LocalVideoTrack.addSimulcastTrack() for a codec that is already in simulcastCodecs, which throws IllegalStateException: VP8 already added!. The throw happens inside the SDK's own signal-handling coroutine (Default dispatcher), so it cannot be caught from application code and crashes the entire app. In our case (a first-responder drone app), it kills the publisher mid-flight during live streaming.
This appears to be the same crash as #777, which was auto-closed by linear[bot] with no comments.
java.lang.IllegalStateException: VP8 already added!
at io.livekit.android.room.track.LocalVideoTrack.addSimulcastTrack(LocalVideoTrack.kt:~421)
at io.livekit.android.room.participant.LocalParticipant.publishAdditionalCodecForTrack(LocalParticipant.kt)
at io.livekit.android.room.participant.LocalParticipant.handleSubscribedQualityUpdate(LocalParticipant.kt)
at io.livekit.android.room.RTCEngine.onSubscribedQualityUpdate(RTCEngine.kt)
at io.livekit.android.room.SignalClient (signal response dispatch, SharedFlow collector)
at kotlinx.coroutines.* (DefaultDispatcher-worker)
(Reconstructed from an R8-obfuscated production trace; app on SDK 2.27.0, path verified byte-identical in 2.28.0.)
To Reproduce
Steps to reproduce the behavior (intermittent by nature — it is a timing race; hit rate improves with a degraded publisher uplink):
- Publish a video track from an Android app to a LiveKit Cloud room with:
VideoTrackPublishOptions(
videoCodec = VideoCodec.H265.codecName,
backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
simulcast = true,
)
Degrade the publisher's uplink (latency/loss) to stretch the AddTrack round-trip.
- Have one or more Firefox sessions join and subscribe. Firefox has no H.265 WebRTC decode, so each one makes the server request the VP8 backup publish via
SubscribedQualityUpdate — the only path that reaches addSimulcastTrack().
- Briefly interrupt the publisher's network to force a reconnect while the VP8 backup publish is pending / freshly added. Flapping within ~1–2 s of the Firefox join works best; Firefox sessions leaving/rejoining during the degraded period also helps.
- See error: publisher app crashes with
IllegalStateException: VP8 already added!.
We reproduced this in-house with 2× Firefox + 1× Chrome subscribers and a publisher-side network flap. No special hardware is required — it should reproduce with the meet example app using the publish options above.
Expected behavior
A duplicate publish request for an already-known backup codec should be a no-op, not an app-killing throw. This is exactly what the JS SDK does: client-sdk-js has the identical architecture and the identical !simulcastCodecInfo || !simulcastCodecInfo.sender detection, but its addSimulcastTrack handles the duplicate gracefully (LocalVideoTrack.ts):
if (this.simulcastCodecs.has(codec)) {
this.log.error(`${codec} already added, skipping adding simulcast codec`, this.logContext);
return;
}
The Kotlin SDK throws in the same spot instead.
Screenshots
N/A (stack trace above).
Device Info:
- Device: DJI RC Plus / DJI RC Plus 2 remote controllers (ARM64); also reproduced on non-DJI hardware
- OS: Android 10 / Android 11
- LiveKit SDK version: 2.27.0 (production crashes); crash path verified byte-identical in 2.28.0 and present on current
main
Additional context
Root cause — the "does this codec still need publishing?" check and the "add codec" mutation are not atomic with respect to the async publish round-trip:
LocalVideoTrack.setPublishingCodecs() treats a backup codec as new (needs publishing) when simulcastCodecs[codec]?.sender == null.
addSimulcastTrack() inserts the codec into simulcastCodecs synchronously, but the entry's sender is only assigned later, inside LocalParticipant.publishAdditionalCodecForTrack()'s coroutine, after the AddTrack server round-trip completes.
- So there is a window — entry present,
sender still null — during which a second SubscribedQualityUpdate for the same codec passes the check in (1), re-enters addSimulcastTrack(), and hits throw IllegalStateException("$codec already added!"). A reconnect that leaves a stale entry with a null sender on a reused LocalVideoTrack triggers the same path.
The window is normally milliseconds, which is why the crash is rare — but on a degraded uplink (high RTT/loss, the normal condition for a mobile publisher), the round-trip stretches to seconds and the race becomes realistic. All of our production crash events show connection loss seconds before the crash.
This logic was introduced with SVC codec support in 60ebdd5 (#304, Nov 2023) and is unchanged through 2.28.0 and current main.
Proposed fix:
- Make
addSimulcastTrack() idempotent — mirror the JS behavior: if the codec is already present, log and return the existing SimulcastTrackInfo instead of throwing.
- Optionally, harden
setPublishingCodecs(): treat "entry exists with sender == null" as publish in flight and skip it, rather than returning it as a codec that needs publishing.
Happy to open a PR for (1) if that's welcome.
Related: #777 (same crash, closed without resolution), #304 (introduced the code path)
Describe the bug
When a track is published with a
backupCodec(e.g. H265 primary + VP8 backup,simulcast = true), a duplicate/lateSubscribedQualityUpdatefrom the server can re-enterLocalVideoTrack.addSimulcastTrack()for a codec that is already insimulcastCodecs, which throwsIllegalStateException: VP8 already added!. The throw happens inside the SDK's own signal-handling coroutine (Default dispatcher), so it cannot be caught from application code and crashes the entire app. In our case (a first-responder drone app), it kills the publisher mid-flight during live streaming.This appears to be the same crash as #777, which was auto-closed by linear[bot] with no comments.
(Reconstructed from an R8-obfuscated production trace; app on SDK 2.27.0, path verified byte-identical in 2.28.0.)
To Reproduce
Steps to reproduce the behavior (intermittent by nature — it is a timing race; hit rate improves with a degraded publisher uplink):
SubscribedQualityUpdate— the only path that reachesaddSimulcastTrack().IllegalStateException: VP8 already added!.We reproduced this in-house with 2× Firefox + 1× Chrome subscribers and a publisher-side network flap. No special hardware is required — it should reproduce with the meet example app using the publish options above.
Expected behavior
A duplicate publish request for an already-known backup codec should be a no-op, not an app-killing throw. This is exactly what the JS SDK does:
client-sdk-jshas the identical architecture and the identical!simulcastCodecInfo || !simulcastCodecInfo.senderdetection, but itsaddSimulcastTrackhandles the duplicate gracefully (LocalVideoTrack.ts):The Kotlin SDK throws in the same spot instead.
Screenshots
N/A (stack trace above).
Device Info:
mainAdditional context
Root cause — the "does this codec still need publishing?" check and the "add codec" mutation are not atomic with respect to the async publish round-trip:
LocalVideoTrack.setPublishingCodecs()treats a backup codec as new (needs publishing) whensimulcastCodecs[codec]?.sender == null.addSimulcastTrack()inserts the codec intosimulcastCodecssynchronously, but the entry'ssenderis only assigned later, insideLocalParticipant.publishAdditionalCodecForTrack()'s coroutine, after the AddTrack server round-trip completes.senderstillnull— during which a secondSubscribedQualityUpdatefor the same codec passes the check in (1), re-entersaddSimulcastTrack(), and hitsthrow IllegalStateException("$codec already added!"). A reconnect that leaves a stale entry with a nullsenderon a reusedLocalVideoTracktriggers the same path.The window is normally milliseconds, which is why the crash is rare — but on a degraded uplink (high RTT/loss, the normal condition for a mobile publisher), the round-trip stretches to seconds and the race becomes realistic. All of our production crash events show connection loss seconds before the crash.
This logic was introduced with SVC codec support in 60ebdd5 (#304, Nov 2023) and is unchanged through 2.28.0 and current
main.Proposed fix:
addSimulcastTrack()idempotent — mirror the JS behavior: if the codec is already present, log and return the existingSimulcastTrackInfoinstead of throwing.setPublishingCodecs(): treat "entry exists withsender == null" as publish in flight and skip it, rather than returning it as a codec that needs publishing.Happy to open a PR for (1) if that's welcome.
Related: #777 (same crash, closed without resolution), #304 (introduced the code path)