Skip to content

Commit 0702374

Browse files
committed
fix: race in VolumeMonitor listener cleanup
stopListening/startListening race meant async cleanup read the new device ID and even cleared the new listener reference. Old listeners leaked on every device switch.
1 parent 0b726ca commit 0702374

1 file changed

Lines changed: 45 additions & 35 deletions

File tree

VolumeGrid/Audio/VolumeMonitor.swift

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -215,26 +215,29 @@ class VolumeMonitor: ObservableObject {
215215
deviceChangeDebounceTask?.cancel()
216216
keyPressDebounceTask?.cancel()
217217

218-
// CRITICAL: Ensure CoreAudio listeners are removed before deallocation
219-
// Must use strong references to dependencies to guarantee cleanup even if deinit runs on background thread
220-
let state = self.state
218+
// Capture all state synchronously during deinit before dispatching to main thread.
219+
let capturedDeviceID = state.listeningDeviceIDValue()
220+
let capturedVolumeElements = state.registeredVolumeElementsSnapshot()
221+
let capturedMuteElements = state.registeredMuteElementsSnapshot()
221222
let deviceManager = self.deviceManager
222223
let audioQueue = self.audioQueue
223224
let systemEventMonitor = self.systemEventMonitor
224-
let volumeListener = self.volumeListener
225-
let muteListener = self.muteListener
226-
let deviceListener = self.deviceListener
225+
let capturedVolumeListener = self.volumeListener
226+
let capturedMuteListener = self.muteListener
227+
let capturedDeviceListener = self.deviceListener
227228

228229
// Always dispatch to main thread for @MainActor performCleanup
229230
DispatchQueue.main.async {
230231
Self.performCleanup(
231-
state: state,
232+
capturedDeviceID: capturedDeviceID,
233+
capturedVolumeElements: capturedVolumeElements,
234+
capturedMuteElements: capturedMuteElements,
232235
deviceManager: deviceManager,
233236
audioQueue: audioQueue,
234237
systemEventMonitor: systemEventMonitor,
235-
volumeListener: volumeListener,
236-
muteListener: muteListener,
237-
deviceListener: deviceListener
238+
volumeListener: capturedVolumeListener,
239+
muteListener: capturedMuteListener,
240+
deviceListener: capturedDeviceListener
238241
)
239242
}
240243
}
@@ -632,7 +635,9 @@ class VolumeMonitor: ObservableObject {
632635

633636
@MainActor
634637
private static func performCleanup(
635-
state: VolumeStateStore,
638+
capturedDeviceID: AudioDeviceID?,
639+
capturedVolumeElements: [AudioObjectPropertyElement],
640+
capturedMuteElements: [AudioObjectPropertyElement],
636641
deviceManager: AudioDeviceManager,
637642
audioQueue: DispatchQueue,
638643
systemEventMonitor: SystemEventMonitor,
@@ -657,12 +662,10 @@ class VolumeMonitor: ObservableObject {
657662
mElement: 0
658663
)
659664

660-
let removalDeviceID =
661-
state.listeningDeviceIDValue() ?? deviceManager.getDefaultOutputDevice()
665+
let removalDeviceID = capturedDeviceID ?? deviceManager.getDefaultOutputDevice()
662666

663667
if let volumeListener = volumeListener {
664-
let registeredVolumes = state.registeredVolumeElementsSnapshot()
665-
for element in registeredVolumes {
668+
for element in capturedVolumeElements {
666669
var volumeAddress = deviceManager.makePropertyAddress(
667670
selector: kAudioDevicePropertyVolumeScalar, element: element)
668671
if removalDeviceID != 0 {
@@ -673,8 +676,7 @@ class VolumeMonitor: ObservableObject {
673676
}
674677

675678
if let muteListener = muteListener {
676-
let registeredMutes = state.registeredMuteElementsSnapshot()
677-
for element in registeredMutes {
679+
for element in capturedMuteElements {
678680
var muteAddress = deviceManager.makePropertyAddress(
679681
selector: kAudioDevicePropertyMute, element: element)
680682
if removalDeviceID != 0 {
@@ -688,10 +690,7 @@ class VolumeMonitor: ObservableObject {
688690
AudioObjectRemovePropertyListenerBlock(
689691
AudioObjectID(kAudioObjectSystemObject), &deviceAddress, audioQueue, deviceListener)
690692
}
691-
692-
state.updateListeningDeviceID(nil)
693-
state.updateRegisteredVolumeElements([])
694-
state.updateRegisteredMuteElements([])
693+
// State and listener properties are already cleared synchronously in stopListening()/deinit.
695694
}
696695

697696
func stopListening() {
@@ -703,30 +702,41 @@ class VolumeMonitor: ObservableObject {
703702
// This ensures startListening() called right after will be blocked by the guard check
704703
state.setListeningActive(false)
705704

706-
// Capture dependencies strongly for cleanup
707-
let state = self.state
705+
// Synchronously capture and clear all state before dispatching async cleanup.
706+
// Clearing here (not in the async block) ensures that startListening()'s subsequent
707+
// writes are never overwritten by a delayed cleanup dispatch.
708+
let capturedDeviceID = state.listeningDeviceIDValue()
709+
let capturedVolumeElements = state.registeredVolumeElementsSnapshot()
710+
let capturedMuteElements = state.registeredMuteElementsSnapshot()
711+
let capturedVolumeListener = self.volumeListener
712+
let capturedMuteListener = self.muteListener
713+
let capturedDeviceListener = self.deviceListener
714+
715+
state.updateListeningDeviceID(nil)
716+
state.updateRegisteredVolumeElements([])
717+
state.updateRegisteredMuteElements([])
718+
self.volumeListener = nil
719+
self.muteListener = nil
720+
self.deviceListener = nil
721+
722+
// Capture strong dependencies for async cleanup
708723
let deviceManager = self.deviceManager
709724
let audioQueue = self.audioQueue
710725
let systemEventMonitor = self.systemEventMonitor
711-
let volumeListener = self.volumeListener
712-
let muteListener = self.muteListener
713-
let deviceListener = self.deviceListener
714726

715727
// Dispatch cleanup to main thread with strong references
716-
DispatchQueue.main.async { [weak self] in
728+
DispatchQueue.main.async {
717729
Self.performCleanup(
718-
state: state,
730+
capturedDeviceID: capturedDeviceID,
731+
capturedVolumeElements: capturedVolumeElements,
732+
capturedMuteElements: capturedMuteElements,
719733
deviceManager: deviceManager,
720734
audioQueue: audioQueue,
721735
systemEventMonitor: systemEventMonitor,
722-
volumeListener: volumeListener,
723-
muteListener: muteListener,
724-
deviceListener: deviceListener
736+
volumeListener: capturedVolumeListener,
737+
muteListener: capturedMuteListener,
738+
deviceListener: capturedDeviceListener
725739
)
726-
// Clear instance listener properties to release closures
727-
self?.volumeListener = nil
728-
self?.muteListener = nil
729-
self?.deviceListener = nil
730740
}
731741
}
732742

0 commit comments

Comments
 (0)