Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/clear-per-codec-senders-on-unpublish
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions .changes/republish-per-track-errors
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions .changes/resolve-dimensions-after-capture-restart
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions .changes/screen-share-survives-reconnect
Original file line number Diff line number Diff line change
@@ -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"
38 changes: 34 additions & 4 deletions Sources/LiveKit/Participant/LocalParticipant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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 }
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/LiveKit/Track/Capturers/VideoCapturer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ public class VideoCapturer: NSObject, @unchecked Sendable, Loggable, VideoCaptur
$0.capturer?(self, didUpdate: .stopped)
}

dimensionsCompleter.reset()
set(dimensions: nil)

return true
}
Expand Down
166 changes: 166 additions & 0 deletions Tests/LiveKitCoreTests/Room/RepublishTracksTests.swift
Original file line number Diff line number Diff line change
@@ -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<Track.Sid?>(nil)
private let _completer = AsyncCompleter<LocalTrackPublication>(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<Void, Never> {
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)
}
}
}
}
Loading