Skip to content

fix: escalate an interactive connect to a bond refresh when a bonded device is in range but refuses to open - #103

Merged
MegaManSec merged 1 commit into
mainfrom
fix/stale-bond-escalation
Aug 10, 2026
Merged

fix: escalate an interactive connect to a bond refresh when a bonded device is in range but refuses to open#103
MegaManSec merged 1 commit into
mainfrom
fix/stale-bond-escalation

Conversation

@MegaManSec

Copy link
Copy Markdown
Owner

Problem

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 (dropdown click with the peer unreachable, takePeripheralFromPeer's fallback, the take-all fallback) then hits the bonded branch in connectPeripheral, fails openConnection(), 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 -remove a 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

  • Full-module swiftc -typecheck passes (this machine has no Xcode.app, so no xcodebuild run; the only pre-existing failure under CLT is the #Preview macro plugin).
  • Needs a two-Mac sanity pass: take a peripheral that's bonded to the sleeping Mac via the dropdown with no peer reachable (should now re-pair instead of erroring), and click a switched-off peripheral bonded to this Mac (should still fail with "Couldn't Connect" and reconnect on its own when powered back on).

…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.
@MegaManSec
MegaManSec merged commit 28c0242 into main Aug 10, 2026
2 checks passed
@MegaManSec
MegaManSec deleted the fix/stale-bond-escalation branch August 10, 2026 10:37
@github-actions

Copy link
Copy Markdown

🎉 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant