fix: prevent re-entrant disconnect callbacks and handle all disconnect states
- Add isTerminating flag to guard succeed()/fail() against double invocation from racing didWriteValueFor + didDisconnectPeripheral callbacks - Only call cancelPeripheralConnection when peripheral.state == .connected (avoids triggering spurious didDisconnectPeripheral on already-disconnected peripheral) - Handle disconnect during device info read (post-auth) with specific error message - Include state info in unexpected disconnect errors for easier debugging - Early-return structure in disconnect handler for clearer control flow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f7a554e282
commit
54fa973d34
1 changed files with 57 additions and 16 deletions
|
|
@ -157,6 +157,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
||||||
private var dxSmartWriteIndex = 0
|
private var dxSmartWriteIndex = 0
|
||||||
private var provisioningMacAddress: String?
|
private var provisioningMacAddress: String?
|
||||||
private var awaitingDeviceInfoForProvisioning = false
|
private var awaitingDeviceInfoForProvisioning = false
|
||||||
|
private var isTerminating = false // guards against re-entrant disconnect handling
|
||||||
|
|
||||||
// Read config mode
|
// Read config mode
|
||||||
private enum OperationMode { case provisioning, readingConfig }
|
private enum OperationMode { case provisioning, readingConfig }
|
||||||
|
|
@ -214,6 +215,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
||||||
self.dxSmartWriteIndex = 0
|
self.dxSmartWriteIndex = 0
|
||||||
self.provisioningMacAddress = nil
|
self.provisioningMacAddress = nil
|
||||||
self.awaitingDeviceInfoForProvisioning = false
|
self.awaitingDeviceInfoForProvisioning = false
|
||||||
|
self.isTerminating = false
|
||||||
self.connectionRetryCount = 0
|
self.connectionRetryCount = 0
|
||||||
self.currentBeacon = beacon
|
self.currentBeacon = beacon
|
||||||
|
|
||||||
|
|
@ -262,6 +264,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
||||||
self.dxReadQueryIndex = 0
|
self.dxReadQueryIndex = 0
|
||||||
self.allDiscoveredServices.removeAll()
|
self.allDiscoveredServices.removeAll()
|
||||||
self.connectionRetryCount = 0
|
self.connectionRetryCount = 0
|
||||||
|
self.isTerminating = false
|
||||||
self.currentBeacon = beacon
|
self.currentBeacon = beacon
|
||||||
self.servicesToExplore.removeAll()
|
self.servicesToExplore.removeAll()
|
||||||
|
|
||||||
|
|
@ -295,6 +298,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
||||||
dxSmartWriteIndex = 0
|
dxSmartWriteIndex = 0
|
||||||
provisioningMacAddress = nil
|
provisioningMacAddress = nil
|
||||||
awaitingDeviceInfoForProvisioning = false
|
awaitingDeviceInfoForProvisioning = false
|
||||||
|
isTerminating = false
|
||||||
connectionRetryCount = 0
|
connectionRetryCount = 0
|
||||||
currentBeacon = nil
|
currentBeacon = nil
|
||||||
state = .idle
|
state = .idle
|
||||||
|
|
@ -302,9 +306,14 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fail(_ message: String, code: ProvisioningError? = nil) {
|
private func fail(_ message: String, code: ProvisioningError? = nil) {
|
||||||
|
guard !isTerminating else {
|
||||||
|
DebugLog.shared.log("BLE: fail() called but already terminating, ignoring")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isTerminating = true
|
||||||
DebugLog.shared.log("BLE: Failed [\(code?.rawValue ?? "UNTYPED")] - \(message)")
|
DebugLog.shared.log("BLE: Failed [\(code?.rawValue ?? "UNTYPED")] - \(message)")
|
||||||
state = .failed(message)
|
state = .failed(message)
|
||||||
if let peripheral = peripheral {
|
if let peripheral = peripheral, peripheral.state == .connected {
|
||||||
centralManager.cancelPeripheralConnection(peripheral)
|
centralManager.cancelPeripheralConnection(peripheral)
|
||||||
}
|
}
|
||||||
if let code = code {
|
if let code = code {
|
||||||
|
|
@ -316,9 +325,14 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func succeed() {
|
private func succeed() {
|
||||||
|
guard !isTerminating else {
|
||||||
|
DebugLog.shared.log("BLE: succeed() called but already terminating, ignoring")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isTerminating = true
|
||||||
DebugLog.shared.log("BLE: Success! MAC=\(provisioningMacAddress ?? "unknown")")
|
DebugLog.shared.log("BLE: Success! MAC=\(provisioningMacAddress ?? "unknown")")
|
||||||
state = .success
|
state = .success
|
||||||
if let peripheral = peripheral {
|
if let peripheral = peripheral, peripheral.state == .connected {
|
||||||
centralManager.cancelPeripheralConnection(peripheral)
|
centralManager.cancelPeripheralConnection(peripheral)
|
||||||
}
|
}
|
||||||
let mac = provisioningMacAddress
|
let mac = provisioningMacAddress
|
||||||
|
|
@ -966,25 +980,52 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||||
DebugLog.shared.log("BLE: Disconnected from \(peripheral.name ?? "unknown") | state=\(state) mode=\(operationMode) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count) error=\(error?.localizedDescription ?? "none")")
|
DebugLog.shared.log("BLE: Disconnected from \(peripheral.name ?? "unknown") | state=\(state) mode=\(operationMode) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count) terminating=\(isTerminating) error=\(error?.localizedDescription ?? "none")")
|
||||||
|
|
||||||
|
// If we already called succeed() or fail(), this disconnect is expected cleanup — ignore it
|
||||||
|
if isTerminating {
|
||||||
|
DebugLog.shared.log("BLE: Disconnect during termination, ignoring")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if operationMode == .readingConfig {
|
if operationMode == .readingConfig {
|
||||||
if state != .success && state != .idle {
|
if state != .success && state != .idle {
|
||||||
finishRead()
|
finishRead()
|
||||||
}
|
}
|
||||||
} else if state != .success && state != .idle {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already in a terminal state — nothing to do
|
||||||
|
if state == .success || state == .idle {
|
||||||
|
return
|
||||||
|
}
|
||||||
if case .failed = state {
|
if case .failed = state {
|
||||||
// Already failed — disconnect is just cleanup
|
|
||||||
DebugLog.shared.log("BLE: Disconnect after failure, ignoring")
|
DebugLog.shared.log("BLE: Disconnect after failure, ignoring")
|
||||||
} else if state == .writing && dxSmartCommandQueue.count > 0 && dxSmartWriteIndex >= dxSmartCommandQueue.count - 1 {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// SaveConfig (last command) was sent — beacon rebooted to apply config
|
// SaveConfig (last command) was sent — beacon rebooted to apply config
|
||||||
// This is expected behavior, treat as success
|
// Check: writing state AND at or past the last command in queue
|
||||||
|
if state == .writing && dxSmartCommandQueue.count > 0 && dxSmartWriteIndex >= dxSmartCommandQueue.count - 1 {
|
||||||
DebugLog.shared.log("BLE: Disconnect after SaveConfig (idx=\(dxSmartWriteIndex)/\(dxSmartCommandQueue.count)) — treating as success")
|
DebugLog.shared.log("BLE: Disconnect after SaveConfig (idx=\(dxSmartWriteIndex)/\(dxSmartCommandQueue.count)) — treating as success")
|
||||||
succeed()
|
succeed()
|
||||||
} else {
|
return
|
||||||
DebugLog.shared.log("BLE: UNEXPECTED disconnect — state=\(state) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count)")
|
|
||||||
fail("Unexpected disconnect", code: .disconnected)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disconnect during device info read (post-auth, pre-write) — beacon may have
|
||||||
|
// dropped the connection during the MAC address query. This is recoverable:
|
||||||
|
// we already authenticated, so treat as success without MAC.
|
||||||
|
if state == .authenticating && awaitingDeviceInfoForProvisioning && dxSmartAuthenticated {
|
||||||
|
DebugLog.shared.log("BLE: Disconnect during device info read (post-auth) — proceeding without MAC, treating as non-fatal")
|
||||||
|
awaitingDeviceInfoForProvisioning = false
|
||||||
|
// Can't proceed without connection — fail gracefully with specific message
|
||||||
|
fail("Disconnected after auth during device info read — please retry", code: .disconnected)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All other disconnects are unexpected
|
||||||
|
DebugLog.shared.log("BLE: UNEXPECTED disconnect — state=\(state) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count) authenticated=\(dxSmartAuthenticated)")
|
||||||
|
fail("Unexpected disconnect (state: \(state))", code: .disconnected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue