Skip to content

Fixed full reconnect republish racing concurrent publishes of the same track - #990

Closed
adrian-niculescu wants to merge 1 commit into
livekit:mainfrom
adrian-niculescu:fix/reconnect-republish-publish-race
Closed

Fixed full reconnect republish racing concurrent publishes of the same track#990
adrian-niculescu wants to merge 1 commit into
livekit:mainfrom
adrian-niculescu:fix/reconnect-republish-publish-race

Conversation

@adrian-niculescu

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

Copy link
Copy Markdown
Contributor

Fixes #989.

republishTracks() published outside the per-source publish lock, so a full reconnect could overlap setMicrophoneEnabled or a direct publishAudioTrack on the same default track instance. The losing path's duplicate publish failed and its failure branch stopped the shared track, leaving the publication unmuted but sending silence. When the pre-reconnect publication was muted, republish also unpublished a publication the app had just created mid-reconnect and skipped republishing it.

All publishes now serialize on the per-source lock: publishAudioTrack/publishVideoTrack acquire it, while setTrackEnabled and republishTracks call the unlocked internals under the lock they hold. republishTracks skips snapshot entries whose track already has a live publication instead of unpublishing them, and only stops a track after a failed republish if it is not published.

Two adjacent holes in the same reconnect window are closed as well: the republish snapshot now accumulates across reconnect attempts with the newest entry per track winning, so a publication created between failed attempts is restored instead of dropped and a mute during the reconnect is preserved, and a track that a failed concurrent enable stopped (e.g. on the add track timeout) is restarted before republishing rather than republished silent. Only tracks the SDK itself stopped are restarted; a track the consumer stopped through the public Track API stays stopped.

Added MockE2ETest coverage for the reconnect interleavings, the direct-publish overlap, the multi-attempt cases, and the stopped-track restart, driven by withholding the TrackPublished response.

@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f22a18c

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

@adrian-niculescu
adrian-niculescu force-pushed the fix/reconnect-republish-publish-race branch from c856263 to e242bd9 Compare July 31, 2026 10:55

@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 found 2 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines 1376 to 1379
}
if (!success) {
if (!success && !isTrackPublished(track)) {
track.stop()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Microphone keeps recording after a failed track restore following reconnect

The microphone is re-armed for recording (prewarm() at livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt:1403) but is never turned back off when the restore fails, so the device keeps capturing audio indefinitely.
Impact: After a failed reconnect restore, the microphone stays active and the system recording indicator remains on even though nothing is being sent.

Prewarm is started in restartTrackIfStopped but the republish failure path only calls track.stop()

restartTrackIfStopped calls track.prewarm() + track.start() for LocalAudioTrack (livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt:1402-1405). prewarm() forwards to JavaAudioRecordPrewarmer.prewarm which starts recording on the audio device module and is only cancelled by stopPrewarm() (livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalAudioTrack.kt:104-110).

In republishTrack, when the republish fails and the track is still unpublished, only track.stop() is called (line 1377-1379), unlike the equivalent failure path in setTrackEnabled which calls both track.stop() and track.stopPrewarm() (livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt:387-389). The prewarmed recording therefore leaks.

Suggested change
}
if (!success) {
if (!success && !isTrackPublished(track)) {
track.stop()
}
if (!success && !isTrackPublished(track)) {
track.stop()
(track as? LocalAudioTrack)?.stopPrewarm()
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@adrian-niculescu adrian-niculescu Jul 31, 2026

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.

Fixed. The failure path now mirrors setTrackEnabled and stops the prewarm.

Comment on lines +1407 to +1410
is LocalVideoTrack -> {
track.start()
track.startCapture()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Screen share that the user ended during a reconnect is restarted, which can crash the app

A screen share that the user already ended is forcibly restarted (startCapture() at livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt:1409) while restoring tracks after a reconnect, so the app can crash or resume a dead screen share.
Impact: If the user stops screen sharing while the connection is being re-established, the app may crash or end up publishing a broken screen share, and other tracks are not restored.

restartTrackIfStopped does not distinguish screencast tracks, whose projection and foreground service are already gone

When the user stops screen capture through the system UI, MediaProjectionCallback invokes LocalScreencastVideoTrack.stop(), which stops the capturer, sets enabled = false and shuts down the capture foreground service (livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalScreencastVideoTrack.kt:186-190). The participant's own onStop callback calls unpublishTrack, which early-returns during a full reconnect because trackPublications was already cleared by prepareForFullReconnect (livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt:996-1001).

The accumulated republish snapshot therefore still holds an unmuted screen share publication whose track is disabled. republishTrack then calls restartTrackIfStopped, whose is LocalVideoTrack branch matches LocalScreencastVideoTrack and calls start() + startCapture(). The media projection permission result has already been consumed and the foreground service is stopped, so ScreenCapturerAndroid.startCapture fails (throwing on modern Android). The exception escapes republishTracks() into Room.onPostReconnect and the RTCEngine reconnect coroutine, which has no exception handler (livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt:213, :693), aborting the republish of any remaining tracks. AGENTS.md also forbids crashing consumer code via unchecked exceptions.

Consider skipping restart (and republish) for screencast tracks that are no longer live, and guarding the restart/publish loop against exceptions.

Prompt for agents
restartTrackIfStopped in LocalParticipant.kt restarts any disabled LocalVideoTrack before republishing, including LocalScreencastVideoTrack instances. A screen share whose MediaProjection was ended by the user during a full reconnect ends up in the republish snapshot with muted=false and enabled=false (unpublishTrack early-returns during reconnect because trackPublications was cleared). Restarting such a track calls ScreenCapturerAndroid.startCapture with an already-consumed projection result and without the capture foreground service, which fails/throws; the exception propagates out of republishTracks into Room.onPostReconnect and the RTCEngine reconnect coroutine (no exception handler), aborting republishing of the remaining tracks. Consider excluding screencast tracks from the restart path (and from republishing when their capture is dead), and making the per-track republish loop resilient to exceptions so one track cannot abort the rest.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@adrian-niculescu adrian-niculescu Jul 31, 2026

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.

Correct, a stopped MediaProjection cannot be reused. Fixed: republishTrack skips a stopped screencast since the share has ended, restartTrackIfStopped no longer touches screencasts, and the republish loop catches per-track failures so one entry cannot abort the rest.

@adrian-niculescu
adrian-niculescu marked this pull request as draft July 31, 2026 11:19
@adrian-niculescu
adrian-niculescu force-pushed the fix/reconnect-republish-publish-race branch 12 times, most recently from 49c99ef to 14231aa Compare July 31, 2026 13:59
@adrian-niculescu
adrian-niculescu force-pushed the fix/reconnect-republish-publish-race branch from 14231aa to f22a18c Compare July 31, 2026 14:05
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.

Full reconnect republishTracks races setTrackEnabled on the shared default track, leaving the mic silent or unpublished

1 participant