Skip to content

Commit 51c6d6b

Browse files
committed
test: add start crash repro harness
1 parent 3106137 commit 51c6d6b

3 files changed

Lines changed: 312 additions & 12 deletions

File tree

START_CRASH_FINDINGS.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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.

apps/simple-camera/__tests__/visioncamera.camera-view.harness.tsx

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createRef } from 'react'
1+
import { createRef, useCallback, useMemo, useState } from 'react'
22
import { type LayoutChangeEvent, StyleSheet } from 'react-native'
33
import {
44
afterEach,
@@ -9,7 +9,12 @@ import {
99
it,
1010
render,
1111
} from 'react-native-harness'
12-
import type { CameraDevice, CameraRef, Point } from 'react-native-vision-camera'
12+
import type {
13+
CameraDevice,
14+
CameraRef,
15+
Constraint,
16+
Point,
17+
} from 'react-native-vision-camera'
1318
import {
1419
Camera,
1520
CommonResolutions,
@@ -428,6 +433,104 @@ describe('VisionCamera - Camera View', () => {
428433
expectPreviewGeometry(secondCamera, secondCameraLayout)
429434
})
430435

436+
it('reconfigures outputs and immediately restarts from Camera lifecycle callbacks', async () => {
437+
const maxCycles = 120
438+
const finished = deferred<number>()
439+
let sessionError: Error | undefined
440+
441+
function RestartingCamera() {
442+
const [isActive, setIsActive] = useState(true)
443+
const [cycle, setCycle] = useState(0)
444+
const [variant, setVariant] = useState(0)
445+
const [enablePhotoHDR, setEnablePhotoHDR] = useState(false)
446+
447+
const photoOutput = useMemo(
448+
() =>
449+
VisionCamera.createPhotoOutput({
450+
targetResolution: CommonResolutions.HD_4_3,
451+
containerFormat: 'jpeg',
452+
quality: 0.8,
453+
qualityPrioritization: 'balanced',
454+
}),
455+
[],
456+
)
457+
const videoOutput = useMemo(
458+
() =>
459+
VisionCamera.createVideoOutput({
460+
targetResolution:
461+
variant % 2 === 0
462+
? CommonResolutions.FHD_16_9
463+
: CommonResolutions.HD_16_9,
464+
targetBitRate: variant % 2 === 0 ? 20_000_000 : 12_000_000,
465+
enableAudio: false,
466+
}),
467+
[variant],
468+
)
469+
const outputs = useMemo(
470+
() => [photoOutput, videoOutput],
471+
[photoOutput, videoOutput],
472+
)
473+
const constraints = useMemo<Constraint[]>(() => {
474+
const nextConstraints: Constraint[] = []
475+
if (backDevice.supportsFPS(60)) {
476+
nextConstraints.push({ fps: 60 })
477+
}
478+
if (backDevice.supportsVideoStabilizationMode('cinematic-extended')) {
479+
nextConstraints.push({ videoStabilizationMode: 'cinematic-extended' })
480+
}
481+
if (enablePhotoHDR && backDevice.supportsPhotoHDR) {
482+
nextConstraints.unshift({ photoHDR: true })
483+
}
484+
return nextConstraints
485+
}, [enablePhotoHDR])
486+
const onStarted = useCallback(() => {
487+
setIsActive(false)
488+
}, [])
489+
const onStopped = useCallback(() => {
490+
const nextCycle = cycle + 1
491+
if (nextCycle >= maxCycles) {
492+
console.log(
493+
`start crash repro harness completed ${nextCycle} cycles on ${backDevice.localizedName}`,
494+
)
495+
finished.resolve(nextCycle)
496+
return
497+
}
498+
499+
setCycle(nextCycle)
500+
setVariant((current) => current + 1)
501+
setEnablePhotoHDR((current) => !current)
502+
setIsActive(true)
503+
}, [cycle])
504+
const onError = useCallback((error: Error) => {
505+
sessionError = error
506+
finished.reject(error)
507+
}, [])
508+
509+
return (
510+
<Camera
511+
device={backDevice}
512+
isActive={isActive}
513+
outputs={outputs}
514+
constraints={constraints}
515+
style={StyleSheet.absoluteFill}
516+
onStarted={onStarted}
517+
onStopped={onStopped}
518+
onError={onError}
519+
/>
520+
)
521+
}
522+
523+
await render(<RestartingCamera />)
524+
const cycles = await withTimeout(
525+
finished.promise,
526+
120_000,
527+
'Camera output reconfigure/restart cycles',
528+
)
529+
530+
expect(cycles).toBe(maxCycles)
531+
expect(sessionError).toBe(undefined)
532+
})
533+
431534
it('fires stop callbacks when isActive is rerendered from true to false', async () => {
432535
const cameraRef = createRef<CameraRef>()
433536
const layout = deferred<Layout>()

apps/simple-camera/src/screens/CameraScreen.tsx

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { useIsFocused, useNavigation } from '@react-navigation/native'
2-
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
33
import { StatusBar, StyleSheet, Text, View } from 'react-native'
44
import {
5+
CommonResolutions,
6+
type Constraint,
57
type Recorder,
68
useCameraDeviceExtensions,
79
useCameraDevices,
@@ -21,6 +23,8 @@ import { useIsActive } from '../hooks/useIsActive'
2123
import { useSafeAreaPadding } from '../hooks/useSafeAreaPadding'
2224
import { logDevices } from '../logDevices'
2325

26+
const START_CRASH_REPRO = true
27+
2428
export function CameraScreen() {
2529
const navigation = useNavigation()
2630
const isAppActive = useIsActive()
@@ -32,8 +36,20 @@ export function CameraScreen() {
3236
const [enableDepthStream, setEnableDepthStream] = useState(false)
3337

3438
const devices = useCameraDevices()
35-
const defaultDevice = devices[0]
39+
const defaultDevice = useMemo(
40+
() =>
41+
devices.find(
42+
(d) => d.position === 'back' && d.physicalDevices.length > 1,
43+
) ??
44+
devices.find((d) => d.position === 'back') ??
45+
devices[0],
46+
[devices],
47+
)
3648
const [device, setDevice] = useState(defaultDevice)
49+
const [startCrashReproActive, setStartCrashReproActive] = useState(true)
50+
const [startCrashReproCycle, setStartCrashReproCycle] = useState(0)
51+
const [startCrashReproVariant, setStartCrashReproVariant] = useState(0)
52+
const [startCrashReproHDR, setStartCrashReproHDR] = useState(false)
3753

3854
useEffect(() => {
3955
setDevice(defaultDevice)
@@ -62,8 +78,31 @@ export function CameraScreen() {
6278

6379
const photoOutput = usePhotoOutput({})
6480
const videoOutput = useVideoOutput({
65-
enableAudio: true,
81+
targetResolution:
82+
startCrashReproVariant % 2 === 0
83+
? CommonResolutions.FHD_16_9
84+
: CommonResolutions.HD_16_9,
85+
targetBitRate: startCrashReproVariant % 2 === 0 ? 20_000_000 : 12_000_000,
86+
enableAudio: false,
6687
})
88+
const cameraOutputs = useMemo(
89+
() => (START_CRASH_REPRO ? [photoOutput, videoOutput] : [photoOutput]),
90+
[photoOutput, videoOutput],
91+
)
92+
const cameraConstraints = useMemo<Constraint[]>(() => {
93+
if (!START_CRASH_REPRO) return []
94+
const nextConstraints: Constraint[] = []
95+
if (device?.supportsFPS(60)) {
96+
nextConstraints.push({ fps: 60 })
97+
}
98+
if (device?.supportsVideoStabilizationMode('cinematic-extended')) {
99+
nextConstraints.push({ videoStabilizationMode: 'cinematic-extended' })
100+
}
101+
if (startCrashReproHDR && device?.supportsPhotoHDR) {
102+
nextConstraints.unshift({ photoHDR: true })
103+
}
104+
return nextConstraints
105+
}, [device, startCrashReproHDR])
67106
const { resizer, error } = useResizer({
68107
width: 192,
69108
height: 192,
@@ -217,6 +256,26 @@ export function CameraScreen() {
217256
await recorder.stopRecording()
218257
console.log(`Recording stopped!`)
219258
}, [])
259+
const onCameraStarted = useCallback(() => {
260+
if (!START_CRASH_REPRO) return
261+
console.log(
262+
`[START_CRASH_REPRO] cycle ${startCrashReproCycle} started; stopping before next reconfigure/start`,
263+
)
264+
setStartCrashReproActive(false)
265+
}, [startCrashReproCycle])
266+
const onCameraStopped = useCallback(() => {
267+
if (!START_CRASH_REPRO) return
268+
setStartCrashReproCycle((cycle) => {
269+
const nextCycle = cycle + 1
270+
console.log(
271+
`[START_CRASH_REPRO] cycle ${cycle} stopped; swapping output/constraints and immediately restarting cycle ${nextCycle}`,
272+
)
273+
return nextCycle
274+
})
275+
setStartCrashReproVariant((variant) => variant + 1)
276+
setStartCrashReproHDR((hdr) => !hdr)
277+
setStartCrashReproActive(true)
278+
}, [])
220279

221280
if (device == null) {
222281
return (
@@ -230,15 +289,17 @@ export function CameraScreen() {
230289
<StatusBar barStyle="light-content" />
231290

232291
<CameraView
233-
isActive={isAppActive && isScreenFocused}
292+
isActive={
293+
isAppActive &&
294+
isScreenFocused &&
295+
(!START_CRASH_REPRO || startCrashReproActive)
296+
}
234297
device={device}
235-
outputs={[photoOutput]}
298+
outputs={cameraOutputs}
236299
mirrorMode={device.position === 'front' ? 'on' : 'off'}
237-
constraints={
238-
[
239-
// Session Constraints
240-
]
241-
}
300+
constraints={cameraConstraints}
301+
onStarted={onCameraStarted}
302+
onStopped={onCameraStopped}
242303
onInterruptionStarted={(reason) =>
243304
console.log(`Camera interrupted! Reason: ${reason}`)
244305
}

0 commit comments

Comments
 (0)