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
- Call
BleClient.connect(deviceId, callback, { skipDescriptorDiscovery: true })
- Perform a BLE operation (read/write)
- Disconnect
- Immediately connect again with the same options
- → "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).
Bug Description
When
skipDescriptorDiscovery: trueis 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.swiftstoresskipDescriptorDiscoveryas a plainBoolproperty with no synchronization:setOnConnected()writes this property from Capacitor's bridge threaddidDiscoverCharacteristicsFor()reads it from the main thread (CoreBluetooth delegate queue, set inDeviceManager.swiftviaCBCentralManager(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
didDiscoverCharacteristicsForfire near-instantly — before the bridge thread's write is visible on the main thread. The stale default valuefalseis 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
BleClient.connect(deviceId, callback, { skipDescriptorDiscovery: true })Expected Behavior
All connections should succeed when
skipDescriptorDiscovery: trueis passed, regardless of whether it's the first or subsequent connection.Proposed Fix
Use
NSLockto synchronize access toskipDescriptorDiscovery:Write in
setOnConnected():Read in
didDiscoverCharacteristicsFor():Environment
Additional Context
We're currently using
patch-packagewith 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.syncalone does not fix the issue. The actual root cause is inThreadSafeDictionary.swift— the subscript setter usesqueue.async(flags: .barrier):This means
self.callbackMap[key] = callbackinsetOnConnectedreturns before the write completes. On reconnect, CoreBluetooth delegates fire near-instantly (cached GATT) and callself.callbackMap.removeValue(forKey: "connect")— which usesqueue.sync— but the async write hasn't materialized yet. The callback isnil, the resolve is skipped, and the connection times out.Fix: Change the setter to synchronous:
Tested with this one-line change + PR #810's
DispatchQueue.main.syncapproach — all reconnections succeed consistently (6/6 connects, zero timeouts).