diff --git a/patches/react-native-vision-camera/details.md b/patches/react-native-vision-camera/details.md index 0da1260fc1a9..2152b6fb0dc7 100644 --- a/patches/react-native-vision-camera/details.md +++ b/patches/react-native-vision-camera/details.md @@ -1,13 +1,28 @@ # `react-native-vision-camera` patches -### [react-native-vision-camera+4.7.2+001+fix-main-thread-hang-on-ios.patch](react-native-vision-camera+4.7.2+001+fix-main-thread-hang-on-ios.patch) +### [react-native-vision-camera+4.7.2+001+fix-ios-camera-teardown-hang-and-config-leak.patch](react-native-vision-camera+4.7.2+001+fix-ios-camera-teardown-hang-and-config-leak.patch) + +This patch carries two related iOS fixes to `CameraSession.configure`. The second addresses a crash that the first one exposed. + +- Reason (1) — **main-thread hangs when leaving a camera screen**: VisionCamera could deallocate its preview while `AVCaptureSession` was still busy configuring, causing the main thread to wait on the capture-session lock. -- Reason: Fixes iOS main-thread hangs when leaving a screen that uses the camera. VisionCamera could deallocate its preview while `AVCaptureSession` was still busy configuring, causing the main thread to wait on the capture-session lock. - **How it works**: Deactivates camera resources before teardown, uses a thread-safe counter to cancel stale configuration calls, and adds logging for slow or completed configurations. - **Upstream source**: Ports the three-file solution from [react-native-vision-camera PR #3664](https://github.com/mrousavy/react-native-vision-camera/pull/3664). The library's creator and maintainer reviewed that PR, and the author revised it in response to the feedback. It was never merged and was ultimately closed as obsolete after VisionCamera V5 rewrote the upstream codebase. - **Local correction**: Omits the upstream counter increment from `CameraView.deinit`. Normal configuration closures retain `CameraView` and therefore prevent `deinit`, while the teardown closure does not. Incrementing the counter from `deinit` could consequently invalidate and skip the queued teardown that disables the active camera session. - **Why it was patched locally**: As explained in the [decision to patch locally](https://github.com/Expensify/App/issues/91293#issuecomment-4917304380), this was a known bug in the VisionCamera version used when the patch was introduced. At that time, upgrading to V5 was not a targeted alternative because it was a major rewrite that was still receiving similar reports, so porting the reviewed fix to the existing version was the lower-risk approach. -- Upstream PR/issue: https://github.com/mrousavy/react-native-vision-camera/issues/3636, https://github.com/mrousavy/react-native-vision-camera/pull/3664 -- E/App issue: https://github.com/Expensify/App/issues/91293 -- PR introducing patch: https://github.com/Expensify/App/pull/95984 +- Reason (2) — **`NSGenericException` from an unbalanced capture-session configuration**: `-[AVCaptureSession stopRunning] stopRunning may not be called between calls to beginConfiguration and commitConfiguration`, thrown from `CameraSession.checkIsActive` on the camera queue. + + - **How it works**: `configure` calls `beginConfiguration()` inside one `if difference.isSessionConfigurationDirty` block and `commitConfiguration()` inside a second block further down, so that input, output and device changes batch into a single hardware update. Any throw between the two — `configureDevice`, `configureOutputs`, the `noDevice` guard, or any of the device-configuration steps that follow it — skipped the commit and left the session's configuration counter permanently held. Every later `startRunning()` or `stopRunning()` on that session then threw, for the remainder of its lifetime. The patch tracks whether the configuration was opened and commits it on the error path, preserving the existing batching on the success path. + - **Relationship to fix 1**: The defect is upstream and predates this patch. Fix 1 added `deactivateCameraSession()`, which issues a `configure` call during teardown that reaches `checkIsActive` — turning a previously latent leak into a crash on the way out of the camera screen. Closing the upstream hole is preferred to reverting fix 1, which would reintroduce the hangs while leaving the leak in place. + - **Scope**: Video capture session only. The audio block in the same function has the identical defect, but it is unreachable in this app: `audio` defaults to `false` natively, no call site enables it, `startRecording` is never called, and every throw site in `configureAudioSession` sits behind `if enableAudio`. Revisit if either of those changes. + +- Upstream PR/issue: + - Fix 1: https://github.com/mrousavy/react-native-vision-camera/issues/3636, https://github.com/mrousavy/react-native-vision-camera/pull/3664 + - Fix 2: N/A. The defect is real upstream, but `4.7.2` belongs to the V4 line, which is no longer supported — VisionCamera V5 rewrote this code, so a fix against V4 would not be accepted and a report against the rewritten V5 code would not describe the same source. This is the same situation that closed [PR #3664](https://github.com/mrousavy/react-native-vision-camera/pull/3664) as obsolete. The patch will be dropped if and when we move to V5. +- E/App issue: + - Fix 1: https://github.com/Expensify/App/issues/91293 + - Fix 2: https://github.com/Expensify/App/issues/97103 +- PR introducing patch: + - Fix 1: https://github.com/Expensify/App/pull/95984 + - Fix 2: https://github.com/Expensify/App/pull/97499 diff --git a/patches/react-native-vision-camera/react-native-vision-camera+4.7.2+001+fix-main-thread-hang-on-ios.patch b/patches/react-native-vision-camera/react-native-vision-camera+4.7.2+001+fix-ios-camera-teardown-hang-and-config-leak.patch similarity index 79% rename from patches/react-native-vision-camera/react-native-vision-camera+4.7.2+001+fix-main-thread-hang-on-ios.patch rename to patches/react-native-vision-camera/react-native-vision-camera+4.7.2+001+fix-ios-camera-teardown-hang-and-config-leak.patch index 363697f964b9..2f8d4f0eed14 100644 --- a/patches/react-native-vision-camera/react-native-vision-camera+4.7.2+001+fix-main-thread-hang-on-ios.patch +++ b/patches/react-native-vision-camera/react-native-vision-camera+4.7.2+001+fix-ios-camera-teardown-hang-and-config-leak.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-vision-camera/ios/Core/CameraSession.swift b/node_modules/react-native-vision-camera/ios/Core/CameraSession.swift -index 10b0f33..0e1d6fd 100644 +index 10b0f33..5d33236 100644 --- a/node_modules/react-native-vision-camera/ios/Core/CameraSession.swift +++ b/node_modules/react-native-vision-camera/ios/Core/CameraSession.swift @@ -113,8 +113,22 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat @@ -25,7 +25,42 @@ index 10b0f33..0e1d6fd 100644 // Let caller configure a new configuration for the Camera. let config = CameraConfiguration(copyOf: self.configuration) do { -@@ -215,7 +229,9 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat +@@ -131,10 +145,16 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat + + VisionLogger.log(level: .info, message: "configure { ... }: Updating CameraSession Configuration... \(difference)") + ++ // beginConfiguration() and commitConfiguration() are split across two separate ++ // isSessionConfigurationDirty blocks so device changes batch into a single commit. ++ // Anything that throws in between would otherwise leave the session's configuration ++ // counter permanently held, and every later start/stop then throws NSGenericException. ++ var isConfigurationOpen = false + do { + // If needed, configure the AVCaptureSession (inputs, outputs) + if difference.isSessionConfigurationDirty { + self.captureSession.beginConfiguration() ++ isConfigurationOpen = true + + // 1. Update input device + if difference.inputChanged { +@@ -193,6 +213,7 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat + // We commit the session config updates AFTER the device config, + // that way we can also batch those changes into one update instead of doing two updates. + self.captureSession.commitConfiguration() ++ isConfigurationOpen = false + } + + // 10. Start or stop the session if needed +@@ -210,12 +231,19 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat + // After configuring, set this to the new configuration. + self.configuration = config + } catch { ++ if isConfigurationOpen { ++ // Release the configuration lock before reporting, otherwise the session stays ++ // wedged for the rest of its lifetime and every startRunning/stopRunning throws. ++ self.captureSession.commitConfiguration() ++ } + self.onConfigureError(error) + } // Set up Audio Capture Session (on audio queue) if difference.audioSessionChanged { @@ -35,7 +70,7 @@ index 10b0f33..0e1d6fd 100644 do { // Lock Capture Session for configuration VisionLogger.log(level: .info, message: "Beginning AudioSession configuration...") -@@ -234,7 +250,9 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat +@@ -234,7 +262,9 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat // Set up Location streaming (on location queue) if difference.locationChanged { @@ -45,7 +80,7 @@ index 10b0f33..0e1d6fd 100644 do { VisionLogger.log(level: .info, message: "Beginning Location Output configuration...") try self.configureLocationOutput(configuration: config) -@@ -265,7 +283,7 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat +@@ -265,7 +295,7 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat } }