Skip to content

AudioPlayer: skip mixer→output reconnection when the output node reports an invalid format - #979

Closed
CharlesWiltgen wants to merge 2 commits into
sbooth:mainfrom
CharlesWiltgen:poppy/configuration-change-format-guard
Closed

CharlesWiltgen wants to merge 2 commits into
sbooth:mainfrom
CharlesWiltgen:poppy/configuration-change-format-guard

Conversation

@CharlesWiltgen

@CharlesWiltgen CharlesWiltgen commented Aug 8, 2026

Copy link
Copy Markdown

(Hey @sbooth! Although this PR is heavily AI-assisted, I, a RealHuman™, spent a couple hours building it in order to fix a crashing bug that I'm seeing in my SFBAudioEngine-using app in the field. I've tried to make it as easy as possible to reproduce and merge. Please let me know if you need anything else!)

Summary

AudioPlayer::handleAudioEngineConfigurationChange reconnects the main mixer node to the output node using the output node's current output format. When the output route is being torn down — a route change, an interruption, or the audio session being deactivated — that format transiently reports 0 Hz and/or 0 channels. -[AVAudioEngine connect:to:format:] then fails its internal precondition IsFormatSampleRateAndChannelCountValid(format) and raises an Objective-C exception. Because handleAudioEngineConfigurationChange is declared noexcept, the exception cannot unwind and the process is terminated (std::terminateSIGABRT).

This change guards the reconnection: the mixer→output connection is only rebuilt while the output node reports a usable format. When the format is invalid the reconfiguration is skipped and logged; AVAudioEngine posts a fresh AVAudioEngineConfigurationChangeNotification once valid output hardware is available, and that pass performs the reconnection. The existing "restart the engine if it was previously running" logic is unchanged, so playback still resumes.

Crash signature

required condition is false: IsFormatSampleRateAndChannelCountValid(format)
  _AVAE_CheckAndReturnErr
  AVAudioEngineGraph::_Connect(...)
  -[AVAudioEngine connect:to:format:]
  sfb::AudioPlayer::handleAudioEngineConfigurationChange(...)   ← noexcept
  __exceptionPreprocess / objc_exception_throw → std::terminate → SIGABRT

Root cause

AVAudioFormat *outputNodeOutputFormat = [outputNode outputFormatForBus:0];   // can be 0 Hz / 0 ch
AVAudioFormat *mixerNodeOutputFormat  = [mixerNode  outputFormatForBus:0];   // last valid format

if (outputNodeOutputFormat.sampleRate  != mixerNodeOutputFormat.sampleRate ||
    outputNodeOutputFormat.channelCount != mixerNodeOutputFormat.channelCount) {
    ...
    [engine_ connect:mixerNode to:outputNode format:outputNodeOutputFormat];  // ← aborts on 0/0
}

The guard asks only whether the format differs, never whether it is usable. An invalid 0/0 format always differs from the previously valid mixer format, so the mismatch branch is always taken and the invalid format flows straight into connect:to:format:.

This is the sibling of the guard a few lines above —

if (engine_.isRunning) { [engine_ stop]; }   // avoids the disconnect-while-running exception

— the next precondition in the same method that a transient hardware state can trip.
That guard (#907) made disconnectNodeInput: survivable; this one makes the following
connect:to:format: survivable.

How it reproduces

My app is an iOS music player that deactivates its AVAudioSession while an SFBAudioPlayer is still attached, on backgrounding, so that Control Center reflects true rendering state rather than playbackRate. Deactivating the session tears down the output route, so -[outputNode outputFormatForBus:0] reports 0 Hz / 0 ch at exactly the moment the resulting configuration-change notification is delivered.

That ordering appears to be why this has gone unreported: most clients keep the session active for the lifetime of the player, so they never observe the output node in this state.

Deterministic repro:

  1. Begin playback through an SFBAudioPlayer.
  2. With the player still attached, deactivate the audio session
    ([[AVAudioSession sharedInstance] setActive:NO ...]) — e.g. from
    UIApplicationWillResignActiveNotification.
  3. The configuration-change notification arrives with an invalid output format and the
    process aborts.

Field data: a low but steady rate across several users, unchanged over four consecutive app releases, on both iOS 26.x and iOS 27, across four iPhone hardware generations, so it is neither OS-specific nor device-specific. Every sampled crash ends with the app transitioning to background, i.e. at session deactivation.

Testing

  • Builds clean. swift build --target CSFBAudioEngine succeeds on the patched branch (Swift 6.4 / Xcode 27, macOS 27), with no new warnings.
  • Code inspection against the crash stack: this is the only connect:to:format: in the handler, and the format originates from the hardware output bus.
  • Not covered by a unit test. The triggering condition is a hardware-derived format from a real output bus and cannot be injected through any public API, so there is no headless test for it. The change is a pure precondition on values already in hand.
  • No field soak yet. I am pinning this branch now, but the crash is infrequent enough that a meaningful soak is weeks away. I did not want to claim a confirmation that does not exist — happy to report back once there is one.

Notes

  • Behavior is unchanged for every valid format: the added conjunct can only be false when the hardware format is unusable.
  • No public API change.
  • The skip path logs via os_log_error so the condition is visible rather than silent.

AirheadMobile and others added 2 commits August 8, 2026 07:55
`handleAudioEngineConfigurationChange` reconnects the main mixer to the output
node using the output node's hardware format. During a route change, audio
interruption, or session deactivation that format can transiently report
0 Hz / 0 channels.

`-[AVAudioEngine connect:to:format:]` then fails its
`IsFormatSampleRateAndChannelCountValid(format)` precondition and raises an
Objective-C exception. Because the handler is `noexcept`, the exception cannot
propagate and the process terminates via `std::terminate` → SIGABRT.

Only reconfigure the connection while the output node reports a usable format.
AVAudioEngine posts a further configuration change notification once valid
output hardware is available, and the connection is updated then.

Sibling of the `[engine_ stop]` guard in sbooth#907 — the next precondition in the
same method.
@sbooth

sbooth commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Thank you for this PR. Apparently this is a known quirk of AVAudioEngine but was unknown to me.

I wonder whether instead of reconnecting everything but the output node it would be preferable to either 1) ignore the notification when an invalid output format is observed and wait for the follow-on notification with a valid format or 2) check whether the audio session is active and handle the notification differently in that case?

@CharlesWiltgen

Copy link
Copy Markdown
Author

IMHO a good path forward would be to merge this PR as it stands and treat (1) as an optional follow-up.

I'd rule out (2), because AVAudioSession has setActive: but no isActive getter. Activation would have to be inferred from outputNumberOfChannels/sampleRate/currentRoute, all of which are API_UNAVAILABLE(macos), and this handler is compiled for macOS too, unlike handleAudioSessionInterruption. Those properties also derive from the same route state the output format already reports, and a route change or media services reset arrives with the session still active, so the format check would be needed anyway.

(1) is close to what the patch already does: the guard skips the entire reconfiguration block, stop through prepare. What's left is the flag bookkeeping and the restart. Returning early drops prevState, so the follow-on notification wouldn't know the engine had been running and playback wouldn't resume. That needs the pre-change state held between the two notifications, a preInterruptState_ equivalent that isn't iOS-only. I'm happy to push that version if you'd prefer it!

Thank you, @sbooth! [*Human written/reviewed, sanity-checked with AI]

@sbooth

sbooth commented Sep 12, 2026

Copy link
Copy Markdown
Owner

I finally got around to this: please give #1000 a try

@sbooth

sbooth commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Fixed in #1000

@sbooth sbooth closed this Sep 13, 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