fix: escalate an interactive connect to a bond refresh when a bonded device is in range but refuses to open - #103
Merged
Conversation
…device is in range but refuses to open A Magic device can sit at paired=true locally while it actually answers to the other Mac (a handoff done outside the app, or desynced state). An interactive no-peer take then hits the bonded branch, fails openConnection(), and reports 'Couldn't Connect' with no path that ever breaks the dead bond - the user is stuck until they unpair manually. Now, when the plain open fails and the RSSI probe can still see the device - alive and in range, yet refusing the bonded connect - the attempt removes the stale record and falls through to a fresh pair, the same refresh the peer-takeover path already performs. The probe gate keeps a healthy bond safe: a device that is merely off or out of range doesn't answer the probe, and its bond (and macOS's automatic reconnect) is left intact, failing exactly as before. Only the interactive entry point opts in; the background watcher and reclaim paths keep retrying the plain open so a transient link failure in a retry loop can't repeatedly tear bonds down. The remove-and-settle block is extracted into removeStaleBond, shared with the existing refreshPairingBeforeConnect path.
|
🎉 This PR is included in version 2.25.5 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
MegaManSec
added a commit
that referenced
this pull request
Aug 20, 2026
#103 shipped the bond refresh deliberately narrow — its title names the condition, "when a bonded device is in range but refuses to open." #108 extended it to takeovers by replacing that requirement with `(skipRangeCheck && autoReconnectIsOn)`. An absent peer and an absent peripheral are different facts. A registered peripheral that is merely switched off matches "bonded but refuses openConnection" exactly, so a takeover removed its healthy pairing record. `takeAllPeripherals` loops every registered peripheral, so the headline menu-bar flow reaches it, as do the display-dock trigger and ADOPT_RELEASED; auto-reconnect defaults to on, which is all the old gate required. The documented recovery cannot fire: `probeAndReclaim` gates on RSSI, and an unbonded Magic device stops answering until it is power cycled — see the comment above `directReclaimAfterWake` and README's double-sleep note. The refresh now requires the probe on every path, which also drops the last use of `autoReconnectIsOn`. Also from the same review: - removeStaleBond re-checks the attempt token immediately before `-remove`. The caller's gate runs a blocking `rssi()` after its own check, and a cancel landing in that window lost a bond it could not re-pair. - The bonded-open success arm takes the same `isCurrentAttempt` guard the failure arm three lines below already had, so a cancel during the blocking open is no longer silently undone. - bondsAwaitingRepair clears when the pair succeeds, not only on `.connected`. A re-pair that succeeded but whose openConnection failed reported "Pairing Was Reset" and told the user to rebuild a pairing that was intact — and README documents that stuck-device case as common. - cancelConnect reports a bond the attempt already removed. It stands the watcher down, so nothing else would have rebuilt it. - handlePairTimeout supersedes the attempt it just retired, so a Bluetooth-queue block still behind a blocking open cannot start a pair with no watchdog. - HOLDS_ONE answers from live Bluetooth state again. The registered-list precheck made this Mac reply "not holding" for a peripheral it was actively using, and the peer treats any failure as permission to reclaim; the two lists only converge on a manual Sync. - takeReleasesInFlight counts instead of flagging, so the first of two overlapping takes cannot clear the guard while the other is on the wire. - The connected-row adoption in fetchConnectedPeripherals goes through setConnectionState, so it completes the peer's connect waiter instead of leaving it to time out. - An unproven resolve can no longer relocate a record parked behind an Identity Mismatch, which also silently cleared the parking. - Corrects the comment claiming Magic devices stay bonded to both Macs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A Magic device can sit at
paired=truelocally while it actually answers to the other Mac — a handoff done outside the app, or desynced state. An interactive no-peer take (dropdown click with the peer unreachable,takePeripheralFromPeer's fallback, the take-all fallback) then hits the bonded branch inconnectPeripheral, failsopenConnection(), and reports "Couldn't Connect" — with no path that ever breaks the dead bond. The user is stuck until they unpair manually in System Settings.This ports the useful half of xneg's 22ed2d3, which routes no-peer takes through the pairing-refresh path unconditionally. Unconditional refresh would regress the common case though: clicking a switched-off peripheral that's healthily bonded to this Mac would
-removea good bond, fail the re-pair against an absent device, and cost the automatic reconnect macOS performs when the device comes back.Fix
Escalate instead of always refreshing. When the plain open fails and the RSSI probe can still see the device — alive and in range, yet refusing the bonded connect, which is the stale-bond signature — the attempt removes the stale record and falls through to a fresh pair, the same refresh the peer-takeover path (
refreshPairingBeforeConnect) already performs. If the probe can't see the device (merely off / out of range), it fails exactly as before and the bond is left intact.Only the interactive entry point (
connectPeripheral(_:)) opts in. The background watcher and reclaim paths keep retrying the plain open, so a transient link failure inside a retry loop can't repeatedly tear bonds down.The remove-and-settle block is extracted into
removeStaleBond(of:id:name:), shared between the existing refresh path and the new escalation.Testing
swiftc -typecheckpasses (this machine has no Xcode.app, so noxcodebuildrun; the only pre-existing failure under CLT is the#Previewmacro plugin).