From 68c3cdce9ebd18f365a666463bb6fefcd4f50ea1 Mon Sep 17 00:00:00 2001 From: Adrian Niculescu <15037449+adrian-niculescu@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:45:51 +0300 Subject: [PATCH] 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. --- .../video-track-publish-unpublish-leaks.md | 5 + .../java/io/livekit/android/room/RTCEngine.kt | 15 +++ .../room/participant/LocalParticipant.kt | 15 +++ .../android/room/track/LocalVideoTrack.kt | 19 +++- .../android/test/mock/MockVideoSource.kt | 7 +- .../LocalParticipantMockE2ETest.kt | 101 +++++++++++++++++- 6 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 .changeset/video-track-publish-unpublish-leaks.md diff --git a/.changeset/video-track-publish-unpublish-leaks.md b/.changeset/video-track-publish-unpublish-leaks.md new file mode 100644 index 000000000..536904311 --- /dev/null +++ b/.changeset/video-track-publish-unpublish-leaks.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +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. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index dee0adab0..4f338ce7d 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -1498,6 +1498,21 @@ internal constructor( } } + internal fun stopTransceivers(transceivers: List) { + if (transceivers.isEmpty()) { + return + } + runBlocking { + publisher?.withPeerConnection { + for (transceiver in transceivers) { + if (!transceiver.isStopped) { + transceiver.stopInternal() + } + } + } + } + } + @VisibleForTesting fun getPublisherPeerConnection() = publisher!!.peerConnection diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt index 5daa840db..a1fe88614 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt @@ -660,6 +660,10 @@ internal constructor( return null } + if (track is LocalVideoTrack) { + track.clearSimulcastCodecs() + } + val cid = try { track.rtcTrack.id() } catch (e: Exception) { @@ -953,6 +957,16 @@ internal constructor( if (engine.connectionState == ConnectionState.CONNECTED) { engine.removeTrack(track.rtcTrack) + + // Each publish creates a new transceiver, plus one per backup codec. Removing the + // track from its sender doesn't release them, so they would otherwise be retained + // until the connection closes. Stopping them releases the native resources and frees + // the SDP m-sections for reuse. Limited to video, where this leak is significant. + if (track is LocalVideoTrack) { + engine.stopTransceivers(listOfNotNull(track.transceiver) + track.simulcastTransceivers) + track.transceiver = null + track.clearSimulcastCodecs() + } } if (stopOnUnpublish) { track.stop() @@ -1198,6 +1212,7 @@ internal constructor( LKLog.w { "couldn't create new transceiver! $codec" } return@launch } + simulcastTrack.transceiver = transceiver val trackRequest = AddTrackRequest.newBuilder().apply { sid = existingPublication.sid muted = !track.enabled diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt index 2ca919e22..925e46e35 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt @@ -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. @@ -113,6 +113,12 @@ constructor( internal val sender: RtpSender? get() = transceiver?.sender + /** + * The transceivers created for additional backup codecs (e.g. when using SVC with a backup codec). + */ + internal val simulcastTransceivers: List + get() = simulcastCodecs.values.mapNotNull { it.transceiver } + private val closeableManager = CloseableManager() /** @@ -141,6 +147,7 @@ constructor( override fun dispose() { super.dispose() capturer.dispose() + source.dispose() closeableManager.close() } @@ -436,6 +443,15 @@ constructor( return simulcastTrackInfo } + /** + * Clears the backup codec state so it is re-established from scratch on the next publish, + * rather than reusing senders whose transceivers were stopped on unpublish. + */ + internal fun clearSimulcastCodecs() { + subscribedCodecs = null + simulcastCodecs.clear() + } + @AssistedFactory interface Factory { fun create( @@ -543,5 +559,6 @@ internal data class SimulcastTrackInfo( var codec: String, var rtcTrack: MediaStreamTrack, var sender: RtpSender? = null, + var transceiver: RtpTransceiver? = null, var encodings: List? = null, ) diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockVideoSource.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockVideoSource.kt index ed7d6e54b..9280dc6f4 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockVideoSource.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockVideoSource.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 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. @@ -18,4 +18,7 @@ package io.livekit.android.test.mock import livekit.org.webrtc.VideoSource -class MockVideoSource(nativeSource: Long = 100) : VideoSource(nativeSource) +class MockVideoSource(nativeSource: Long = 100) : VideoSource(nativeSource) { + override fun dispose() { + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt index 0048872d6..7c54db41b 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt @@ -219,6 +219,72 @@ class LocalParticipantMockE2ETest : MockE2ETest() { assertEquals(publishOptions.stream, sentRequest.addTrack.stream) } + @Test + fun unpublishStopsVideoTransceiver() = runTest { + connect() + val videoTrack = createLocalTrack() + room.localParticipant.publishVideoTrack(videoTrack) + + val transceiver = getPublisherPeerConnection().transceivers.first() + room.localParticipant.unpublishTrack(videoTrack) + + Mockito.verify(transceiver).stopInternal() + } + + @Test + fun unpublishStopsBackupCodecTransceivers() = runTest { + room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy( + videoCodec = VideoCodec.VP9.codecName, + scalabilityMode = "L3T3", + backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName), + ) + + connect() + val videoTrack = createLocalTrack() + room.localParticipant.publishVideoTrack(videoTrack) + + receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid) + + val transceivers = getPublisherPeerConnection().transceivers + assertEquals(2, transceivers.size) + + room.localParticipant.unpublishTrack(videoTrack) + + transceivers.forEach { Mockito.verify(it).stopInternal() } + } + + @Test + fun republishAfterBackupCodecUnpublishCreatesNewBackupTransceiver() = runTest { + room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy( + videoCodec = VideoCodec.VP9.codecName, + scalabilityMode = "L3T3", + backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName), + ) + + connect() + val videoTrack = createLocalTrack() + room.localParticipant.publishVideoTrack(videoTrack) + + receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid) + assertEquals(2, getPublisherPeerConnection().transceivers.size) + + room.localParticipant.unpublishTrack(videoTrack, stopOnUnpublish = false) + room.localParticipant.publishVideoTrack(videoTrack) + receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid) + + assertEquals(4, getPublisherPeerConnection().transceivers.size) + } + + @Test + fun disposeDisposesVideoSource() { + val source = mock(VideoSource::class.java) + val videoTrack = createLocalTrack(source = source) + + videoTrack.dispose() + + Mockito.verify(source).dispose() + } + @Test fun updateMetadata() = runTest { connect() @@ -292,9 +358,40 @@ class LocalParticipantMockE2ETest : MockE2ETest() { ) } - private fun createLocalTrack(width: Int = 1280, height: Int = 720, isScreencast: Boolean = false) = LocalVideoTrack( + private fun receiveSubscribedQualityUpdate(trackSid: String) { + wsFactory.receiveMessage( + with(LivekitRtc.SignalResponse.newBuilder()) { + subscribedQualityUpdate = with(LivekitRtc.SubscribedQualityUpdate.newBuilder()) { + this.trackSid = trackSid + addAllSubscribedCodecs( + listOf("vp9", "vp8").map { codecName -> + with(SubscribedCodec.newBuilder()) { + codec = codecName + addQualities( + SubscribedQuality.newBuilder() + .setQuality(LivekitModels.VideoQuality.HIGH) + .setEnabled(true) + .build(), + ) + build() + } + }, + ) + build() + } + build().toOkioByteString() + }, + ) + } + + private fun createLocalTrack( + width: Int = 1280, + height: Int = 720, + isScreencast: Boolean = false, + source: VideoSource = mock(VideoSource::class.java), + ) = LocalVideoTrack( capturer = MockVideoCapturer(), - source = mock(VideoSource::class.java), + source = source, name = "", options = LocalVideoTrackOptions( isScreencast = isScreencast,