From 0c0fbb5c90bae4511f00862d3ec39751fcf80798 Mon Sep 17 00:00:00 2001 From: Yuan Shan Date: Sun, 7 Jun 2026 01:36:14 -0400 Subject: [PATCH] fix(bluetooth-sdk/ios): make ObservableStore thread-safe ObservableStore is annotated @MainActor, but it is reached from BLE background callbacks through the Obj-C/React Native bridge, which bypasses Swift actor isolation. The unsynchronized access races on the backing `values` dictionary and intermittently crashes the app with: -[__NSTaggedDate countByEnumeratingWithState:objects:count:]: unrecognized selector sent to instance 0x8000000000000000 The crash surfaces inside getCategory()'s `for (key, value) in values` enumeration (via DeviceStore.shared.store.getCategory in the bluetoothStatus getter, during dispatchStoreUpdate) once the dictionary reference has been corrupted by the race. Guard all reads/writes of `values` and `listeners` with an NSRecursiveLock (recursive so a listener firing synchronously on the same thread can safely re-enter). In set(), listeners are snapshotted under the lock and the lock is released before emitting so a cross-thread re-entrant listener cannot deadlock. Co-authored-by: Cursor --- .../ios/Source/ObservableStore.swift | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) 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) {