fix: add timeout for characteristic rediscovery to prevent hang

When FFE2 goes missing during writes, the rediscovery path had no
timeout — if CoreBluetooth never called back didDiscoverCharacteristics,
the app would hang at "Re-discovering characteristics..." indefinitely.

Adds a 5-second timeout per rediscovery attempt. If it fires, it either
retries (up to MAX_CHAR_REDISCOVERY) or fails with .timeout instead of
hanging forever.
This commit is contained in:
Schwifty 2026-03-22 03:18:43 +00:00
parent 813198599a
commit f0d2b2ae90

View file

@ -192,6 +192,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
private var writeTimeoutTimer: DispatchWorkItem?
private var charRediscoveryCount = 0
private static let MAX_CHAR_REDISCOVERY = 2
private var charRediscoveryTimer: DispatchWorkItem?
private static let CHAR_REDISCOVERY_TIMEOUT: Double = 5.0
private static let WRITE_TIMEOUT_SECONDS: Double = 5.0
private var writeRetryCount = 0
private static let MAX_WRITE_RETRIES = 1 // Retry once per command before failing
@ -331,6 +333,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
private func cleanup() {
cancelWriteTimeout()
cancelResponseGateTimeout()
cancelCharRediscoveryTimeout()
awaitingCommandResponse = false
peripheral = nil
config = nil
@ -594,6 +597,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
DebugLog.shared.log("BLE: FFE2 missing — re-discovering characteristics (attempt \(charRediscoveryCount)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))")
progress = "Re-discovering characteristics..."
state = .discoveringServices
scheduleCharRediscoveryTimeout()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.peripheral?.discoverCharacteristics([
BeaconProvisioner.DXSMART_NOTIFY_CHAR,
@ -666,6 +670,42 @@ class BeaconProvisioner: NSObject, ObservableObject {
writeTimeoutTimer = nil
}
/// Schedule a timeout for characteristic rediscovery.
/// If didDiscoverCharacteristicsFor doesn't fire within 5 seconds,
/// either retry or fail instead of hanging forever.
private func scheduleCharRediscoveryTimeout() {
cancelCharRediscoveryTimeout()
let timer = DispatchWorkItem { [weak self] in
guard let self = self else { return }
guard self.state == .discoveringServices else { return }
let attempt = self.charRediscoveryCount
DebugLog.shared.log("BLE: Characteristic rediscovery timeout (attempt \(attempt)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))")
if attempt < BeaconProvisioner.MAX_CHAR_REDISCOVERY, let service = self.configService {
// Try another rediscovery attempt
self.charRediscoveryCount += 1
DebugLog.shared.log("BLE: Retrying characteristic rediscovery (attempt \(self.charRediscoveryCount)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))")
self.scheduleCharRediscoveryTimeout()
self.peripheral?.discoverCharacteristics([
BeaconProvisioner.DXSMART_NOTIFY_CHAR,
BeaconProvisioner.DXSMART_COMMAND_CHAR,
BeaconProvisioner.DXSMART_PASSWORD_CHAR
], for: service)
} else {
self.fail("Characteristic rediscovery timed out after \(attempt) attempts", code: .timeout)
}
}
charRediscoveryTimer = timer
DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.CHAR_REDISCOVERY_TIMEOUT, execute: timer)
}
/// Cancel any pending characteristic rediscovery timeout
private func cancelCharRediscoveryTimeout() {
charRediscoveryTimer?.cancel()
charRediscoveryTimer = nil
}
/// Calculate adaptive delay after writing a command.
/// Frame selection (0x11-0x16) and type commands (0x61, 0x62) trigger internal
/// state changes on the beacon MCU that need extra processing time.
@ -1368,6 +1408,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
exploreNextService()
} else if charRediscoveryCount > 0 && dxSmartAuthenticated && !dxSmartCommandQueue.isEmpty {
// Rediscovery during active write resume writing directly (already authenticated)
cancelCharRediscoveryTimeout()
DebugLog.shared.log("BLE: Characteristics re-discovered after FFE2 miss — resuming write")
state = .writing
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in