From 98979c9f485eb8b031a122730163dde4affd8525 Mon Sep 17 00:00:00 2001 From: Ethan Millstein Date: Sat, 18 Oct 2025 13:38:55 -0700 Subject: [PATCH 1/5] feat(shutdown): implement graceful shutdown for camera session - Add shutdown method to stop all outputs and release resources - Schedule shutdown with warning if it takes longer than expected --- .../Core/CameraSession+Configuration.swift | 6 +++ package/ios/Core/CameraSession.swift | 37 ++++++++++++++++++- package/ios/React/CameraView.swift | 32 ++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/package/ios/Core/CameraSession+Configuration.swift b/package/ios/Core/CameraSession+Configuration.swift index ecd2a94daf..dde1017630 100644 --- a/package/ios/Core/CameraSession+Configuration.swift +++ b/package/ios/Core/CameraSession+Configuration.swift @@ -61,6 +61,12 @@ extension CameraSession { // Remove all outputs for output in captureSession.outputs { + if let metadataOutput = output as? AVCaptureMetadataOutput { + metadataOutput.setMetadataObjectsDelegate(nil, queue: nil) + } + if let videoOutput = output as? AVCaptureVideoDataOutput { + videoOutput.setSampleBufferDelegate(nil, queue: nil) + } captureSession.removeOutput(output) } photoOutput = nil diff --git a/package/ios/Core/CameraSession.swift b/package/ios/Core/CameraSession.swift index 10b0f3399c..fab5c2c0bb 100644 --- a/package/ios/Core/CameraSession.swift +++ b/package/ios/Core/CameraSession.swift @@ -83,6 +83,18 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance) + let cameraCaptureSession = captureSession + CameraQueues.cameraQueue.async { + if cameraCaptureSession.isRunning { + cameraCaptureSession.stopRunning() + } + } + let cameraAudioSession = audioCaptureSession + CameraQueues.audioQueue.async { + if cameraAudioSession.isRunning { + cameraAudioSession.stopRunning() + } + } } /** @@ -108,11 +120,14 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat Any changes in here will be re-configured only if required, and under a lock (in this case, the serial cameraQueue DispatchQueue). The `configuration` object is a copy of the currently active configuration that can be modified by the caller in the lambda. */ - func configure(_ lambda: @escaping (_ configuration: CameraConfiguration) throws -> Void) { + func configure(_ lambda: @escaping (_ configuration: CameraConfiguration) throws -> Void, + completion: (() -> Void)? = nil) { initialize() VisionLogger.log(level: .info, message: "configure { ... }: Waiting for lock...") + let completionBlock = completion + // Set up Camera (Video) Capture Session (on camera queue, acts like a lock) CameraQueues.cameraQueue.async { // Let caller configure a new configuration for the Camera. @@ -121,10 +136,12 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat try lambda(config) } catch CameraConfiguration.AbortThrow.abort { // call has been aborted and changes shall be discarded + completionBlock?() return } catch { // another error occured, possibly while trying to parse enums self.onConfigureError(error) + completionBlock?() return } let difference = CameraConfiguration.Difference(between: self.configuration, and: config) @@ -244,9 +261,25 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat } } } + completionBlock?() } } + /** + Gracefully stop streaming and tear down any active outputs. Completion executes on the camera queue. + */ + func shutdown(completion: (() -> Void)? = nil) { + configure({ config in + config.photo = .disabled + config.video = .disabled + config.audio = .disabled + config.codeScanner = .disabled + config.enableLocation = false + config.torch = .off + config.isActive = false + }, completion: completion) + } + /** Starts or stops the CaptureSession if needed (`isActive`) */ @@ -265,7 +298,7 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat } } - public final func captureOutput(_ captureOutput: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + final func captureOutput(_ captureOutput: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { switch captureOutput { case is AVCaptureVideoDataOutput: onVideoFrame(sampleBuffer: sampleBuffer, orientation: connection.orientation, isMirrored: connection.isVideoMirrored) diff --git a/package/ios/React/CameraView.swift b/package/ios/React/CameraView.swift index c773975353..0826cd48ab 100644 --- a/package/ios/React/CameraView.swift +++ b/package/ios/React/CameraView.swift @@ -103,6 +103,7 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat var isMounted = false private var currentConfigureCall: DispatchTime? private let fpsSampleCollector = FpsSampleCollector() + private var didScheduleShutdown = false // CameraView+Zoom var pinchGestureRecognizer: UIPinchGestureRecognizer? @@ -129,16 +130,22 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat super.willMove(toSuperview: newSuperview) if newSuperview != nil { + didScheduleShutdown = false fpsSampleCollector.start() if !isMounted { isMounted = true onViewReadyEvent?(nil) } } else { + shutdownCameraSession() fpsSampleCollector.stop() } } + deinit { + shutdownCameraSession() + } + override public func layoutSubviews() { if let previewView { previewView.frame = frame @@ -283,6 +290,31 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat UIApplication.shared.isIdleTimerDisabled = isActive } + private func shutdownCameraSession() { + if didScheduleShutdown { + return + } + didScheduleShutdown = true + + UIApplication.shared.isIdleTimerDisabled = false + + #if VISION_CAMERA_ENABLE_FRAME_PROCESSORS + frameProcessor = nil + #endif + + let slowShutdownWarning = DispatchWorkItem { + VisionLogger.log(level: .warning, message: "CameraSession shutdown is still running after 2 seconds.") + } + CameraQueues.cameraQueue.asyncAfter(deadline: .now() + .seconds(2), execute: slowShutdownWarning) + + cameraSession.shutdown { [weak self] in + slowShutdownWarning.cancel() + DispatchQueue.main.async { + self?.didScheduleShutdown = false + } + } + } + func updatePreview() { if preview && previewView == nil { // Create PreviewView and add it From 457fb9d218c957f9a23c54ea4ea1e3624af8075f Mon Sep 17 00:00:00 2001 From: Doug Russell Date: Thu, 6 Nov 2025 17:27:46 -0500 Subject: [PATCH 2/5] Instead of introducing a new shutdown method revise use of configure * Cleaning up capture sessions in deinit likely indicates some bookkeeping isn't completing and is likely to obscure rather than fix bugs * Adding a completion handler to config adds a lot of complexity we can avoid and replace with a more correct DispatchGroup (the group doesn't notify until any async audio and location work complete) * Switch existing DispatchTime usage for dropping redundant config calls to a simple monotonic counter that uses an unfair lock * remove didScheduleShutdown as deactivateCameraSession() is safe to call repeatedly --- package/ios/Core/CameraSession.swift | 51 ++++++++++----------------- package/ios/React/CameraView.swift | 50 ++++++++++++++------------ package/ios/React/Utils/Counter.swift | 34 ++++++++++++++++++ 3 files changed, 79 insertions(+), 56 deletions(-) create mode 100644 package/ios/React/Utils/Counter.swift diff --git a/package/ios/Core/CameraSession.swift b/package/ios/Core/CameraSession.swift index fab5c2c0bb..0e1d6fd415 100644 --- a/package/ios/Core/CameraSession.swift +++ b/package/ios/Core/CameraSession.swift @@ -83,18 +83,6 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance) - let cameraCaptureSession = captureSession - CameraQueues.cameraQueue.async { - if cameraCaptureSession.isRunning { - cameraCaptureSession.stopRunning() - } - } - let cameraAudioSession = audioCaptureSession - CameraQueues.audioQueue.async { - if cameraAudioSession.isRunning { - cameraAudioSession.stopRunning() - } - } } /** @@ -120,28 +108,37 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat Any changes in here will be re-configured only if required, and under a lock (in this case, the serial cameraQueue DispatchQueue). The `configuration` object is a copy of the currently active configuration that can be modified by the caller in the lambda. */ - func configure(_ lambda: @escaping (_ configuration: CameraConfiguration) throws -> Void, - completion: (() -> Void)? = nil) { + func configure(_ lambda: @escaping (_ configuration: CameraConfiguration) throws -> Void) { initialize() VisionLogger.log(level: .info, message: "configure { ... }: Waiting for lock...") - let completionBlock = completion + let slowConfigurationWarning = DispatchWorkItem { + VisionLogger.log(level: .warning, message: "configure { ... }: is still running after 2 seconds.") + } + CameraQueues.cameraQueue.asyncAfter(deadline: .now() + .seconds(2), execute: slowConfigurationWarning) + + let completionGroup = DispatchGroup() + + completionGroup.enter() + completionGroup.notify(queue: CameraQueues.cameraQueue) { + slowConfigurationWarning.cancel() + VisionLogger.log(level: .info, message: "configure { ... }: completed.") + } // Set up Camera (Video) Capture Session (on camera queue, acts like a lock) CameraQueues.cameraQueue.async { + defer { completionGroup.leave() } // Let caller configure a new configuration for the Camera. let config = CameraConfiguration(copyOf: self.configuration) do { try lambda(config) } catch CameraConfiguration.AbortThrow.abort { // call has been aborted and changes shall be discarded - completionBlock?() return } catch { // another error occured, possibly while trying to parse enums self.onConfigureError(error) - completionBlock?() return } let difference = CameraConfiguration.Difference(between: self.configuration, and: config) @@ -232,7 +229,9 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat // Set up Audio Capture Session (on audio queue) if difference.audioSessionChanged { + completionGroup.enter() CameraQueues.audioQueue.async { + defer { completionGroup.leave() } do { // Lock Capture Session for configuration VisionLogger.log(level: .info, message: "Beginning AudioSession configuration...") @@ -251,7 +250,9 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat // Set up Location streaming (on location queue) if difference.locationChanged { + completionGroup.enter() CameraQueues.locationQueue.async { + defer { completionGroup.leave() } do { VisionLogger.log(level: .info, message: "Beginning Location Output configuration...") try self.configureLocationOutput(configuration: config) @@ -261,25 +262,9 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat } } } - completionBlock?() } } - /** - Gracefully stop streaming and tear down any active outputs. Completion executes on the camera queue. - */ - func shutdown(completion: (() -> Void)? = nil) { - configure({ config in - config.photo = .disabled - config.video = .disabled - config.audio = .disabled - config.codeScanner = .disabled - config.enableLocation = false - config.torch = .off - config.isActive = false - }, completion: completion) - } - /** Starts or stops the CaptureSession if needed (`isActive`) */ diff --git a/package/ios/React/CameraView.swift b/package/ios/React/CameraView.swift index 0826cd48ab..aa49e85e85 100644 --- a/package/ios/React/CameraView.swift +++ b/package/ios/React/CameraView.swift @@ -101,9 +101,8 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat var cameraSession = CameraSession() var previewView: PreviewView? var isMounted = false - private var currentConfigureCall: DispatchTime? + private let currentConfigureCall: Counter = .init() private let fpsSampleCollector = FpsSampleCollector() - private var didScheduleShutdown = false // CameraView+Zoom var pinchGestureRecognizer: UIPinchGestureRecognizer? @@ -130,20 +129,19 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat super.willMove(toSuperview: newSuperview) if newSuperview != nil { - didScheduleShutdown = false fpsSampleCollector.start() if !isMounted { isMounted = true onViewReadyEvent?(nil) } } else { - shutdownCameraSession() + deactivateCameraSession() fpsSampleCollector.stop() } } deinit { - shutdownCameraSession() + deactivateCameraSession() } override public func layoutSubviews() { @@ -188,17 +186,18 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat // pragma MARK: Props updating override public final func didSetProps(_ changedProps: [String]!) { VisionLogger.log(level: .info, message: "Updating \(changedProps.count) props: [\(changedProps.joined(separator: ", "))]") - let now = DispatchTime.now() - currentConfigureCall = now + let currentConfigureCall = self.currentConfigureCall + let now = currentConfigureCall.increment() cameraSession.configure { [self] config in // Check if we're still the latest call to configure { ... } - guard currentConfigureCall == now else { + guard currentConfigureCall.check(now) else { // configure waits for a lock, and if a new call to update() happens in the meantime we can drop this one. // this works similar to how React implemented concurrent rendering, the newer call to update() has higher priority. - VisionLogger.log(level: .info, message: "A new configure { ... } call arrived, aborting this one...") + VisionLogger.log(level: .info, message: "A new configure { ... } call arrived, aborting this one [\(now)]…") throw CameraConfiguration.AbortThrow.abort } + VisionLogger.log(level: .info, message: "configure { ... } [\(now)]") // Input Camera Device config.cameraId = cameraId as? String @@ -290,28 +289,33 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat UIApplication.shared.isIdleTimerDisabled = isActive } - private func shutdownCameraSession() { - if didScheduleShutdown { - return - } - didScheduleShutdown = true - + private func deactivateCameraSession() { + // Allow phone to sleep UIApplication.shared.isIdleTimerDisabled = false #if VISION_CAMERA_ENABLE_FRAME_PROCESSORS frameProcessor = nil #endif - let slowShutdownWarning = DispatchWorkItem { - VisionLogger.log(level: .warning, message: "CameraSession shutdown is still running after 2 seconds.") - } - CameraQueues.cameraQueue.asyncAfter(deadline: .now() + .seconds(2), execute: slowShutdownWarning) + let currentConfigureCall = self.currentConfigureCall + let now = currentConfigureCall.increment() - cameraSession.shutdown { [weak self] in - slowShutdownWarning.cancel() - DispatchQueue.main.async { - self?.didScheduleShutdown = false + cameraSession.configure { config in + // Check if we're still the latest call to configure { ... } + guard currentConfigureCall.check(now) else { + // configure waits for a lock, and if a new call to update() happens in the meantime we can drop this one. + // this works similar to how React implemented concurrent rendering, the newer call to update() has higher priority. + VisionLogger.log(level: .info, message: "A new configure { ... } call arrived, aborting this one [\(now)]…") + throw CameraConfiguration.AbortThrow.abort } + VisionLogger.log(level: .info, message: "configure { ... } [\(now)]") + config.photo = .disabled + config.video = .disabled + config.audio = .disabled + config.codeScanner = .disabled + config.enableLocation = false + config.torch = .off + config.isActive = false } } diff --git a/package/ios/React/Utils/Counter.swift b/package/ios/React/Utils/Counter.swift new file mode 100644 index 0000000000..35dd9317d3 --- /dev/null +++ b/package/ios/React/Utils/Counter.swift @@ -0,0 +1,34 @@ +import os.lock + +final class Counter { + /** + * https://forums.swift.org/t/atomic-property-wrapper-for-standard-library/30468/18 + */ + private var unfair_lock: os_unfair_lock_t + private var count = 1 + init() { + unfair_lock = .allocate(capacity: 1) + unfair_lock.initialize(to: os_unfair_lock()) + } + deinit { + unfair_lock.deinitialize(count: 1) + unfair_lock.deallocate() + } + private func lock() { + os_unfair_lock_lock(unfair_lock) + } + private func unlock() { + os_unfair_lock_unlock(unfair_lock) + } + func increment() -> Int { + lock() + defer { unlock() } + count &+= 1 + return count + } + func check(_ count: Int) -> Bool { + lock() + defer { unlock() } + return self.count == count + } +} From b2f9089af7faa755e4b2c18c4fef2a873dad8514 Mon Sep 17 00:00:00 2001 From: Doug Russell Date: Tue, 11 Nov 2025 15:58:04 -0500 Subject: [PATCH 3/5] Drop async work from CameraView deinit --- package/ios/React/CameraView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package/ios/React/CameraView.swift b/package/ios/React/CameraView.swift index aa49e85e85..0be6e4731d 100644 --- a/package/ios/React/CameraView.swift +++ b/package/ios/React/CameraView.swift @@ -141,7 +141,10 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat } deinit { - deactivateCameraSession() + // Allow phone to sleep + UIApplication.shared.isIdleTimerDisabled = false + // Bail on any in flight configure calls + currentConfigureCall.increment() } override public func layoutSubviews() { From eb07d6a2808a7fb1d3b15be849de4c4f6ceb450a Mon Sep 17 00:00:00 2001 From: Doug Russell Date: Tue, 11 Nov 2025 15:58:16 -0500 Subject: [PATCH 4/5] Detailed comment to justify cleaning up output delegate --- package/ios/Core/CameraSession+Configuration.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/package/ios/Core/CameraSession+Configuration.swift b/package/ios/Core/CameraSession+Configuration.swift index dde1017630..5209e86704 100644 --- a/package/ios/Core/CameraSession+Configuration.swift +++ b/package/ios/Core/CameraSession+Configuration.swift @@ -61,6 +61,15 @@ extension CameraSession { // Remove all outputs for output in captureSession.outputs { + // The following delegates are weak references + // so cleaning them up is not strictly necessary + // from a safety standpoint. + // It is good to mark that between this point + // and logic further down configureOutput() + // messages to delegate would be in an undefined + // state and with delegate == self the weak + // references wouldn't be cleared during that + // window. if let metadataOutput = output as? AVCaptureMetadataOutput { metadataOutput.setMetadataObjectsDelegate(nil, queue: nil) } From 2a0bf9a07fccf7b5b4958d1bae2fe3e080fab410 Mon Sep 17 00:00:00 2001 From: Doug Russell Date: Wed, 12 Nov 2025 13:05:37 -0500 Subject: [PATCH 5/5] Remove unneeded delegate cleanup --- .../ios/Core/CameraSession+Configuration.swift | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/package/ios/Core/CameraSession+Configuration.swift b/package/ios/Core/CameraSession+Configuration.swift index 5209e86704..ecd2a94daf 100644 --- a/package/ios/Core/CameraSession+Configuration.swift +++ b/package/ios/Core/CameraSession+Configuration.swift @@ -61,21 +61,6 @@ extension CameraSession { // Remove all outputs for output in captureSession.outputs { - // The following delegates are weak references - // so cleaning them up is not strictly necessary - // from a safety standpoint. - // It is good to mark that between this point - // and logic further down configureOutput() - // messages to delegate would be in an undefined - // state and with delegate == self the weak - // references wouldn't be cleared during that - // window. - if let metadataOutput = output as? AVCaptureMetadataOutput { - metadataOutput.setMetadataObjectsDelegate(nil, queue: nil) - } - if let videoOutput = output as? AVCaptureVideoDataOutput { - videoOutput.setSampleBufferDelegate(nil, queue: nil) - } captureSession.removeOutput(output) } photoOutput = nil