From ebab556d8f70a7b056e169e6bbdf7f1d31d66d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 6 May 2026 09:59:28 +0200 Subject: [PATCH 1/8] fix(core): surface transport failures as .network errors When the WebRTC peer connection went to .disconnected/.failed, the transport delegate called primaryTransportConnectedCompleter.reset(), which threw LiveKitError(.cancelled) to anyone awaiting the completer. Consumers couldn't distinguish a network-induced disconnect from a user-initiated cancel through room.disconnectError or didDisconnectWithError. - AsyncCompleter.reset(throwing:) accepts an optional Error and normalizes via LiveKitError.from. WaitEntry.cancel(throwing:) takes an optional LiveKitError, defaulting to .cancelled, so the withTaskCancellationHandler.onCancel path still produces .cancelled for real Task cancellation. - Room+TransportDelegate now resets the transport completers with a typed LiveKitError(.network, message:) on PC disconnect/failed. - Room.cleanUp / SignalClient.cleanUp thread the disconnect cause through to in-flight completer waiters instead of masking it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changes/typed-disconnect-error | 1 + .../LiveKit/Core/Room+TransportDelegate.swift | 7 ++- Sources/LiveKit/Core/Room.swift | 6 +-- Sources/LiveKit/Core/SignalClient.swift | 2 +- .../Support/Async/AsyncCompleter.swift | 12 +++-- Tests/LiveKitCoreTests/CompleterTests.swift | 46 +++++++++++++++++++ 6 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 .changes/typed-disconnect-error diff --git a/.changes/typed-disconnect-error b/.changes/typed-disconnect-error new file mode 100644 index 000000000..a29bde27f --- /dev/null +++ b/.changes/typed-disconnect-error @@ -0,0 +1 @@ +patch type="fixed" "Report transport-level disconnects as LiveKitError(.network) instead of LiveKitError(.cancelled) so consumers can distinguish network failures from user-initiated cancellation" diff --git a/Sources/LiveKit/Core/Room+TransportDelegate.swift b/Sources/LiveKit/Core/Room+TransportDelegate.swift index de299cb59..a8bff156f 100644 --- a/Sources/LiveKit/Core/Room+TransportDelegate.swift +++ b/Sources/LiveKit/Core/Room+TransportDelegate.swift @@ -32,12 +32,15 @@ extension Room: TransportDelegate { func transport(_ transport: Transport, didUpdateState pcState: LKRTCPeerConnectionState) { log("target: \(transport.target), connectionState: \(pcState.description)") + let pcError = LiveKitError(.network, + message: "Transport \(transport.target) state changed to \(pcState.description)") + // primary connected if transport.isPrimary { if pcState.isConnected { primaryTransportConnectedCompleter.resume(returning: ()) } else if pcState.isDisconnected { - primaryTransportConnectedCompleter.reset() + primaryTransportConnectedCompleter.reset(throwing: pcError) } } @@ -46,7 +49,7 @@ extension Room: TransportDelegate { if pcState.isConnected { publisherTransportConnectedCompleter.resume(returning: ()) } else if pcState.isDisconnected { - publisherTransportConnectedCompleter.reset() + publisherTransportConnectedCompleter.reset(throwing: pcError) } } diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 1ec3773bf..ab4c2334e 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -545,9 +545,9 @@ extension Room { log("withError: \(String(describing: disconnectError)), isFullReconnect: \(isFullReconnect)") // Reset completers - _sidCompleter.reset() - primaryTransportConnectedCompleter.reset() - publisherTransportConnectedCompleter.reset() + _sidCompleter.reset(throwing: disconnectError) + primaryTransportConnectedCompleter.reset(throwing: disconnectError) + publisherTransportConnectedCompleter.reset(throwing: disconnectError) await signalClient.cleanUp(withError: disconnectError) // Cancel all track stats timers before closing transports to prevent diff --git a/Sources/LiveKit/Core/SignalClient.swift b/Sources/LiveKit/Core/SignalClient.swift index 3e0ea7baa..5b38c0497 100644 --- a/Sources/LiveKit/Core/SignalClient.swift +++ b/Sources/LiveKit/Core/SignalClient.swift @@ -253,7 +253,7 @@ actor SignalClient: Loggable { $0.lastJoinResponse = nil } - _connectResponseCompleter.reset() + _connectResponseCompleter.reset(throwing: disconnectError) await _addTrackCompleters.reset() await _requestQueue.clear() diff --git a/Sources/LiveKit/Support/Async/AsyncCompleter.swift b/Sources/LiveKit/Support/Async/AsyncCompleter.swift index a00a0a297..b3a77d735 100644 --- a/Sources/LiveKit/Support/Async/AsyncCompleter.swift +++ b/Sources/LiveKit/Support/Async/AsyncCompleter.swift @@ -69,8 +69,8 @@ final class AsyncCompleter: @unchecked Sendable, Loggable { let continuation: CheckedContinuation let timeoutBlock: DispatchWorkItem - func cancel() { - continuation.resume(throwing: LiveKitError(.cancelled)) + func cancel(throwing error: LiveKitError? = nil) { + continuation.resume(throwing: error ?? LiveKitError(.cancelled)) timeoutBlock.cancel() } @@ -96,6 +96,10 @@ final class AsyncCompleter: @unchecked Sendable, Loggable { private let _lock: some Lock = createLock() + var waiterCount: Int { + _lock.sync { _entries.count } + } + init(label: String, defaultTimeout: TimeInterval) { self.label = label _defaultTimeout = defaultTimeout.toDispatchTimeInterval @@ -111,10 +115,10 @@ final class AsyncCompleter: @unchecked Sendable, Loggable { } } - func reset() { + func reset(throwing error: Error? = nil) { _lock.sync { for entry in _entries.values { - entry.cancel() + entry.cancel(throwing: LiveKitError.from(error: error)) } _entries.removeAll() _result = nil diff --git a/Tests/LiveKitCoreTests/CompleterTests.swift b/Tests/LiveKitCoreTests/CompleterTests.swift index 177d9abd6..936ad19f9 100644 --- a/Tests/LiveKitCoreTests/CompleterTests.swift +++ b/Tests/LiveKitCoreTests/CompleterTests.swift @@ -108,4 +108,50 @@ struct CompleterTests { print("Unknown error: \(error)") } } + + @Test func resetThrowingPropagatesTypedError() async { + let completer = AsyncCompleter(label: "reset-throwing", defaultTimeout: 30) + let task = Task { try await completer.wait() } + await waitForRegistration(of: completer) + + completer.reset(throwing: LiveKitError(.network, message: "transport failed")) + + let error = await #expect(throws: LiveKitError.self) { + try await task.value + } + #expect(error?.type == .network) + } + + @Test func taskCancellationStillProducesCancelled() async { + let completer = AsyncCompleter(label: "task-cancel", defaultTimeout: 30) + let task = Task { try await completer.wait() } + await waitForRegistration(of: completer) + + task.cancel() + + let error = await #expect(throws: LiveKitError.self) { + try await task.value + } + #expect(error?.type == .cancelled) + } + + @Test func resetClearsResultForReuse() async throws { + let completer = AsyncCompleter(label: "reuse-after-throw", defaultTimeout: 30) + + let firstTask = Task { try await completer.wait() } + await waitForRegistration(of: completer) + completer.reset(throwing: LiveKitError(.network)) + _ = await firstTask.result + + let secondTask = Task { try await completer.wait() } + await waitForRegistration(of: completer) + completer.resume(returning: ()) + try await secondTask.value + } + + private func waitForRegistration(of completer: AsyncCompleter) async { + while completer.waiterCount == 0 { + await Task.yield() + } + } } From 37218e5ce98021a2c70eb1a90ef7f1647b8324db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 6 May 2026 10:17:10 +0200 Subject: [PATCH 2/8] fix(core): thread disconnect cause to remaining completer waiters Extends the previous .network-typing fix to the completers that the initial change missed: - CompleterMapActor.reset(throwing:) mirrors AsyncCompleter; SignalClient now passes disconnectError to _addTrackCompleters so a sendAddTrack waiter caught mid-disconnect sees the underlying cause. - DataChannelPair.reset(throwing:) + Room.cleanUpRTC(withError:) so the publisherDataChannel.openCompleter waiter inside Room.send(dataPacket:) sees .network instead of .cancelled when the room is being torn down. Adds CompleterMapActorTests covering fan-out + default-to-cancelled behavior, and promotes waitForRegistration to file scope so both suites share one helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/LiveKit/Core/DataChannelPair.swift | 4 +- Sources/LiveKit/Core/Room+Engine.swift | 6 +-- Sources/LiveKit/Core/Room.swift | 2 +- Sources/LiveKit/Core/SignalClient.swift | 2 +- .../Support/Async/AsyncCompleter.swift | 4 +- Tests/LiveKitCoreTests/CompleterTests.swift | 44 +++++++++++++++++-- 6 files changed, 49 insertions(+), 13 deletions(-) diff --git a/Sources/LiveKit/Core/DataChannelPair.swift b/Sources/LiveKit/Core/DataChannelPair.swift index e0d04a72b..662461d4e 100644 --- a/Sources/LiveKit/Core/DataChannelPair.swift +++ b/Sources/LiveKit/Core/DataChannelPair.swift @@ -299,7 +299,7 @@ class DataChannelPair: NSObject, @unchecked Sendable, Loggable { } } - func reset() { + func reset(throwing error: Error? = nil) { let (lossy, reliable) = _state.mutate { let result = ($0.lossy, $0.reliable) $0.reliable = nil @@ -312,7 +312,7 @@ class DataChannelPair: NSObject, @unchecked Sendable, Loggable { lossy?.close() reliable?.close() - openCompleter.reset() + openCompleter.reset(throwing: error) } // MARK: - Send diff --git a/Sources/LiveKit/Core/Room+Engine.swift b/Sources/LiveKit/Core/Room+Engine.swift index 66a5c8c6c..d12335814 100644 --- a/Sources/LiveKit/Core/Room+Engine.swift +++ b/Sources/LiveKit/Core/Room+Engine.swift @@ -39,10 +39,10 @@ extension Room { } // Resets state of transports - func cleanUpRTC() async { + func cleanUpRTC(withError disconnectError: Error? = nil) async { // Close data channels - publisherDataChannel.reset() - subscriberDataChannel.reset() + publisherDataChannel.reset(throwing: disconnectError) + subscriberDataChannel.reset(throwing: disconnectError) await _state.transport?.close() diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index ab4c2334e..4bd0b1739 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -553,7 +553,7 @@ extension Room { // Cancel all track stats timers before closing transports to prevent // stats collection from accessing destroyed WebRTC channels. cancelTimers() - await cleanUpRTC() + await cleanUpRTC(withError: disconnectError) await cleanUpParticipants(isFullReconnect: isFullReconnect) // Cleanup for E2EE diff --git a/Sources/LiveKit/Core/SignalClient.swift b/Sources/LiveKit/Core/SignalClient.swift index 5b38c0497..89b021d7b 100644 --- a/Sources/LiveKit/Core/SignalClient.swift +++ b/Sources/LiveKit/Core/SignalClient.swift @@ -255,7 +255,7 @@ actor SignalClient: Loggable { _connectResponseCompleter.reset(throwing: disconnectError) - await _addTrackCompleters.reset() + await _addTrackCompleters.reset(throwing: disconnectError) await _requestQueue.clear() await _responseQueue.clear() diff --git a/Sources/LiveKit/Support/Async/AsyncCompleter.swift b/Sources/LiveKit/Support/Async/AsyncCompleter.swift index b3a77d735..9e53e2deb 100644 --- a/Sources/LiveKit/Support/Async/AsyncCompleter.swift +++ b/Sources/LiveKit/Support/Async/AsyncCompleter.swift @@ -53,10 +53,10 @@ actor CompleterMapActor { completer.resume(throwing: error) } - func reset() { + func reset(throwing error: Error? = nil) { // Reset call completers... for (_, value) in _completerMap { - value.reset() + value.reset(throwing: error) } // Clear all completers... _completerMap.removeAll() diff --git a/Tests/LiveKitCoreTests/CompleterTests.swift b/Tests/LiveKitCoreTests/CompleterTests.swift index 936ad19f9..3e42d9900 100644 --- a/Tests/LiveKitCoreTests/CompleterTests.swift +++ b/Tests/LiveKitCoreTests/CompleterTests.swift @@ -148,10 +148,46 @@ struct CompleterTests { completer.resume(returning: ()) try await secondTask.value } +} - private func waitForRegistration(of completer: AsyncCompleter) async { - while completer.waiterCount == 0 { - await Task.yield() - } +private func waitForRegistration(of completer: AsyncCompleter) async { + while completer.waiterCount == 0 { + await Task.yield() + } +} + +@Suite(.tags(.concurrency)) +struct CompleterMapActorTests { + @Test func resetThrowingFanOutsTypedErrorToAllCompleters() async { + let map = CompleterMapActor(label: "map-test", defaultTimeout: 30) + + let completerA = await map.completer(for: "a") + let completerB = await map.completer(for: "b") + + let taskA = Task { try await completerA.wait() } + let taskB = Task { try await completerB.wait() } + + await waitForRegistration(of: completerA) + await waitForRegistration(of: completerB) + + await map.reset(throwing: LiveKitError(.network, message: "fan-out")) + + let errorA = await #expect(throws: LiveKitError.self) { try await taskA.value } + let errorB = await #expect(throws: LiveKitError.self) { try await taskB.value } + #expect(errorA?.type == .network) + #expect(errorB?.type == .network) + } + + @Test func resetWithoutErrorDefaultsToCancelled() async { + let map = CompleterMapActor(label: "map-test", defaultTimeout: 30) + let completer = await map.completer(for: "a") + let task = Task { try await completer.wait() } + + await waitForRegistration(of: completer) + + await map.reset() + + let error = await #expect(throws: LiveKitError.self) { try await task.value } + #expect(error?.type == .cancelled) } } From 04f3866914ad746f3d1587d73d9f0a63bb1dcdb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 6 May 2026 10:35:26 +0200 Subject: [PATCH 3/8] fix(core): close activeParticipantCompleters leak across cleanUp Closes the only completer that previously had no error-propagation path on disconnect. Both halves of this change are needed; either alone leaves the leak open. - Room.cleanUp(withError:) now resets activeParticipantCompleters with the typed disconnectError, so any RemoteParticipant.waitUntilActive in flight is unblocked immediately with .network (or whatever cause) instead of waiting up to its full 10s timeout. - CompleterMapActor.resume(throwing:, for:) is now a no-op when no completer exists for the key. cleanUpParticipants mutates each participant's state to .unknown, which fires a fire-and-forget Task { resume(throwing: .participantRemoved, ...) } from the state observer in Participant.swift. Without this guard, those Tasks land after the reset and re-populate the just-cleared map with stale .failure(.participantRemoved) entries, which then greet the next session's same-identity waitUntilActive. resume(returning:, for:) keeps its auto-create behavior: success is meaningfully sticky ("the participant is active, anyone asking later should know"), failure is not. Tests refactored to a do/catch + Issue.record helper because #expect(throws:) returns Void (not E?) on the swift-testing bundled with Xcode 16.2. New CompleterMapActorTests cover the asymmetry (no-op on missing key for throws, remember-success for returning, existing-waiter still receives throws). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/LiveKit/Core/Room.swift | 1 + .../Support/Async/AsyncCompleter.swift | 2 +- Tests/LiveKitCoreTests/CompleterTests.swift | 67 +++++++++++++++---- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 4bd0b1739..4c6906c19 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -548,6 +548,7 @@ extension Room { _sidCompleter.reset(throwing: disconnectError) primaryTransportConnectedCompleter.reset(throwing: disconnectError) publisherTransportConnectedCompleter.reset(throwing: disconnectError) + await activeParticipantCompleters.reset(throwing: disconnectError) await signalClient.cleanUp(withError: disconnectError) // Cancel all track stats timers before closing transports to prevent diff --git a/Sources/LiveKit/Support/Async/AsyncCompleter.swift b/Sources/LiveKit/Support/Async/AsyncCompleter.swift index 9e53e2deb..a661fd4a7 100644 --- a/Sources/LiveKit/Support/Async/AsyncCompleter.swift +++ b/Sources/LiveKit/Support/Async/AsyncCompleter.swift @@ -49,7 +49,7 @@ actor CompleterMapActor { } func resume(throwing error: any Error, for key: String) { - let completer = completer(for: key) + guard let completer = _completerMap[key] else { return } completer.resume(throwing: error) } diff --git a/Tests/LiveKitCoreTests/CompleterTests.swift b/Tests/LiveKitCoreTests/CompleterTests.swift index 3e42d9900..6c63b96aa 100644 --- a/Tests/LiveKitCoreTests/CompleterTests.swift +++ b/Tests/LiveKitCoreTests/CompleterTests.swift @@ -116,10 +116,7 @@ struct CompleterTests { completer.reset(throwing: LiveKitError(.network, message: "transport failed")) - let error = await #expect(throws: LiveKitError.self) { - try await task.value - } - #expect(error?.type == .network) + await expectLiveKitError(.network, from: task) } @Test func taskCancellationStillProducesCancelled() async { @@ -129,10 +126,7 @@ struct CompleterTests { task.cancel() - let error = await #expect(throws: LiveKitError.self) { - try await task.value - } - #expect(error?.type == .cancelled) + await expectLiveKitError(.cancelled, from: task) } @Test func resetClearsResultForReuse() async throws { @@ -156,6 +150,17 @@ private func waitForRegistration(of completer: AsyncCompleter) async { } } +private func expectLiveKitError(_ expected: LiveKitErrorType, from task: Task) async { + do { + _ = try await task.value + Issue.record("Expected LiveKitError(.\(expected)) to be thrown") + } catch let error as LiveKitError { + #expect(error.type == expected) + } catch { + Issue.record("Expected LiveKitError, got \(error)") + } +} + @Suite(.tags(.concurrency)) struct CompleterMapActorTests { @Test func resetThrowingFanOutsTypedErrorToAllCompleters() async { @@ -172,10 +177,8 @@ struct CompleterMapActorTests { await map.reset(throwing: LiveKitError(.network, message: "fan-out")) - let errorA = await #expect(throws: LiveKitError.self) { try await taskA.value } - let errorB = await #expect(throws: LiveKitError.self) { try await taskB.value } - #expect(errorA?.type == .network) - #expect(errorB?.type == .network) + await expectLiveKitError(.network, from: taskA) + await expectLiveKitError(.network, from: taskB) } @Test func resetWithoutErrorDefaultsToCancelled() async { @@ -187,7 +190,43 @@ struct CompleterMapActorTests { await map.reset() - let error = await #expect(throws: LiveKitError.self) { try await task.value } - #expect(error?.type == .cancelled) + await expectLiveKitError(.cancelled, from: task) + } + + @Test func resumeThrowingForMissingKeyIsNoOp() async throws { + let map = CompleterMapActor(label: "no-op-test", defaultTimeout: 30) + + // No completer for the key yet — resume(throwing:) must not auto-create. + await map.resume(throwing: LiveKitError(.participantRemoved), for: "absent") + + // Subsequent wait on the same key must NOT see a stale "remembered" failure. + let completer = await map.completer(for: "absent") + let task = Task { try await completer.wait() } + await waitForRegistration(of: completer) + completer.resume(returning: ()) + try await task.value + } + + @Test func resumeReturningForMissingKeyRemembersSuccess() async throws { + let map = CompleterMapActor(label: "remember-success", defaultTimeout: 30) + + // resume(returning:) on a missing key creates and remembers the value. + await map.resume(returning: (), for: "key") + + // A later wait must see the success immediately. + let completer = await map.completer(for: "key") + try await completer.wait() + } + + @Test func resumeThrowingReachesExistingWaiter() async { + let map = CompleterMapActor(label: "existing-waiter", defaultTimeout: 30) + + let completer = await map.completer(for: "key") + let task = Task { try await completer.wait() } + await waitForRegistration(of: completer) + + await map.resume(throwing: LiveKitError(.network), for: "key") + + await expectLiveKitError(.network, from: task) } } From cdcdaff90d1b163319ec6f51b099f04efaa79522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 6 May 2026 11:01:25 +0200 Subject: [PATCH 4/8] test(core): silence weak-var false-positive in WeakRoomRefs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compiler's "never mutated" analysis flags `weak var weakRP: ... = remoteParticipant` because the runtime nil-out isn't user-code mutation — but its suggested fix (`let`) won't compile, since `weak let` is illegal. Move `weak` into the closure capture list instead. The closure now holds the only weak reference and reads it when the leak check fires; same semantics, no warning, fewer lines. Co-Authored-By: Claude Opus 4.7 (1M context) --- Tests/LiveKitCoreTests/Room/RoomTests.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/LiveKitCoreTests/Room/RoomTests.swift b/Tests/LiveKitCoreTests/Room/RoomTests.swift index 66ad13352..7f82e81f4 100644 --- a/Tests/LiveKitCoreTests/Room/RoomTests.swift +++ b/Tests/LiveKitCoreTests/Room/RoomTests.swift @@ -152,8 +152,7 @@ private struct WeakRoomRefs: @unchecked Sendable { localParticipant = room.localParticipant for remoteParticipant in room.remoteParticipants.values { - weak var weakRP: RemoteParticipant? = remoteParticipant - remoteParticipantChecks.append { weakRP == nil } + remoteParticipantChecks.append { [weak remoteParticipant] in remoteParticipant == nil } } state = room._state From 52d9b9263d68b80e96b47e1020800ffc3265cd29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 6 May 2026 12:11:14 +0200 Subject: [PATCH 5/8] test(audio): drop deprecated Stopwatch/split usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopwatch is a deprecated typealias for Span and split(label:) was renamed to record(_:at:). Update PublishDeviceOptimization tests accordingly and rename the local sw → span to match the type. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PublishDeviceOptimization.swift | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Tests/LiveKitAudioTests/PublishDeviceOptimization.swift b/Tests/LiveKitAudioTests/PublishDeviceOptimization.swift index dcc0ede8a..1286b12a8 100644 --- a/Tests/LiveKitAudioTests/PublishDeviceOptimization.swift +++ b/Tests/LiveKitAudioTests/PublishDeviceOptimization.swift @@ -27,20 +27,20 @@ import LiveKitTestSupport // Default publish flow @Test func defaultMicPublish() async throws { - var sw = Stopwatch(label: "Test: Normal publish sequence") + var span = Span(label: "Test: Normal publish sequence") let room1Opts = RoomTestingOptions(url: url, token: token, canPublish: true) try await TestEnvironment.withRooms([room1Opts]) { rooms in - sw.split(label: "Connected to room") + span.record("Connected to room") // Alias to Rooms let room1 = rooms[0] try await room1.localParticipant.setMicrophone(enabled: true) - sw.split(label: "Did publish mic") + span.record("Did publish mic") } - sw.split(label: "Sequence complete") - print(sw) + span.record("Sequence complete") + print(span) - print("Total time: \(sw.total())") + print("Total time: \(span.total())") } // No-VP publish flow @@ -48,39 +48,39 @@ import LiveKitTestSupport // Turn off Apple's VP try AudioManager.shared.setVoiceProcessingEnabled(false) - var sw = Stopwatch(label: "Test: No-VP publish sequence") + var span = Span(label: "Test: No-VP publish sequence") let room1Opts = RoomTestingOptions(url: url, token: token, canPublish: true) try await TestEnvironment.withRooms([room1Opts]) { rooms in - sw.split(label: "Connected to room") + span.record("Connected to room") // Alias to Rooms let room1 = rooms[0] try await room1.localParticipant.setMicrophone(enabled: true) - sw.split(label: "Did publish mic") + span.record("Did publish mic") } - sw.split(label: "Sequence complete") - print(sw) + span.record("Sequence complete") + print(span) - print("Total time: \(sw.total())") + print("Total time: \(span.total())") } // Concurrent device acquisition publish flow @Test func concurrentMicPublish() async throws { - var sw = Stopwatch(label: "Test: Normal publish sequence") + var span = Span(label: "Test: Normal publish sequence") let room1Opts = RoomTestingOptions(url: url, token: token, enableMicrophone: true, canPublish: true) try await TestEnvironment.withRooms([room1Opts]) { rooms in - sw.split(label: "Connected to room") + span.record("Connected to room") // Alias to Rooms let room1 = rooms[0] // Mic should be already enabled at this point let isMicEnabled = room1.localParticipant.isMicrophoneEnabled() #expect(isMicEnabled, "Mic should be enabled at this point") - sw.split(label: "Did publish mic") + span.record("Did publish mic") } - sw.split(label: "Sequence complete") - print(sw) + span.record("Sequence complete") + print(span) - print("Total time: \(sw.total())") + print("Total time: \(span.total())") } } From 12758534f3d05230618a0b61dd57bb944c37b78e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 6 May 2026 12:52:47 +0200 Subject: [PATCH 6/8] test(audio): make span a let constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Span is a class, so the local doesn't need to be var — the var triggers Swift's "never mutated" warning on stricter CI builds (Xcode 26.4). Switch to let; record(...) calls work either way. Co-Authored-By: Claude Opus 4.7 (1M context) --- Tests/LiveKitAudioTests/PublishDeviceOptimization.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/LiveKitAudioTests/PublishDeviceOptimization.swift b/Tests/LiveKitAudioTests/PublishDeviceOptimization.swift index 1286b12a8..84c619889 100644 --- a/Tests/LiveKitAudioTests/PublishDeviceOptimization.swift +++ b/Tests/LiveKitAudioTests/PublishDeviceOptimization.swift @@ -27,7 +27,7 @@ import LiveKitTestSupport // Default publish flow @Test func defaultMicPublish() async throws { - var span = Span(label: "Test: Normal publish sequence") + let span = Span(label: "Test: Normal publish sequence") let room1Opts = RoomTestingOptions(url: url, token: token, canPublish: true) try await TestEnvironment.withRooms([room1Opts]) { rooms in @@ -48,7 +48,7 @@ import LiveKitTestSupport // Turn off Apple's VP try AudioManager.shared.setVoiceProcessingEnabled(false) - var span = Span(label: "Test: No-VP publish sequence") + let span = Span(label: "Test: No-VP publish sequence") let room1Opts = RoomTestingOptions(url: url, token: token, canPublish: true) try await TestEnvironment.withRooms([room1Opts]) { rooms in @@ -66,7 +66,7 @@ import LiveKitTestSupport // Concurrent device acquisition publish flow @Test func concurrentMicPublish() async throws { - var span = Span(label: "Test: Normal publish sequence") + let span = Span(label: "Test: Normal publish sequence") let room1Opts = RoomTestingOptions(url: url, token: token, enableMicrophone: true, canPublish: true) try await TestEnvironment.withRooms([room1Opts]) { rooms in From aa99573071c33fae02997f7b45a944966d14db0e 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, 7 May 2026 11:15:10 +0200 Subject: [PATCH 7/8] fix(core): skip typed network error during room teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @hiroshihorie's review on PR #987: a peer-connection .disconnected/.failed callback that fires while the room is already .disconnecting/.disconnected (i.e. as part of Transport.close() during cleanUpRTC, before _pc.delegate = nil takes effect) would otherwise reset the transport completers with LiveKitError(.network). A caller that registered a wait between cleanUp's reset and the late delegate firing would then see .network — even though the disconnect was user-initiated. Gate the typed-network construction on connection state: when the room is in teardown, fall back to default .cancelled so the distinction between network-induced disconnects and user cancellation stays reliable. The race is timing-dependent and not deterministic to reproduce as a unit test (would require either a real livekit-server connection or a Transport mock that doesn't currently exist in the test infrastructure). Existing CompleterTests cover the underlying mechanism on both branches. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/LiveKit/Core/Room+TransportDelegate.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/LiveKit/Core/Room+TransportDelegate.swift b/Sources/LiveKit/Core/Room+TransportDelegate.swift index a8bff156f..1d4abd707 100644 --- a/Sources/LiveKit/Core/Room+TransportDelegate.swift +++ b/Sources/LiveKit/Core/Room+TransportDelegate.swift @@ -32,8 +32,10 @@ extension Room: TransportDelegate { func transport(_ transport: Transport, didUpdateState pcState: LKRTCPeerConnectionState) { log("target: \(transport.target), connectionState: \(pcState.description)") - let pcError = LiveKitError(.network, - message: "Transport \(transport.target) state changed to \(pcState.description)") + let isTearingDown = _state.connectionState == .disconnecting || _state.connectionState == .disconnected + let pcError: LiveKitError? = isTearingDown ? nil : LiveKitError( + .network, message: "Transport \(transport.target) state changed to \(pcState.description)" + ) // primary connected if transport.isPrimary { From 56d4a8b49df0b760916e848bbc68377d83b01cda 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, 7 May 2026 11:32:28 +0200 Subject: [PATCH 8/8] refactor(core): extract ConnectionState.isTearingDown helper Cleans up the gate added in aa995730 by reading the state once via a small computed property on the enum. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/LiveKit/Core/Room+TransportDelegate.swift | 3 +-- Sources/LiveKit/Types/ConnectionState.swift | 6 ++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/LiveKit/Core/Room+TransportDelegate.swift b/Sources/LiveKit/Core/Room+TransportDelegate.swift index 1d4abd707..064eb0026 100644 --- a/Sources/LiveKit/Core/Room+TransportDelegate.swift +++ b/Sources/LiveKit/Core/Room+TransportDelegate.swift @@ -32,8 +32,7 @@ extension Room: TransportDelegate { func transport(_ transport: Transport, didUpdateState pcState: LKRTCPeerConnectionState) { log("target: \(transport.target), connectionState: \(pcState.description)") - let isTearingDown = _state.connectionState == .disconnecting || _state.connectionState == .disconnected - let pcError: LiveKitError? = isTearingDown ? nil : LiveKitError( + let pcError: LiveKitError? = _state.connectionState.isTearingDown ? nil : LiveKitError( .network, message: "Transport \(transport.target) state changed to \(pcState.description)" ) diff --git a/Sources/LiveKit/Types/ConnectionState.swift b/Sources/LiveKit/Types/ConnectionState.swift index e2cf4f884..e41ca5da8 100644 --- a/Sources/LiveKit/Types/ConnectionState.swift +++ b/Sources/LiveKit/Types/ConnectionState.swift @@ -43,3 +43,9 @@ extension ConnectionState: Identifiable { rawValue } } + +extension ConnectionState { + var isTearingDown: Bool { + self == .disconnecting || self == .disconnected + } +}