Skip to content

Commit 9e601b3

Browse files
jonamesonclaude
andcommitted
coreaudio: unregister interruption observer unconditionally on close
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 #2900). This closes the setCategory: leak that has remained since. The same root cause is reported in SDL3 as #12660. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c8ebb14 commit 9e601b3

1 file changed

Lines changed: 32 additions & 7 deletions

File tree

src/audio/coreaudio/SDL_coreaudio.m

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,13 @@ @implementation SDLInterruptionListener
351351
- (void)audioSessionInterruption:(NSNotification *)note
352352
{
353353
@synchronized(self) {
354+
/* Defense in depth: if the device was already torn down, do nothing.
355+
The close path now unregisters at the top of update_audio_session,
356+
so this should not fire after close, but guarding here keeps the
357+
callback safe if a future edit rearranges the cleanup flow. */
358+
if (self.device == NULL) {
359+
return;
360+
}
354361
NSNumber *type = note.userInfo[AVAudioSessionInterruptionTypeKey];
355362
if (type.unsignedIntegerValue == AVAudioSessionInterruptionTypeBegan) {
356363
interruption_begin(self.device);
@@ -363,6 +370,9 @@ - (void)audioSessionInterruption:(NSNotification *)note
363370
- (void)applicationBecameActive:(NSNotification *)note
364371
{
365372
@synchronized(self) {
373+
if (self.device == NULL) {
374+
return;
375+
}
366376
interruption_end(self.device);
367377
}
368378
}
@@ -375,6 +385,25 @@ static BOOL update_audio_session(_THIS, SDL_bool open, SDL_bool allow_playandrec
375385
AVAudioSession *session = [AVAudioSession sharedInstance];
376386
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
377387

388+
/* Close path: unregister the interruption observer FIRST, before any
389+
code that can early-return. When [AVAudioSession setCategory:] fails
390+
(phone call, Siri, CarPlay or AirPlay handoff, session owned by
391+
another app), the early-return below previously left the listener
392+
registered with a stale device pointer; NotificationCenter would
393+
later deliver foreground events to freed memory, crashing in
394+
interruption_end(). See issue #2900 for the sibling setActive:YES
395+
leak that was fixed in 2018; this closes the setCategory: leak
396+
that has remained since. */
397+
if (!open && this->hidden != NULL && this->hidden->interruption_listener != NULL) {
398+
SDLInterruptionListener *listener =
399+
(SDLInterruptionListener *)CFBridgingRelease(this->hidden->interruption_listener);
400+
this->hidden->interruption_listener = NULL;
401+
[center removeObserver:listener];
402+
@synchronized(listener) {
403+
listener.device = NULL;
404+
}
405+
}
406+
378407
NSString *category = AVAudioSessionCategoryPlayback;
379408
NSString *mode = AVAudioSessionModeDefault;
380409
NSUInteger options = AVAudioSessionCategoryOptionMixWithOthers;
@@ -498,14 +527,10 @@ static BOOL update_audio_session(_THIS, SDL_bool open, SDL_bool allow_playandrec
498527
object:nil];
499528

500529
this->hidden->interruption_listener = CFBridgingRetain(listener);
501-
} else {
502-
SDLInterruptionListener *listener = nil;
503-
listener = (SDLInterruptionListener *)CFBridgingRelease(this->hidden->interruption_listener);
504-
[center removeObserver:listener];
505-
@synchronized(listener) {
506-
listener.device = NULL;
507-
}
508530
}
531+
/* Close-path unregister is handled at the top of this function so it
532+
runs unconditionally, even when a setCategory:/setActive:
533+
early-return skips the rest of the body. */
509534
}
510535

511536
return YES;

0 commit comments

Comments
 (0)