From 0d82ab953997fd2cfeb4931542afda62bd76e059 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Wed, 4 Nov 2026 09:11:37 +0000 Subject: [PATCH 1/8] fix: wait up to 15s for a released peripheral to disconnect --- .../Model/Store/BluetoothPeripheralStore.swift | 18 ++++++++++-------- .../Model/Store/NetworkDeviceStore.swift | 5 +++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index a0b3107..32fd21f 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -42,6 +42,10 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// How long to let `-remove` settle in the Bluetooth daemon before pairing /// again. Re-pairing inside this window races the unbond and fails. static let unbondSettle: TimeInterval = 0.5 + /// How long `waitForLocalDisconnect` waits for a released device to + /// disconnect. Must stay under `intentionalReleaseGrace` and the peer's + /// `releaseBodyTimeout`, less the time `-remove` itself takes. + static let releaseDisconnectTimeout: TimeInterval = 15 /// How long after wake to wait before deciding whether the peer holds a /// peripheral we released for sleep. Gives Wi-Fi time to reassociate so a /// peer that's actively using the peripheral doesn't look unreachable and @@ -79,7 +83,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// the watcher; a disconnect seen later is treated as a genuine drop. This /// stops a release whose disconnect notification never arrived from /// leaving a stale flag that suppresses a real reconnect. - static let intentionalReleaseGrace: TimeInterval = 15 + static let intentionalReleaseGrace: TimeInterval = 30 /// Consecutive peer-absent `HOLDS_ONE` probes an *adoption* needs before /// it takes a peripheral. Two probes (one extra tick) give Wi-Fi that's /// still reassociating after wake a chance to come up — so a peer that's @@ -1144,17 +1148,15 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip } } - /// Polls IOBluetooth (not the cached `connectionStates`) every 0.5s up - /// to 5 times, waiting for every device in `ids` to actually disconnect. - /// Call on main; the completion fires on main. + /// Polls IOBluetooth (not the cached `connectionStates`) every 0.5s for up + /// to `Constants.releaseDisconnectTimeout`, waiting for every device in + /// `ids` to actually disconnect. Call on main; the completion fires on main. func waitForLocalDisconnect( of ids: [String], completion: @escaping (Bool) -> Void ) { - var attempts = 0 - let maxAttempts = 5 + let deadline = Date().addingTimeInterval(Constants.releaseDisconnectTimeout) func check() { - attempts += 1 bluetoothQueue.async { let anyConnected = ids.contains { IOBluetoothDevice(addressString: $0)?.isConnected() ?? false @@ -1162,7 +1164,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip DispatchQueue.main.async { if !anyConnected { completion(true) - } else if attempts < maxAttempts { + } else if Date() < deadline { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { check() } } else { completion(false) diff --git a/Magic Switch/Model/Store/NetworkDeviceStore.swift b/Magic Switch/Model/Store/NetworkDeviceStore.swift index bbc44a7..69fe0bd 100644 --- a/Magic Switch/Model/Store/NetworkDeviceStore.swift +++ b/Magic Switch/Model/Store/NetworkDeviceStore.swift @@ -97,9 +97,10 @@ final class NetworkDeviceStore: ObservableObject, NetworkDeviceManageable { /// default. private static let handoffBodyTimeout: TimeInterval = 75 /// Body-read timeout for `UNREGISTER_ALL` and `UNREGISTER_ONE`, whose - /// receiver acks only once its peripherals have actually disconnected. + /// receiver acks only once its peripherals have actually disconnected, or + /// after its `releaseDisconnectTimeout` (15s) gives up. /// `IncomingConnection.idleTimeout` must stay >= this too. - private static let releaseBodyTimeout: TimeInterval = 20 + private static let releaseBodyTimeout: TimeInterval = 30 /// Consecutive failed `.ping` polls per device id (runtime only). Drives /// the peer-vanished adoption trigger: one missed poll is routine (Wi-Fi From 8c15eac18baf4b74ee4424c4b3de289d2ce691c6 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Wed, 4 Nov 2026 09:12:12 +0000 Subject: [PATCH 2/8] fix: keep watching a taken peripheral while the peer finishes releasing it --- Magic Switch/AppDelegate/AppDelegate.swift | 3 +- .../Store/BluetoothPeripheralStore.swift | 36 +++++++++++++++---- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/Magic Switch/AppDelegate/AppDelegate.swift b/Magic Switch/AppDelegate/AppDelegate.swift index 9febe60..f4372dd 100644 --- a/Magic Switch/AppDelegate/AppDelegate.swift +++ b/Magic Switch/AppDelegate/AppDelegate.swift @@ -794,7 +794,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, // holding it — and recovers the case where the peer released but // the ack was lost. self.bluetoothStore.peripherals.forEach { peripheral in - self.bluetoothStore.armReconnectForTakeover(peripheral.id) + self.bluetoothStore.armReconnectForTakeover( + peripheral.id, peerMayStillBeReleasing: true) } NotificationManager.showNotification( title: String(localized: "Couldn't Switch"), diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 32fd21f..387f5d0 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -83,7 +83,11 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// the watcher; a disconnect seen later is treated as a genuine drop. This /// stops a release whose disconnect notification never arrived from /// leaving a stale flag that suppresses a real reconnect. - static let intentionalReleaseGrace: TimeInterval = 30 + static let intentionalReleaseGrace: TimeInterval = 60 + /// How long after a take's release fails the watcher reads the peer's + /// "holding" as a release still finishing. `intentionalReleaseGrace` must + /// cover this plus `releaseDisconnectTimeout`. + static let peerReleaseGrace: TimeInterval = 30 /// Consecutive peer-absent `HOLDS_ONE` probes an *adoption* needs before /// it takes a peripheral. Two probes (one extra tick) give Wi-Fi that's /// still reassociating after wake a chance to come up — so a peer that's @@ -268,6 +272,10 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// up on a routine drop or adoption is not. Main-only. private var takeArmed: Set = [] + /// Per id, when `Constants.peerReleaseGrace` ends; see `armTakeReconnect`. + /// Main-only. + private var peerReleaseDeadlines: [String: Date] = [:] + /// Per-id bookkeeping for adoption arms; see `adoptionProgress`. private struct AdoptionProgress { /// Consecutive `HOLDS_ONE` probes that ended peer-absent (unreachable at @@ -992,7 +1000,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip // once the peer confirms it isn't holding it — and recovers the case // where the peer released but the ack was lost. self.setConnectionState(.disconnected, for: peripheral.id) - self.armTakeReconnect(peripheral.id) + self.armTakeReconnect(peripheral.id, peerMayStillBeReleasing: true) self.setPeripheralError(String(localized: "Switch failed."), for: peripheral.id) NotificationManager.showNotification( title: String(localized: "Couldn't Switch"), @@ -2137,18 +2145,26 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// peripheral the peer confirms it's holding) and retries for the full /// `reconnectMaxWindow`, so a device stuck in the bonded-but-not-connected /// state comes back the moment the user power-cycles it. - func armReconnectForTakeover(_ id: String) { + func armReconnectForTakeover(_ id: String, peerMayStillBeReleasing: Bool = false) { guard Thread.isMainThread else { - DispatchQueue.main.async { [weak self] in self?.armReconnectForTakeover(id) } + DispatchQueue.main.async { [weak self] in + self?.armReconnectForTakeover(id, peerMayStillBeReleasing: peerMayStillBeReleasing) + } return } - armTakeReconnect(id) + armTakeReconnect(id, peerMayStillBeReleasing: peerMayStillBeReleasing) } /// `armReconnect` for an attempt the user started; see `takeArmed`. - private func armTakeReconnect(_ id: String) { + /// `peerMayStillBeReleasing`: the peer's release failed, so keep watching + /// through its "holding" answers for `Constants.peerReleaseGrace`. + private func armTakeReconnect(_ id: String, peerMayStillBeReleasing: Bool = false) { armReconnect(id) - if reconnectWatchlist[id] != nil { takeArmed.insert(id) } + guard reconnectWatchlist[id] != nil else { return } + takeArmed.insert(id) + if peerMayStillBeReleasing { + peerReleaseDeadlines[id] = Date().addingTimeInterval(Constants.peerReleaseGrace) + } } private static var retryMinutes: Int { Int(Constants.reconnectMaxWindow / 60) } @@ -2171,6 +2187,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip reconnectInFlight.remove(id) adoptionProgress.removeValue(forKey: id) takeArmed.remove(id) + peerReleaseDeadlines.removeValue(forKey: id) guard reconnectWatchlist.removeValue(forKey: id) != nil else { return } if reconnectWatchlist.isEmpty { stopReconnectTimer() } } @@ -2398,6 +2415,11 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip self.reconnectInFlight.remove(id) switch result { case .success: + if let deadline = self.peerReleaseDeadlines[id], Date() < deadline { + Log.bluetooth.notice( + "Auto-reconnect: \(peripheral.name) still on \(device.name); waiting for its release") + return + } // Peer is actively holding it — leave it there. Log.bluetooth.notice( "Auto-reconnect: \(peripheral.name) held by \(device.name); leaving it") From c7adf5e0b2c5a5ada4c07ec79fdfa565d5b5db35 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Wed, 4 Nov 2026 09:13:51 +0000 Subject: [PATCH 3/8] fix: roll a timed-out send back onto this Mac --- Magic Switch/AppDelegate/AppDelegate.swift | 8 ++- .../Store/BluetoothPeripheralStore.swift | 49 ++++++++----------- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/Magic Switch/AppDelegate/AppDelegate.swift b/Magic Switch/AppDelegate/AppDelegate.swift index f4372dd..872804c 100644 --- a/Magic Switch/AppDelegate/AppDelegate.swift +++ b/Magic Switch/AppDelegate/AppDelegate.swift @@ -918,7 +918,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, bluetoothStore.waitForLocalDisconnect(of: ids) { [weak self] allDisconnected in guard let self = self else { return } guard allDisconnected else { - self.bluetoothStore.finishFullSetRelease(success: false) + self.bluetoothStore.rollBackFullSetRelease() self.endTransfer() NotificationManager.showNotification( title: String(localized: "Couldn't Switch"), @@ -939,9 +939,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, // locally. Without this the user is left with peripherals paired // nowhere. `connectPeripheral` flips each row to `.connecting`, // which clears the "Sending…" state on its own. - self.bluetoothStore.peripherals.forEach { peripheral in - self.bluetoothStore.connectPeripheral(peripheral) - } + self.bluetoothStore.rollBackFullSetRelease() self.endTransfer() NotificationManager.showNotification( title: String(localized: "Couldn't Switch"), @@ -951,7 +949,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, identifier: "switch-connect-failed" ) } else { - self.bluetoothStore.finishFullSetRelease(success: true) + self.bluetoothStore.finishFullSetRelease() self.endTransfer() } } diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 387f5d0..f32ff2b 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -1102,6 +1102,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip guard success else { // It never disconnected, so it's still on this Mac — drop back to // connected rather than leave it stuck "Sending…". + self.clearIntentionalRelease(peripheral.id) self.setConnectionState(.connected, for: peripheral.id) self.setPeripheralError(String(localized: "Didn't disconnect."), for: peripheral.id) NotificationManager.showNotification( @@ -1191,22 +1192,24 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// Mark every registered peripheral "Sending…" for the menu-bar full-set /// handoff (the per-peripheral send drives its own row in `performSendHandoff`). - /// Call after the local releases land; pair with `finishFullSetRelease(success:)`. + /// Call after the local releases land; pair with `finishFullSetRelease()` or + /// `rollBackFullSetRelease()`. func beginFullSetRelease() { peripherals.forEach { setConnectionState(.releasing, for: $0.id) } } - /// End the full-set "Sending…" display. On success the peripherals now live - /// on the peer, so they read `.disconnected`; on a non-success that didn't - /// actually move them (e.g. the local disconnect failed), restore the live - /// state. The `CONNECT_ALL`-failure rollback re-pairs locally via - /// `connectPeripheral` instead, which clears the row itself — don't call this - /// there. - func finishFullSetRelease(success: Bool) { - if success { - peripherals.forEach { setConnectionState(.disconnected, for: $0.id) } - } else { - fetchConnectedPeripherals(overrideTransient: true) + /// End the full-set "Sending…" display once the peripherals live on the peer. + func finishFullSetRelease() { + peripherals.forEach { setConnectionState(.disconnected, for: $0.id) } + } + + /// Bring a full-set send that didn't complete back onto this Mac: re-pair + /// what left, adopt what didn't, and let a later disconnect re-arm the + /// watcher. + func rollBackFullSetRelease() { + for peripheral in peripherals { + clearIntentionalRelease(peripheral.id) + connectPeripheral(peripheral) } } @@ -1538,15 +1541,6 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip } func fetchConnectedPeripherals() { - fetchConnectedPeripherals(overrideTransient: false) - } - - /// - Parameter overrideTransient: when `true`, a live read replaces even an - /// in-flight `.connecting`/`.releasing` state. `false` (the usual path) - /// protects those transients from a stale snapshot (the Peripherals tab - /// polls this on a timer); `finishFullSetRelease(success:)` passes `true` - /// to restore the real state after an aborted handoff. - private func fetchConnectedPeripherals(overrideTransient: Bool) { let runSnapshot: ([String]) -> Void = { [weak self] registeredIDs in guard let self = self else { return } self.bluetoothQueue.async { @@ -1602,13 +1596,12 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip for id in registeredIDs { let isConnected = connectedAddresses.contains(id) // Don't overwrite an in-flight .connecting/.releasing state with a - // stale read (unless a caller explicitly wants the live value). - // One exception: a `.connecting` row with no pair pending and a - // live connection is a connect made behind our back (System - // Settings, macOS auto-reconnect) — adopt it rather than hold - // "Pairing…" until the watchdog fires. - if !overrideTransient, self.connectionStates[id] == .releasing { continue } - if !overrideTransient, self.connectionStates[id] == .connecting, + // stale read. One exception: a `.connecting` row with no pair + // pending and a live connection is a connect made behind our back + // (System Settings, macOS auto-reconnect) — adopt it rather than + // hold "Pairing…" until the watchdog fires. + if self.connectionStates[id] == .releasing { continue } + if self.connectionStates[id] == .connecting, self.pendingPairs[id] != nil || !isConnected { continue From 468ae2781e32cb88cf8ec58a4e36a690857e3bfe Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Wed, 4 Nov 2026 09:33:23 +0000 Subject: [PATCH 4/8] fix: clear a leftover release flag when this Mac connects the peripheral --- Magic Switch/Model/Store/BluetoothPeripheralStore.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index f32ff2b..a985267 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -1207,10 +1207,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// what left, adopt what didn't, and let a later disconnect re-arm the /// watcher. func rollBackFullSetRelease() { - for peripheral in peripherals { - clearIntentionalRelease(peripheral.id) - connectPeripheral(peripheral) - } + for peripheral in peripherals { connectPeripheral(peripheral) } } func connectPeripheral(_ peripheral: BluetoothPeripheral) { @@ -1261,6 +1258,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip if let completion = completion { addConnectResultWaiter(for: peripheral.id, completion) } + clearIntentionalRelease(peripheral.id) setConnectionState(.connecting, for: peripheral.id) let attempt = beginConnectAttempt(for: peripheral.id) schedulePairWatchdog(for: peripheral, announceTimeout: announcePairTimeout, attempt: attempt) From 41f24b4832665e6d52472d69592e1cdf86c971b6 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Wed, 4 Nov 2026 09:33:36 +0000 Subject: [PATCH 5/8] fix: withdraw the take-failed notice once the peripheral connects --- Magic Switch/Model/Store/BluetoothPeripheralStore.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index a985267..473d6e1 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -1845,6 +1845,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip self.bondsAwaitingRepair.remove(id) NotificationManager.removeNotification(identifier: "pair-failed-\(id)") NotificationManager.removeNotification(identifier: "pair-timeout-\(id)") + NotificationManager.removeNotification(identifier: "take-failed-\(id)") self.completeConnectResultWaiters(for: id, success: true) } else if state == .disconnected { self.completeConnectResultWaiters(for: id, success: false) From 473e217c4d3a9b980a361ee65a8d02f96e6fbbd1 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Thu, 5 Nov 2026 22:28:47 +0000 Subject: [PATCH 6/8] fix: reclaim mid-release only on an explicit "not holding" from the peer --- Magic Switch/Model/Store/BluetoothPeripheralStore.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 473d6e1..0c0219b 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -2421,6 +2421,14 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip !self.isFullSetSwitchPending, self.connectionState(for: id) == .disconnected else { return } + if let deadline = self.peerReleaseDeadlines[id], Date() < deadline { + guard case .remoteOperationFailed = failure else { + Log.bluetooth.notice( + "Auto-reconnect: couldn't ask \(device.name) about \(peripheral.name); waiting for its release" + ) + return + } + } if self.adoptionProgress[id] != nil { self.continueAdoption(of: peripheral, after: failure) return From be9a4294b4a496a590b91974d91f32bd1ca473f0 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Thu, 5 Nov 2026 22:28:59 +0000 Subject: [PATCH 7/8] fix: roll a timed-out single send back the way the full-set send does --- Magic Switch/Model/Store/BluetoothPeripheralStore.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 0c0219b..7f13c23 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -1100,11 +1100,9 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip waitForLocalDisconnect(of: [peripheral.id]) { [weak self] success in guard let self = self else { return } guard success else { - // It never disconnected, so it's still on this Mac — drop back to - // connected rather than leave it stuck "Sending…". - self.clearIntentionalRelease(peripheral.id) - self.setConnectionState(.connected, for: peripheral.id) - self.setPeripheralError(String(localized: "Didn't disconnect."), for: peripheral.id) + // It never disconnected, so it's still on this Mac — adopt it (or + // re-pair it if it dropped meanwhile), as the full-set rollback does. + self.connectPeripheral(peripheral) NotificationManager.showNotification( title: String(localized: "Couldn't Switch"), body: String(localized: "\(peripheral.name) didn't disconnect from this Mac."), From 8f510260d780c296716dbb66223113ef91f15c49 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Thu, 5 Nov 2026 22:30:06 +0000 Subject: [PATCH 8/8] fix: finish a failed take without the watcher when auto-reconnect is off --- .../Store/BluetoothPeripheralStore.swift | 63 ++++++++++++++++--- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 7f13c23..2a934e7 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -84,9 +84,10 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// stops a release whose disconnect notification never arrived from /// leaving a stale flag that suppresses a real reconnect. static let intentionalReleaseGrace: TimeInterval = 60 - /// How long after a take's release fails the watcher reads the peer's - /// "holding" as a release still finishing. `intentionalReleaseGrace` must - /// cover this plus `releaseDisconnectTimeout`. + /// How long after a take's release fails the watcher (or, with + /// auto-reconnect off, `pollPeerRelease`) reads the peer's "holding" as a + /// release still finishing. `intentionalReleaseGrace` must cover this plus + /// `releaseDisconnectTimeout`. static let peerReleaseGrace: TimeInterval = 30 /// Consecutive peer-absent `HOLDS_ONE` probes an *adoption* needs before /// it takes a peripheral. Two probes (one extra tick) give Wi-Fi that's @@ -273,7 +274,7 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip private var takeArmed: Set = [] /// Per id, when `Constants.peerReleaseGrace` ends; see `armTakeReconnect`. - /// Main-only. + /// Doubles as `pollPeerRelease`'s token. Main-only. private var peerReleaseDeadlines: [String: Date] = [:] /// Per-id bookkeeping for adoption arms; see `adoptionProgress`. @@ -2147,13 +2148,59 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip /// `armReconnect` for an attempt the user started; see `takeArmed`. /// `peerMayStillBeReleasing`: the peer's release failed, so keep watching - /// through its "holding" answers for `Constants.peerReleaseGrace`. + /// through its "holding" answers for `Constants.peerReleaseGrace`. With + /// auto-reconnect off there is no watcher, so `pollPeerRelease` stands in + /// for it over the same window. private func armTakeReconnect(_ id: String, peerMayStillBeReleasing: Bool = false) { armReconnect(id) - guard reconnectWatchlist[id] != nil else { return } + let deadline = Date().addingTimeInterval(Constants.peerReleaseGrace) + guard reconnectWatchlist[id] != nil else { + if peerMayStillBeReleasing { + peerReleaseDeadlines[id] = deadline + pollPeerRelease(id, deadline: deadline) + } + return + } takeArmed.insert(id) - if peerMayStillBeReleasing { - peerReleaseDeadlines[id] = Date().addingTimeInterval(Constants.peerReleaseGrace) + if peerMayStillBeReleasing { peerReleaseDeadlines[id] = deadline } + } + + /// Watcher-less `peerReleaseGrace`: ask the peer (`HOLDS_ONE`) every probe + /// interval until `deadline`, and connect once it answers an explicit "not + /// holding". `peerReleaseDeadlines[id]` must still equal `deadline`, so a + /// newer arm or a `disarmReconnect` ends an older poll. Main-only. + private func pollPeerRelease(_ id: String, deadline: Date) { + DispatchQueue.main.asyncAfter(deadline: .now() + Constants.reconnectProbeInterval) { + [weak self] in + guard let self, self.peerReleaseDeadlines[id] == deadline else { return } + guard self.reconnectWatchlist[id] == nil, + !self.isFullSetSwitchPending, + self.connectionState(for: id) == .disconnected, + let peripheral = self.peripherals.first(where: { $0.id == id }), + PairingStore.shared.isPaired, + let device = NetworkDeviceStore.shared.networkDevices.first, + device.pendingFingerprint == nil + else { return } + NetworkDeviceStore.shared.executeHoldsOne(address: id, on: device) { [weak self] result in + DispatchQueue.main.async { + guard let self, self.peerReleaseDeadlines[id] == deadline else { return } + if case .failure(.remoteOperationFailed) = result { + self.peerReleaseDeadlines.removeValue(forKey: id) + guard !self.isFullSetSwitchPending, self.connectionState(for: id) == .disconnected + else { return } + Log.bluetooth.notice("\(device.name) released \(peripheral.name); connecting it") + self.connectPeripheral(peripheral) + return + } + guard Date() < deadline else { + self.peerReleaseDeadlines.removeValue(forKey: id) + Log.bluetooth.notice( + "\(peripheral.name) still on \(device.name) after the release grace; leaving it") + return + } + self.pollPeerRelease(id, deadline: deadline) + } + } } }