coreaudio: unregister interruption observer unconditionally on close#15439
coreaudio: unregister interruption observer unconditionally on close#15439jonameson wants to merge 2 commits intolibsdl-org:SDL2from
Conversation
update_audio_session()'s existing close-path observer teardown lived at the bottom of the function, after two [AVAudioSession setCategory:] and one [setActive:] calls that early-return on failure. When setCategory fails during close (phone call, Siri, CarPlay / AirPlay handoff, or a session owned by another app), the early-return skipped the unregister and the SDLInterruptionListener stayed subscribed to NSNotificationCenter with a stale device pointer. The next UIApplicationWillEnterForeground or UIApplicationDidBecomeActive notification then dispatched into interruption_end() on freed memory, crashing with EXC_BAD_ACCESS. Move the close-path unregister to the top of the function so it runs unconditionally, before any early-return. Also add self.device == NULL guards in both listener callbacks as defense in depth so that any future cleanup-flow edit can't silently reintroduce the crash. The sibling setActive:YES leak on last-device close was fixed in 2018 (issue libsdl-org#2900). This closes the setCategory: leak that has remained since. The same root cause is reported in SDL3 as libsdl-org#12660. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9e601b3 to
db04f7b
Compare
|
This looks good. Once you fix the CI errors I'll go ahead and merged this. Can you please also make a PR for main? |
|
|
The bigger question is: why are we trying to set up the audio session when closing the audio device? I do think Claude's patch will stop the crash, but I think the actual bug here is that update_audio_session is doing way more than it should during COREAUDIO_CloseDevice. |
|
Tossing this into 3.4.6 to try my idea (and will backport to SDL2). |
Yeah, update_audio_session does do a lot... but we aren't trying to setup audio session when closing but we are reusing and cleaning up the audio session on closing the audio device. Just looks like a large method that a refactor would eventually be nice. So about the AI use for patch, I just couldn't not share it... we had it review our app for top crashes and this came up and the suggested patch that made sense. 😅 |
|
Any ideas why we're failing here? This seems like existing failing checks. I didn't change any declarations: |
Unlike SDL3 (main), SDL2 builds with diff --git a/src/audio/coreaudio/SDL_coreaudio.m b/src/audio/coreaudio/SDL_coreaudio.m
index 95251b2..c33f3db 100644
--- a/src/audio/coreaudio/SDL_coreaudio.m
+++ b/src/audio/coreaudio/SDL_coreaudio.m
@@ -351,11 +351,12 @@ static void interruption_end(_THIS)
- (void)audioSessionInterruption:(NSNotification *)note
{
@synchronized(self) {
+ NSNumber *type;
/* Defensive: skip if the device was already torn down. */
if (self.device == NULL) {
return;
}
- NSNumber *type = note.userInfo[AVAudioSessionInterruptionTypeKey];
+ type = note.userInfo[AVAudioSessionInterruptionTypeKey];
if (type.unsignedIntegerValue == AVAudioSessionInterruptionTypeBegan) {
interruption_begin(self.device);
} else {
@@ -382,6 +383,12 @@ static BOOL update_audio_session(_THIS, SDL_bool open, SDL_bool allow_playandrec
AVAudioSession *session = [AVAudioSession sharedInstance];
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
+ NSString *category;
+ NSString *mode;
+ NSUInteger options;
+ NSError *err = nil;
+ const char *hint;
+
/* Close path: unregister the interruption observer FIRST, before any
code that can early-return. [AVAudioSession setCategory:] failures
(phone call, Siri, CarPlay / AirPlay handoff, etc.) would otherwise
@@ -399,11 +406,9 @@ static BOOL update_audio_session(_THIS, SDL_bool open, SDL_bool allow_playandrec
}
}
- NSString *category = AVAudioSessionCategoryPlayback;
- NSString *mode = AVAudioSessionModeDefault;
- NSUInteger options = AVAudioSessionCategoryOptionMixWithOthers;
- NSError *err = nil;
- const char *hint;
+ category = AVAudioSessionCategoryPlayback;
+ mode = AVAudioSessionModeDefault;
+ options = AVAudioSessionCategoryOptionMixWithOthers;
hint = SDL_GetHint(SDL_HINT_AUDIO_CATEGORY);
if (hint) { |
…er-statement The iOS/tvOS CI builds compile with -Werror=declaration-after-statement. The previous ordering put the new guard and close-path blocks above existing C89-style declarations in the same scope, which tripped the warning. Reorder the inserted blocks so declarations stay contiguous at the top of each scope. No logic change.
This makes UpdateAudioSession only deal with setting the category/options; actually setting the session active and adding interruption listeners is moved to COREAUDIO_OpenDevice. OpenDevice exclusively calls UpdateAudioSession now; there's no reason to change the session during device close...even if we could loosen the session's config when closing a recording device but leaving playback running (or vice-versa), it doesn't seem worth it. Likewise, deactivating the session and removing listeners is now handled in COREAUDIO_CloseDevice. The attempt to try setting a more limited category if setting the session to simultaneous recording and playback fails has been removed; this would presumably cause problems in general, and different problems depending on which device you opened first. It's better to just fail in this case, I think. A bunch of code that proved superfluous is now gone; we don't enumerate all devices to count open ones (we just maintain a simple global counter instead, as atomic ints, just in case this might have a subtle threading concern). The (Pause|Resume)AllDevices() code is gone, as we don't need it anymore with the simplified session management. We still pause/resume per-device in the interruption listener. This should also fix a subtle crash bug, where we sometimes fail to change the session on close, causing an early return from UpdateAudioSession and thus never unregistering the listeners, which would touch a free'd pointer if the the listener fires later. Now the listeners are always unregistered in CloseDevice and UpdateAudioSession is never called from there at all. Closes libsdl-org#15439.
This makes UpdateAudioSession only deal with setting the category/options; actually setting the session active and adding interruption listeners is moved to COREAUDIO_OpenDevice. OpenDevice exclusively calls UpdateAudioSession now; there's no reason to change the session during device close...even if we could loosen the session's config when closing a recording device but leaving playback running (or vice-versa), it doesn't seem worth it. Likewise, deactivating the session and removing listeners is now handled in COREAUDIO_CloseDevice. The attempt to try setting a more limited category if setting the session to simultaneous recording and playback fails has been removed; this would presumably cause problems in general, and different problems depending on which device you opened first. It's better to just fail in this case, I think. A bunch of code that proved superfluous is now gone; we don't enumerate all devices to count open ones (we just maintain a simple global counter instead, as atomic ints, just in case this might have a subtle threading concern). The (Pause|Resume)AllDevices() code is gone, as we don't need it anymore with the simplified session management. We still pause/resume per-device in the interruption listener. This should also fix a subtle crash bug, where we sometimes fail to change the session on close, causing an early return from UpdateAudioSession and thus never unregistering the listeners, which would touch a free'd pointer if the the listener fires later. Now the listeners are always unregistered in CloseDevice and UpdateAudioSession is never called from there at all. Closes libsdl-org#15439.
Summary
Fix a long-standing CoreAudio observer-lifetime bug where
SDLInterruptionListeneroutlives its backingSDL_AudioDevice, leading to an EXC_BAD_ACCESS on the next app foreground.update_audio_session()registers the listener on open and unregisters it on close — but the close-path unregister sits at the bottom of the function, after[AVAudioSession setCategory:…error:&err]and[setActive:…error:&err]calls that canreturn NOearly. If any of those AVAudioSession calls fails during close (which happens in the wild: phone calls, Siri, CarPlay / AirPlay handoff, session owned by another app), the listener is never removed fromNSNotificationCenter. Itsdevicepointer continues to reference memory that is freed bySDL_free(this->hidden)inCOREAUDIO_CloseDeviceright afterupdate_audio_sessionreturns. A laterUIApplicationWillEnterForegroundNotificationorUIApplicationDidBecomeActiveNotificationis then delivered to the now-dangling listener, which callsinterruption_end(self.device)and crashes.This is the same class of bug as #2900 (2018) — except that fix only closed the
setActive:YESearly-return path on last-device close. ThesetCategory:early-return paths have remained since. We're seeing it in production from a real-world app with ~40 crashes over 7 days on a single app version, all with the exact stack pattern (SDL_SemPost/interruption_end-adjacent →__CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__→-[UIApplication _sendWillEnterForegroundCallbacks]). The same root cause is still reported against SDL3 in #12660.Change
update_audio_session(), so it runs unconditionally before anysetCategory:/setActive:early-return. Close is a teardown path; session-config failures should not be able to leak resources.elsebranch.if (self.device == NULL) return;guards at the top of bothSDLInterruptionListenercallbacks (audioSessionInterruption:andapplicationBecameActive:). These are defense-in-depth — with the top-of-function unregister in place, the callbacks should never fire post-close, but the guards prevent any future edit to the cleanup flow from silently re-introducing the crash.No API surface changes, no behavioral change on the open path, no behavioral change on the close path when all AVAudioSession calls succeed.
Testing
testautomationsuite passes locally on macOS (no new failures vs. the baseline).SDL_OpenAudioDevice/SDL_CloseAudioDeviceinside an Fc2AudioMixer wrapper), thewillEnterForeground-after-background crash stops reproducing. Without the patch, injecting a simulatedsetCategory:failure at close reproduces the crash reliably.Notes
main(file renamed toUpdateAudioSession, same structural bug) can be done as a follow-up PR if the project prefers it separated.