@@ -70,6 +70,15 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip
7070 static let adoptionMaxConnectAttempts = 3
7171 }
7272
73+ /// Result of asking IOBluetooth to close an existing baseband connection.
74+ /// `closeConnection()` is synchronous and its success return is authoritative;
75+ /// an immediately-following `isConnected()` snapshot can still be stale or
76+ /// reflect a rapid macOS reconnect, so it must never overturn that result.
77+ private enum CloseConnectionOutcome {
78+ case released
79+ case failed( IOReturn )
80+ }
81+
7382 // MARK: - Dependencies
7483
7584 private let bluetoothQueue = DispatchQueue ( label: Constants . queueLabel, qos: . userInitiated)
@@ -411,10 +420,10 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip
411420 else { continue }
412421 connectedIDs. append ( peripheral. id)
413422 guard shouldRelease else { continue }
414- let result = device . closeConnection ( )
415- if !device . isConnected ( ) {
423+ switch closeConnectionIfNeeded ( device ) {
424+ case . released :
416425 releasedIDs. append ( peripheral. id)
417- } else {
426+ case . failed ( let result ) :
418427 print ( " Before sleep: failed to disconnect \( peripheral. name) : \( result) " )
419428 }
420429 }
@@ -616,47 +625,54 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip
616625 print ( " Bluetooth is off; \( peripheral. name) is already disconnected " )
617626 markDisconnectedAfterRelease ( peripheral. id)
618627 finishReleasePeripheral (
619- peripheral, success: true , connectedAfterFailure: false ,
620- detail: " " , completion: completion)
628+ peripheral, success: true , detail: " " , completion: completion)
621629 return
622630 }
623631 guard let device = IOBluetoothDevice ( addressString: peripheral. id) else {
624632 print ( " \( peripheral. name) is absent from the Bluetooth stack and already disconnected " )
625633 markDisconnectedAfterRelease ( peripheral. id)
626634 finishReleasePeripheral (
627- peripheral, success: true , connectedAfterFailure: false ,
628- detail: " " , completion: completion)
635+ peripheral, success: true , detail: " " , completion: completion)
629636 return
630637 }
631638
632- var result = kIOReturnSuccess
633- if device. isConnected ( ) {
634- result = device. closeConnection ( )
635- }
636- let success = !device. isConnected ( )
637- if success {
639+ switch closeConnectionIfNeeded ( device) {
640+ case . released:
638641 print ( " Disconnected \( peripheral. name) without changing its pairing " )
639642 markDisconnectedAfterRelease ( peripheral. id)
640- } else {
643+ finishReleasePeripheral (
644+ peripheral, success: true , detail: " " , completion: completion)
645+ case . failed( let result) :
641646 print ( " Failed to disconnect \( peripheral. name) : \( result) " )
647+ finishReleasePeripheral (
648+ peripheral, success: false , detail: " closeConnection returned \( result) " ,
649+ completion: completion)
650+ }
651+ }
652+
653+ /// Closes `device` without modifying its pairing record. A successful
654+ /// IOBluetooth return is final. When the command reports an error, a live
655+ /// state check may still prove that the connection disappeared concurrently,
656+ /// which also satisfies the release request.
657+ private func closeConnectionIfNeeded( _ device: IOBluetoothDevice ) -> CloseConnectionOutcome {
658+ guard device. isConnected ( ) else { return . released }
659+ let result = device. closeConnection ( )
660+ if result == kIOReturnSuccess || !device. isConnected ( ) {
661+ return . released
642662 }
643- finishReleasePeripheral (
644- peripheral, success: success, connectedAfterFailure: device. isConnected ( ) ,
645- detail: " closeConnection returned \( result) " , completion: completion)
663+ return . failed( result)
646664 }
647665
648666 private func finishReleasePeripheral(
649667 _ peripheral: BluetoothPeripheral ,
650668 success: Bool ,
651- connectedAfterFailure: Bool ,
652669 detail: String ,
653670 completion: ( ( Bool ) -> Void ) ?
654671 ) {
655672 DispatchQueue . main. async {
656673 if !success {
657674 self . clearReleaseLease ( peripheral. id)
658- self . setConnectionState (
659- connectedAfterFailure ? . connected : . disconnected, for: peripheral. id)
675+ self . setConnectionState ( . connected, for: peripheral. id)
660676 self . setPeripheralError ( " Couldn't release. " , for: peripheral. id)
661677 NotificationManager . showNotification (
662678 title: " Couldn't Release Peripheral " ,
@@ -1458,16 +1474,13 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip
14581474 guard hasActiveReleaseLease ( address) else { return }
14591475 bluetoothQueue. async { [ weak self] in
14601476 guard let self = self else { return }
1461- var result = kIOReturnSuccess
1462- if device. isConnected ( ) {
1463- result = device. closeConnection ( )
1464- }
1465- let released = !device. isConnected ( )
1477+ let outcome = self . closeConnectionIfNeeded ( device)
14661478 DispatchQueue . main. async {
14671479 guard self . hasActiveReleaseLease ( address) else { return }
1468- if released {
1480+ switch outcome {
1481+ case . released:
14691482 self . markDisconnectedAfterRelease ( address)
1470- } else {
1483+ case . failed ( let result ) :
14711484 print ( " Release lease couldn't disconnect \( address) : \( result) " )
14721485 self . setPeripheralError ( " Couldn't keep released. " , for: address)
14731486 NotificationManager . showNotification (
@@ -1611,8 +1624,8 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip
16111624 /// failure; an adoption — no prior claim — takes it only once the peer is
16121625 /// provably absent: unreachable at the connect layer for
16131626 /// `adoptionRequiredAbsentStreak` consecutive probes. A peer that answers
1614- /// at all — an explicit "not holding" (`.bodyFailed `) included — outranks
1615- /// us, so stand down and leave the move to its reclaim or to the user.
1627+ /// at all — an explicit "not holding" (`.remoteOperationFailed `) included —
1628+ /// outranks us, so stand down and leave the move to its reclaim or to the user.
16161629 /// Connection attempts are capped: repeated failures usually mean the
16171630 /// device is busy with a peer we cannot reach.
16181631 private func continueAdoption( of peripheral: BluetoothPeripheral , after failure: OutgoingFailure )
0 commit comments