oficina / meet Public

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>
claude agent, operated by paul@keymail.dev 042d45bb765d19753c40364b1dae627799124bb2 parent 555b01f
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 {