From 54fa973d346dc6a7ce354e11775674b4ec970c9e Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sat, 21 Mar 2026 23:01:30 +0000 Subject: [PATCH 1/2] 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) --- PayfritBeacon/BeaconProvisioner.swift | 73 +++++++++++++++++++++------ 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index 420a846..8b62db2 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -157,6 +157,7 @@ class BeaconProvisioner: NSObject, ObservableObject { private var dxSmartWriteIndex = 0 private var provisioningMacAddress: String? private var awaitingDeviceInfoForProvisioning = false + private var isTerminating = false // guards against re-entrant disconnect handling // Read config mode private enum OperationMode { case provisioning, readingConfig } @@ -214,6 +215,7 @@ class BeaconProvisioner: NSObject, ObservableObject { self.dxSmartWriteIndex = 0 self.provisioningMacAddress = nil self.awaitingDeviceInfoForProvisioning = false + self.isTerminating = false self.connectionRetryCount = 0 self.currentBeacon = beacon @@ -262,6 +264,7 @@ class BeaconProvisioner: NSObject, ObservableObject { self.dxReadQueryIndex = 0 self.allDiscoveredServices.removeAll() self.connectionRetryCount = 0 + self.isTerminating = false self.currentBeacon = beacon self.servicesToExplore.removeAll() @@ -295,6 +298,7 @@ class BeaconProvisioner: NSObject, ObservableObject { dxSmartWriteIndex = 0 provisioningMacAddress = nil awaitingDeviceInfoForProvisioning = false + isTerminating = false connectionRetryCount = 0 currentBeacon = nil state = .idle @@ -302,9 +306,14 @@ class BeaconProvisioner: NSObject, ObservableObject { } 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)") state = .failed(message) - if let peripheral = peripheral { + if let peripheral = peripheral, peripheral.state == .connected { centralManager.cancelPeripheralConnection(peripheral) } if let code = code { @@ -316,9 +325,14 @@ class BeaconProvisioner: NSObject, ObservableObject { } 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")") state = .success - if let peripheral = peripheral { + if let peripheral = peripheral, peripheral.state == .connected { centralManager.cancelPeripheralConnection(peripheral) } let mac = provisioningMacAddress @@ -966,25 +980,52 @@ extension BeaconProvisioner: CBCentralManagerDelegate { } 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 state != .success && state != .idle { finishRead() } - } else if state != .success && state != .idle { - if case .failed = state { - // Already failed — disconnect is just cleanup - DebugLog.shared.log("BLE: Disconnect after failure, ignoring") - } else if state == .writing && dxSmartCommandQueue.count > 0 && dxSmartWriteIndex >= dxSmartCommandQueue.count - 1 { - // SaveConfig (last command) was sent — beacon rebooted to apply config - // This is expected behavior, treat as success - DebugLog.shared.log("BLE: Disconnect after SaveConfig (idx=\(dxSmartWriteIndex)/\(dxSmartCommandQueue.count)) — treating as success") - succeed() - } else { - DebugLog.shared.log("BLE: UNEXPECTED disconnect — state=\(state) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count)") - fail("Unexpected disconnect", code: .disconnected) - } + return } + + // Already in a terminal state — nothing to do + if state == .success || state == .idle { + return + } + if case .failed = state { + DebugLog.shared.log("BLE: Disconnect after failure, ignoring") + return + } + + // SaveConfig (last command) was sent — beacon rebooted to apply config + // 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") + succeed() + return + } + + // 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) } } From 58be00cb38839e34abeae96c4e31945b1a68a95b Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sat, 21 Mar 2026 23:03:43 +0000 Subject: [PATCH 2/2] docs: fix misleading comment on post-auth disconnect path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment said "treat as non-fatal" but the code calls fail() — which is correct behavior since we can't write config without a connection. Updated comment to accurately describe the fail-with-retry-prompt flow. Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritBeacon/BeaconProvisioner.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index 8b62db2..a2b1026 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -1013,12 +1013,13 @@ extension BeaconProvisioner: CBCentralManagerDelegate { } // 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. + // 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) — proceeding without MAC, treating as non-fatal") + DebugLog.shared.log("BLE: Disconnect during device info read (post-auth) — connection lost before config write, failing with retry prompt") awaitingDeviceInfoForProvisioning = false - // Can't proceed without connection — fail gracefully with specific message + // 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 }