diff --git a/.changes/objc-async-checked-continuation b/.changes/objc-async-checked-continuation new file mode 100644 index 000000000..96641926e --- /dev/null +++ b/.changes/objc-async-checked-continuation @@ -0,0 +1 @@ +patch type="fixed" "Wrap ObjC completion-handler async calls in explicit checked continuations (fixes mixed Swift 5/6 continuation-bridge EXC_BAD_ACCESS)" diff --git a/AGENTS.md b/AGENTS.md index d8e46e49f..768aa0511 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,7 @@ private static let playAndRecordOptions: AVAudioSession.CategoryOptions = [.mixW - Only `Task.discarding` preserves caller actor isolation in its closure; other `Support/Async/` helpers (`Task.retrying`, `AsyncSerialDelegate.notifyAsync`, etc.) run their bodies nonisolated — hop explicitly with `await MainActor.run { ... }` when UI-bound work is needed - Use async primitives in `Support/Async` and `Support/Schedulers` when operation order matters - Prefer native Swift async/await over `Combine` for new code +- Until the minimum supported compiler is Swift 6.3, wrap calls to imported Objective-C completion-handler methods made via their synthesized `async` overload in an explicit `withCheckedThrowingContinuation`, since the bare auto-bridge hits a mixed Swift 5/6 thunk-coalescing crash (swiftlang/swift#81846) fixed in 6.3 ### Error Handling diff --git a/Sources/LiveKit/Core/Transport.swift b/Sources/LiveKit/Core/Transport.swift index c00731416..ff53bbd72 100644 --- a/Sources/LiveKit/Core/Transport.swift +++ b/Sources/LiveKit/Core/Transport.swift @@ -68,7 +68,12 @@ actor Transport: NSObject, Loggable { guard let self else { return } do { - try await _pc.add(iceCandidate.toRTCType()) + let rtcCandidate = iceCandidate.toRTCType() + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self._pc.add(rtcCandidate) { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } } catch { log("Failed to add(iceCandidate:) with error: \(error)", .error) } diff --git a/Sources/LiveKit/Track/Capturers/CameraCapturer.swift b/Sources/LiveKit/Track/Capturers/CameraCapturer.swift index f15b351d1..118e8f259 100644 --- a/Sources/LiveKit/Track/Capturers/CameraCapturer.swift +++ b/Sources/LiveKit/Track/Capturers/CameraCapturer.swift @@ -285,7 +285,11 @@ public class CameraCapturer: VideoCapturer, @unchecked Sendable { log("starting camera capturer device: \(device), format: \(selectedFormat), fps: \(selectedFps)(\(fpsRange))", .info) - try await capturer.startCapture(with: device, format: selectedFormat.format, fps: selectedFps) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + capturer.startCapture(with: device, format: selectedFormat.format, fps: selectedFps) { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } // Update internal vars _cameraCapturerState.mutate { @@ -305,7 +309,11 @@ public class CameraCapturer: VideoCapturer, @unchecked Sendable { // Already stopped guard didStop else { return false } - await capturer.stopCapture() + await withCheckedContinuation { (continuation: CheckedContinuation) in + capturer.stopCapture { + continuation.resume() + } + } // Update internal vars set(dimensions: nil) diff --git a/Sources/LiveKit/Track/Capturers/InAppCapturer.swift b/Sources/LiveKit/Track/Capturers/InAppCapturer.swift index 40f095d8d..8e0aad6fa 100644 --- a/Sources/LiveKit/Track/Capturers/InAppCapturer.swift +++ b/Sources/LiveKit/Track/Capturers/InAppCapturer.swift @@ -39,11 +39,15 @@ public class InAppScreenCapturer: VideoCapturer, @unchecked Sendable { guard didStart else { return false } // TODO: force pixel format kCVPixelFormatType_420YpCbCr8BiPlanarFullRange - try await RPScreenRecorder.shared().startCapture { [weak self] sampleBuffer, type, _ in - guard let self else { return } - // Only process .video - if type == .video { - capture(sampleBuffer: sampleBuffer, capturer: capturer, options: options) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + RPScreenRecorder.shared().startCapture { [weak self] sampleBuffer, type, _ in + guard let self else { return } + // Only process .video + if type == .video { + capture(sampleBuffer: sampleBuffer, capturer: capturer, options: options) + } + } completionHandler: { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } } }