Skip to content

Commit a912313

Browse files
committed
fix: correctly classify Bluetooth releases and peer responses
1 parent 3bd72fe commit a912313

3 files changed

Lines changed: 103 additions & 66 deletions

File tree

Magic Switch/Manager/OutgoingConnection.swift

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ enum OutgoingFailure: Error {
1010
case connectionFailed(String)
1111
case connectTimeout
1212
case handshakeFailed(SecureChannelError)
13+
/// The authenticated peer received the request but reported that it could
14+
/// not complete the operation.
15+
case remoteOperationFailed
16+
/// The authenticated peer replied with data that is not valid for the
17+
/// current command.
18+
case invalidResponse
1319
case bodyFailed
1420
/// Self-throttled because of recent repeated failures to this host.
1521
/// Prevents a runaway retry loop from racking up failures the peer's
@@ -36,6 +42,10 @@ enum OutgoingFailure: Error {
3642
return "Couldn't establish a secure connection (possible tampering)."
3743
case .handshakeFailed:
3844
return "Couldn't establish a secure connection."
45+
case .remoteOperationFailed:
46+
return "The other Mac reported that it couldn't complete the operation."
47+
case .invalidResponse:
48+
return "The other Mac returned an invalid response."
3949
case .bodyFailed:
4050
return "The connection dropped mid-message."
4151
case .tooManyRecentFailures:
@@ -163,7 +173,9 @@ final class OutgoingConnection {
163173
/// receives a `Result` whose failure case carries a categorised reason
164174
/// (see `OutgoingFailure`) so the caller can render a useful notification.
165175
func run(
166-
body: @escaping (SecureChannel, @escaping (Bool) -> Void) -> Void,
176+
body: @escaping (
177+
SecureChannel, @escaping (Result<Void, OutgoingFailure>) -> Void
178+
) -> Void,
167179
completion: @escaping (Result<Void, OutgoingFailure>) -> Void
168180
) {
169181
selfRef = self
@@ -222,9 +234,8 @@ final class OutgoingConnection {
222234
provedByHandshake: provedFingerprint)
223235
}
224236
self.startBodyTimer(completion: completion)
225-
body(channel) { ok in
226-
self.finish(
227-
ok ? .success(()) : .failure(.bodyFailed), completion: completion)
237+
body(channel) { result in
238+
self.finish(result, completion: completion)
228239
}
229240
case .failure(let err):
230241
print("OutgoingConnection handshake failed: \(err)")
@@ -270,13 +281,15 @@ final class OutgoingConnection {
270281
bodyTimer = nil
271282
channel?.cancel()
272283
connection.cancel()
273-
// Feed the outbound rate limiter so a series of failures throttles
274-
// future attempts, and a success clears the counter immediately. Skip
284+
// Feed the outbound rate limiter so a series of transport/protocol
285+
// failures throttles future attempts, and a healthy authenticated reply
286+
// clears the counter immediately. OP_FAILED is an operation failure, not
287+
// a network failure: the peer received and answered the request. Skip
275288
// `tooManyRecentFailures` — that's the limiter's own refusal and would
276289
// double-count. Background reachability probes opt out entirely.
277290
if countsTowardRateLimit {
278291
switch result {
279-
case .success:
292+
case .success, .failure(.remoteOperationFailed):
280293
rateLimiter.recordSuccess(host: host)
281294
case .failure(.tooManyRecentFailures):
282295
break

Magic Switch/Model/Store/BluetoothPeripheralStore.swift

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

Magic Switch/Model/Store/NetworkDeviceStore.swift

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,25 @@ enum DeviceCommand: String, Codable {
908908
case introduce = "INTRODUCE"
909909
}
910910

911+
/// Decodes the standard OP_SUCCESS/OP_FAILED acknowledgement used by normal
912+
/// commands. A valid failure acknowledgement proves the authenticated channel
913+
/// stayed healthy; malformed or command-shaped replies are protocol errors.
914+
private func decodeOperationResponse(_ data: Data) -> Result<Void, OutgoingFailure> {
915+
guard let response = String(data: data, encoding: .utf8),
916+
let command = DeviceCommand(rawValue: response)
917+
else {
918+
return .failure(.invalidResponse)
919+
}
920+
switch command {
921+
case .operationSuccess:
922+
return .success(())
923+
case .operationFailed:
924+
return .failure(.remoteOperationFailed)
925+
default:
926+
return .failure(.invalidResponse)
927+
}
928+
}
929+
911930
/// Identity carried by `INTRODUCE` frames: `<listenPort>|<name>`, port first
912931
/// so the name may contain `|`.
913932
struct IntroducedIdentity {
@@ -997,21 +1016,16 @@ extension NetworkDeviceStore {
9971016
channel.send(Data(command.rawValue.utf8)) { sendErr in
9981017
if let sendErr = sendErr {
9991018
print("Failed to send command: \(sendErr)")
1000-
done(false)
1019+
done(.failure(.bodyFailed))
10011020
return
10021021
}
10031022
channel.receive { result in
10041023
switch result {
10051024
case .failure(let err):
10061025
print("Failed to receive response: \(err)")
1007-
done(false)
1026+
done(.failure(.bodyFailed))
10081027
case .success(let data):
1009-
let response = String(data: data, encoding: .utf8) ?? ""
1010-
if let resp = DeviceCommand(rawValue: response) {
1011-
done(resp == .operationSuccess)
1012-
} else {
1013-
done(false)
1014-
}
1028+
done(decodeOperationResponse(data))
10151029
}
10161030
}
10171031
}
@@ -1038,14 +1052,14 @@ extension NetworkDeviceStore {
10381052
channel.send(Data(DeviceCommand.notification.rawValue.utf8)) { err in
10391053
if let err = err {
10401054
print("Notification command send failed: \(err)")
1041-
done(false)
1055+
done(.failure(.bodyFailed))
10421056
return
10431057
}
10441058
let payload = "\(title)|\(message)"
10451059
channel.send(Data(payload.utf8)) { err2 in
10461060
if let err2 = err2 {
10471061
print("Notification payload send failed: \(err2)")
1048-
done(false)
1062+
done(.failure(.bodyFailed))
10491063
return
10501064
}
10511065
// Wait for the receiver's OP_SUCCESS/OP_FAILED before tearing
@@ -1057,10 +1071,9 @@ extension NetworkDeviceStore {
10571071
switch result {
10581072
case .failure(let err):
10591073
print("Notification ack receive failed: \(err)")
1060-
done(false)
1074+
done(.failure(.bodyFailed))
10611075
case .success(let data):
1062-
let response = String(data: data, encoding: .utf8) ?? ""
1063-
done(DeviceCommand(rawValue: response) == .operationSuccess)
1076+
done(decodeOperationResponse(data))
10641077
}
10651078
}
10661079
}
@@ -1102,13 +1115,13 @@ extension NetworkDeviceStore {
11021115
channel.send(Data(DeviceCommand.syncPeripherals.rawValue.utf8)) { err in
11031116
if let err = err {
11041117
print("syncPeripherals command send failed: \(err)")
1105-
done(false)
1118+
done(.failure(.bodyFailed))
11061119
return
11071120
}
11081121
channel.send(Data(jsonString.utf8)) { err2 in
11091122
if let err2 = err2 {
11101123
print("syncPeripherals payload send failed: \(err2)")
1111-
done(false)
1124+
done(.failure(.bodyFailed))
11121125
return
11131126
}
11141127
// Same rationale as the notification path: wait for the
@@ -1118,10 +1131,9 @@ extension NetworkDeviceStore {
11181131
switch result {
11191132
case .failure(let err):
11201133
print("syncPeripherals ack receive failed: \(err)")
1121-
done(false)
1134+
done(.failure(.bodyFailed))
11221135
case .success(let data):
1123-
let response = String(data: data, encoding: .utf8) ?? ""
1124-
done(DeviceCommand(rawValue: response) == .operationSuccess)
1136+
done(decodeOperationResponse(data))
11251137
}
11261138
}
11271139
}
@@ -1223,32 +1235,32 @@ extension NetworkDeviceStore {
12231235
channel.send(Data(DeviceCommand.introduce.rawValue.utf8)) { err in
12241236
if let err = err {
12251237
print("INTRODUCE command send failed: \(err)")
1226-
done(false)
1238+
done(.failure(.bodyFailed))
12271239
return
12281240
}
12291241
channel.send(Data(local.encoded.utf8)) { err2 in
12301242
if let err2 = err2 {
12311243
print("INTRODUCE payload send failed: \(err2)")
1232-
done(false)
1244+
done(.failure(.bodyFailed))
12331245
return
12341246
}
12351247
channel.receive { result in
12361248
switch result {
12371249
case .failure(let err):
12381250
print("INTRODUCE reply receive failed: \(err)")
1239-
done(false)
1251+
done(.failure(.bodyFailed))
12401252
case .success(let data):
12411253
let response = String(data: data, encoding: .utf8) ?? ""
12421254
if let identity = IntroducedIdentity(payload: response),
12431255
let proved = outgoing.provedFingerprint
12441256
{
12451257
reply = .peer(identity, provedFingerprint: proved)
1246-
done(true)
1258+
done(.success(()))
12471259
} else if DeviceCommand(rawValue: response) == .operationFailed {
12481260
reply = .legacy
1249-
done(true)
1261+
done(.success(()))
12501262
} else {
1251-
done(false)
1263+
done(.failure(.invalidResponse))
12521264
}
12531265
}
12541266
}
@@ -1258,7 +1270,7 @@ extension NetworkDeviceStore {
12581270
completion: { result in
12591271
switch result {
12601272
case .success:
1261-
completion(reply.map { .success($0) } ?? .failure(.bodyFailed))
1273+
completion(reply.map { .success($0) } ?? .failure(.invalidResponse))
12621274
case .failure(let err):
12631275
completion(.failure(err))
12641276
}
@@ -1358,23 +1370,22 @@ extension NetworkDeviceStore {
13581370
channel.send(Data(command.rawValue.utf8)) { err in
13591371
if let err = err {
13601372
print("\(command.rawValue) command send failed: \(err)")
1361-
done(false)
1373+
done(.failure(.bodyFailed))
13621374
return
13631375
}
13641376
channel.send(Data(payload.utf8)) { err2 in
13651377
if let err2 = err2 {
13661378
print("\(command.rawValue) payload send failed: \(err2)")
1367-
done(false)
1379+
done(.failure(.bodyFailed))
13681380
return
13691381
}
13701382
channel.receive { result in
13711383
switch result {
13721384
case .failure(let err):
13731385
print("\(command.rawValue) ack receive failed: \(err)")
1374-
done(false)
1386+
done(.failure(.bodyFailed))
13751387
case .success(let data):
1376-
let response = String(data: data, encoding: .utf8) ?? ""
1377-
done(DeviceCommand(rawValue: response) == .operationSuccess)
1388+
done(decodeOperationResponse(data))
13781389
}
13791390
}
13801391
}

0 commit comments

Comments
 (0)