Merge pull request 'fix: prevent re-entrant disconnect callbacks' (#4) from schwifty/fix-disconnect-race into main

This commit is contained in:
schwifty 2026-03-21 23:37:07 +00:00
commit a1d3b0f457

View file

@ -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,53 @@ 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. We authenticated but
// lost connection before writing config, so this is a failure but a known
// one with a clear retry path, not an unexplained "Unexpected disconnect".
if state == .authenticating && awaitingDeviceInfoForProvisioning && dxSmartAuthenticated {
DebugLog.shared.log("BLE: Disconnect during device info read (post-auth) — connection lost before config write, failing with retry prompt")
awaitingDeviceInfoForProvisioning = false
// Connection lost can't write config without it, fail 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)
} }
} }