Skip to content

Fixed ScreenCaptureService staying bound when screen share setup is cancelled - #983

Merged
davidliu merged 2 commits into
livekit:mainfrom
adrian-niculescu:fix/screen-capture-connection-bind-leak
Jul 27, 2026
Merged

Fixed ScreenCaptureService staying bound when screen share setup is cancelled#983
davidliu merged 2 commits into
livekit:mainfrom
adrian-niculescu:fix/screen-capture-connection-bind-leak

Conversation

@adrian-niculescu

@adrian-niculescu adrian-niculescu commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

ScreenCaptureConnection records a binding only when onServiceConnected arrives, but the binding exists from the bindService call onwards. Cancel the coroutine in that window and the ServiceConnection stays registered, so BIND_AUTO_CREATE keeps ScreenCaptureService alive for the lifetime of the context.

stop() cannot be the whole answer here. LocalParticipant.setScreenShareEnabled awaits the bind inside setTrackEnabled, and the track it creates is not published or stored yet, so a cancellation there abandons the track without any finally to stop it. Nothing reaches stop() at all. A cancelled connect now releases the binding itself once no caller is left waiting on it, and stop() unbinds on that same wider condition. Callers stay tracked until connect() actually returns, so this covers a cancellation landing after onServiceConnected but before the resumed caller runs, which is otherwise the same leak with the binding already connected.

Two smaller problems live in the same window:

  • bindService registers a connection even when it returns false or throws, which the platform docs call out, so the existing throw IllegalStateException path leaked one as well.
  • stop() while a connect is pending never resumed the queued continuations, so startForegroundService suspended forever.

Two more ways a caller could suspend forever came out of tightening the state handling, and are fixed here too. 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 then wait on with nothing pending.

Six tests cover the cancellation, rebind, post-connect cancellation, and pending-stop paths; five of them fail on main and pass here. The sixth pins the happy path, so releasing on cancellation cannot tear down a binding a connected caller still holds. ./gradlew test, spotless, and detekt pass.

Worth flagging separately: cancellation after createScreencastTrack can also orphan the new track because setTrackEnabled only cleans it up when publishVideoTrack returns false; cancellation from startForegroundService or publishVideoTrack exits before that branch. Cancellation during the service bind happens before capture starts, so it strands the initialized SurfaceTextureHelper, VideoSource, capturer, and rtcTrack, but not a MediaProjection. Cancellation while publication is suspended happens after startCapture and can leave the active MediaProjection running too. This is outside the ScreenCaptureConnection fix. Fully addressing it requires cancellation-safe ownership transfer in LocalParticipant and correct SurfaceTextureHelper ownership in LocalScreencastVideoTrack, and is not attempted here, but happy to take it on if you want it in the same change.

@changeset-bot

changeset-bot Bot commented Jul 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f275e98

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
client-sdk-android Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@adrian-niculescu
adrian-niculescu force-pushed the fix/screen-capture-connection-bind-leak branch from 9a1ad60 to 7528bc6 Compare July 26, 2026 09:03
…ancelled

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.
@adrian-niculescu
adrian-niculescu force-pushed the fix/screen-capture-connection-bind-leak branch from 7528bc6 to 40e1881 Compare July 26, 2026 10:14

@xianshijing-lk xianshijing-lk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm in general, one question

private var hasConnectedCaller = false
private val queuedConnects = mutableSetOf<CancellableContinuation<Unit>>()
private val connection: ServiceConnection = object : ServiceConnection {
override fun onServiceDisconnected(name: ComponentName) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the only way that binding can fail ? I wonder if there are other callbacks that we should handle as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reachable failures are covered: bindService returning false or throwing, both handled in bind(). I went through the rest of the ServiceConnection surface, and for this service the other callbacks cannot fire today:

  • onNullBinding fires when the service's onBind returns null. The intent targets the exact ScreenCaptureService class and its onBind unconditionally returns the binder. An app that disables the service via manifest merge lands in the bindService-returns-false path.
  • onBindingDied fires when the binding is permanently dead, i.e. the package hosting the service was updated or force-stopped. The service is declared in the SDK manifest without android:process, so it runs in the client's own process and dies with it.
  • onServiceDisconnected is handled, and queued waiters intentionally stay queued there: the binding survives a service crash, BIND_AUTO_CREATE recreates the service, and onServiceConnected resumes them.

That said, if onNullBinding or onBindingDied ever became reachable (onBind made conditional, or the service moved out of process), neither is followed by onServiceConnected, so a queued caller would suspend forever, the same bug class this PR fixes. f275e98 handles both as extra caution: release the binding and fail the waiters with IllegalStateException, with a test for each.

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.

@xianshijing-lk xianshijing-lk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, thanks

@adrian-niculescu

adrian-niculescu commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for looking at this so swiftly!

@davidliu davidliu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, I think this will need a follow up in LocalParticipant as well, not only to fix up the things mentioned, but cancelling there would also still leak the service binding I think (as far as ScreenCaptureConnection knows, it handled everything fine and it now on the owner to clean up the bind).

@davidliu
davidliu merged commit 500fd85 into livekit:main Jul 27, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants