From 723e32146417ab9bcfbeb8f039137846e7184fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 14 May 2026 10:56:04 +0200 Subject: [PATCH 1/6] Keep screen share alive across full reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `republishAllTracks()` routes every local track through `unpublish()`, which calls `track.stop()` and tears down the capturer. For iOS broadcast extension and ReplayKit screen capture this kills the IPC and the extension self-terminates — the track gets re-added to the new publisher but no frames flow. Detach screen share publications from the old publisher peer connection without going through `unpublish()` so the capturer keeps running, then let `_publish` reattach it to the new publisher. Resolves #1004 Co-Authored-By: Claude Opus 4.7 (1M context) --- .changes/screen-share-survives-reconnect | 1 + .../LiveKit/Participant/LocalParticipant.swift | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 .changes/screen-share-survives-reconnect diff --git a/.changes/screen-share-survives-reconnect b/.changes/screen-share-survives-reconnect new file mode 100644 index 000000000..fcd245c7b --- /dev/null +++ b/.changes/screen-share-survives-reconnect @@ -0,0 +1 @@ +patch type="fixed" "Screen sharing no longer fails to resume after a full reconnect: the capture source (iOS broadcast extension IPC, ReplayKit) is kept alive while the track is reattached to the new publisher" diff --git a/Sources/LiveKit/Participant/LocalParticipant.swift b/Sources/LiveKit/Participant/LocalParticipant.swift index 146fda51b..06d17971d 100644 --- a/Sources/LiveKit/Participant/LocalParticipant.swift +++ b/Sources/LiveKit/Participant/LocalParticipant.swift @@ -304,6 +304,21 @@ extension LocalParticipant { func republishAllTracks() async throws { let mediaTracks = _state.trackPublications.values.map { $0.track as? LocalTrack }.compactMap(\.self) + // Detach screen share tracks without stopping their capturers — the + // underlying sources (iOS broadcast extension IPC, ReplayKit) cannot + // be restarted programmatically. + let screenShareTracks = mediaTracks.filter { $0.source == .screenShareVideo } + for track in screenShareTracks { + let sidsToRemove = _state.trackPublications.filter { $0.value.track === track }.map(\.key) + _state.mutate { + for sid in sidsToRemove { + $0.trackPublications.removeValue(forKey: sid) + } + } + await track.set(transport: nil, rtpSender: nil) + track._state.mutate { $0.rtpSenderForCodec.removeAll() } + } + await unpublishAll() for mediaTrack in mediaTracks { From 7075330c68fab56e034910792e56619d788f702e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:44:05 +0200 Subject: [PATCH 2/6] fix: surface per-track errors when re-publishing on reconnect republishAllTracks() aborted the whole loop on the first _publish failure and only logged a generic error at the call site, hiding which track failed. Catch per track, log the source, and keep re-publishing the rest so one track's failure no longer blocks the others. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/LiveKit/Participant/LocalParticipant.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/LiveKit/Participant/LocalParticipant.swift b/Sources/LiveKit/Participant/LocalParticipant.swift index 06d17971d..8782bc4ec 100644 --- a/Sources/LiveKit/Participant/LocalParticipant.swift +++ b/Sources/LiveKit/Participant/LocalParticipant.swift @@ -324,7 +324,11 @@ extension LocalParticipant { for mediaTrack in mediaTracks { // Don't re-publish muted tracks if mediaTrack.isMuted { continue } - try await _publish(track: mediaTrack, options: mediaTrack.publishOptions) + do { + try await _publish(track: mediaTrack, options: mediaTrack.publishOptions) + } catch { + log("Failed to re-publish \(mediaTrack.source) track, error: \(error)", .error) + } } } } From e586b70e2d9204fe2d002380532f7944fda7f387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:17:18 +0200 Subject: [PATCH 3/6] refactor(participant): route screen share republish through unpublish The detach block added in 723e3214 hand-rolled a partial unpublish, which skipped four steps unpublish() performs: sender removal from the publisher, didUnpublishTrack, publication.set(track: nil) and onUnpublish(). On the room move path (_republishLocalTracks) transports are never torn down, so leaving the sender in place kept the old sendonly transceiver attached to the running capturer and _publish added a second one for the same media track. Add an internal _unpublish(publication:notify:stopTrack:) and keep the public unpublish() as a forwarder so the ObjC selector is unchanged. _unpublish is the old body with the stop gated on the flag, so tracks that pass stopTrack: true behave exactly as before. Screen shares take the same path with stopTrack: false, which stops the transceiver and frees the m-line while Track.start()'s already-started guard keeps the capturer counter at 1. The detached publication also drops its track reference, so it stops observing the capturer that is deliberately left running, and its per-codec senders are cleared because _publish re-attaches the main sender but never the rtpSenderForCodec entries. Also restore the republish contract weakened in 7075330c: per-track failures are still logged individually, but the first error is rethrown so both call sites' error handling is reachable again. Muted screen shares are no longer detached-then-skipped, which silently dropped their publication with no delegate event. Co-Authored-By: Claude Opus 5 (1M context) --- .changes/republish-per-track-errors | 1 + .../Participant/LocalParticipant.swift | 40 ++++++++++++------- 2 files changed, 26 insertions(+), 15 deletions(-) create mode 100644 .changes/republish-per-track-errors diff --git a/.changes/republish-per-track-errors b/.changes/republish-per-track-errors new file mode 100644 index 000000000..2505315c2 --- /dev/null +++ b/.changes/republish-per-track-errors @@ -0,0 +1 @@ +patch type="changed" "Re-publishing local tracks after a reconnect or room move no longer stops at the first failing track: every track is attempted, each failure is logged with its source, and the first error is reported to the caller" diff --git a/Sources/LiveKit/Participant/LocalParticipant.swift b/Sources/LiveKit/Participant/LocalParticipant.swift index a069046a9..0e3c18944 100644 --- a/Sources/LiveKit/Participant/LocalParticipant.swift +++ b/Sources/LiveKit/Participant/LocalParticipant.swift @@ -66,6 +66,10 @@ public class LocalParticipant: Participant, @unchecked Sendable { /// unpublish an existing published track /// this will also stop the track public func unpublish(publication: LocalTrackPublication, notify _notify: Bool = true) async throws { + try await _unpublish(publication: publication, notify: _notify, stopTrack: true) + } + + func _unpublish(publication: LocalTrackPublication, notify _notify: Bool, stopTrack: Bool) async throws { let room = try requireRoom() func _notifyDidUnpublish() async { @@ -99,7 +103,7 @@ public class LocalParticipant: Participant, @unchecked Sendable { } // Wait for track to stop (if required) - if room._state.roomOptions.stopLocalTrackOnUnpublish { + if stopTrack, room._state.roomOptions.stopLocalTrackOnUnpublish { try await track.stop() } @@ -330,24 +334,27 @@ extension LocalParticipant { } func republishAllTracks() async throws { - let mediaTracks = _state.trackPublications.values.map { $0.track as? LocalTrack }.compactMap(\.self) - - // Detach screen share tracks without stopping their capturers — the - // underlying sources (iOS broadcast extension IPC, ReplayKit) cannot - // be restarted programmatically. - let screenShareTracks = mediaTracks.filter { $0.source == .screenShareVideo } - for track in screenShareTracks { - let sidsToRemove = _state.trackPublications.filter { $0.value.track === track }.map(\.key) - _state.mutate { - for sid in sidsToRemove { - $0.trackPublications.removeValue(forKey: sid) + let publications = _state.trackPublications.values.compactMap { $0 as? LocalTrackPublication } + let mediaTracks = publications.compactMap { $0.track as? LocalTrack } + + for publication in publications { + // Screen share capture sources (broadcast extension IPC, ReplayKit) cannot be + // restarted programmatically, so keep them capturing until re-published. + let isScreenShare = publication.source == .screenShareVideo + || (publication.kind == .video && publication.name == Track.screenShareVideoName) + let keepCapturing = isScreenShare && !(publication.track?.isMuted ?? true) + do { + try await _unpublish(publication: publication, notify: true, stopTrack: !keepCapturing) + if keepCapturing, let track = publication.track { + await publication.set(track: nil) + track._state.mutate { $0.rtpSenderForCodec.removeAll() } } + } catch { + log("Failed to unpublish track \(publication.sid) with error \(error)", .error) } - await track.set(transport: nil, rtpSender: nil) - track._state.mutate { $0.rtpSenderForCodec.removeAll() } } - await unpublishAll() + var firstError: Error? for mediaTrack in mediaTracks { // Don't re-publish muted tracks @@ -356,8 +363,11 @@ extension LocalParticipant { try await _publish(track: mediaTrack, options: mediaTrack.publishOptions) } catch { log("Failed to re-publish \(mediaTrack.source) track, error: \(error)", .error) + firstError = firstError ?? error } } + + if let firstError { throw firstError } } } From 53f8a038b4d6e6d3fef813d7376f90f1c349d529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:28:33 +0200 Subject: [PATCH 4/6] fix(participant): clear per-codec senders on every unpublish republishAllTracks() cleared rtpSenderForCodec only for the screen share it detaches. _publish re-attaches the main rtpSender but never the per-codec entries, so after a full reconnect any other video track published with a backup codec still pointed at senders of the destroyed peer connection: VideoTrack._set(subscribedCodec:) matched one, reported the codec as still sent, and publish(additionalVideoCodec:) never re-added it. Clear the dict in _unpublish once the simulcast senders have been read and removed, so it applies to every unpublished track. Co-Authored-By: Claude Opus 5 (1M context) --- .changes/clear-per-codec-senders-on-unpublish | 1 + Sources/LiveKit/Participant/LocalParticipant.swift | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 .changes/clear-per-codec-senders-on-unpublish diff --git a/.changes/clear-per-codec-senders-on-unpublish b/.changes/clear-per-codec-senders-on-unpublish new file mode 100644 index 000000000..ab6171fcd --- /dev/null +++ b/.changes/clear-per-codec-senders-on-unpublish @@ -0,0 +1 @@ +patch type="fixed" "A backup video codec is re-published after a full reconnect again: unpublishing a track now clears its per-codec senders, which previously kept pointing at the destroyed peer connection and made the SDK believe the codec was still being sent" diff --git a/Sources/LiveKit/Participant/LocalParticipant.swift b/Sources/LiveKit/Participant/LocalParticipant.swift index 0e3c18944..387c3ebfd 100644 --- a/Sources/LiveKit/Participant/LocalParticipant.swift +++ b/Sources/LiveKit/Participant/LocalParticipant.swift @@ -102,6 +102,8 @@ public class LocalParticipant: Participant, @unchecked Sendable { try await room.publisherShouldNegotiate() } + track._state.mutate { $0.rtpSenderForCodec.removeAll() } + // Wait for track to stop (if required) if stopTrack, room._state.roomOptions.stopLocalTrackOnUnpublish { try await track.stop() @@ -345,9 +347,8 @@ extension LocalParticipant { let keepCapturing = isScreenShare && !(publication.track?.isMuted ?? true) do { try await _unpublish(publication: publication, notify: true, stopTrack: !keepCapturing) - if keepCapturing, let track = publication.track { + if keepCapturing { await publication.set(track: nil) - track._state.mutate { $0.rtpSenderForCodec.removeAll() } } } catch { log("Failed to unpublish track \(publication.sid) with error \(error)", .error) From 4e0dc82c81d589141f1dd2bb7af8d95002ae951b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:28:44 +0200 Subject: [PATCH 5/6] fix(capturer): clear cached dimensions when capture stops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VideoCapturer.stopCapture() reset dimensionsCompleter but left _state.dimensions at its last value, and set(dimensions:) only resumes the completer when the value changes. A source that resumes at the same resolution therefore never resolved dimensions again, so _publish's "Waiting for dimensions to resolve" timed out after 10s and the re-publish failed. CameraCapturer already worked around this with its own set(dimensions: nil) on stop; BufferCapturer and BroadcastScreenCapturer did not, so no buffer-backed video track could be re-published after a full reconnect — the second half of the screen share failure. Nil the dimensions in the base class instead, which resets the completer as a side effect. Co-Authored-By: Claude Opus 5 (1M context) --- .changes/resolve-dimensions-after-capture-restart | 1 + Sources/LiveKit/Track/Capturers/VideoCapturer.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .changes/resolve-dimensions-after-capture-restart diff --git a/.changes/resolve-dimensions-after-capture-restart b/.changes/resolve-dimensions-after-capture-restart new file mode 100644 index 000000000..2bbcb59a5 --- /dev/null +++ b/.changes/resolve-dimensions-after-capture-restart @@ -0,0 +1 @@ +patch type="fixed" "Re-publishing a video track after its capturer was stopped no longer times out waiting for dimensions: stopping a capturer now clears the cached dimensions, so a source that resumes at the same resolution resolves them again" diff --git a/Sources/LiveKit/Track/Capturers/VideoCapturer.swift b/Sources/LiveKit/Track/Capturers/VideoCapturer.swift index 421acf16a..9d5f01c12 100644 --- a/Sources/LiveKit/Track/Capturers/VideoCapturer.swift +++ b/Sources/LiveKit/Track/Capturers/VideoCapturer.swift @@ -183,7 +183,7 @@ public class VideoCapturer: NSObject, @unchecked Sendable, Loggable, VideoCaptur $0.capturer?(self, didUpdate: .stopped) } - dimensionsCompleter.reset() + set(dimensions: nil) return true } From 2acd96d2f2d3ae3b0be4a74ff84cfde633f8ab6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:28:44 +0200 Subject: [PATCH 6/6] test: cover local track re-publish across a full reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parameterized over microphone, camera and screen share: each track is published, the room is put through debug_simulate(scenario: .fullReconnect), and the test waits on a RoomDelegate-backed AsyncCompleter for the replacement publication rather than polling. Screen share must come back with its capturer untouched (startStopCounter still 1, no .stopped transition); every other source is expected to be torn down and started again. Writing it surfaced that the screen share predicate applied the name fallback to any source, while Participant.getTrackPublication(source:) only applies it to .unknown — createBufferTrack keeps the screen share name by default, so a `source: .camera` track was matched as a screen share. Narrow the predicate to match. Co-Authored-By: Claude Opus 5 (1M context) --- .../Participant/LocalParticipant.swift | 2 +- .../Room/RepublishTracksTests.swift | 166 ++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 Tests/LiveKitCoreTests/Room/RepublishTracksTests.swift diff --git a/Sources/LiveKit/Participant/LocalParticipant.swift b/Sources/LiveKit/Participant/LocalParticipant.swift index 387c3ebfd..09b9b7289 100644 --- a/Sources/LiveKit/Participant/LocalParticipant.swift +++ b/Sources/LiveKit/Participant/LocalParticipant.swift @@ -343,7 +343,7 @@ extension LocalParticipant { // Screen share capture sources (broadcast extension IPC, ReplayKit) cannot be // restarted programmatically, so keep them capturing until re-published. let isScreenShare = publication.source == .screenShareVideo - || (publication.kind == .video && publication.name == Track.screenShareVideoName) + || (publication.source == .unknown && publication.kind == .video && publication.name == Track.screenShareVideoName) let keepCapturing = isScreenShare && !(publication.track?.isMuted ?? true) do { try await _unpublish(publication: publication, notify: true, stopTrack: !keepCapturing) diff --git a/Tests/LiveKitCoreTests/Room/RepublishTracksTests.swift b/Tests/LiveKitCoreTests/Room/RepublishTracksTests.swift new file mode 100644 index 000000000..3ba51840c --- /dev/null +++ b/Tests/LiveKitCoreTests/Room/RepublishTracksTests.swift @@ -0,0 +1,166 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import CoreVideo +@testable import LiveKit +import Testing +#if canImport(LiveKitTestSupport) +import LiveKitTestSupport +#endif + +/// Resolves once the local participant publishes a track for `source` that is not the +/// publication the reconnect was expected to replace. +private final class RepublishWatcher: NSObject, RoomDelegate, @unchecked Sendable { + private let source: Track.Source + private let _replacedSid = StateSync(nil) + private let _completer = AsyncCompleter(label: "Re-published track", defaultTimeout: 30) + + init(source: Track.Source) { + self.source = source + super.init() + } + + /// Arms the watcher: from now on, any publication for `source` other than `sid` resolves the wait. + func expectRepublish(replacing sid: Track.Sid) { + _replacedSid.mutate { $0 = sid } + } + + func waitForRepublish() async throws -> LocalTrackPublication { + try await _completer.wait() + } + + func room(_: Room, participant _: LocalParticipant, didPublishTrack publication: LocalTrackPublication) { + guard publication.source == source, + let replacedSid = _replacedSid.copy(), publication.sid != replacedSid else { return } + _completer.resume(returning: publication) + } +} + +/// Records `.stopped` transitions, so a test can distinguish a capture source that was torn +/// down from one that was only detached from its sender. +private final class CapturerStopSpy: VideoCapturerDelegate, @unchecked Sendable { + private let _stops = StateSync(0) + var stops: Int { _stops.copy() } + + func capturer(_: VideoCapturer, didUpdate state: VideoCapturer.CapturerState) { + guard state == .stopped else { return } + _stops.mutate { $0 += 1 } + } +} + +@Suite(.serialized, .tags(.media, .broadcast, .e2e)) +struct RepublishTracksTests { + /// What a full reconnect is expected to do to a track's capture source. + enum CaptureOutcome: Sendable { + /// Screen share sources (broadcast extension IPC, ReplayKit) cannot be restarted + /// programmatically, so capture has to survive the reconnect. + case uninterrupted + /// Any other video source is torn down and started again. + case restarted + /// Audio tracks have no video capturer to observe. + case noCapturer + } + + struct Scenario: Sendable, CustomTestStringConvertible { + let source: Track.Source + let capture: CaptureOutcome + let makeTrack: @Sendable () -> LocalTrack + + var testDescription: String { String(describing: source) } + } + + private static let dimensions: Dimensions = .h720_169 + + @Test(arguments: [ + Scenario(source: .microphone, capture: .noCapturer) { TestAudioTrack() }, + Scenario(source: .camera, capture: .restarted) { + LocalVideoTrack.createBufferTrack(name: "camera", + source: .camera, + options: BufferCaptureOptions(dimensions: dimensions)) + }, + // createBufferTrack defaults to the screen share name and source + Scenario(source: .screenShareVideo, capture: .uninterrupted) { + LocalVideoTrack.createBufferTrack(options: BufferCaptureOptions(dimensions: dimensions)) + }, + ]) + func fullReconnectRepublishesTrack(scenario: Scenario) async throws { + let watcher = RepublishWatcher(source: scenario.source) + + try await TestEnvironment.withRooms([RoomTestingOptions(delegate: watcher, canPublish: true)]) { rooms in + let room = rooms[0] + + let track = scenario.makeTrack() + let capturer = (track as? LocalVideoTrack)?.capturer as? BufferCapturer + + let stopSpy = CapturerStopSpy() + capturer?.add(delegate: stopSpy) + + let feeder = capturer.map { startFeeding($0) } + defer { feeder?.cancel() } + + let publication = try await publish(track, in: room) + watcher.expectRepublish(replacing: publication.sid) + + try await room.debug_simulate(scenario: .fullReconnect) + let republished = try await watcher.waitForRepublish() + + #expect(republished.sid != publication.sid) + #expect(republished.track === track) + #expect(track._state.trackState == .started) + #expect(track._state.rtpSender != nil, "Track was not re-attached to the new publisher") + + switch scenario.capture { + case .uninterrupted: + #expect(stopSpy.stops == 0, "Screen share capture must survive a full reconnect") + #expect(capturer?._state.startStopCounter == 1) + case .restarted: + #expect(stopSpy.stops == 1, "Only screen share capture should survive a full reconnect") + #expect(capturer?.captureState == .started) + case .noCapturer: + #expect(capturer == nil) + } + } + } + + private func publish(_ track: LocalTrack, in room: Room) async throws -> LocalTrackPublication { + if let audioTrack = track as? LocalAudioTrack { + return try await room.localParticipant.publish(audioTrack: audioTrack) + } + let videoTrack = try #require(track as? LocalVideoTrack) + return try await room.localParticipant.publish(videoTrack: videoTrack) + } + + /// Publishing waits on the capturer's dimensions, and `stopCapture()` resets them, so a + /// re-published track needs frames to keep arriving for the whole test. + private func startFeeding(_ capturer: BufferCapturer) -> Task { + let dimensions = Self.dimensions + return Task { + var pixelBuffer: CVPixelBuffer? + CVPixelBufferCreate(kCFAllocatorDefault, + Int(dimensions.width), + Int(dimensions.height), + kCVPixelFormatType_32BGRA, + nil, + &pixelBuffer) + guard let pixelBuffer else { return } + + while !Task.isCancelled { + capturer.capture(pixelBuffer) + try? await Task.sleep(nanoseconds: 33_000_000) + } + } + } +}