Description
When an engine configuration change is handled while sessionDeactivationInvalidatedGraph is set and a tracked graph exists, AudioEngine's startEngine and its rebuild method call each other in unbounded mutual recursion until the thread stack overflows. The app dies with EXC_BAD_ACCESS / KERN_PROTECTION_FAILURE at a stack guard page — the crash report says it outright:
"message": "Thread stack size exceeded due to excessive recursion"
-[AudioEngine startEngine]
-[AudioEngine rebuildAudioEngineAndResumeIfNeeded]
-[AudioEngine startEngine]
-[AudioEngine rebuildAudioEngineAndResumeIfNeeded]
... (alternating for 245 rounds, to the bottom of the stack)
Expected: configuration changes (recorder start/stop, audio route changes) rebuild/restart the engine and playback continues. Actual: the app crashes.
Affected: 0.13.1 (startEngine ↔ rebuildAudioEngineAndResumeIfNeeded) and 0.12.2 (startEngine ↔ restartAudioEngine — same cycle before the refactor). Crash reports for both versions, from a physical iPhone 14 Pro (iOS 18.7.8) and from the iOS 26.2 Simulator, are in the MRE repo: https://github.com/bjorlov/rnaa-recursion-mre/tree/main/crash-reports
Root cause
sessionDeactivationInvalidatedGraph is preserved across rebuilds (destroyAudioEnginePreservingSessionDeactivationState:YES) and is only cleared at the end of a successful startEngine — but startEngine re-enters the rebuild before ever reaching that point:
// AudioEngine.mm — startEngine
if (self.state == AudioEngineState::AudioEngineStateInterrupted || self.graphNeedsRebuild ||
self.sessionDeactivationInvalidatedGraph) {
[self rebuildAudioEngineAndResumeIfNeeded]; // ← re-enters before the flag can ever clear
}
...
self.state = AudioEngineState::AudioEngineStateRunning;
self.sessionDeactivationInvalidatedGraph = false; // ← never reached while the loop spins
// AudioEngine.mm — rebuildAudioEngineAndResumeIfNeeded
[self rebuildAudioEngine]; // clears graphNeedsRebuild, PRESERVES the session flag
if (self.state == AudioEngineState::AudioEngineStateRunning) {
[self startEngine]; // ← sees the still-set flag → recursion
}
Nothing inside the loop can clear the flag, because clearing requires the successful start the loop prevents.
Suggested fix (verified)
Clear the flag once the graph has been rebuilt — the rebuild is the remedy for whatever the deactivation invalidated — plus optionally a re-entrancy guard:
- (void)rebuildAudioEngineAndResumeIfNeeded
{
static BOOL isRebuilding = NO;
if (isRebuilding) {
return;
}
isRebuilding = YES;
if ([self.audioEngine isRunning]) {
[self.audioEngine stop];
}
[self rebuildAudioEngine];
self.sessionDeactivationInvalidatedGraph = false; // the graph was just rebuilt — nothing is stale
if (self.state == AudioEngineState::AudioEngineStateRunning) {
[self startEngine];
}
isRebuilding = NO;
}
We run this as a local patch on 0.13.1 in our app (a guitar tuner: AudioContext playback + AudioRecorder + session category switching); the previously 100%-reproducible crash is gone under the same torture testing. In the real app the most reliable trigger was Bluetooth (A2DP) headphones: with playAndRecord+defaultToSpeaker ↔ playback category switching, every switch is also a hardware route change, and the app crashed on nearly every tuner-screen ↔ playback-screen navigation. The MRE proves Bluetooth is not required, though — recorder start/stop + category churn alone reaches the same path.
Steps to reproduce
- Clone https://github.com/bjorlov/rnaa-recursion-mre — one screen, react-native-audio-api 0.13.1 pinned: an oscillator beep (tracked source node) while a loop starts/stops the
AudioRecorder and flips the audio session between playAndRecord(+defaultToSpeaker) and playback.
npm install && npx expo run:ios — the iOS Simulator is enough, no hardware needed.
- Tap Start torture.
- The app dies with SIGSEGV within seconds (usually 2-4 cycles). The crash report shows the alternating
startEngine / rebuildAudioEngineAndResumeIfNeeded frames with "Thread stack size exceeded due to excessive recursion".
Snack or a link to a repository
https://github.com/bjorlov/rnaa-recursion-mre
React Native Audio API version
0.13.1 (0.12.2 also affected — see description)
React Native version
0.86.0
Platforms
iOS
JavaScript runtime
Hermes
Workflow
Expo Dev Client
Architecture
Fabric (New Architecture)
Build type
Debug app & dev bundle
Device
Real device
Device model
iPhone 17 Pro Simulator (iOS 26.2); also iPhone 14 Pro (iOS 18.7.8)
Acknowledgements
Yes
Description
When an engine configuration change is handled while
sessionDeactivationInvalidatedGraphis set and a tracked graph exists,AudioEngine'sstartEngineand its rebuild method call each other in unbounded mutual recursion until the thread stack overflows. The app dies withEXC_BAD_ACCESS / KERN_PROTECTION_FAILUREat a stack guard page — the crash report says it outright:Expected: configuration changes (recorder start/stop, audio route changes) rebuild/restart the engine and playback continues. Actual: the app crashes.
Affected: 0.13.1 (
startEngine↔rebuildAudioEngineAndResumeIfNeeded) and 0.12.2 (startEngine↔restartAudioEngine— same cycle before the refactor). Crash reports for both versions, from a physical iPhone 14 Pro (iOS 18.7.8) and from the iOS 26.2 Simulator, are in the MRE repo: https://github.com/bjorlov/rnaa-recursion-mre/tree/main/crash-reportsRoot cause
sessionDeactivationInvalidatedGraphis preserved across rebuilds (destroyAudioEnginePreservingSessionDeactivationState:YES) and is only cleared at the end of a successfulstartEngine— butstartEnginere-enters the rebuild before ever reaching that point:Nothing inside the loop can clear the flag, because clearing requires the successful start the loop prevents.
Suggested fix (verified)
Clear the flag once the graph has been rebuilt — the rebuild is the remedy for whatever the deactivation invalidated — plus optionally a re-entrancy guard:
We run this as a local patch on 0.13.1 in our app (a guitar tuner:
AudioContextplayback +AudioRecorder+ session category switching); the previously 100%-reproducible crash is gone under the same torture testing. In the real app the most reliable trigger was Bluetooth (A2DP) headphones: withplayAndRecord+defaultToSpeaker↔playbackcategory switching, every switch is also a hardware route change, and the app crashed on nearly every tuner-screen ↔ playback-screen navigation. The MRE proves Bluetooth is not required, though — recorder start/stop + category churn alone reaches the same path.Steps to reproduce
AudioRecorderand flips the audio session betweenplayAndRecord(+defaultToSpeaker) andplayback.npm install && npx expo run:ios— the iOS Simulator is enough, no hardware needed.startEngine/rebuildAudioEngineAndResumeIfNeededframes with "Thread stack size exceeded due to excessive recursion".Snack or a link to a repository
https://github.com/bjorlov/rnaa-recursion-mre
React Native Audio API version
0.13.1 (0.12.2 also affected — see description)
React Native version
0.86.0
Platforms
iOS
JavaScript runtime
Hermes
Workflow
Expo Dev Client
Architecture
Fabric (New Architecture)
Build type
Debug app & dev bundle
Device
Real device
Device model
iPhone 17 Pro Simulator (iOS 26.2); also iPhone 14 Pro (iOS 18.7.8)
Acknowledgements
Yes