Skip to content

Commit c5ca4b0

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, so transceivers accumulated until the connection closed.
1 parent 91cd3ed commit c5ca4b0

6 files changed

Lines changed: 60 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`; since a new transceiver is created on every publish, removing the track from its sender alone left the transceiver retained until the connection closed.

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

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

1501+
internal fun stopTransceiver(transceiver: RtpTransceiver) {
1502+
runBlocking {
1503+
publisher?.withPeerConnection {
1504+
if (!transceiver.isStopped) {
1505+
transceiver.stopInternal()
1506+
}
1507+
}
1508+
}
1509+
}
1510+
15011511
@VisibleForTesting
15021512
fun getPublisherPeerConnection() =
15031513
publisher!!.peerConnection

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

Lines changed: 9 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+
// A new transceiver is created for each publish. Removing the track from its sender
958+
// doesn't release the transceiver, so it would otherwise be retained until the
959+
// connection closes. Stopping it releases the native resources and frees the SDP
960+
// m-section for reuse. Limited to video, which is where this leak is significant.
961+
if (track is LocalVideoTrack) {
962+
track.transceiver?.let { engine.stopTransceiver(it) }
963+
track.transceiver = null
964+
}
956965
}
957966
if (stopOnUnpublish) {
958967
track.stop()

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

Lines changed: 2 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.
@@ -141,6 +141,7 @@ constructor(
141141
override fun dispose() {
142142
super.dispose()
143143
capturer.dispose()
144+
source.dispose()
144145
closeableManager.close()
145146
}
146147

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: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,28 @@ 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 disposeDisposesVideoSource() {
236+
val source = mock(VideoSource::class.java)
237+
val videoTrack = createLocalTrack(source = source)
238+
239+
videoTrack.dispose()
240+
241+
Mockito.verify(source).dispose()
242+
}
243+
222244
@Test
223245
fun updateMetadata() = runTest {
224246
connect()
@@ -292,9 +314,14 @@ class LocalParticipantMockE2ETest : MockE2ETest() {
292314
)
293315
}
294316

295-
private fun createLocalTrack(width: Int = 1280, height: Int = 720, isScreencast: Boolean = false) = LocalVideoTrack(
317+
private fun createLocalTrack(
318+
width: Int = 1280,
319+
height: Int = 720,
320+
isScreencast: Boolean = false,
321+
source: VideoSource = mock(VideoSource::class.java),
322+
) = LocalVideoTrack(
296323
capturer = MockVideoCapturer(),
297-
source = mock(VideoSource::class.java),
324+
source = source,
298325
name = "",
299326
options = LocalVideoTrackOptions(
300327
isScreencast = isScreencast,

0 commit comments

Comments
 (0)