|
| 1 | +# Issue #3773 start crash findings |
| 2 | + |
| 3 | +Issue: https://github.com/mrousavy/react-native-vision-camera/issues/3773 |
| 4 | + |
| 5 | +Branch: `codex/start-crash-3773` |
| 6 | + |
| 7 | +## Current status |
| 8 | + |
| 9 | +This branch contains an event-driven reproducer in the simple-camera app and an iOS Harness stress test. I have not produced the exact AVFoundation assertion locally yet. The local multi-physical-camera iPhone 15 Pro became locked and SpringBoard denied further launches, while the available iPhone SE 3rd gen ran the repro loop without crashing. |
| 10 | + |
| 11 | +The strongest confirmed root-cause evidence is therefore the original issue stack plus the VisionCamera call ordering below. The stack proves `AVCaptureSession.startRunning()` overlapped with AVFoundation's private configuration-commit notification path. The exact assertion condition inside `-[AVCaptureOutput attachToFigCaptureSession:]` is private Apple code, so the remaining internal invariant is inferred from the stack, not decompiled source. |
| 12 | + |
| 13 | +## What the crash stack proves |
| 14 | + |
| 15 | +The crash stack in #3773 has two important concurrent paths: |
| 16 | + |
| 17 | +- One thread is inside `HybridCameraSession.start()` -> `AVCaptureSession.startRunning()` -> AVFoundation graph build. |
| 18 | +- Another thread is on `FigCaptureSessionNotificationQueue` inside AVFoundation's configuration-commit notification path, including `_handleConfigurationCommittedNotificationWithPayload`, `_makeConfigurationLive:`, and `-[AVCaptureOutput attachToFigCaptureSession:]`. |
| 19 | + |
| 20 | +That proves VisionCamera had already called `startRunning()` while AVFoundation/CoreMedia was still making a recently committed configuration live on its private notification queue. |
| 21 | + |
| 22 | +Because VisionCamera serializes `configure()` and `start()` on `HybridCameraSession.queue`, this overlap cannot be explained by two VisionCamera calls running at the same time on our queue. The only sequence consistent with the stack is: |
| 23 | + |
| 24 | +1. VisionCamera enters `session.beginConfiguration()`. |
| 25 | +2. VisionCamera mutates inputs, outputs, connections, formats, FPS, and stabilization. |
| 26 | +3. VisionCamera calls `session.commitConfiguration()`. |
| 27 | +4. `commitConfiguration()` returns to VisionCamera before AVFoundation's private "configuration committed" work has fully drained. |
| 28 | +5. VisionCamera's next queued `start()` runs and calls `session.startRunning()`. |
| 29 | +6. AVFoundation is still attaching outputs from the committed configuration on `FigCaptureSessionNotificationQueue`. |
| 30 | +7. AVFoundation hits its private assertion in `attachToFigCaptureSession`. |
| 31 | + |
| 32 | +## VisionCamera code path responsible |
| 33 | + |
| 34 | +Native serialization is in `packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift`: |
| 35 | + |
| 36 | +- `configure(...)` uses `Promise.parallel(Self.queue)`, then calls `beginConfiguration()` and `commitConfiguration()`. |
| 37 | +- `start()` uses the same `Self.queue`, then calls `session.startRunning()`. |
| 38 | +- `stop()` uses the same `Self.queue`, then calls `session.stopRunning()`. |
| 39 | + |
| 40 | +The queue guarantees call order only for VisionCamera's own work. It does not guarantee AVFoundation's private CoreMedia notification queue has finished processing the effects of `commitConfiguration()`. |
| 41 | + |
| 42 | +The high-level React hook path can enqueue `configure()` and `start()` back-to-back during one React update: |
| 43 | + |
| 44 | +- `useCameraController(...)` starts an async `session.configure(...)` effect when outputs or constraints change. |
| 45 | +- `useCamera()` then computes `hasController = controller != null`. |
| 46 | +- `useCameraSessionIsRunning(session, isActive && hasController)` starts an async `session.start()` effect. |
| 47 | + |
| 48 | +On a reconfiguration update, the previous controller can still be non-null until the new `configure()` resolves. If `isActive` is true in that same commit, React schedules both effects. Since `useCameraController(...)` is called before `useCameraSessionIsRunning(...)`, the native queue receives: |
| 49 | + |
| 50 | +```text |
| 51 | +session.configure(new outputs / constraints) |
| 52 | +session.start() |
| 53 | +``` |
| 54 | + |
| 55 | +That is a valid VisionCamera queue order, but it is the exact dangerous order if AVFoundation's `commitConfiguration()` is not a full barrier for its private "make configuration live" work. |
| 56 | + |
| 57 | +## Local reproducer added |
| 58 | + |
| 59 | +`apps/simple-camera/src/screens/CameraScreen.tsx` now has `START_CRASH_REPRO = true`. |
| 60 | + |
| 61 | +The example app starts the camera automatically, then loops from native callbacks: |
| 62 | + |
| 63 | +1. `onStarted` sets `isActive=false`. |
| 64 | +2. `onStopped` swaps video output settings and optional HDR constraints. |
| 65 | +3. `onStopped` immediately sets `isActive=true`. |
| 66 | +4. The next React commit reconfigures outputs/constraints and restarts without sleeps. |
| 67 | + |
| 68 | +The loop attaches both photo and video outputs, alternates `HD_16_9`/`FHD_16_9` video output settings, requests 60 FPS when supported, requests cinematic-extended stabilization when supported, and toggles `photoHDR` when supported. |
| 69 | + |
| 70 | +This is intentionally event-driven. It does not use artificial sleeps or timeouts to create the race window. |
| 71 | + |
| 72 | +## Harness reproducer added |
| 73 | + |
| 74 | +`apps/simple-camera/__tests__/visioncamera.camera-view.harness.tsx` now contains: |
| 75 | + |
| 76 | +```text |
| 77 | +reconfigures outputs and immediately restarts from Camera lifecycle callbacks |
| 78 | +``` |
| 79 | + |
| 80 | +It renders a high-level `<Camera>` and runs 120 cycles of: |
| 81 | + |
| 82 | +```text |
| 83 | +onStarted -> inactive |
| 84 | +onStopped -> replace video output + toggle constraints + active |
| 85 | +``` |
| 86 | + |
| 87 | +This is the closest Harness shape to the reported crash because it exercises the React hook lifecycle that can enqueue `configure()` followed by `start()` in the same commit. A crash on CI should appear as an iOS Harness process failure rather than a normal assertion failure. |
| 88 | + |
| 89 | +## Device observations |
| 90 | + |
| 91 | +Local devices seen by `xcrun devicectl list devices`: |
| 92 | + |
| 93 | +- iPhone SE 3rd gen, iOS 26.5, single back wide camera. |
| 94 | +- iPhone 15 Pro, iOS 26.5, multi-physical back camera. |
| 95 | + |
| 96 | +Observed locally: |
| 97 | + |
| 98 | +- iPhone SE 3rd gen completed roughly 480 event-driven repro cycles without the AVFoundation assertion. |
| 99 | +- iPhone 15 Pro reached roughly 140 cycles in an earlier mixed-log run without the assertion, then the app process ended with exit code 0. I did not find a local crash report for `SimpleCamera`. |
| 100 | +- Subsequent iPhone 15 Pro launches were denied because the device was locked: `Unable to launch com.margelo.nitro.camera.example.simple because the device was not, or could not be, unlocked`. |
| 101 | +- The attached Teams video shows an app crash alert after an active video recording session, but it does not expose a native stack. It supports the general "active camera plus output/session state changes" trigger shape, not the internal AVFoundation diagnosis by itself. |
| 102 | + |
| 103 | +Negative evidence: the iPhone SE result suggests the crash is not a simple deterministic JavaScript loop bug on all hardware/OS combinations. Hardware, OS version, camera topology, or a narrower recording/output transition likely matters. |
| 104 | + |
| 105 | +## Verification done |
| 106 | + |
| 107 | +Commands that passed: |
| 108 | + |
| 109 | +```sh |
| 110 | +bun run build |
| 111 | +bunx tsc --noEmit --project apps/simple-camera/tsconfig.json |
| 112 | +git diff --check |
| 113 | +bun --cwd apps/simple-camera react-native bundle --platform ios --dev true --entry-file index.js --bundle-output /tmp/simple-camera-start-crash.bundle --assets-dest /tmp/simple-camera-start-crash-assets --reset-cache |
| 114 | +``` |
| 115 | + |
| 116 | +`bunx biome check apps/simple-camera/__tests__/visioncamera.camera-view.harness.tsx apps/simple-camera/src/screens/CameraScreen.tsx` reported only pre-existing unused-variable warnings in `CameraScreen.tsx`. The new Harness file had no Biome diagnostics. |
| 117 | + |
| 118 | +Local `react-native-harness --harnessRunner ios` tried to boot an iOS simulator, so I stopped it. The simulator is not useful for this camera pipeline race; the PR CI iOS Harness run on real hardware is the relevant signal. |
| 119 | + |
| 120 | +## Current root-cause statement |
| 121 | + |
| 122 | +The best current issue description is: |
| 123 | + |
| 124 | +> VisionCamera can enqueue `AVCaptureSession.startRunning()` immediately after a configuration commit that replaces outputs or changes output-affecting constraints. VisionCamera's own serial queue ensures `startRunning()` happens after `commitConfiguration()` returns, but AVFoundation/CoreMedia may still be processing the committed configuration asynchronously on `FigCaptureSessionNotificationQueue`. On affected hardware/OS combinations, `startRunning()` can overlap that private output-attachment work and trip AVFoundation's internal assertion in `-[AVCaptureOutput attachToFigCaptureSession:]`. |
| 125 | +
|
| 126 | +This is proven at the call-order/concurrency level by the crash stack and VisionCamera source. It is not yet proven at the "Apple internal field X had value Y" level because AVFoundation source is private and the exact assertion condition is not visible. |
| 127 | + |
| 128 | +## What would count as proof of a fix |
| 129 | + |
| 130 | +A proper fix needs to change the ordering contract, then re-run the same repro loop and Harness test: |
| 131 | + |
| 132 | +- prevent `startRunning()` from being enqueued while a reconfiguration is still becoming live, or |
| 133 | +- make the high-level hook clear/withhold the previous controller during reconfiguration so a prop update cannot schedule `configure()` and `start()` in the same React commit, or |
| 134 | +- add a native barrier based on a real AVFoundation lifecycle signal if one exists. |
| 135 | + |
| 136 | +Artificial sleeps are not proof. They would only lower the race probability. |
0 commit comments