Skip to content

Commit aa4456c

Browse files
committed
test: stress photo video output transitions
1 parent 01c08db commit aa4456c

5 files changed

Lines changed: 284 additions & 145 deletions

File tree

START_CRASH_FINDINGS.md

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ Branch: `codex/start-crash-3773`
66

77
## Current status
88

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 or in CI 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. The first CI iOS Harness run also ran the new 120-cycle repro on a multi-camera iOS Device Farm device without crashing.
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 or in CI 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 earlier repro loop without crashing. The first CI iOS Harness run also ran the earlier 120-cycle repro on a multi-camera iOS Device Farm device without crashing.
1010

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.
11+
The strongest confirmed root-cause evidence is therefore the original issue stack plus the VisionCamera call ordering below. The original stack proves `AVCaptureSession.startRunning()` overlapped with AVFoundation's private configuration-commit notification path. A separate client report mentions the sibling private assertion `AVCaptureOutput detachFromFigCaptureSession` during rapid `photo -> video -> photo` changes, especially with a filter-preview path involved. That client report is not a stack trace, but it is directionally important because it points at the same output lifetime boundary from the detach side rather than the attach side. The exact assertion conditions inside `-[AVCaptureOutput attachToFigCaptureSession:]` and `detachFromFigCaptureSession` are private Apple code, so the remaining internal invariant is inferred from stacks/logs, not decompiled source.
1212

1313
## What the crash stack proves
1414

@@ -29,6 +29,8 @@ Because VisionCamera serializes `configure()` and `start()` on `HybridCameraSess
2929
6. AVFoundation is still attaching outputs from the committed configuration on `FigCaptureSessionNotificationQueue`.
3030
7. AVFoundation hits its private assertion in `attachToFigCaptureSession`.
3131

32+
The newer client report describes `detachFromFigCaptureSession`, which is the same class of invariant violation during output teardown instead of output attachment. Taken together, the evidence points at AVFoundation output attachment/detachment lifetime crossing a session start or a subsequent output topology change. It does not point at JavaScript timing alone, and it does not prove that a single specific output type is always responsible.
33+
3234
## VisionCamera code path responsible
3335

3436
Native serialization is in `packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift`:
@@ -61,30 +63,31 @@ That is a valid VisionCamera queue order, but it is the exact dangerous order if
6163
The example app starts the camera automatically, then loops from native callbacks:
6264

6365
1. `onStarted` sets `isActive=false`.
64-
2. `onStopped` swaps video output settings and optional HDR constraints.
66+
2. `onStopped` alternates the active output topology between photo-only and video-only.
6567
3. `onStopped` immediately sets `isActive=true`.
6668
4. The next React commit reconfigures outputs/constraints and restarts without sleeps.
6769

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.
70+
The current loop uses `<SkiaCamera>` so the preview path is a frame output rendered through Skia rather than a native preview output. It alternates `photo -> video -> photo`, alternates `HD_16_9`/`FHD_16_9` video output settings, requests 60 FPS when supported, requests cinematic-extended stabilization during video cycles when supported, and toggles `photoHDR` during photo cycles when supported.
6971

7072
This is intentionally event-driven. It does not use artificial sleeps or timeouts to create the race window.
7173

7274
## Harness reproducer added
7375

74-
`apps/simple-camera/__tests__/visioncamera.camera-view.harness.tsx` now contains:
76+
`apps/simple-camera/__tests__/visioncamera.start-crash.harness.tsx` now contains two iOS-only stress tests:
7577

7678
```text
77-
reconfigures outputs and immediately restarts from Camera lifecycle callbacks
79+
cycles photo -> video -> photo outputs through native preview start/stop
80+
cycles photo -> video -> photo outputs with Skia frame-preview attached
7881
```
7982

80-
It renders a high-level `<Camera>` and runs 120 cycles of:
83+
They render high-level camera components and run 60 cycles of:
8184

8285
```text
8386
onStarted -> inactive
84-
onStopped -> replace video output + toggle constraints + active
87+
onStopped -> switch photo-only/video-only output topology + toggle constraints + active
8588
```
8689

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.
90+
The first stress test uses the normal `<Camera>` native preview path. The second uses `<SkiaCamera>`, which always adds a frame output for preview rendering and therefore exercises the filter-preview shape mentioned in the newer client report. A crash on CI should appear as an iOS Harness process failure rather than a normal assertion failure.
8891

8992
## Device observations
9093

@@ -100,16 +103,18 @@ Observed locally:
100103
- 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`.
101104
- 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.
102105

103-
CI observations from PR #4001 / Harness AWS Device run `27018903509`:
106+
CI observations from PR #4001 / Harness AWS Device run `27018903509` for the earlier, now-replaced Harness repro:
104107

105108
- Build iOS passed.
106109
- Test iOS executed on a multi-camera iOS device exposing wide, ultra-wide, telephoto, dual, dual-wide, triple, lidar-depth, and true-depth devices.
107110
- The new Harness test completed 120 cycles on `Back Camera` in 25.766s: `reconfigures outputs and immediately restarts from Camera lifecycle callbacks`.
108111
- iOS Harness summary was green: 12 test suites passed, 125 tests passed, 15 skipped, 0 failed.
109112
- The GitHub `Test iOS` job still reported failure because the Device Farm step itself returned failed, but the Harness output did not contain an AVFoundation crash, app crash, failed test, or assertion.
110-
- Android also ran the new test and completed 120 cycles in 27.414s. Android failed in unrelated existing suites (`photo`, `coordinates`, `controller`, `video`), not in the new Camera-view repro.
113+
- Android also ran the earlier test and completed 120 cycles in 27.414s. Android failed in unrelated existing suites (`photo`, `coordinates`, `controller`, `video`), not in the Camera-view repro.
114+
115+
Why CI passed: that earlier Harness test was not equivalent to either report. It kept photo and video outputs attached together, then mostly recreated the video output and restarted. It did not remove the photo output before adding video, did not remove video before re-adding photo, did not use the Skia/filter-preview path, did not record video, and did not run on the original iPhone 11 / iOS 18.7.7 matrix. Its green result only proves that "replace video while photo remains attached, then restart" did not hit the assertion on that AWS device in 120 cycles.
111116

112-
Negative evidence: the iPhone SE result and the first iOS Device Farm result suggest the current loop is not sufficient to deterministically reproduce the crash. Hardware/OS version, camera topology, recording state, recorder preparation, lens switching, mount/unmount timing, or a narrower output transition likely matters.
117+
Negative evidence: the iPhone SE result and the first iOS Device Farm result suggest the earlier loop was not sufficient to deterministically reproduce the crash. Hardware/OS version, camera topology, recording state, recorder preparation, lens switching, mount/unmount timing, or the narrower photo-only/video-only output transition likely matters.
113118

114119
## Verification done
115120

@@ -122,15 +127,15 @@ git diff --check
122127
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
123128
```
124129

125-
`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.
130+
`bunx biome check apps/simple-camera/__tests__/visioncamera.start-crash.harness.tsx apps/simple-camera/__tests__/visioncamera.camera-view.harness.tsx apps/simple-camera/src/screens/CameraScreen.tsx START_CRASH_FINDINGS.md apps/simple-camera/__tests__/README.md` reported only pre-existing unused-variable warnings in `CameraScreen.tsx`. The new stress Harness file had no Biome diagnostics.
126131

127132
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.
128133

129134
## Current root-cause statement
130135

131136
The best current issue description is:
132137

133-
> 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:]`.
138+
> 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()` or a subsequent topology change can overlap that private output attachment/detachment work and trip AVFoundation's internal assertions in `-[AVCaptureOutput attachToFigCaptureSession:]` or `detachFromFigCaptureSession`.
134139
135140
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.
136141

apps/simple-camera/__tests__/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Tests are split by domain. Each file tests one slice of the imperative `VisionCa
3030
| [visioncamera.coordinates.harness.ts](visioncamera.coordinates.harness.ts) | `Frame.convertFramePointToCameraPoint` / `convertCameraPointToFramePoint`, `PreviewView.convertViewPointToCameraPoint` / `convertCameraPointToViewPoint`, `PreviewView.createMeteringPoint`, `convertScannedObjectCoordinatesToViewCoordinates`, end-to-end Frame → Camera → View round-trip |
3131
| [visioncamera.nativepreviewview.harness.tsx](visioncamera.nativepreviewview.harness.tsx) | Bare `NativePreviewView` lifecycle, layout-sensitive preview regression coverage, `resizeMode`, Android `implementationMode`, gesture controllers, multi-preview mounting, `PreviewView` ref methods, Android `takeSnapshot()` dimensions |
3232
| [visioncamera.camera-view.harness.tsx](visioncamera.camera-view.harness.tsx) | High-level `<Camera>` preview lifecycle, photo output integration, controller props, native gestures, `CameraRef` methods, `isActive`, mount / unmount / replacement behavior |
33+
| [visioncamera.start-crash.harness.tsx](visioncamera.start-crash.harness.tsx) | iOS AVFoundation output attach/detach stress repro for issue #3773, including photo -> video -> photo output topology and the Skia frame-preview path |
3334

3435
Pick the file that best matches what you're testing. If you're reproducing a bug that spans multiple outputs, put it in the file most central to the failure. If nothing fits, open a new `visioncamera.<domain>.harness.ts` — Jest picks up anything matching `__tests__/**/*.harness.{ts,tsx}`.
3536

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

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

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-
534431
it('fires stop callbacks when isActive is rerendered from true to false', async () => {
535432
const cameraRef = createRef<CameraRef>()
536433
const layout = deferred<Layout>()

0 commit comments

Comments
 (0)