Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions mobile/modules/bluetooth-sdk/ios/Source/ObservableStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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) {
Expand Down