Skip to content

Commit 25fd158

Browse files
Fixed setTrackEnabled orphaning track resources when cancelled before publish completes
A cancellation landing in startForegroundService or publishVideoTrack unwound out of setTrackEnabled without any cleanup, abandoning the newly created screencast track and, when the bind had already succeeded, leaving ScreenCaptureService bound for the lifetime of the context. Each enable path tears the unpublished track down in a finally block, so cancellation and publish failures release capture, track resources, and the service binding, and invoke the onStop callback exactly once, even when the platform ended the projection while publishing was in flight. A projection stop already dispatched on its callback thread can reach the track after that cleanup disposed it, where ScreenCapturerAndroid throws from stopCapture. LocalScreencastVideoTrack.stop() tolerates losing that race instead of crashing the callback thread. publishTrackImpl negotiates the sender transceiver concurrently with the add track request, so a failed or cancelled request could leave a negotiated transceiver holding its sender and SDP m-section with no publication to unpublish. The rollback targets exactly the sender this attempt created on the transport that created it, so it cannot detach a concurrent publish of the same track, and the handle is recorded non-cancellably so a cancellation cannot discard the RTC-thread result. Sender handles retain the signal session state that owned their transceiver. Aborting pending publishes and starting a reconnect replace that state before cleanup can run, so a delayed continuation cannot mutate the same publisher after a soft reconnect. The final eligibility check runs on the RTC thread immediately before mutation. Stopping the transceiver and releasing its m-section is reserved for video, matching unpublishTrack. LocalScreencastVideoTrack also never released its SurfaceTextureHelper, so its capture thread outlived dispose() even on the normal teardown path. The helper is registered with the track's CloseableManager, matching LocalVideoTrack. Fixes #985
1 parent 500fd85 commit 25fd158

7 files changed

Lines changed: 593 additions & 91 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+
Fixed setTrackEnabled leaking track resources when cancelled before the track is published, including the sender negotiated for a failed publish, and LocalScreencastVideoTrack leaking its SurfaceTextureHelper on dispose.

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

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ internal constructor(
133133
}
134134
when (newVal) {
135135
ConnectionState.CONNECTED -> {
136+
signalSessionState = SignalSessionState(ended = false)
136137
if (oldVal == ConnectionState.DISCONNECTED || oldVal == ConnectionState.CONNECTING) {
137138
LKLog.d { "primary ICE connected" }
138139
listener?.onEngineConnected()
@@ -158,8 +159,17 @@ internal constructor(
158159

159160
@Volatile
160161
internal var reconnectType: ReconnectType = ReconnectType.DEFAULT
162+
163+
@Volatile
161164
private var reconnectingJob: Job? = null
162165

166+
/**
167+
* Replaced at each signal session boundary. Sender handles retain the state that owned their
168+
* transceiver, so cleanup cannot mutate a publisher after that session ends.
169+
*/
170+
@Volatile
171+
private var signalSessionState = SignalSessionState(ended = true)
172+
163173
@Volatile
164174
private var fullReconnectOnNext = false
165175

@@ -424,10 +434,13 @@ internal constructor(
424434
internal suspend fun createSenderTransceiver(
425435
rtcTrack: MediaStreamTrack,
426436
transInit: RtpTransceiverInit,
427-
): RtpTransceiver? {
428-
return publisher?.withPeerConnection {
437+
): SenderTransceiverHandle? {
438+
val sessionState = signalSessionState
439+
val publisher = publisher ?: return null
440+
val transceiver = publisher.withPeerConnection {
429441
addTransceiver(rtcTrack, transInit)
430-
}
442+
} ?: return null
443+
return SenderTransceiverHandle(publisher, transceiver, sessionState)
431444
}
432445

433446
fun updateSubscriptionPermissions(
@@ -505,6 +518,9 @@ internal constructor(
505518
}
506519

507520
private fun abortPendingPublishTracks() {
521+
// Ordered ahead of the resumes: each one unwinds a publish into cleanup that reads this,
522+
// and on some paths the reconnect is only triggered later, by the socket closing.
523+
signalSessionState = SignalSessionState(ended = true)
508524
synchronized(pendingTrackResolvers) {
509525
pendingTrackResolvers.values.forEach {
510526
it.resumeWithException(TrackException.PublishException("pending track aborted"))
@@ -535,6 +551,7 @@ internal constructor(
535551
}
536552
val forceFullReconnect = fullReconnectOnNext
537553
fullReconnectOnNext = false
554+
signalSessionState = SignalSessionState(ended = true)
538555
val job = coroutineScope.launch {
539556
var hasResumedOnce = false
540557
var hasReconnectedOnce = false
@@ -1515,15 +1532,64 @@ internal constructor(
15151532
}
15161533
}
15171534

1535+
/**
1536+
* Detaches a sender transceiver whose publish attempt produced no publication.
1537+
*
1538+
* A publish fails most often because the signal connection died, and that same failure
1539+
* starts a reconnect which renegotiates this peer connection. Mutating its senders and
1540+
* transceivers alongside that negotiation races it, so the rollback is skipped unless the
1541+
* publisher is connected and idle. The sender retains its signal session state so an aborted
1542+
* publish cannot mutate the same publisher after a soft reconnect.
1543+
*
1544+
* @param stopTransceiver releases the transceiver and its SDP m-section for reuse. Reserved
1545+
* for video, matching the teardown an unpublish performs.
1546+
* @return whether the sender was detached, so the track can drop its reference to it.
1547+
*/
1548+
internal fun rollbackSenderTransceiver(
1549+
senderTransceiver: SenderTransceiverHandle,
1550+
stopTransceiver: Boolean,
1551+
): Boolean {
1552+
val publisher = senderTransceiver.publisher
1553+
return runBlocking {
1554+
publisher.withPeerConnection {
1555+
val sessionState = signalSessionState
1556+
val publisherIsIdle = this@RTCEngine.publisher === publisher &&
1557+
connectionState == ConnectionState.CONNECTED &&
1558+
reconnectingJob?.isActive != true &&
1559+
senderTransceiver.signalSessionState === sessionState &&
1560+
!sessionState.ended
1561+
if (!publisherIsIdle) {
1562+
return@withPeerConnection false
1563+
}
1564+
val transceiver = senderTransceiver.transceiver
1565+
removeTrack(transceiver.sender)
1566+
if (stopTransceiver && !transceiver.isStopped) {
1567+
transceiver.stopInternal()
1568+
}
1569+
true
1570+
} ?: false
1571+
}
1572+
}
1573+
15181574
@VisibleForTesting
15191575
fun getPublisherPeerConnection() =
15201576
publisher!!.peerConnection
15211577

15221578
@VisibleForTesting
15231579
fun getSubscriberPeerConnection() =
15241580
subscriber!!.peerConnection
1581+
1582+
private class SignalSessionState(
1583+
val ended: Boolean,
1584+
)
15251585
}
15261586

1587+
internal class SenderTransceiverHandle(
1588+
internal val publisher: PeerConnectionTransport,
1589+
internal val transceiver: RtpTransceiver,
1590+
internal val signalSessionState: Any,
1591+
)
1592+
15271593
/**
15281594
* @suppress
15291595
*/

0 commit comments

Comments
 (0)