diff --git a/mobile/modules/bluetooth-sdk/ios/Source/ObservableStore.swift b/mobile/modules/bluetooth-sdk/ios/Source/ObservableStore.swift index 7be1feabb9..8e6f438680 100644 --- a/mobile/modules/bluetooth-sdk/ios/Source/ObservableStore.swift +++ b/mobile/modules/bluetooth-sdk/ios/Source/ObservableStore.swift @@ -13,6 +13,16 @@ class ObservableStore { private var onEmit: ((String, [String: Any]) -> Void)? private var listeners: [String: (String, [String: Any]) -> Void] = [:] + // Serialize every access to `values` / `listeners`. This type is annotated + // @MainActor, but it is reached from BLE background callbacks through the + // Obj-C/React Native bridge, which bypasses Swift actor isolation. The + // resulting data race corrupts the backing dictionary and crashes with + // "-[__NSTaggedDate countByEnumeratingWithState:objects:count:]: unrecognized + // selector" inside getCategory's enumeration. A recursive lock makes the + // dictionary access safe regardless of the calling thread (recursive so a + // listener firing on the same thread can safely re-enter). + private let lock = NSRecursiveLock() + nonisolated static let bluetoothCategory = "bluetooth" private nonisolated static let legacyCoreCategory = "core" @@ -26,45 +36,60 @@ class ObservableStore { func addListener(_ listener: @escaping (String, [String: Any]) -> Void) -> String { let id = UUID().uuidString + lock.lock() listeners[id] = listener + lock.unlock() return id } func removeListener(_ id: String) { + lock.lock() listeners.removeValue(forKey: id) + lock.unlock() } func set(_ category: String, _ key: String, _ value: Any) { let normalizedCategory = Self.normalizeCategory(category) let fullKey = "\(normalizedCategory).\(key)" - let oldValue = values[fullKey] + lock.lock() + let oldValue = values[fullKey] // Skip if unchanged if let old = oldValue, areEqual(old, value) { + lock.unlock() return } - values[fullKey] = value + // Snapshot listeners under the lock, then release before emitting so a + // listener that re-enters the store on another thread cannot deadlock. + let listenersSnapshot = Array(listeners.values) + lock.unlock() // Emit immediately let changes = [key: value] onEmit?(normalizedCategory, changes) - for listener in Array(listeners.values) { + for listener in listenersSnapshot { listener(normalizedCategory, changes) } } func get(_ category: String, _ key: String) -> Any? { - values["\(Self.normalizeCategory(category)).\(key)"] + lock.lock() + defer { lock.unlock() } + return values["\(Self.normalizeCategory(category)).\(key)"] } func wouldSkipSet(_ category: String, _ key: String, _ value: Any) -> Bool { let fullKey = "\(Self.normalizeCategory(category)).\(key)" + lock.lock() + defer { lock.unlock() } guard let oldValue = values[fullKey] else { return false } return areEqual(oldValue, value) } func getCategory(_ category: String) -> [String: Any] { + lock.lock() + defer { lock.unlock() } var result: [String: Any] = [:] let prefix = "\(Self.normalizeCategory(category))." for (key, value) in values where key.hasPrefix(prefix) {