From 98979c9f485eb8b031a122730163dde4affd8525 Mon Sep 17 00:00:00 2001 From: Ethan Millstein Date: Sat, 18 Oct 2025 13:38:55 -0700 Subject: [PATCH 1/3] 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 f4b06eea5d64f3efd0399649a63a53ecd1d2cd96 Mon Sep 17 00:00:00 2001 From: Ethan Millstein Date: Sun, 23 Nov 2025 09:27:16 -0700 Subject: [PATCH 2/3] Rename slowShutdownWarning to logSlowShutdownWarning Co-authored-by: Marc Rousavy --- package/ios/React/CameraView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package/ios/React/CameraView.swift b/package/ios/React/CameraView.swift index 0826cd48ab..21fbb6aec1 100644 --- a/package/ios/React/CameraView.swift +++ b/package/ios/React/CameraView.swift @@ -302,13 +302,13 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat frameProcessor = nil #endif - let slowShutdownWarning = DispatchWorkItem { + let logSlowShutdownWarning = DispatchWorkItem { VisionLogger.log(level: .warning, message: "CameraSession shutdown is still running after 2 seconds.") } - CameraQueues.cameraQueue.asyncAfter(deadline: .now() + .seconds(2), execute: slowShutdownWarning) + CameraQueues.cameraQueue.asyncAfter(deadline: .now() + .seconds(2), execute: logSlowShutdownWarning) cameraSession.shutdown { [weak self] in - slowShutdownWarning.cancel() + logSlowShutdownWarning.cancel() DispatchQueue.main.async { self?.didScheduleShutdown = false } From d3121cad5cf835b26b2d60dc2aba73917d00efb1 Mon Sep 17 00:00:00 2001 From: Ethan Millstein Date: Sun, 23 Nov 2025 09:31:49 -0700 Subject: [PATCH 3/3] fix(shutdown): resolve shutdown freeze issue on iOS 26 - Remove unnecessary delegate assignments before removing outputs - Ensure completion block is called in all error scenarios during configuration --- package/ios/Core/CameraSession+Configuration.swift | 6 ------ package/ios/Core/CameraSession.swift | 7 ++++--- package/ios/React/CameraView.swift | 4 ---- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/package/ios/Core/CameraSession+Configuration.swift b/package/ios/Core/CameraSession+Configuration.swift index dde1017630..ecd2a94daf 100644 --- a/package/ios/Core/CameraSession+Configuration.swift +++ b/package/ios/Core/CameraSession+Configuration.swift @@ -61,12 +61,6 @@ 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 fab5c2c0bb..6e7463fdfa 100644 --- a/package/ios/Core/CameraSession.swift +++ b/package/ios/Core/CameraSession.swift @@ -130,18 +130,20 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat // Set up Camera (Video) Capture Session (on camera queue, acts like a lock) CameraQueues.cameraQueue.async { + defer { + completionBlock?() + } + // 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) @@ -261,7 +263,6 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat } } } - completionBlock?() } } diff --git a/package/ios/React/CameraView.swift b/package/ios/React/CameraView.swift index 21fbb6aec1..04637c2938 100644 --- a/package/ios/React/CameraView.swift +++ b/package/ios/React/CameraView.swift @@ -142,10 +142,6 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat } } - deinit { - shutdownCameraSession() - } - override public func layoutSubviews() { if let previewView { previewView.frame = frame