Skip to content

Commit 500fd85

Browse files
Fixed ScreenCaptureService staying bound when screen share setup is cancelled (#983)
* Fixed ScreenCaptureService staying bound when screen share setup is cancelled ScreenCaptureConnection recorded a binding only once onServiceConnected arrived. A coroutine cancelled before connect() returned left the ServiceConnection registered, so BIND_AUTO_CREATE kept the service alive for the lifetime of the context. LocalParticipant.setScreenShareEnabled awaits the bind internally and abandons the track it just created when cancelled, so nothing reached stop() on that path. A cancelled connect now releases the binding itself once no caller is left waiting on it, and stop() unbinds on the same wider condition. Callers stay tracked until connect() actually returns, so a cancellation landing after the service connected but before the caller resumed releases the binding too. Both cover the documented case where bindService leaves a connection registered while reporting failure or throwing. Two paths could also leave connect() suspended forever. A stop() racing a connect could unbind and snapshot an empty waiter set just before the caller enqueued itself, so requesting the bind and registering the waiter now happen under one lock. A failed bindService left the state claiming a bind was in flight, which the next caller would wait on with nothing pending. * Failed pending connects when the binding dies or comes back null Neither onBindingDied nor onNullBinding is followed by onServiceConnected, so a queued caller would stay suspended until stop(). Both now release the binding and fail the waiters with IllegalStateException.
1 parent 4892817 commit 500fd85

3 files changed

Lines changed: 349 additions & 33 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"client-sdk-android": patch
3+
---
4+
5+
Fix `ScreenCaptureService` staying bound when screen share setup is cancelled. `ScreenCaptureConnection` recorded a binding only once `onServiceConnected` arrived, so a coroutine cancelled before `connect()` returned left the `ServiceConnection` registered, and `BIND_AUTO_CREATE` kept the service alive for the lifetime of the context. `LocalParticipant.setScreenShareEnabled` awaits the bind internally and abandons the track it just created if cancelled, so nothing reached `stop()` on that path. A cancelled connect now releases the binding itself once no caller is left waiting on it, and `stop()` unbinds on the same wider condition. Callers stay tracked until `connect()` actually returns, so a cancellation landing after the service connected but before the caller resumed releases the binding too. This also covers the documented case where `bindService` leaves a connection registered while reporting failure or throwing.
6+
7+
Two paths that could leave `connect()` suspended forever are fixed as well. `stop()` racing a connect no longer strands the caller, since requesting the bind and registering the waiter now happen under one lock, and a failed `bindService` no longer leaves the state claiming a bind is in flight for the next caller to wait on.
Lines changed: 151 additions & 33 deletions
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.
@@ -24,76 +24,194 @@ import android.content.Intent
2424
import android.content.ServiceConnection
2525
import android.os.IBinder
2626
import io.livekit.android.util.LKLog
27+
import kotlinx.coroutines.CancellableContinuation
28+
import kotlinx.coroutines.CancellationException
2729
import kotlinx.coroutines.suspendCancellableCoroutine
28-
import kotlin.coroutines.Continuation
2930
import kotlin.coroutines.resume
31+
import kotlin.coroutines.resumeWithException
3032

3133
/**
3234
* Handles connecting to a [ScreenCaptureService].
3335
*/
3436
internal class ScreenCaptureConnection(private val context: Context) {
37+
/**
38+
* True while [ScreenCaptureService] is connected and [startForeground] can reach it.
39+
*/
3540
var isBound = false
3641
private set
42+
43+
/**
44+
* True from the start of a bind attempt until its matching unbind attempt. This is a wider
45+
* window than [isBound]: the binding is owed an unbind even if the service never connects.
46+
*/
47+
private var isBindRequested = false
3748
private var service: ScreenCaptureService? = null
38-
private val queuedConnects = mutableSetOf<Continuation<Unit>>()
49+
private var hasConnectedCaller = false
50+
private val queuedConnects = mutableSetOf<CancellableContinuation<Unit>>()
3951
private val connection: ServiceConnection = object : ServiceConnection {
4052
override fun onServiceDisconnected(name: ComponentName) {
4153
LKLog.v { "Screen capture service is disconnected" }
42-
isBound = false
43-
service = null
54+
synchronized(this@ScreenCaptureConnection) {
55+
isBound = false
56+
service = null
57+
}
4458
}
4559

4660
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
4761
LKLog.v { "Screen capture service is connected" }
4862
val screenCaptureBinder = binder as ScreenCaptureService.ScreenCaptureBinder
49-
service = screenCaptureBinder.service
50-
handleConnect()
63+
val connects = synchronized(this@ScreenCaptureConnection) {
64+
if (!isBindRequested) {
65+
return
66+
}
67+
service = screenCaptureBinder.service
68+
isBound = true
69+
queuedConnects.filter { it.isActive }
70+
}
71+
connects.forEach { it.resume(Unit) }
5172
}
52-
}
5373

54-
suspend fun connect() {
55-
if (isBound) {
56-
return
74+
override fun onBindingDied(name: ComponentName) {
75+
LKLog.w { "Screen capture service binding died" }
76+
failPendingConnects("ScreenCaptureService binding died.")
5777
}
5878

59-
val intent = Intent(context, ScreenCaptureService::class.java)
60-
val bound = context.bindService(intent, connection, BIND_AUTO_CREATE)
61-
if (!bound) {
62-
throw IllegalStateException("Failed to bind ScreenCaptureService.")
79+
override fun onNullBinding(name: ComponentName) {
80+
LKLog.w { "Screen capture service returned a null binding" }
81+
failPendingConnects("ScreenCaptureService returned a null binding.")
6382
}
64-
return suspendCancellableCoroutine { cont ->
65-
cont.invokeOnCancellation {
66-
synchronized(this) {
67-
queuedConnects.remove(cont)
68-
}
69-
}
83+
}
84+
85+
/**
86+
* Binds to [ScreenCaptureService] and suspends until it is connected.
87+
*
88+
* @throws IllegalStateException if the service could not be bound, or the binding died or
89+
* came back null before the service connected.
90+
* @throws SecurityException if the caller cannot access the service.
91+
* @throws CancellationException if [stop] tears the connection down while connecting.
92+
*/
93+
suspend fun connect() {
94+
lateinit var continuation: CancellableContinuation<Unit>
95+
suspendCancellableCoroutine { cont ->
96+
continuation = cont
97+
// Binding and enqueueing happen under one lock, so a concurrent stop() either
98+
// precedes the bind or cancels the waiter, and never strands it against a
99+
// connection that has already been unbound.
100+
var outcome: Result<Unit>? = null
70101
synchronized(this) {
71102
if (isBound) {
72-
cont.resume(Unit)
103+
outcome = Result.success(Unit)
73104
} else {
74105
queuedConnects.add(cont)
106+
val failure = runCatching { if (!isBindRequested) bind() }.exceptionOrNull()
107+
if (failure != null) {
108+
queuedConnects.remove(cont)
109+
outcome = Result.failure(failure)
110+
}
75111
}
76112
}
113+
114+
val result = outcome
115+
if (result == null) {
116+
// Registered once the waiter is queued, so a continuation that was already
117+
// cancelled still releases the binding it just requested.
118+
cont.invokeOnCancellation { abandonConnect(cont) }
119+
} else {
120+
// Resumed outside the lock, since resuming runs the continuation inline.
121+
cont.resumeWith(result)
122+
}
77123
}
78-
}
79124

80-
fun startForeground(notificationId: Int? = null, notification: Notification? = null) {
81-
service?.start(notificationId, notification)
125+
val connected = synchronized(this) {
126+
queuedConnects.remove(continuation)
127+
if (isBindRequested && isBound) {
128+
hasConnectedCaller = true
129+
true
130+
} else {
131+
if (queuedConnects.isEmpty() && !hasConnectedCaller) {
132+
unbind()
133+
}
134+
false
135+
}
136+
}
137+
if (!connected) {
138+
throw CancellationException("ScreenCaptureService connection was stopped.")
139+
}
82140
}
83141

84-
private fun handleConnect() {
142+
/**
143+
* Releases a binding that no longer has anyone waiting on it. Screen share setup is routinely
144+
* driven from a cancellable scope, and the caller that requested the bind does not necessarily
145+
* survive to call [stop].
146+
*/
147+
private fun abandonConnect(cont: CancellableContinuation<Unit>) {
85148
synchronized(this) {
86-
isBound = true
87-
queuedConnects.forEach { it.resume(Unit) }
88-
queuedConnects.clear()
149+
queuedConnects.remove(cont)
150+
if (queuedConnects.isEmpty() && !hasConnectedCaller) {
151+
unbind()
152+
}
89153
}
90154
}
91155

92-
fun stop() {
93-
if (isBound) {
94-
context.unbindService(connection)
156+
/**
157+
* Releases a binding the platform has reported will never connect, and fails every caller
158+
* waiting on it. Neither report is followed by [ServiceConnection.onServiceConnected], so a
159+
* queued caller would otherwise stay suspended until [stop].
160+
*/
161+
private fun failPendingConnects(message: String) {
162+
val failedConnects = synchronized(this) {
163+
if (!isBindRequested) {
164+
return
165+
}
166+
unbind()
167+
queuedConnects.toList().also { queuedConnects.clear() }
95168
}
96-
service = null
169+
// Resumed outside the lock, since resuming runs the continuation inline.
170+
failedConnects.forEach { it.resumeWithException(IllegalStateException(message)) }
171+
}
172+
173+
private fun bind() {
174+
val intent = Intent(context, ScreenCaptureService::class.java)
175+
// A connection is registered even when bindService reports failure, so the unbind is
176+
// owed from the moment the call is made.
177+
isBindRequested = true
178+
val bound = try {
179+
context.bindService(intent, connection, BIND_AUTO_CREATE)
180+
} catch (e: Exception) {
181+
unbind()
182+
throw e
183+
}
184+
if (!bound) {
185+
unbind()
186+
throw IllegalStateException("Failed to bind ScreenCaptureService.")
187+
}
188+
}
189+
190+
private fun unbind() {
191+
if (!isBindRequested) {
192+
return
193+
}
194+
isBindRequested = false
97195
isBound = false
196+
service = null
197+
hasConnectedCaller = false
198+
try {
199+
context.unbindService(connection)
200+
} catch (e: IllegalArgumentException) {
201+
LKLog.v(e) { "Screen capture service was not bound" }
202+
}
203+
}
204+
205+
fun startForeground(notificationId: Int? = null, notification: Notification? = null) {
206+
service?.start(notificationId, notification)
207+
}
208+
209+
fun stop() {
210+
val abandonedConnects = synchronized(this) {
211+
unbind()
212+
queuedConnects.toList().also { queuedConnects.clear() }
213+
}
214+
// Cancelled outside the lock, since cancellation runs handlers inline.
215+
abandonedConnects.forEach { it.cancel() }
98216
}
99217
}

0 commit comments

Comments
 (0)