Cut a camera capturer loose after a failed or timed-out open
An open that times out (a camera re-enumerating mid-open) can still finish later on the capturer's own queue and start delivering frames from the abandoned device into the call's video source, beside or instead of the fallback camera's. The failed capturer now loses its delegate, is stopped and is not reused; the next open gets a fresh one. Found in review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+29
−10
native/apple/Sources/CallsKit/Capture.swift+29 −10
CI passed — run details
diff --git a/native/apple/Sources/CallsKit/Capture.swift b/native/apple/Sources/CallsKit/Capture.swift| index a1d1388..a39a622 100644 |
| --- a/native/apple/Sources/CallsKit/Capture.swift |
| +++ b/native/apple/Sources/CallsKit/Capture.swift |
| @@ -508,6 +508,35 @@ public struct CameraFormatChoice: Equatable, Sendable { |
| // from the format's own range is set on the device afterwards. |
| let fps = max(1, Int(rate.rounded())) |
| let once = OSAllocatedUnfairLock(initialState: false) |
| + do { |
| + try await start(capture, device: device, format: format, fps: fps, once: once) |
| + } catch { |
| + // A failed or abandoned open may still finish later on the |
| + // capturer's own queue and start delivering frames from this |
| + // device. The capturer is cut loose — no delegate, stopped, not |
| + // reused — so those frames reach nobody, and the next open gets a |
| + // fresh one. |
| + capture.delegate = nil |
| + capture.stopCapture(completionHandler: {}) |
| + capturer = nil |
| + throw error |
| + } |
| + if Double(fps) != rate, |
| + let range = ranges.first(where: { $0.minFrameRate <= rate && rate <= $0.maxFrameRate }) |
| + { |
| + let duration = rate == range.maxFrameRate ? range.minFrameDuration : range.maxFrameDuration |
| + if (try? device.lockForConfiguration()) != nil { |
| + device.activeVideoMinFrameDuration = duration |
| + device.activeVideoMaxFrameDuration = duration |
| + device.unlockForConfiguration() |
| + } |
| + } |
| + } |
| + |
| + private func start( |
| + _ capture: RTCCameraVideoCapturer, device: AVCaptureDevice, format: AVCaptureDevice.Format, |
| + fps: Int, once: OSAllocatedUnfairLock<Bool> |
| + ) async throws { |
| try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in |
| let finish: @Sendable (Error?) -> Void = { error in |
| let first = once.withLock { done -> Bool in |
| @@ -524,16 +553,6 @@ public struct CameraFormatChoice: Equatable, Sendable { |
| domain: "Calls", code: 6, userInfo: [NSLocalizedDescriptionKey: "The camera did not start."])) |
| } |
| } |
| - if Double(fps) != rate, |
| - let range = ranges.first(where: { $0.minFrameRate <= rate && rate <= $0.maxFrameRate }) |
| - { |
| - let duration = rate == range.maxFrameRate ? range.minFrameDuration : range.maxFrameDuration |
| - if (try? device.lockForConfiguration()) != nil { |
| - device.activeVideoMinFrameDuration = duration |
| - device.activeVideoMaxFrameDuration = duration |
| - device.unlockForConfiguration() |
| - } |
| - } |
| } |
| private func makeCapturer(delegate: RTCVideoCapturerDelegate) -> RTCCameraVideoCapturer { |