Skip to content

Commit eab8fac

Browse files
committed
test: exercise active photo video capture transitions
1 parent aa4456c commit eab8fac

3 files changed

Lines changed: 226 additions & 13 deletions

File tree

START_CRASH_FINDINGS.md

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ 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 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.
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 during the first attempt and a later local `run-ios` attempt failed in Xcode before launching the app. The available iPhone SE 3rd gen ran the earlier repro loop without crashing. CI has now run both the earlier broad 120-cycle repro and the corrected `photo -> video -> photo` native/Skia topology repro on real iOS Device Farm hardware without crashing.
1010

1111
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

@@ -62,32 +62,46 @@ That is a valid VisionCamera queue order, but it is the exact dangerous order if
6262

6363
The example app starts the camera automatically, then loops from native callbacks:
6464

65-
1. `onStarted` sets `isActive=false`.
66-
2. `onStopped` alternates the active output topology between photo-only and video-only.
67-
3. `onStopped` immediately sets `isActive=true`.
68-
4. The next React commit reconfigures outputs/constraints and restarts without sleeps.
65+
1. `onStarted` runs actual capture work for the current mode.
66+
2. Photo cycles call `capturePhoto(...)` and dispose the returned `Photo`.
67+
3. Video cycles create a recorder, start recording, and stop recording immediately after `startRecording(...)` resolves.
68+
4. The capture cycle then sets `isActive=false`.
69+
5. `onStopped` alternates the active output topology between photo-only and video-only.
70+
6. `onStopped` immediately sets `isActive=true`.
71+
7. The next React commit reconfigures outputs/constraints and restarts without sleeps.
6972

7073
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.
7174

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

7477
## Harness reproducer added
7578

76-
`apps/simple-camera/__tests__/visioncamera.start-crash.harness.tsx` now contains two iOS-only stress tests:
79+
`apps/simple-camera/__tests__/visioncamera.start-crash.harness.tsx` now contains three iOS-only stress tests:
7780

7881
```text
7982
cycles photo -> video -> photo outputs through native preview start/stop
8083
cycles photo -> video -> photo outputs with Skia frame-preview attached
84+
captures photo -> records video -> captures photo with Skia frame-preview attached
8185
```
8286

83-
They render high-level camera components and run 60 cycles of:
87+
The first two tests render high-level camera components and run 60 cycles of:
8488

8589
```text
8690
onStarted -> inactive
8791
onStopped -> switch photo-only/video-only output topology + toggle constraints + active
8892
```
8993

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.
94+
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.
95+
96+
The third test keeps the Skia preview path and runs 18 cycles of real capture activity:
97+
98+
```text
99+
photo mode -> capturePhoto -> inactive
100+
video mode -> create recorder -> startRecording -> stopRecording -> inactive
101+
onStopped -> switch photo-only/video-only output topology + active
102+
```
103+
104+
A crash on CI should appear as an iOS Harness process failure rather than a normal assertion failure.
91105

92106
## Device observations
93107

@@ -101,6 +115,7 @@ Observed locally:
101115
- iPhone SE 3rd gen completed roughly 480 event-driven repro cycles without the AVFoundation assertion.
102116
- 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`.
103117
- 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`.
118+
- A later iPhone 15 Pro launch attempt on the same branch failed before app launch with `xcodebuild` exit code 65 and a final linker failure. That local run did not exercise the camera repro.
104119
- 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.
105120

106121
CI observations from PR #4001 / Harness AWS Device run `27018903509` for the earlier, now-replaced Harness repro:
@@ -114,7 +129,15 @@ CI observations from PR #4001 / Harness AWS Device run `27018903509` for the ear
114129

115130
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.
116131

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.
132+
CI observations from PR #4001 / Harness AWS Device run `27022466051` on commit `aa4456ce71f6a81a9dab7c89de1c7c69604c15ff`:
133+
134+
- Build iOS passed.
135+
- Test iOS executed on Apple iPhone 16 Pro.
136+
- The corrected start-crash stress file passed: 60 native preview topology cycles completed in 13.257s and 60 Skia topology cycles completed in 13.668s.
137+
- iOS Harness summary was green: 13 test suites passed, 126 tests passed, 15 skipped, 0 failed.
138+
- The GitHub `Test iOS` job still reported failure because AWS Device Farm reported one failed item outside the Harness summary: `Total: 3, passed: 2, failed: 1`. The printed Harness output did not contain an AVFoundation crash, app crash, failed test, `attachToFigCaptureSession`, or `detachFromFigCaptureSession`.
139+
140+
Negative evidence: the iPhone SE result and the first two iOS Device Farm results suggest output topology changes alone are not sufficient to deterministically reproduce the crash. Hardware/OS version, camera topology, active recording/capture state, recorder preparation, lens switching, mount/unmount timing, or the original iPhone 11 / iOS 18.7.7 matrix likely matters.
118141

119142
## Verification done
120143

apps/simple-camera/__tests__/visioncamera.start-crash.harness.tsx

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useMemo, useState } from 'react'
1+
import { useCallback, useMemo, useRef, useState } from 'react'
22
import { Platform, StyleSheet } from 'react-native'
33
import {
44
afterEach,
@@ -168,6 +168,142 @@ describe('VisionCamera - Start Crash Stress', () => {
168168
expect(sessionError).toBe(undefined)
169169
}
170170

171+
async function runActiveCaptureTopologyStress(maxCycles: number) {
172+
const finished = deferred<number>()
173+
let sessionError: Error | undefined
174+
175+
function CapturingCamera() {
176+
const [isActive, setIsActive] = useState(true)
177+
const [cycle, setCycle] = useState(0)
178+
const [variant, setVariant] = useState(0)
179+
const captureInFlight = useRef(false)
180+
const mode = getStartCrashStressMode(cycle)
181+
182+
const photoOutput = useMemo(
183+
() =>
184+
VisionCamera.createPhotoOutput({
185+
targetResolution: CommonResolutions.HD_4_3,
186+
containerFormat: 'jpeg',
187+
quality: 0.8,
188+
qualityPrioritization: 'balanced',
189+
}),
190+
[],
191+
)
192+
const videoOutput = useMemo(
193+
() =>
194+
VisionCamera.createVideoOutput({
195+
targetResolution:
196+
variant % 2 === 0
197+
? CommonResolutions.FHD_16_9
198+
: CommonResolutions.HD_16_9,
199+
targetBitRate: variant % 2 === 0 ? 20_000_000 : 12_000_000,
200+
enableAudio: false,
201+
}),
202+
[variant],
203+
)
204+
const outputs = useMemo(
205+
() => (mode === 'video' ? [videoOutput] : [photoOutput]),
206+
[mode, photoOutput, videoOutput],
207+
)
208+
const constraints = useMemo<Constraint[]>(() => {
209+
const nextConstraints: Constraint[] = []
210+
if (backDevice.supportsFPS(60)) {
211+
nextConstraints.push({ fps: 60 })
212+
}
213+
if (
214+
mode === 'video' &&
215+
backDevice.supportsVideoStabilizationMode('cinematic-extended')
216+
) {
217+
nextConstraints.push({ videoStabilizationMode: 'cinematic-extended' })
218+
}
219+
return nextConstraints
220+
}, [mode])
221+
const onStarted = useCallback(() => {
222+
if (captureInFlight.current) return
223+
captureInFlight.current = true
224+
225+
const runCapture = async () => {
226+
try {
227+
if (mode === 'photo') {
228+
const photo = await photoOutput.capturePhoto(
229+
{ flashMode: 'off', enableShutterSound: false },
230+
{},
231+
)
232+
photo.dispose()
233+
} else {
234+
const recorder = await videoOutput.createRecorder({})
235+
await recorder.startRecording(
236+
() => {},
237+
(error) => {
238+
sessionError = error
239+
finished.reject(error)
240+
},
241+
)
242+
await recorder.stopRecording()
243+
}
244+
} catch (error) {
245+
sessionError = error as Error
246+
finished.reject(sessionError)
247+
} finally {
248+
captureInFlight.current = false
249+
setIsActive(false)
250+
}
251+
}
252+
253+
void runCapture()
254+
}, [mode, photoOutput, videoOutput])
255+
const onStopped = useCallback(() => {
256+
const nextCycle = cycle + 1
257+
if (nextCycle >= maxCycles) {
258+
console.log(
259+
`start crash active capture stress completed ${nextCycle} Skia cycles on ${backDevice.localizedName}`,
260+
)
261+
finished.resolve(nextCycle)
262+
return
263+
}
264+
265+
setCycle(nextCycle)
266+
setVariant((current) => current + 1)
267+
setIsActive(true)
268+
}, [cycle])
269+
const onError = useCallback((error: Error) => {
270+
sessionError = error
271+
finished.reject(error)
272+
}, [])
273+
274+
return (
275+
<SkiaCamera
276+
device={backDevice}
277+
isActive={isActive}
278+
outputs={outputs}
279+
constraints={constraints}
280+
style={StyleSheet.absoluteFill}
281+
enablePreviewSizedOutputBuffers={true}
282+
onFrame={(frame, render) => {
283+
'worklet'
284+
render(({ canvas, frameTexture }) => {
285+
canvas.drawImage(frameTexture, 0, 0)
286+
})
287+
frame.dispose()
288+
}}
289+
onStarted={onStarted}
290+
onStopped={onStopped}
291+
onError={onError}
292+
/>
293+
)
294+
}
295+
296+
await render(<CapturingCamera />)
297+
const cycles = await withTimeout(
298+
finished.promise,
299+
90_000,
300+
'Skia photo capture/video recording/photo capture topology stress',
301+
)
302+
303+
expect(cycles).toBe(maxCycles)
304+
expect(sessionError).toBe(undefined)
305+
}
306+
171307
it('cycles photo -> video -> photo outputs through native preview start/stop', async (context) => {
172308
if (Platform.OS !== 'ios') {
173309
return context.skip(
@@ -187,4 +323,14 @@ describe('VisionCamera - Start Crash Stress', () => {
187323

188324
await runOutputTopologyStress(true, 60)
189325
})
326+
327+
it('captures photo -> records video -> captures photo with Skia frame-preview attached', async (context) => {
328+
if (Platform.OS !== 'ios') {
329+
return context.skip(
330+
'AVFoundation attach/detach assertion stress: iOS only',
331+
)
332+
}
333+
334+
await runActiveCaptureTopologyStress(18)
335+
})
190336
})

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

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ export function CameraScreen() {
228228

229229
const preparedRecorder = useRef<Recorder>(undefined)
230230
const activeRecorder = useRef<Recorder>(undefined)
231+
const startCrashReproCaptureInFlight = useRef(false)
231232
const startRecording = useCallback(async () => {
232233
console.log(`Starting Recording...`)
233234
// get previously prepared recorder (cached)
@@ -272,13 +273,56 @@ export function CameraScreen() {
272273
await recorder.stopRecording()
273274
console.log(`Recording stopped!`)
274275
}, [])
276+
const runStartCrashReproCaptureCycle = useCallback(async () => {
277+
if (startCrashReproCaptureInFlight.current) return
278+
startCrashReproCaptureInFlight.current = true
279+
try {
280+
if (startCrashReproMode === 'photo') {
281+
const photo = await photoOutput.capturePhoto(
282+
{ flashMode: 'off', enableShutterSound: false },
283+
{},
284+
)
285+
console.log(
286+
`[START_CRASH_REPRO] captured ${photo.width}x${photo.height} photo in cycle ${startCrashReproCycle}`,
287+
)
288+
photo.dispose()
289+
} else {
290+
const recorder = await videoOutput.createRecorder({})
291+
await recorder.startRecording(
292+
(path, reason) => {
293+
console.log(
294+
`[START_CRASH_REPRO] recording finished with ${reason}; path: ${path}`,
295+
)
296+
},
297+
(error) =>
298+
console.error(`[START_CRASH_REPRO] recording failed`, error),
299+
)
300+
await recorder.stopRecording()
301+
console.log(
302+
`[START_CRASH_REPRO] started and stopped video recording in cycle ${startCrashReproCycle}`,
303+
)
304+
}
305+
} catch (error) {
306+
console.error(
307+
`[START_CRASH_REPRO] ${startCrashReproMode} capture cycle failed`,
308+
error,
309+
)
310+
} finally {
311+
startCrashReproCaptureInFlight.current = false
312+
setStartCrashReproActive(false)
313+
}
314+
}, [photoOutput, startCrashReproCycle, startCrashReproMode, videoOutput])
275315
const onCameraStarted = useCallback(() => {
276316
if (!START_CRASH_REPRO) return
277317
console.log(
278-
`[START_CRASH_REPRO] ${startCrashReproMode} cycle ${startCrashReproCycle} started; stopping before next reconfigure/start`,
318+
`[START_CRASH_REPRO] ${startCrashReproMode} cycle ${startCrashReproCycle} started; running capture before next reconfigure/start`,
279319
)
280-
setStartCrashReproActive(false)
281-
}, [startCrashReproCycle, startCrashReproMode])
320+
void runStartCrashReproCaptureCycle()
321+
}, [
322+
runStartCrashReproCaptureCycle,
323+
startCrashReproCycle,
324+
startCrashReproMode,
325+
])
282326
const onCameraStopped = useCallback(() => {
283327
if (!START_CRASH_REPRO) return
284328
setStartCrashReproCycle((cycle) => {

0 commit comments

Comments
 (0)