From 551e9d49e590f51f94d97b3077b9a4ca1df07996 Mon Sep 17 00:00:00 2001 From: Ian Date: Wed, 17 Jun 2026 14:59:38 +0900 Subject: [PATCH 1/5] Wrap ObjC auto-async calls in explicit checked continuations `addIceCandidate` (Transport) and `startCapture` (CameraCapturer) are invoked through the compiler-synthesized async overloads of the completion-handler ObjC methods. That overload emits an *unsafe* continuation thunk whose symbol is keyed only by the imported signature, so it is shared across modules. When an app statically links/merges modules built in mixed Swift language modes (Swift 5 and Swift 6), the unsafe thunk emitted by a Swift-5 module wins symbol deduplication over the checked thunk this SDK (Swift 6) intends to use. At runtime these two resume paths are downgraded to unsafe, and under concurrent peer-connection negotiation the unsafe continuation corrupts the task allocator, producing EXC_BAD_ACCESS on connect (addIceCandidate) and camera start (startCapture). Calling the completion-handler API explicitly inside `withCheckedThrowingContinuation` means the shared auto-async thunk is never generated or referenced, so there is no symbol for dedup to collapse and the resume stays checked regardless of how consuming modules are built. This mirrors the explicit continuations already used for `setRemoteDescription` / `setLocalDescription` / `createOffer`. --- Sources/LiveKit/Core/Transport.swift | 7 ++++++- Sources/LiveKit/Track/Capturers/CameraCapturer.swift | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/LiveKit/Core/Transport.swift b/Sources/LiveKit/Core/Transport.swift index c00731416..aca791a0f 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 + _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..21177298e 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 { From ec27d0486ff3032f62c2c4948b516337068aa4be Mon Sep 17 00:00:00 2001 From: Ian Date: Wed, 17 Jun 2026 15:29:12 +0900 Subject: [PATCH 2/5] Use explicit self for _pc inside nested continuation closure The addIceCandidate site lives inside the `_iceCandidatesQueue` `[weak self]` closure. Moving the call into a nested withCheckedThrowingContinuation closure requires explicit `self.` to make capture semantics explicit under the Swift 6 language mode. --- Sources/LiveKit/Core/Transport.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/LiveKit/Core/Transport.swift b/Sources/LiveKit/Core/Transport.swift index aca791a0f..ff53bbd72 100644 --- a/Sources/LiveKit/Core/Transport.swift +++ b/Sources/LiveKit/Core/Transport.swift @@ -70,7 +70,7 @@ actor Transport: NSObject, Loggable { do { let rtcCandidate = iceCandidate.toRTCType() try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - _pc.add(rtcCandidate) { error in + self._pc.add(rtcCandidate) { error in if let error { continuation.resume(throwing: error) } else { continuation.resume() } } } From 77425c48d79c158f5f2802d5e7489fd09959d73c Mon Sep 17 00:00:00 2001 From: Ian Date: Thu, 18 Jun 2026 13:49:26 +0900 Subject: [PATCH 3/5] Wrap remaining ObjC auto-bridge sites; add AGENTS rule + changeset Address review on #1044: cover all imported ObjC completion-handler async-overload call sites, not just the two WebRTC ones. - CameraCapturer.stopCapture -> capturer.stopCapture (void completion) - MacOSScreenCapturer.startCapture -> stream.startCapture - MacOSScreenCapturer.stopCapture -> stream.stopCapture - MacOSScreenCapturer.sources -> SCShareableContent.getExcludingDesktopWindows - InAppCapturer.startCapture -> RPScreenRecorder.startCapture(handler:completionHandler:) Each now calls the completion-handler API inside an explicit checked continuation so the synthesized async-bridge thunk is never emitted. Also add the AGENTS.md 'Concurrency and State' rule and a .changes/objc-async-checked-continuation entry. --- .changes/objc-async-checked-continuation | 1 + AGENTS.md | 1 + .../Track/Capturers/CameraCapturer.swift | 6 ++++- .../Track/Capturers/InAppCapturer.swift | 14 +++++++---- .../Track/Capturers/MacOSScreenCapturer.swift | 24 ++++++++++++++++--- 5 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 .changes/objc-async-checked-continuation 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/Track/Capturers/CameraCapturer.swift b/Sources/LiveKit/Track/Capturers/CameraCapturer.swift index 21177298e..118e8f259 100644 --- a/Sources/LiveKit/Track/Capturers/CameraCapturer.swift +++ b/Sources/LiveKit/Track/Capturers/CameraCapturer.swift @@ -309,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() } } } diff --git a/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift b/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift index 685f3b94e..79f799536 100644 --- a/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift +++ b/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift @@ -118,7 +118,11 @@ public class MacOSScreenCapturer: VideoCapturer, @unchecked Sendable { if #available(macOS 13.0, *) { try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: nil) } - try await stream.startCapture() + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + stream.startCapture { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } _screenCapturerState.mutate { $0.scStream = stream } @@ -140,7 +144,11 @@ public class MacOSScreenCapturer: VideoCapturer, @unchecked Sendable { $0.resendTimer = nil } - try await stream.stopCapture() + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + stream.stopCapture { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } try stream.removeStreamOutput(self, type: .screen) _screenCapturerState.mutate { @@ -449,7 +457,17 @@ public extension MacOSScreenCapturer { /// Enumerate ``MacOSDisplay`` or ``MacOSWindow`` sources. @objc static func sources(for type: MacOSScreenShareSourceType, includeCurrentApplication: Bool = false) async throws -> [MacOSScreenCaptureSource] { - let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + let content = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: true) { content, error in + if let error { + continuation.resume(throwing: error) + } else if let content { + continuation.resume(returning: content) + } else { + continuation.resume(throwing: LiveKitError(.invalidState, message: "SCShareableContent unavailable")) + } + } + } let displays = content.displays.map { MacOSDisplay(from: $0, content: content) } let windows = content.windows // remove windows from this app From 21b81acb800d4fa07ab3a3b3b2ecd5780126e6d4 Mon Sep 17 00:00:00 2001 From: Ian Date: Thu, 18 Jun 2026 14:05:49 +0900 Subject: [PATCH 4/5] Keep getExcludingDesktopWindows on the async overload The synthesized async overload returns a non-Sendable SCShareableContent; wrapping it in a manual continuation surfaces a region-isolation 'sending content risks data races' error (the compiler bridge handles this), and the extra lines push sources(for:) past SwiftLint's function_body_length. It is a cold macOS enumeration path, so leave it on the async overload for now. The error-only and void macOS/ReplayKit bridges remain wrapped. --- .../Track/Capturers/MacOSScreenCapturer.swift | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift b/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift index 79f799536..07a1287d0 100644 --- a/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift +++ b/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift @@ -457,17 +457,7 @@ public extension MacOSScreenCapturer { /// Enumerate ``MacOSDisplay`` or ``MacOSWindow`` sources. @objc static func sources(for type: MacOSScreenShareSourceType, includeCurrentApplication: Bool = false) async throws -> [MacOSScreenCaptureSource] { - let content = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: true) { content, error in - if let error { - continuation.resume(throwing: error) - } else if let content { - continuation.resume(returning: content) - } else { - continuation.resume(throwing: LiveKitError(.invalidState, message: "SCShareableContent unavailable")) - } - } - } + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) let displays = content.displays.map { MacOSDisplay(from: $0, content: content) } let windows = content.windows // remove windows from this app From 0c04afc7c072076536f7a7a698c9ef50383aaa4b Mon Sep 17 00:00:00 2001 From: Ian Date: Thu, 18 Jun 2026 14:11:16 +0900 Subject: [PATCH 5/5] Defer all MacOSScreenCapturer bridge sites to fast-follow Reverting MacOSScreenCapturer to pristine. Two macOS-only frictions: - getExcludingDesktopWindows returns non-Sendable SCShareableContent; a manual continuation trips region-isolation 'sending' checks. - Wrapping stream.startCapture tips the already-long startCapture() past SwiftLint's function_body_length (54 > 50). These are cold macOS screen-capture paths. Keeping the WebRTC + camera + in-app (ReplayKit) sites wrapped here; the macOS SCStream / SCShareableContent sites can come as a focused fast-follow. --- .../Track/Capturers/MacOSScreenCapturer.swift | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift b/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift index 07a1287d0..685f3b94e 100644 --- a/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift +++ b/Sources/LiveKit/Track/Capturers/MacOSScreenCapturer.swift @@ -118,11 +118,7 @@ public class MacOSScreenCapturer: VideoCapturer, @unchecked Sendable { if #available(macOS 13.0, *) { try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: nil) } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - stream.startCapture { error in - if let error { continuation.resume(throwing: error) } else { continuation.resume() } - } - } + try await stream.startCapture() _screenCapturerState.mutate { $0.scStream = stream } @@ -144,11 +140,7 @@ public class MacOSScreenCapturer: VideoCapturer, @unchecked Sendable { $0.resendTimer = nil } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - stream.stopCapture { error in - if let error { continuation.resume(throwing: error) } else { continuation.resume() } - } - } + try await stream.stopCapture() try stream.removeStreamOutput(self, type: .screen) _screenCapturerState.mutate {