Skip to content

Commit 68c3cdc

Browse files
fix(tracks): dispose video source and stop transceiver on unpublish
LocalVideoTrack.dispose() left its VideoSource undisposed, leaking the source's native memory for the lifetime of the process; only the track and capturer were released. Unpublishing a video track removed the track from its sender but never stopped the transceiver, which is created fresh on every publish (plus one per backup codec), so transceivers accumulated until the connection closed.
1 parent 91cd3ed commit 68c3cdc

6 files changed

Lines changed: 157 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"client-sdk-android": patch
3+
---
4+
5+
Fix native memory leaks on video track publish/unpublish cycles (#521). `LocalVideoTrack.dispose()` now disposes its backing `VideoSource`, which was previously left undisposed and leaked for the lifetime of the process (only the track and capturer were released). Unpublishing a video track now also stops its `RtpTransceiver`, along with any extra transceivers added for backup codecs; since a new transceiver is created on every publish, removing the track from its sender alone left them retained until the connection closed.

livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,6 +1498,21 @@ internal constructor(
14981498
}
14991499
}
15001500

1501+
internal fun stopTransceivers(transceivers: List<RtpTransceiver>) {
1502+
if (transceivers.isEmpty()) {
1503+
return
1504+
}
1505+
runBlocking {
1506+
publisher?.withPeerConnection {
1507+
for (transceiver in transceivers) {
1508+
if (!transceiver.isStopped) {
1509+
transceiver.stopInternal()
1510+
}
1511+
}
1512+
}
1513+
}
1514+
}
1515+
15011516
@VisibleForTesting
15021517
fun getPublisherPeerConnection() =
15031518
publisher!!.peerConnection

livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,10 @@ internal constructor(
660660
return null
661661
}
662662

663+
if (track is LocalVideoTrack) {
664+
track.clearSimulcastCodecs()
665+
}
666+
663667
val cid = try {
664668
track.rtcTrack.id()
665669
} catch (e: Exception) {
@@ -953,6 +957,16 @@ internal constructor(
953957

954958
if (engine.connectionState == ConnectionState.CONNECTED) {
955959
engine.removeTrack(track.rtcTrack)
960+
961+
// Each publish creates a new transceiver, plus one per backup codec. Removing the
962+
// track from its sender doesn't release them, so they would otherwise be retained
963+
// until the connection closes. Stopping them releases the native resources and frees
964+
// the SDP m-sections for reuse. Limited to video, where this leak is significant.
965+
if (track is LocalVideoTrack) {
966+
engine.stopTransceivers(listOfNotNull(track.transceiver) + track.simulcastTransceivers)
967+
track.transceiver = null
968+
track.clearSimulcastCodecs()
969+
}
956970
}
957971
if (stopOnUnpublish) {
958972
track.stop()
@@ -1198,6 +1212,7 @@ internal constructor(
11981212
LKLog.w { "couldn't create new transceiver! $codec" }
11991213
return@launch
12001214
}
1215+
simulcastTrack.transceiver = transceiver
12011216
val trackRequest = AddTrackRequest.newBuilder().apply {
12021217
sid = existingPublication.sid
12031218
muted = !track.enabled

livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2025 LiveKit, Inc.
2+
* Copyright 2023-2026 LiveKit, Inc.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -113,6 +113,12 @@ constructor(
113113
internal val sender: RtpSender?
114114
get() = transceiver?.sender
115115

116+
/**
117+
* The transceivers created for additional backup codecs (e.g. when using SVC with a backup codec).
118+
*/
119+
internal val simulcastTransceivers: List<RtpTransceiver>
120+
get() = simulcastCodecs.values.mapNotNull { it.transceiver }
121+
116122
private val closeableManager = CloseableManager()
117123

118124
/**
@@ -141,6 +147,7 @@ constructor(
141147
override fun dispose() {
142148
super.dispose()
143149
capturer.dispose()
150+
source.dispose()
144151
closeableManager.close()
145152
}
146153

@@ -436,6 +443,15 @@ constructor(
436443
return simulcastTrackInfo
437444
}
438445

446+
/**
447+
* Clears the backup codec state so it is re-established from scratch on the next publish,
448+
* rather than reusing senders whose transceivers were stopped on unpublish.
449+
*/
450+
internal fun clearSimulcastCodecs() {
451+
subscribedCodecs = null
452+
simulcastCodecs.clear()
453+
}
454+
439455
@AssistedFactory
440456
interface Factory {
441457
fun create(
@@ -543,5 +559,6 @@ internal data class SimulcastTrackInfo(
543559
var codec: String,
544560
var rtcTrack: MediaStreamTrack,
545561
var sender: RtpSender? = null,
562+
var transceiver: RtpTransceiver? = null,
546563
var encodings: List<RtpParameters.Encoding>? = null,
547564
)

livekit-android-test/src/main/java/io/livekit/android/test/mock/MockVideoSource.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 LiveKit, Inc.
2+
* Copyright 2023-2026 LiveKit, Inc.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,4 +18,7 @@ package io.livekit.android.test.mock
1818

1919
import livekit.org.webrtc.VideoSource
2020

21-
class MockVideoSource(nativeSource: Long = 100) : VideoSource(nativeSource)
21+
class MockVideoSource(nativeSource: Long = 100) : VideoSource(nativeSource) {
22+
override fun dispose() {
23+
}
24+
}

livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,72 @@ class LocalParticipantMockE2ETest : MockE2ETest() {
219219
assertEquals(publishOptions.stream, sentRequest.addTrack.stream)
220220
}
221221

222+
@Test
223+
fun unpublishStopsVideoTransceiver() = runTest {
224+
connect()
225+
val videoTrack = createLocalTrack()
226+
room.localParticipant.publishVideoTrack(videoTrack)
227+
228+
val transceiver = getPublisherPeerConnection().transceivers.first()
229+
room.localParticipant.unpublishTrack(videoTrack)
230+
231+
Mockito.verify(transceiver).stopInternal()
232+
}
233+
234+
@Test
235+
fun unpublishStopsBackupCodecTransceivers() = runTest {
236+
room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy(
237+
videoCodec = VideoCodec.VP9.codecName,
238+
scalabilityMode = "L3T3",
239+
backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
240+
)
241+
242+
connect()
243+
val videoTrack = createLocalTrack()
244+
room.localParticipant.publishVideoTrack(videoTrack)
245+
246+
receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid)
247+
248+
val transceivers = getPublisherPeerConnection().transceivers
249+
assertEquals(2, transceivers.size)
250+
251+
room.localParticipant.unpublishTrack(videoTrack)
252+
253+
transceivers.forEach { Mockito.verify(it).stopInternal() }
254+
}
255+
256+
@Test
257+
fun republishAfterBackupCodecUnpublishCreatesNewBackupTransceiver() = runTest {
258+
room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy(
259+
videoCodec = VideoCodec.VP9.codecName,
260+
scalabilityMode = "L3T3",
261+
backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
262+
)
263+
264+
connect()
265+
val videoTrack = createLocalTrack()
266+
room.localParticipant.publishVideoTrack(videoTrack)
267+
268+
receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid)
269+
assertEquals(2, getPublisherPeerConnection().transceivers.size)
270+
271+
room.localParticipant.unpublishTrack(videoTrack, stopOnUnpublish = false)
272+
room.localParticipant.publishVideoTrack(videoTrack)
273+
receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid)
274+
275+
assertEquals(4, getPublisherPeerConnection().transceivers.size)
276+
}
277+
278+
@Test
279+
fun disposeDisposesVideoSource() {
280+
val source = mock(VideoSource::class.java)
281+
val videoTrack = createLocalTrack(source = source)
282+
283+
videoTrack.dispose()
284+
285+
Mockito.verify(source).dispose()
286+
}
287+
222288
@Test
223289
fun updateMetadata() = runTest {
224290
connect()
@@ -292,9 +358,40 @@ class LocalParticipantMockE2ETest : MockE2ETest() {
292358
)
293359
}
294360

295-
private fun createLocalTrack(width: Int = 1280, height: Int = 720, isScreencast: Boolean = false) = LocalVideoTrack(
361+
private fun receiveSubscribedQualityUpdate(trackSid: String) {
362+
wsFactory.receiveMessage(
363+
with(LivekitRtc.SignalResponse.newBuilder()) {
364+
subscribedQualityUpdate = with(LivekitRtc.SubscribedQualityUpdate.newBuilder()) {
365+
this.trackSid = trackSid
366+
addAllSubscribedCodecs(
367+
listOf("vp9", "vp8").map { codecName ->
368+
with(SubscribedCodec.newBuilder()) {
369+
codec = codecName
370+
addQualities(
371+
SubscribedQuality.newBuilder()
372+
.setQuality(LivekitModels.VideoQuality.HIGH)
373+
.setEnabled(true)
374+
.build(),
375+
)
376+
build()
377+
}
378+
},
379+
)
380+
build()
381+
}
382+
build().toOkioByteString()
383+
},
384+
)
385+
}
386+
387+
private fun createLocalTrack(
388+
width: Int = 1280,
389+
height: Int = 720,
390+
isScreencast: Boolean = false,
391+
source: VideoSource = mock(VideoSource::class.java),
392+
) = LocalVideoTrack(
296393
capturer = MockVideoCapturer(),
297-
source = mock(VideoSource::class.java),
394+
source = source,
298395
name = "",
299396
options = LocalVideoTrackOptions(
300397
isScreencast = isScreencast,

0 commit comments

Comments
 (0)