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,