Thread Safety - AudioDeviceManager singleton accessed from multiple threads without synchronization
Thread Safety - AudioDeviceManager singleton accessed from multiple threads without synchronization
Labels: bug, concurrency, crash, high-priority
Description
AudioDeviceManager is a singleton with @Published properties accessed from multiple threads without proper synchronization. The class lacks @MainActor annotation despite using SwiftUI property wrappers that require main thread updates, and has unsynchronized properties like isRecordingActive that can be modified from any thread.
User Impact
-
Random crashes when connecting/disconnecting audio devices during recording
-
Wrong microphone selected when multiple devices are connected/disconnected rapidly
-
Audio recording failures due to race conditions during device selection
-
App freezes or unresponsive audio settings UI
Technical Details
File: VoiceInk/Services/AudioDeviceManager.swift
Lines: 18-448
Issues identified:
-
Class not marked
@MainActordespite using@Publishedproperties (line 18) -
Audio callback runs on audio thread and accesses shared state (lines 352-357)
-
Unsynchronized property access:
-
isRecordingActive(line 26) - plain var accessed from multiple contexts -
selectedDeviceID(line 21) - sometimes wrapped inDispatchQueue.main.async, sometimes not -
availableDevices(line 20) - modified from callback thread
-
Example race condition:
// Line 353: Audio thread callback
let manager = Unmanaged<AudioDeviceManager>.fromOpaque(userData!).takeUnretainedValue()
DispatchQueue.main.async {
manager.handleDeviceListChange() // Main thread
}
// Line 228: Can be called from any thread
inputMode = mode // @Published property - must be on main thread!
Reproduction
-
Connect Bluetooth headphones while recording is active
-
Rapidly switch between audio devices in System Preferences
-
Disconnect USB microphone during recording
-
Observe potential crashes or incorrect device selection
Recommended Fix
Add @MainActor annotation to the class (line 18):
@MainActor
class AudioDeviceManager: ObservableObject {
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "AudioDeviceManager")
@Published var availableDevices: [(id: AudioDeviceID, uid: String, name: String)] = []
@Published var selectedDeviceID: AudioDeviceID?
@Published var inputMode: AudioInputMode = .systemDefault
@Published var prioritizedDevices: [PrioritizedDevice] = []
var fallbackDeviceID: AudioDeviceID?
var isRecordingActive: Bool = false
static let shared = AudioDeviceManager()
// ... rest of implementation
}
The audio callback at line 354 already uses DispatchQueue.main.async, which is compatible with @MainActor.
Testing
-
Connect/disconnect multiple audio devices during recording
-
Verify no crashes occur
-
Test device switching in Settings while recording is active
-
Verify correct device is selected after rapid device changes