Skip to content

iOS: infinite mutual recursion in AudioEngine on configuration change → stack overflow (SIGSEGV) — 0.12.2 and 0.13.1 #1161

Description

@bjorlov

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 (startEnginerebuildAudioEngineAndResumeIfNeeded) and 0.12.2 (startEnginerestartAudioEngine — 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+defaultToSpeakerplayback 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

  1. 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.
  2. npm install && npx expo run:ios — the iOS Simulator is enough, no hardware needed.
  3. Tap Start torture.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions