Skip to content

Commit 8e72e60

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 8e72e60

6 files changed

Lines changed: 118 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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,15 @@ internal constructor(
953953

954954
if (engine.connectionState == ConnectionState.CONNECTED) {
955955
engine.removeTrack(track.rtcTrack)
956+
957+
// Each publish creates a new transceiver, plus one per backup codec. Removing the
958+
// track from its sender doesn't release them, so they would otherwise be retained
959+
// until the connection closes. Stopping them releases the native resources and frees
960+
// the SDP m-sections for reuse. Limited to video, where this leak is significant.
961+
if (track is LocalVideoTrack) {
962+
engine.stopTransceivers(listOfNotNull(track.transceiver) + track.simulcastTransceivers)
963+
track.transceiver = null
964+
}
956965
}
957966
if (stopOnUnpublish) {
958967
track.stop()
@@ -1198,6 +1207,7 @@ internal constructor(
11981207
LKLog.w { "couldn't create new transceiver! $codec" }
11991208
return@launch
12001209
}
1210+
simulcastTrack.transceiver = transceiver
12011211
val trackRequest = AddTrackRequest.newBuilder().apply {
12021212
sid = existingPublication.sid
12031213
muted = !track.enabled

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

Lines changed: 9 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

@@ -543,5 +550,6 @@ internal data class SimulcastTrackInfo(
543550
var codec: String,
544551
var rtcTrack: MediaStreamTrack,
545552
var sender: RtpSender? = null,
553+
var transceiver: RtpTransceiver? = null,
546554
var encodings: List<RtpParameters.Encoding>? = null,
547555
)

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: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,73 @@ 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+
// Server subscribes to the backup vp8 codec, adding a second transceiver for the track.
247+
wsFactory.receiveMessage(
248+
with(LivekitRtc.SignalResponse.newBuilder()) {
249+
subscribedQualityUpdate = with(LivekitRtc.SubscribedQualityUpdate.newBuilder()) {
250+
trackSid = room.localParticipant.videoTrackPublications.first().first.sid
251+
addAllSubscribedCodecs(
252+
listOf("vp9", "vp8").map { codecName ->
253+
with(SubscribedCodec.newBuilder()) {
254+
codec = codecName
255+
addQualities(
256+
SubscribedQuality.newBuilder()
257+
.setQuality(LivekitModels.VideoQuality.HIGH)
258+
.setEnabled(true)
259+
.build(),
260+
)
261+
build()
262+
}
263+
},
264+
)
265+
build()
266+
}
267+
build().toOkioByteString()
268+
},
269+
)
270+
271+
val transceivers = getPublisherPeerConnection().transceivers
272+
assertEquals(2, transceivers.size)
273+
274+
room.localParticipant.unpublishTrack(videoTrack)
275+
276+
transceivers.forEach { Mockito.verify(it).stopInternal() }
277+
}
278+
279+
@Test
280+
fun disposeDisposesVideoSource() {
281+
val source = mock(VideoSource::class.java)
282+
val videoTrack = createLocalTrack(source = source)
283+
284+
videoTrack.dispose()
285+
286+
Mockito.verify(source).dispose()
287+
}
288+
222289
@Test
223290
fun updateMetadata() = runTest {
224291
connect()
@@ -292,9 +359,14 @@ class LocalParticipantMockE2ETest : MockE2ETest() {
292359
)
293360
}
294361

295-
private fun createLocalTrack(width: Int = 1280, height: Int = 720, isScreencast: Boolean = false) = LocalVideoTrack(
362+
private fun createLocalTrack(
363+
width: Int = 1280,
364+
height: Int = 720,
365+
isScreencast: Boolean = false,
366+
source: VideoSource = mock(VideoSource::class.java),
367+
) = LocalVideoTrack(
296368
capturer = MockVideoCapturer(),
297-
source = mock(VideoSource::class.java),
369+
source = source,
298370
name = "",
299371
options = LocalVideoTrackOptions(
300372
isScreencast = isScreencast,

0 commit comments

Comments
 (0)