VoiceInk icon indicating copy to clipboard operation
VoiceInk copied to clipboard

Thread Safety - AudioDeviceManager singleton accessed from multiple threads without synchronization

Open ebrindley opened this issue 1 month ago • 0 comments

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:

  1. Class not marked @MainActor despite using @Published properties (line 18)

  2. Audio callback runs on audio thread and accesses shared state (lines 352-357)

  3. Unsynchronized property access:

    • isRecordingActive (line 26) - plain var accessed from multiple contexts

    • selectedDeviceID (line 21) - sometimes wrapped in DispatchQueue.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

  1. Connect Bluetooth headphones while recording is active

  2. Rapidly switch between audio devices in System Preferences

  3. Disconnect USB microphone during recording

  4. 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

  1. Connect/disconnect multiple audio devices during recording

  2. Verify no crashes occur

  3. Test device switching in Settings while recording is active

  4. Verify correct device is selected after rapid device changes

ebrindley avatar Nov 12 '25 18:11 ebrindley