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/.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/.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/.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 ec6a10190..09b9b7289 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 { @@ -98,8 +102,10 @@ public class LocalParticipant: Participant, @unchecked Sendable { try await room.publisherShouldNegotiate() } + track._state.mutate { $0.rtpSenderForCodec.removeAll() } + // Wait for track to stop (if required) - if room._state.roomOptions.stopLocalTrackOnUnpublish { + if stopTrack, room._state.roomOptions.stopLocalTrackOnUnpublish { try await track.stop() } @@ -330,15 +336,39 @@ extension LocalParticipant { } func republishAllTracks() async throws { - let mediaTracks = _state.trackPublications.values.map { $0.track as? LocalTrack }.compactMap(\.self) + 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.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) + if keepCapturing { + await publication.set(track: nil) + } + } catch { + log("Failed to unpublish track \(publication.sid) with error \(error)", .error) + } + } - await unpublishAll() + var firstError: Error? 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) + firstError = firstError ?? error + } } + + if let firstError { throw firstError } } } 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 } 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) + } + } + } +}