-
Notifications
You must be signed in to change notification settings - Fork 116
Description
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 = falsesetOnConnected()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 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 = falseWrite 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.characteristicsCountEnvironment
- 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).