Skip to content

iOS: Thread-safety race condition on skipDescriptorDiscovery causes connection timeout on reconnect #809

@jisaballo

Description

@jisaballo

Bug Description

When skipDescriptorDiscovery: true is passed in the connect options, the first connection always succeeds, but subsequent connections to the same peripheral consistently fail with "Connection timeout".

Root Cause

Device.swift stores skipDescriptorDiscovery as a plain Bool property with no synchronization:

private var skipDescriptorDiscovery = false
  • setOnConnected() writes this property from Capacitor's bridge thread
  • didDiscoverCharacteristicsFor() reads it from the main thread (CoreBluetooth delegate queue, set in DeviceManager.swift via CBCentralManager(delegate: self, queue: DispatchQueue.main))

On the first connect, CoreBluetooth performs real GATT discovery (~1s), giving the bridge thread's write enough time to propagate to the main thread. On subsequent connects, CoreBluetooth uses cached GATT data, making didDiscoverCharacteristicsFor fire near-instantly — before the bridge thread's write is visible on the main thread. The stale default value false is read, causing the code to wait for descriptor discovery that never completes (since descriptors were never requested), and the connection times out.

Steps to Reproduce

  1. Call BleClient.connect(deviceId, callback, { skipDescriptorDiscovery: true })
  2. Perform a BLE operation (read/write)
  3. Disconnect
  4. Immediately connect again with the same options
  5. "Connection timeout" every time

Expected Behavior

All connections should succeed when skipDescriptorDiscovery: true is passed, regardless of whether it's the first or subsequent connection.

Proposed Fix

Use NSLock to synchronize access to skipDescriptorDiscovery:

// Device.swift

// Replace:
private var skipDescriptorDiscovery = false

// With:
private let skipDescriptorDiscoveryLock = NSLock()
private var _skipDescriptorDiscovery = false

Write in setOnConnected():

self.skipDescriptorDiscoveryLock.lock()
self._skipDescriptorDiscovery = skipDescriptorDiscovery
self.skipDescriptorDiscoveryLock.unlock()

Read in didDiscoverCharacteristicsFor():

self.skipDescriptorDiscoveryLock.lock()
let skipDescriptors = self._skipDescriptorDiscovery
self.skipDescriptorDiscoveryLock.unlock()

if !skipDescriptors {
    for characteristic in service.characteristics ?? [] {
        peripheral.discoverDescriptors(for: characteristic)
    }
}

let shouldResolve = skipDescriptors
    ? self.servicesDiscovered >= self.servicesCount
    : self.servicesDiscovered >= self.servicesCount && self.characteristicsDiscovered >= self.characteristicsCount

Environment

  • Plugin version: 8.1.0
  • Capacitor: 7.x
  • iOS: 17/18
  • Device: iPhone (not simulator — requires real BLE hardware)

Additional Context

We're currently using patch-package with this fix in production. Happy to submit a PR if the maintainers agree with the approach.


Update: Deeper Root Cause Found

After testing PR #810, I found that DispatchQueue.main.sync alone does not fix the issue. The actual root cause is in ThreadSafeDictionary.swift — the subscript setter uses queue.async(flags: .barrier):

set {
    queue.async(flags: .barrier) { self.dictionary[key] = newValue }
}

This means self.callbackMap[key] = callback in setOnConnected returns before the write completes. On reconnect, CoreBluetooth delegates fire near-instantly (cached GATT) and call self.callbackMap.removeValue(forKey: "connect") — which uses queue.sync — but the async write hasn't materialized yet. The callback is nil, the resolve is skipped, and the connection times out.

Fix: Change the setter to synchronous:

set {
    queue.sync(flags: .barrier) { self.dictionary[key] = newValue }
}

Tested with this one-line change + PR #810's DispatchQueue.main.sync approach — all reconnections succeed consistently (6/6 connects, zero timeouts).

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingiOSImpacts the iOS platform

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions