Skip to content

fix(bluetooth-sdk/ios): make ObservableStore thread-safe#3104

Open
cedricshan wants to merge 1 commit into
Mentra-Community:devfrom
cedricshan:observablestore-threadsafety
Open

fix(bluetooth-sdk/ios): make ObservableStore thread-safe#3104
cedricshan wants to merge 1 commit into
Mentra-Community:devfrom
cedricshan:observablestore-threadsafety

Conversation

@cedricshan

@cedricshan cedricshan commented Jun 7, 2026

Copy link
Copy Markdown

Summary

ObservableStore (iOS) 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

Crash backtrace (observed in Xcode, main thread aborts)

__pthread_kill
...
ObservableStore.getCategory(_:)
MentraBluetoothSDK.bluetoothStatus.getter      // DeviceStore.shared.store.getCategory(.bluetoothCategory)
MentraBluetoothSDK.state.getter
MentraBluetoothSDK.dispatchStoreUpdate(_:)
closure #1 in closure #2 in MentraBluetoothSDK...

The crash surfaces inside getCategory()'s for (key, value) in values enumeration once the dictionary reference has been corrupted by the data race (the corrupted pointer 0x8000000000000000 is a tagged NSDate). Because it is a race, it is intermittent — it typically fires after the BLE link has been up for a while and status updates are flowing.

Fix

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 the store.
  • In set(), listeners are snapshotted under the lock and the lock is released before emitting, so a cross-thread re-entrant listener cannot deadlock.
  • Covers set, get, getCategory, wouldSkipSet, addListener, removeListener.

This makes the dictionary access safe regardless of the calling thread without changing the public API or the @MainActor annotation.

Notes / scope

  • iOS only. The Android ObservableStore.kt is out of scope for this PR.
  • No behavior change beyond serialization; the emit path and skip-if-unchanged semantics are preserved.

Test plan

  • Builds.
  • Ran on device (Mentra Live + iPhone, Expo 56 / RN 0.85, New Architecture). The __NSTaggedDate abort no longer reproduces over extended BLE status streaming where it previously crashed intermittently.

Made with Cursor


Note

Medium Risk
Touches shared BLE state used on every status update; locking changes concurrency semantics but is narrowly scoped and preserves existing emit behavior.

Overview
Fixes intermittent iOS crashes from unsynchronized access to ObservableStore's backing dictionaries when BLE / RN bridge callbacks hit the store off the main thread despite @MainActor.

The change adds an NSRecursiveLock around all reads and writes to values and listeners (get, set, getCategory, wouldSkipSet, addListener, removeListener). In set(), listeners are snapshotted under the lock and notifications run after unlock so cross-thread re-entry cannot deadlock. Public API and emit / skip-if-unchanged behavior stay the same.

Reviewed by Cursor Bugbot for commit 0c0fbb5. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Make the iOS ObservableStore thread-safe to stop intermittent crashes caused by BLE background callbacks bypassing @MainActor. Fixes the -[__NSTaggedDate …]: unrecognized selector crash during category enumeration.

  • Bug Fixes
    • Guard all access to values and listeners with NSRecursiveLock.
    • In set, snapshot listeners under the lock and unlock before emitting to avoid re-entrant deadlocks.
    • Applies to set, get, getCategory, wouldSkipSet, addListener, removeListener; no API/behavior changes; iOS only.

Written for commit 0c0fbb5. Summary will update on new commits.

Review in cubic

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 <cursoragent@cursor.com>
@cedricshan cedricshan requested a review from a team as a code owner June 7, 2026 05:36

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

Re-trigger cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant