Skip to content

Pause engine before reconfiguring graph in configuration-change handler#906

Closed
CharlesWiltgen wants to merge 1 commit into
sbooth:mainfrom
CharlesWiltgen:poppy/configuration-change-isrunning
Closed

Pause engine before reconfiguring graph in configuration-change handler#906
CharlesWiltgen wants to merge 1 commit into
sbooth:mainfrom
CharlesWiltgen:poppy/configuration-change-isrunning

Conversation

@CharlesWiltgen
Copy link
Copy Markdown

@CharlesWiltgen CharlesWiltgen commented May 2, 2026

[Hey @sbooth! This is obviously an AI-assisted PR, but I wanted to let you know that I'm Human™ and that I've done my best to validate that this addresses a real (if rare) issue. Thank you for SFBAudioEngine, and I hope this is useful!]

Problem

sfb::AudioPlayer::handleAudioEngineConfigurationChange (AudioPlayer.mm:2199) assumes:

// AVAudioEngine stops itself when a configuration change occurs

…and proceeds to clear its internal Flags::engineIsRunning | Flags::isPlaying flags on that basis, then calls [engine_ disconnectNodeInput:outputNode bus:0] if the output format has changed.

On iOS 26.4.x this assumption does not hold universally. The configuration-change notification can be delivered while engine_.isRunning is still true. disconnectNodeInput: then raises an NSException:

com.apple.coreaudio.avfaudio: required condition is false: !IsRunning()

Because the surrounding C++ method is noexcept with no @try/@catch, the exception propagates through C++ frames to CPPExceptionTerminate()std::terminate() → SIGABRT.

Crash report

Observed on iPhone 16 / iOS 26.4.2, ARM64, TestFlight build of a downstream app. Stack:

[Last Exception Backtrace]
3  AVFAudio   AVAudioEngineGraph::_DisconnectInput          AVAudioEngineGraph.mm:2728  ← NSException raised
4  AVFAudio   -[AVAudioEngine disconnectNodeInput:bus:]     AVAudioEngine.mm:155
5  SFB        sfb::AudioPlayer::handleAudioEngineConfigurationChange  AudioPlayer.mm:2247

[Thread 18 Crashed]
9  SFB        sfb::AudioPlayer::handleAudioEngineConfigurationChange  AudioPlayer.mm:2212  ← std::terminate unwound here
…
14 AVFAudio   IOUnitConfigurationChanged → NSNotificationCenter post  (libdispatch)

App had been running ~5.5 hours before the crash, consistent with an audio-session event trigger (interruption, route change, headphone connect/disconnect, CarPlay) rather than a startup condition.

Fix

Two changes inside the engineMutex_-held block:

  1. Pause the engine before reconfiguring. If engine_.isRunning is still true when the handler runs, call [engine_ pause] before disconnectNodeInput:. This addresses the root cause — the documented "engine auto-stops" invariant doesn't hold here.
  2. Wrap the AVFAudio calls in @try/@catch (NSException *). Defensive belt-and-suspenders against any future precondition that AVFAudio decides to enforce. On catch, log via os_log_error and return. The next play() rebuilds the graph from scratch, so bailout state is recoverable.

The same @try/@catch is applied around the subsequent [engine_ startAndReturnError:&startError] for symmetry — that call also dispatches into AVFAudio internals that can raise.

Behavior changes

  • Successful path: identical, except engine_.pause is called first if the engine is unexpectedly still running. pause preserves engine state, so the existing startAndReturnError: at line 2257 still resumes correctly.
  • Exception path: previously std::terminate(); now logged and the handler bails. The engine's flags have already been cleared (matching the pre-patch state assumption that the engine had stopped). The next play() rebuilds the graph.

Tested

  • Builds clean against main (ab6a858).
  • I do not have a deterministic reproducer for the underlying race; the fix is verified by code inspection against the crash log + Sentry-captured exception reason.

Related

…-change handler

The handler at AudioPlayer.mm:2199 assumes "AVAudioEngine stops itself
when a configuration change occurs" and clears its internal flags on
that basis. On iOS 26.4.x the notification can be delivered while
engine_.isRunning is still true; disconnectNodeInput: then raises
NSException ("required condition is false: !IsRunning()") which
propagates through this noexcept C++ frame to std::terminate.

Pause the engine before reconfiguring, and wrap the AVFAudio calls in
@try/@catch so any future precondition violations bail out gracefully.
The next play() rebuilds the graph from scratch.

Observed in production on iPhone 16 / iOS 26.4.2 (TestFlight crash log
attached to the upstream issue).
@sbooth
Copy link
Copy Markdown
Owner

sbooth commented May 3, 2026

This is an interesting issue. Thank you for the report!

According to Apple's documentation:

When the audio engine’s I/O unit observes a change to the audio input or output hardware’s channel count or sample rate, the audio engine stops, uninitializes itself, and issues this notification. The nodes remain in an attached and connected state with the previously set formats. The app must reestablish connections if the connection formats need to change.

which is the basis of my comment AVAudioEngine stops itself when a configuration change occurs. The fact that the documented behavior is not what you're observing is puzzling to say the least.

I'll post an inquiry on Apple's developer forums to see if I can get any clarification on behavioral changes.

@sbooth
Copy link
Copy Markdown
Owner

sbooth commented May 4, 2026

Link to developer forums inquiry: https://developer.apple.com/forums/thread/825253

@sbooth
Copy link
Copy Markdown
Owner

sbooth commented May 4, 2026

I've drafted #907 in an attempt to address this issue but I'm going to wait for more information (hopefully) before merging.

I'm reluctant to add ObjC exception handling code to catch internal AVFAudio assertions for preconditions and such.

@CharlesWiltgen
Copy link
Copy Markdown
Author

Understood, thank you!

@sbooth
Copy link
Copy Markdown
Owner

sbooth commented May 9, 2026

No reply from Apple yet.

Is it possible that the AVAudioEngine instance stops itself as advertised before issuing the change notification and the app restarted output after the engine stopped itself but before the notification was processed? The notification is submitted on the engine queue, which handles the configuration change from the AudioUnit's property listener, which also is called on a different queue. So it seems possible there could be a race condition. Without knowing how your app is architected it's hard to say for certain.

I think the safest thing to do is to stop the audio engine from within the configuration change notification once engineMutex_ is held, as in this PR and #907.

@CharlesWiltgen
Copy link
Copy Markdown
Author

CharlesWiltgen commented May 10, 2026

Okay, cool! That addresses the observed !IsRunning() crash regardless of which side caused the residual running state. I'm happy if you close #906 in favor of #907 once you're ready to merge.

It does mean that unguarded calls to disconnect, connect, prepare, and start could still raise NSException for other preconditions (different invariant, future iOS), and that those exceptions would still hit noexceptstd::terminate, but I don't know if that matters. ¯\_(ツ)_/¯

@sbooth sbooth closed this May 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants