Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions Magic Switch/Manager/OutgoingConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ enum OutgoingFailure: Error {
case connectionFailed(String)
case connectTimeout
case handshakeFailed(SecureChannelError)
/// The authenticated peer received the request but reported that it could
/// not complete the operation.
case remoteOperationFailed
/// The authenticated peer replied with data that is not valid for the
/// current command.
case invalidResponse
case bodyFailed
/// Self-throttled because of recent repeated failures to this host.
/// Prevents a runaway retry loop from racking up failures the peer's
Expand All @@ -36,6 +42,10 @@ enum OutgoingFailure: Error {
return "Couldn't establish a secure connection (possible tampering)."
case .handshakeFailed:
return "Couldn't establish a secure connection."
case .remoteOperationFailed:
return "The other Mac reported that it couldn't complete the operation."
case .invalidResponse:
return "The other Mac returned an invalid response."
case .bodyFailed:
return "The connection dropped mid-message."
case .tooManyRecentFailures:
Expand Down Expand Up @@ -163,7 +173,10 @@ final class OutgoingConnection {
/// receives a `Result` whose failure case carries a categorised reason
/// (see `OutgoingFailure`) so the caller can render a useful notification.
func run(
body: @escaping (SecureChannel, @escaping (Bool) -> Void) -> Void,
body:
@escaping (
SecureChannel, @escaping (Result<Void, OutgoingFailure>) -> Void
) -> Void,
completion: @escaping (Result<Void, OutgoingFailure>) -> Void
) {
selfRef = self
Expand Down Expand Up @@ -222,9 +235,8 @@ final class OutgoingConnection {
provedByHandshake: provedFingerprint)
}
self.startBodyTimer(completion: completion)
body(channel) { ok in
self.finish(
ok ? .success(()) : .failure(.bodyFailed), completion: completion)
body(channel) { result in
self.finish(result, completion: completion)
}
case .failure(let err):
print("OutgoingConnection handshake failed: \(err)")
Expand Down Expand Up @@ -270,13 +282,15 @@ final class OutgoingConnection {
bodyTimer = nil
channel?.cancel()
connection.cancel()
// Feed the outbound rate limiter so a series of failures throttles
// future attempts, and a success clears the counter immediately. Skip
// Feed the outbound rate limiter so a series of transport/protocol
// failures throttles future attempts, and a healthy authenticated reply
// clears the counter immediately. OP_FAILED is an operation failure, not
// a network failure: the peer received and answered the request. Skip
// `tooManyRecentFailures` — that's the limiter's own refusal and would
// double-count. Background reachability probes opt out entirely.
if countsTowardRateLimit {
switch result {
case .success:
case .success, .failure(.remoteOperationFailed):
rateLimiter.recordSuccess(host: host)
case .failure(.tooManyRecentFailures):
break
Expand Down
2 changes: 1 addition & 1 deletion Magic Switch/Model/Store/BluetoothPeripheralStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2106,7 +2106,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip
/// failure; an adoption — no prior claim — takes it only once the peer is
/// provably absent: unreachable at the connect layer for
/// `adoptionRequiredAbsentStreak` consecutive probes. A peer that answers
/// at all — an explicit "not holding" (`.bodyFailed`) included — outranks
/// at all — an explicit "not holding" (`.remoteOperationFailed`) included — outranks
/// us, so stand down and leave the move to its reclaim or to the user.
/// Pair attempts are capped: a free peripheral pairs on the first try, so
/// repeated failures mean it's busy with a peer we can't reach.
Expand Down
69 changes: 40 additions & 29 deletions Magic Switch/Model/Store/NetworkDeviceStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,25 @@ enum DeviceCommand: String, Codable {
case introduce = "INTRODUCE"
}

/// Decodes the standard OP_SUCCESS/OP_FAILED acknowledgement used by normal
/// commands. A valid failure acknowledgement proves the authenticated channel
/// stayed healthy; malformed or command-shaped replies are protocol errors.
private func decodeOperationResponse(_ data: Data) -> Result<Void, OutgoingFailure> {
guard let response = String(data: data, encoding: .utf8),
let command = DeviceCommand(rawValue: response)
else {
return .failure(.invalidResponse)
}
switch command {
case .operationSuccess:
return .success(())
case .operationFailed:
return .failure(.remoteOperationFailed)
default:
return .failure(.invalidResponse)
}
}

/// Identity carried by `INTRODUCE` frames: `<listenPort>|<name>`, port first
/// so the name may contain `|`.
struct IntroducedIdentity {
Expand Down Expand Up @@ -995,21 +1014,16 @@ extension NetworkDeviceStore {
channel.send(Data(command.rawValue.utf8)) { sendErr in
if let sendErr = sendErr {
print("Failed to send command: \(sendErr)")
done(false)
done(.failure(.bodyFailed))
return
}
channel.receive { result in
switch result {
case .failure(let err):
print("Failed to receive response: \(err)")
done(false)
done(.failure(.bodyFailed))
case .success(let data):
let response = String(data: data, encoding: .utf8) ?? ""
if let resp = DeviceCommand(rawValue: response) {
done(resp == .operationSuccess)
} else {
done(false)
}
done(decodeOperationResponse(data))
}
}
}
Expand All @@ -1036,14 +1050,14 @@ extension NetworkDeviceStore {
channel.send(Data(DeviceCommand.notification.rawValue.utf8)) { err in
if let err = err {
print("Notification command send failed: \(err)")
done(false)
done(.failure(.bodyFailed))
return
}
let payload = "\(title)|\(message)"
channel.send(Data(payload.utf8)) { err2 in
if let err2 = err2 {
print("Notification payload send failed: \(err2)")
done(false)
done(.failure(.bodyFailed))
return
}
// Wait for the receiver's OP_SUCCESS/OP_FAILED before tearing
Expand All @@ -1055,10 +1069,9 @@ extension NetworkDeviceStore {
switch result {
case .failure(let err):
print("Notification ack receive failed: \(err)")
done(false)
done(.failure(.bodyFailed))
case .success(let data):
let response = String(data: data, encoding: .utf8) ?? ""
done(DeviceCommand(rawValue: response) == .operationSuccess)
done(decodeOperationResponse(data))
}
}
}
Expand Down Expand Up @@ -1100,13 +1113,13 @@ extension NetworkDeviceStore {
channel.send(Data(DeviceCommand.syncPeripherals.rawValue.utf8)) { err in
if let err = err {
print("syncPeripherals command send failed: \(err)")
done(false)
done(.failure(.bodyFailed))
return
}
channel.send(Data(jsonString.utf8)) { err2 in
if let err2 = err2 {
print("syncPeripherals payload send failed: \(err2)")
done(false)
done(.failure(.bodyFailed))
return
}
// Same rationale as the notification path: wait for the
Expand All @@ -1116,10 +1129,9 @@ extension NetworkDeviceStore {
switch result {
case .failure(let err):
print("syncPeripherals ack receive failed: \(err)")
done(false)
done(.failure(.bodyFailed))
case .success(let data):
let response = String(data: data, encoding: .utf8) ?? ""
done(DeviceCommand(rawValue: response) == .operationSuccess)
done(decodeOperationResponse(data))
}
}
}
Expand Down Expand Up @@ -1221,32 +1233,32 @@ extension NetworkDeviceStore {
channel.send(Data(DeviceCommand.introduce.rawValue.utf8)) { err in
if let err = err {
print("INTRODUCE command send failed: \(err)")
done(false)
done(.failure(.bodyFailed))
return
}
channel.send(Data(local.encoded.utf8)) { err2 in
if let err2 = err2 {
print("INTRODUCE payload send failed: \(err2)")
done(false)
done(.failure(.bodyFailed))
return
}
channel.receive { result in
switch result {
case .failure(let err):
print("INTRODUCE reply receive failed: \(err)")
done(false)
done(.failure(.bodyFailed))
case .success(let data):
let response = String(data: data, encoding: .utf8) ?? ""
if let identity = IntroducedIdentity(payload: response),
let proved = outgoing.provedFingerprint
{
reply = .peer(identity, provedFingerprint: proved)
done(true)
done(.success(()))
} else if DeviceCommand(rawValue: response) == .operationFailed {
reply = .legacy
done(true)
done(.success(()))
} else {
done(false)
done(.failure(.invalidResponse))
}
}
}
Expand Down Expand Up @@ -1355,23 +1367,22 @@ extension NetworkDeviceStore {
channel.send(Data(command.rawValue.utf8)) { err in
if let err = err {
print("\(command.rawValue) command send failed: \(err)")
done(false)
done(.failure(.bodyFailed))
return
}
channel.send(Data(payload.utf8)) { err2 in
if let err2 = err2 {
print("\(command.rawValue) payload send failed: \(err2)")
done(false)
done(.failure(.bodyFailed))
return
}
channel.receive { result in
switch result {
case .failure(let err):
print("\(command.rawValue) ack receive failed: \(err)")
done(false)
done(.failure(.bodyFailed))
case .success(let data):
let response = String(data: data, encoding: .utf8) ?? ""
done(DeviceCommand(rawValue: response) == .operationSuccess)
done(decodeOperationResponse(data))
}
}
}
Expand Down
Loading