From 7bf838de8bac3438bf7a3161898dcd514fb076d8 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, 31 Mar 2026 09:49:15 +0200 Subject: [PATCH 1/2] fix(publish): reset hasPublished when all tracks are unpublished (#958) After unpublishAll(), the sticky hasPublished flag kept the publisher transport marked as critical. When the idle publisher ICE timed out, this triggered an unnecessary reconnect cycle that escalated from quick to full, connecting without the reconnect flag and causing the server to remove the participant with DUPLICATE_IDENTITY. Reset hasPublished to false when no track publications with non-nil tracks remain, preventing both the spurious reconnect trigger and unnecessary publisher ICE restarts during any server-initiated reconnect. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/LiveKit/Participant/LocalParticipant.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/LiveKit/Participant/LocalParticipant.swift b/Sources/LiveKit/Participant/LocalParticipant.swift index 146fda51b..5c96b9311 100644 --- a/Sources/LiveKit/Participant/LocalParticipant.swift +++ b/Sources/LiveKit/Participant/LocalParticipant.swift @@ -96,6 +96,13 @@ public class LocalParticipant: Participant, @unchecked Sendable { try await publisher.remove(track: sender) // Mark re-negotiation required... try await room.publisherShouldNegotiate() + + // Reset hasPublished when no more tracks are published, so that + // a publisher transport disconnect doesn't trigger an unnecessary reconnect. + let hasPublishedTracks = _state.trackPublications.values.contains { $0.track != nil } + if !hasPublishedTracks { + room._state.mutate { $0.hasPublished = false } + } } // Wait for track to stop (if required) From 875b271b1787dcb968c81c5c4be27c9b0e55238e 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, 23 Apr 2026 15:36:10 +0200 Subject: [PATCH 2/2] test(publish): add failing republish-after-unpublish regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduces #958: publish simulcast video (720x1280, 3 layers) + audio, unpublish all, then republish — only audio reaches the remote side, video never appears (trackPublications.count → 1 instead of 2). The test uses the same parameterized V0/V1 pattern as the existing signaling tests and is expected to fail until the underlying transceiver reuse issue is fixed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../PeerConnectionSignalingTests.swift | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/Tests/LiveKitCoreTests/PeerConnectionSignalingTests.swift b/Tests/LiveKitCoreTests/PeerConnectionSignalingTests.swift index 5156e748c..9e6aa96fb 100644 --- a/Tests/LiveKitCoreTests/PeerConnectionSignalingTests.swift +++ b/Tests/LiveKitCoreTests/PeerConnectionSignalingTests.swift @@ -372,6 +372,63 @@ struct PeerConnectionSignalingTests { } } + @Test(.bug("https://github.com/livekit/client-sdk-swift/issues/958"), + arguments: SignalingMode.allCases) + func republishAfterUnpublishAll(mode: SignalingMode) async throws { + try await TestEnvironment.withRooms([ + roomTestingOptions(mode: mode, canPublish: true), + roomTestingOptions(mode: mode, canSubscribe: true), + ]) { rooms in + let room1 = rooms[0] + let room2 = rooms[1] + + let publisherIdentity = try #require(room1.localParticipant.identity) + let remoteParticipant = try #require(room2.remoteParticipants[publisherIdentity]) + + // Phase 1: Publish audio + simulcast video (720x1280 triggers 3 layers) + let audioTrack1 = TestAudioTrack(name: "audio-1") + let videoTrack1 = LocalVideoTrack.createBufferTrack(name: "video-1") + if let capturer = videoTrack1.capturer as? BufferCapturer { + var pb: CVPixelBuffer? + CVPixelBufferCreate(kCFAllocatorDefault, 720, 1280, kCVPixelFormatType_32BGRA, nil, &pb) + if let pb { capturer.capture(pb) } + } + + try await room1.localParticipant.publish(audioTrack: audioTrack1) + try await room1.localParticipant.publish(videoTrack: videoTrack1) + + try await waitForPublish(on: remoteParticipant) { + $0.trackPublications.count >= 2 + } + + // Phase 2: Unpublish all + await room1.localParticipant.unpublishAll() + + try await waitForPublish(on: remoteParticipant) { + $0.trackPublications.isEmpty + } + + try await Task.sleep(nanoseconds: 2_000_000_000) + + // Phase 3: Republish new tracks + let audioTrack2 = TestAudioTrack(name: "audio-2") + let videoTrack2 = LocalVideoTrack.createBufferTrack(name: "video-2") + if let capturer = videoTrack2.capturer as? BufferCapturer { + var pb: CVPixelBuffer? + CVPixelBufferCreate(kCFAllocatorDefault, 720, 1280, kCVPixelFormatType_32BGRA, nil, &pb) + if let pb { capturer.capture(pb) } + } + + try await room1.localParticipant.publish(audioTrack: audioTrack2) + try await room1.localParticipant.publish(videoTrack: videoTrack2) + + try await waitForPublish(on: remoteParticipant) { + $0.trackPublications.count >= 2 + } + #expect(remoteParticipant.trackPublications.count >= 2, "Remote should see republished tracks") + } + } + @Test(arguments: SignalingMode.allCases) func doubleReconnect(mode: SignalingMode) async throws { let reconnectWatcher = ReconnectWatcher()