From 4af7c7037349dbf3115821ce5f08cb7bca9f9202 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 9 Jun 2026 21:15:30 +0800 Subject: [PATCH 1/2] fix: make magic peripheral handoff resilient --- Magic Switch/AppDelegate/AppDelegate.swift | 2 +- Magic Switch/Manager/IncomingConnection.swift | 56 ++++-- Magic Switch/Manager/OutgoingConnection.swift | 7 +- .../Store/BluetoothPeripheralStore.swift | 163 ++++++++++++++++-- .../Model/Store/NetworkDeviceStore.swift | 11 +- 5 files changed, 202 insertions(+), 37 deletions(-) diff --git a/Magic Switch/AppDelegate/AppDelegate.swift b/Magic Switch/AppDelegate/AppDelegate.swift index 1cec0ba..7ae85c5 100644 --- a/Magic Switch/AppDelegate/AppDelegate.swift +++ b/Magic Switch/AppDelegate/AppDelegate.swift @@ -448,7 +448,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { switch result { case .success: self.bluetoothStore.peripherals.forEach { peripheral in - self.bluetoothStore.connectPeripheral(peripheral) + self.bluetoothStore.connectPeripheralFromPeer(peripheral) } self.endTransfer() case .failure(let err): diff --git a/Magic Switch/Manager/IncomingConnection.swift b/Magic Switch/Manager/IncomingConnection.swift index fbd6e3a..5cbf052 100644 --- a/Magic Switch/Manager/IncomingConnection.swift +++ b/Magic Switch/Manager/IncomingConnection.swift @@ -37,7 +37,7 @@ final class IncomingConnection { // MARK: - Constants /// Cuts off slow-talkers. Reset on every successful frame. - private static let idleTimeout: TimeInterval = 30 + private static let idleTimeout: TimeInterval = 75 /// Hard cap on a single connection regardless of idle activity. Without it, /// a well-behaved-looking attacker could keep `idleTimer` happy with /// well-formed sealed frames indefinitely and pin a listener slot forever. @@ -189,21 +189,34 @@ final class IncomingConnection { break case .connectAll: let store = bluetoothStore - DispatchQueue.main.async { + lastReceivedCommand = nil + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } NotificationCenter.default.post(name: .magicSwitchReceivedConnectAll, object: nil) - store.peripherals.forEach { peripheral in - store.connectPeripheral(peripheral) + let peripherals = store.peripherals + guard !peripherals.isEmpty else { + self.queue.async { + self.sendString(DeviceCommand.operationSuccess.rawValue) + } + return + } + var remaining = peripherals.count + var allSucceeded = true + peripherals.forEach { peripheral in + store.connectPeripheralFromPeer(peripheral) { success in + self.queue.async { + allSucceeded = allSucceeded && success + remaining -= 1 + if remaining == 0 { + self.sendString( + (allSucceeded ? DeviceCommand.operationSuccess : DeviceCommand.operationFailed) + .rawValue + ) + } + } + } } } - // Best-effort ack: OP_SUCCESS here means "command received and - // dispatched," not "all peripherals successfully connected." Local - // pair work is async and may still fail (out of range, peer never - // released, etc.). The peer's goal ("you now hold these") is - // satisfied as long as we attempt — tracking per-peripheral results - // and aggregating would require holding the connection open until - // every IOBluetooth callback lands, which isn't worth the complexity. - sendString(DeviceCommand.operationSuccess.rawValue) - lastReceivedCommand = nil case .unregisterAll: let store = bluetoothStore DispatchQueue.main.async { @@ -292,14 +305,23 @@ final class IncomingConnection { } let store = bluetoothStore let address = message - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } // Peripheral is arriving at this Mac — flash the receiving arrow. NotificationCenter.default.post(name: .magicSwitchPeripheralIncoming, object: nil) - if let peripheral = store.peripherals.first(where: { $0.id == address }) { - store.connectPeripheral(peripheral) + guard let peripheral = store.peripherals.first(where: { $0.id == address }) else { + self.queue.async { + self.sendString(DeviceCommand.operationFailed.rawValue) + } + return + } + store.connectPeripheralFromPeer(peripheral) { success in + self.queue.async { + self.sendString( + (success ? DeviceCommand.operationSuccess : DeviceCommand.operationFailed).rawValue) + } } } - sendString(DeviceCommand.operationSuccess.rawValue) case .holdsOne: guard Self.isValidMACAddress(message) else { print("holdsOne: invalid MAC address: \(message)") diff --git a/Magic Switch/Manager/OutgoingConnection.swift b/Magic Switch/Manager/OutgoingConnection.swift index 6326a1a..dace600 100644 --- a/Magic Switch/Manager/OutgoingConnection.swift +++ b/Magic Switch/Manager/OutgoingConnection.swift @@ -103,7 +103,7 @@ final class OutgoingConnection { /// opcode and don't reply OP_FAILED — i.e. anything older than the /// commit that added the default-case ack) would otherwise hang here /// until the peer's own idle timer (~30s) closes the socket. - private static let bodyTimeout: TimeInterval = 5 + private static let defaultBodyTimeout: TimeInterval = 5 // MARK: - State @@ -119,6 +119,7 @@ final class OutgoingConnection { /// probes can't trip the limiter — and thereby block a user-initiated switch — /// when a peer is down. private let countsTowardRateLimit: Bool + private let bodyTimeout: TimeInterval private let queue: DispatchQueue private var channel: SecureChannel? private var selfRef: OutgoingConnection? @@ -134,6 +135,7 @@ final class OutgoingConnection { pairingStore: PairingStore = .shared, rateLimiter: OutboundRateLimiter = .shared, countsTowardRateLimit: Bool = true, + bodyTimeout: TimeInterval = OutgoingConnection.defaultBodyTimeout, queue: DispatchQueue = DispatchQueue(label: "com.magicswitch.outgoing", qos: .userInitiated) ) { self.connection = NWConnection( @@ -145,6 +147,7 @@ final class OutgoingConnection { self.pairingStore = pairingStore self.rateLimiter = rateLimiter self.countsTowardRateLimit = countsTowardRateLimit + self.bodyTimeout = bodyTimeout self.queue = queue } @@ -258,7 +261,7 @@ final class OutgoingConnection { completion: @escaping (Result) -> Void ) { let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + Self.bodyTimeout) + timer.schedule(deadline: .now() + bodyTimeout) timer.setEventHandler { [weak self] in guard let self = self else { return } self.finish(.failure(.bodyFailed), completion: completion) diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 24dda78..046ba73 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -13,6 +13,9 @@ protocol BluetoothPeripheralManageable { /// Initiates connection to a peripheral func connectPeripheral(_ peripheral: BluetoothPeripheral) + /// Initiates takeover from the peer Mac, refreshing stale local pairing first + func connectPeripheralFromPeer(_ peripheral: BluetoothPeripheral) + /// Disconnects from a peripheral func disconnectPeripheral(_ peripheral: BluetoothPeripheral) } @@ -148,6 +151,11 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// IOBluetooth disconnect notifications. @Published private(set) var connectionStates: [String: PeripheralConnectionState] = [:] + /// One-shot waiters for handoff connect results. Incoming network commands + /// use these so they can acknowledge "connected" instead of merely + /// "connect attempt started". + private var connectResultWaiters: [String: [(Bool) -> Void]] = [:] + /// Inline per-peripheral error shown under the row in the menu-bar dropdown /// (so a failed switch is visible without relying on the system notification). /// Set on a switch failure; fades after 5s, or sooner when `setConnectionState` @@ -610,7 +618,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // connect that fails (e.g. the device is in the stuck state and needs // a power-cycle) keeps retrying instead of leaving it on neither Mac. // It self-disarms once we're connected. - self.connectPeripheral(peripheral) + self.connectPeripheralFromPeer(peripheral) self.armReconnect(peripheral.id) case .failure(.connectionFailed), .failure(.connectTimeout): // We never got a TCP connection up, so the peer's machine is @@ -621,7 +629,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // watcher as the same retry safety net. We deliberately don't grab on // post-connect failures (next case): if the connection opened, the // peer's machine is awake and may still actively hold the peripheral. - self.connectPeripheral(peripheral) + self.connectPeripheralFromPeer(peripheral) self.armReconnect(peripheral.id) case .failure(let err): // Reachable peer but the release errored, so we can't be sure it let @@ -822,21 +830,54 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip } func connectPeripheral(_ peripheral: BluetoothPeripheral) { - connectPeripheral(peripheral, announcePairTimeout: true) + connectPeripheral( + peripheral, + announcePairTimeout: true, + refreshPairingBeforeConnect: false, + completion: nil + ) + } + + func connectPeripheralFromPeer(_ peripheral: BluetoothPeripheral) { + connectPeripheralFromPeer(peripheral, completion: nil) + } + + func connectPeripheralFromPeer( + _ peripheral: BluetoothPeripheral, + completion: ((Bool) -> Void)? + ) { + connectPeripheral( + peripheral, + announcePairTimeout: true, + refreshPairingBeforeConnect: true, + completion: completion + ) } /// - Parameter announcePairTimeout: whether a pair-watchdog timeout should /// raise a user notification. Interactive callers pass `true`; the /// auto-reconnect watcher passes `false` so its retries against a stuck /// device don't spam "Pairing Timed Out". - private func connectPeripheral(_ peripheral: BluetoothPeripheral, announcePairTimeout: Bool) { + /// - Parameter refreshPairingBeforeConnect: whether to remove a stale local + /// pairing record before pairing. Use this only while taking a peripheral + /// from the peer: Magic peripherals can sit at `paired=true` but refuse + /// `openConnection()` until the target Mac re-pairs. + private func connectPeripheral( + _ peripheral: BluetoothPeripheral, + announcePairTimeout: Bool, + refreshPairingBeforeConnect: Bool, + completion: ((Bool) -> Void)? + ) { + if let completion = completion { + addConnectResultWaiter(for: peripheral.id, completion) + } setConnectionState(.connecting, for: peripheral.id) schedulePairWatchdog(for: peripheral, announceTimeout: announcePairTimeout) bluetoothQueue.async { [weak self] in guard let self = self else { return } - guard let btDevice = IOBluetoothDevice(addressString: peripheral.id) else { + guard var btDevice = IOBluetoothDevice(addressString: peripheral.id) else { print("\(peripheral.name) not found") self.setConnectionState(.disconnected, for: peripheral.id) return @@ -848,6 +889,25 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip return } + if refreshPairingBeforeConnect, btDevice.isConnected() { + self.setConnectionState(.connected, for: peripheral.id) + self.registerForDisconnect(device: btDevice, address: peripheral.id) + return + } + + if refreshPairingBeforeConnect, btDevice.isPaired() { + if btDevice.responds(to: Selector(("remove"))) { + btDevice.perform(Selector(("remove"))) + print("Removed stale local pairing before taking \(peripheral.name)") + Thread.sleep(forTimeInterval: 0.5) + if let refreshed = IOBluetoothDevice(addressString: peripheral.id) { + btDevice = refreshed + } + } else { + print("Cannot refresh stale pairing for \(peripheral.name): remove selector unavailable") + } + } + // Already bonded to this Mac. A peripheral we're holding that merely // dropped — power cycle, briefly out of range, wake — keeps its link // key, so macOS reconnects it on its own. Running @@ -856,10 +916,10 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // strands the UI at "(Pairing…)" — the pair callback never fires for an // already-connected device, and `fetchConnectedPeripherals` won't // overwrite the in-flight `.connecting`). So adopt the live connection, - // or just open one — never re-pair. A peripheral handed to the peer was - // `-remove`d (see `unregisterFromPC`), so it isn't bonded here and falls - // through to the pairing path below: that's the take-from-peer case. - if btDevice.isConnected() || btDevice.isPaired() { + // or just open one — never re-pair. For peer takeovers, a stale + // `paired=true connected=false` record is removed above so this branch + // does not mask the required re-pair. + if !refreshPairingBeforeConnect, btDevice.isConnected() || btDevice.isPaired() { if !btDevice.isConnected() { _ = btDevice.openConnection() } @@ -1063,6 +1123,21 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip guard error == kIOReturnSuccess else { print("Pairing failed for \(address): \(error)") + DispatchQueue.main.async { + self.pairTimers[address]?.cancel() + self.pairTimers.removeValue(forKey: address) + let announce = self.pairTimeoutShouldAnnounce.removeValue(forKey: address) ?? true + self.setPeripheralError("Pairing failed.", for: address) + if announce { + let name = device.name ?? address + NotificationManager.showNotification( + title: "Pairing Failed", + body: + "Couldn't pair \(name). Turn it off and on, then try switching again.", + identifier: "pair-failed-\(address)" + ) + } + } setConnectionState(.disconnected, for: address) return } @@ -1084,6 +1159,28 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip } } + @objc func devicePairingUserConfirmationRequest( + _ sender: Any!, + numericValue: BluetoothNumericValue + ) { + guard let pair = sender as? IOBluetoothDevicePair, + let address = pair.device()?.addressString + else { + return + } + print("Accepting Bluetooth pairing confirmation for \(address): \(numericValue)") + pair.replyUserConfirmation(true) + } + + @objc func devicePairingPINCodeRequest(_ sender: Any!) { + guard let pair = sender as? IOBluetoothDevicePair, + let address = pair.device()?.addressString + else { + return + } + print("Bluetooth pairing requested a PIN code for \(address)") + } + /// Selector target for `IOBluetoothDevice.register(forDisconnectNotification:...)`. /// Signature must be `(IOBluetoothUserNotification, IOBluetoothDevice)`. @objc private func handlePeripheralDisconnected( @@ -1134,14 +1231,35 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip private func setConnectionState(_ state: PeripheralConnectionState, for id: String) { let apply: () -> Void = { [weak self] in - self?.connectionStates[id] = state + guard let self = self else { return } + self.connectionStates[id] = state // A fresh attempt (.connecting) or a success (.connected) clears any prior // inline error; a failure that ends in .disconnected keeps it on screen. - if state != .disconnected { self?.clearPeripheralError(id) } + if state != .disconnected { self.clearPeripheralError(id) } + if state == .connected { + self.completeConnectResultWaiters(for: id, success: true) + } else if state == .disconnected { + self.completeConnectResultWaiters(for: id, success: false) + } } if Thread.isMainThread { apply() } else { DispatchQueue.main.async(execute: apply) } } + private func addConnectResultWaiter( + for id: String, + _ completion: @escaping (Bool) -> Void + ) { + let apply: () -> Void = { [weak self] in + self?.connectResultWaiters[id, default: []].append(completion) + } + if Thread.isMainThread { apply() } else { DispatchQueue.main.async(execute: apply) } + } + + private func completeConnectResultWaiters(for id: String, success: Bool) { + guard let waiters = connectResultWaiters.removeValue(forKey: id) else { return } + waiters.forEach { $0(success) } + } + /// Set the inline error for a peripheral, and fade it after 5s so it doesn't /// linger on the row. `setConnectionState` clears it sooner on a new attempt. private func setPeripheralError(_ message: String, for id: String) { @@ -1199,7 +1317,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip pendingPairs.removeValue(forKey: address) pairTimers.removeValue(forKey: address) let announce = pairTimeoutShouldAnnounce.removeValue(forKey: address) ?? true - connectionStates[address] = .disconnected + setConnectionState(.disconnected, for: address) // A silent watcher retry just tries again on the next probe; only // interactive connects surface the timeout to the user. guard announce else { return } @@ -1448,7 +1566,12 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // No trusted peer to consult — none registered, or one flagged as a // TOFU identity mismatch. Either way it's ours; reclaim locally rather // than auto-probing an untrusted peer with our now-stale key. - connectPeripheral(peripheral, announcePairTimeout: false) + connectPeripheral( + peripheral, + announcePairTimeout: false, + refreshPairingBeforeConnect: false, + completion: nil + ) return } NetworkDeviceStore.shared.executeHoldsOne(address: id, on: device) { [weak self] result in @@ -1471,7 +1594,12 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip return } print("Auto-reconnect: reclaiming \(peripheral.name)") - self.connectPeripheral(peripheral, announcePairTimeout: false) + self.connectPeripheral( + peripheral, + announcePairTimeout: false, + refreshPairingBeforeConnect: false, + completion: nil + ) } } } @@ -1513,7 +1641,12 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip progress.pairAttempts += 1 adoptionProgress[id] = progress print("Adoption: taking \(peripheral.name) (attempt \(progress.pairAttempts))") - connectPeripheral(peripheral, announcePairTimeout: false) + connectPeripheral( + peripheral, + announcePairTimeout: false, + refreshPairingBeforeConnect: false, + completion: nil + ) } // MARK: - Private Methods diff --git a/Magic Switch/Model/Store/NetworkDeviceStore.swift b/Magic Switch/Model/Store/NetworkDeviceStore.swift index dbfaf14..b4e6236 100644 --- a/Magic Switch/Model/Store/NetworkDeviceStore.swift +++ b/Magic Switch/Model/Store/NetworkDeviceStore.swift @@ -431,8 +431,13 @@ extension NetworkDeviceStore { return } + let bodyTimeout: TimeInterval = command == .connectAll ? 75 : 5 let outgoing = OutgoingConnection( - host: device.host, port: UInt16(device.port), countsTowardRateLimit: countsTowardRateLimit) + host: device.host, + port: UInt16(device.port), + countsTowardRateLimit: countsTowardRateLimit, + bodyTimeout: bodyTimeout + ) outgoing.run( body: { channel, done in channel.send(Data(command.rawValue.utf8)) { sendErr in @@ -631,9 +636,11 @@ extension NetworkDeviceStore { completion(.failure(.notPaired)) return } + let bodyTimeout: TimeInterval = command == .connectOne ? 75 : 5 let outgoing = OutgoingConnection( host: device.host, port: UInt16(device.port), - countsTowardRateLimit: countsTowardRateLimit) + countsTowardRateLimit: countsTowardRateLimit, + bodyTimeout: bodyTimeout) outgoing.run( body: { channel, done in channel.send(Data(command.rawValue.utf8)) { err in From 5d04a626ec012060a25c577abd31661824922806 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Thu, 18 Jun 2026 19:44:00 +0200 Subject: [PATCH 2/2] fix: extend stale-pairing refresh to adoption; tidy handoff timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the cherry-picked handoff-resilience fix. Behavior change: - `continueAdoption` is a take-from-peer grab (it claims a peripheral when the peer vanishes), so it now passes `refreshPairingBeforeConnect: true` like the other take-from-peer callers. Without it, adoption hits the same stale `paired=true` / `openConnection()`-fails dead end the fix removes. Stays silent (`announcePairTimeout: false`) — it's a background retry. Cleanups (no behavior change): - Extract the duplicated `75` body-timeout literal into a named `NetworkDeviceStore.handoffBodyTimeout`, documenting the cross-file invariant: it must exceed the 60s pair watchdog and stay <= IncomingConnection.idleTimeout, so neither side gives up before the receiver acks the real connect result. - Explain why IncomingConnection.idleTimeout was raised 30s -> 75s. - Comment the post-`-remove` Thread.sleep: it lets the async unbond settle and only stalls the background bluetoothQueue, never the UI. --- Magic Switch/Manager/IncomingConnection.swift | 7 ++++++- .../Model/Store/BluetoothPeripheralStore.swift | 12 +++++++++++- Magic Switch/Model/Store/NetworkDeviceStore.swift | 14 ++++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Magic Switch/Manager/IncomingConnection.swift b/Magic Switch/Manager/IncomingConnection.swift index 5cbf052..53d7dc5 100644 --- a/Magic Switch/Manager/IncomingConnection.swift +++ b/Magic Switch/Manager/IncomingConnection.swift @@ -36,7 +36,12 @@ extension Notification.Name { final class IncomingConnection { // MARK: - Constants - /// Cuts off slow-talkers. Reset on every successful frame. + /// Cuts off slow-talkers. Reset on every successful frame. Held above the + /// peer's worst-case handoff connect time (its pair watchdog is 60s) because + /// a `CONNECT_ALL`/`CONNECT_ONE` receiver only acks after pairing finishes — + /// no frames are exchanged meanwhile — so a tighter idle cap would kill the + /// connection before it could reply. Must stay >= `NetworkDeviceStore`'s + /// `handoffBodyTimeout` (the sender's matching wait). private static let idleTimeout: TimeInterval = 75 /// Hard cap on a single connection regardless of idle activity. Without it, /// a well-behaved-looking attacker could keep `idleTimer` happy with diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 046ba73..b2f146b 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -899,6 +899,12 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip if btDevice.responds(to: Selector(("remove"))) { btDevice.perform(Selector(("remove"))) print("Removed stale local pairing before taking \(peripheral.name)") + // `-remove` tears the bond down asynchronously in the Bluetooth + // daemon; re-pairing before it settles can race the unbond and fail. + // A short fixed settle is simpler than a poll loop here (there's no + // condition to poll — just "give the daemon a moment"). We're on + // `bluetoothQueue`, a background serial queue, so this briefly stalls + // other queued BT work but never the main thread / UI. Thread.sleep(forTimeInterval: 0.5) if let refreshed = IOBluetoothDevice(addressString: peripheral.id) { btDevice = refreshed @@ -1641,10 +1647,14 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip progress.pairAttempts += 1 adoptionProgress[id] = progress print("Adoption: taking \(peripheral.name) (attempt \(progress.pairAttempts))") + // Adoption is a take-from-peer grab (the peer vanished), so refresh a stale + // local bond like the other take-from-peer callers — otherwise a peripheral + // stuck at `paired=true` with `openConnection()` failing never comes over. + // Stays silent (`announcePairTimeout: false`): this is a background retry. connectPeripheral( peripheral, announcePairTimeout: false, - refreshPairingBeforeConnect: false, + refreshPairingBeforeConnect: true, completion: nil ) } diff --git a/Magic Switch/Model/Store/NetworkDeviceStore.swift b/Magic Switch/Model/Store/NetworkDeviceStore.swift index b4e6236..fa6ce29 100644 --- a/Magic Switch/Model/Store/NetworkDeviceStore.swift +++ b/Magic Switch/Model/Store/NetworkDeviceStore.swift @@ -54,6 +54,16 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { private var reachabilityTimer: DispatchSourceTimer? private static let reachabilityInterval: TimeInterval = 30 + /// Body-read timeout for the two commands whose receiver only acks after it + /// has actually finished (re-)pairing — `CONNECT_ALL` and `CONNECT_ONE` — + /// rather than acking on receipt. It must exceed the receiver's worst-case + /// connect time (its pair watchdog, `pairTimeout`, is 60s) so the sender + /// waits for the real result instead of giving up early; and the receiver's + /// `IncomingConnection.idleTimeout` must stay >= this so it doesn't idle-kill + /// the connection before sending that ack. Every other command acks + /// immediately and uses `OutgoingConnection`'s 5s default. + private static let handoffBodyTimeout: TimeInterval = 75 + /// Consecutive failed `.ping` polls per device id (runtime only). Drives /// the peer-vanished adoption trigger: one missed poll is routine (Wi-Fi /// blip, mid-transition), two in a row (~a minute) is a peer that's @@ -431,7 +441,7 @@ extension NetworkDeviceStore { return } - let bodyTimeout: TimeInterval = command == .connectAll ? 75 : 5 + let bodyTimeout: TimeInterval = command == .connectAll ? Self.handoffBodyTimeout : 5 let outgoing = OutgoingConnection( host: device.host, port: UInt16(device.port), @@ -636,7 +646,7 @@ extension NetworkDeviceStore { completion(.failure(.notPaired)) return } - let bodyTimeout: TimeInterval = command == .connectOne ? 75 : 5 + let bodyTimeout: TimeInterval = command == .connectOne ? Self.handoffBodyTimeout : 5 let outgoing = OutgoingConnection( host: device.host, port: UInt16(device.port), countsTowardRateLimit: countsTowardRateLimit,