Skip to content

fix: unstick taking peripherals from an absent peer - #108

Merged
MegaManSec merged 2 commits into
mainfrom
fix/takeover-stuck-pairing
Aug 20, 2026
Merged

fix: unstick taking peripherals from an absent peer#108
MegaManSec merged 2 commits into
mainfrom
fix/takeover-stuck-pairing

Conversation

@MegaManSec

Copy link
Copy Markdown
Owner

Taking peripherals from a locked or sleeping Mac could sit at "Pairing…" for minutes with no way out, while a manual connect from System Settings worked immediately.

  • Take-from-peer connects now try openConnection() on the existing bond first (the same cheap connect System Settings does) and only remove + re-pair when the open is refused; the fresh pair pages continuously instead of gating on an RSSI probe an idle Magic device doesn't answer.
  • A "Pairing…" row in the dropdown is clickable to cancel the attempt: the attempt token is superseded (in-flight side effects re-check it before doing anything destructive), the pending pair is stopped, and the auto-reconnect watcher is stood down. Cancel is refused while a take's release round trip is still on the wire, so a released peripheral can't be stranded unclaimed.
  • A live snapshot now adopts a connect made outside the app while a row shows "Pairing…", instead of holding the stale state until the 60s watchdog fires.

Also fixes a shadowed window check in MenuRowControl.mouseDown that made its stale-row guard a no-op.

Take-from-peer connects open the existing bond first (the connect System
Settings performs) and only remove + re-pair when that open is refused;
the fresh pair pages continuously instead of gating on an RSSI probe an
idle Magic device doesn't answer. A "Pairing…" dropdown row is clickable
to cancel the attempt. Live snapshots adopt a connect made outside the
app instead of holding "Pairing…" until the watchdog fires.
…nnect (#111)

* fix: stop the unbond settle from stalling every other peripheral's connect

`removeStaleBond` slept 0.5s on `bluetoothQueue` to let `-remove` settle
in the daemon. That queue is serial and shared by every in-flight
connect, so a full-set switch served peripherals one at a time, each
waiting out its predecessors' unbonds — the later rows spending longest
bonded to no Mac at all, which is the window where macOS answers the
peripheral's own reconnect with its "Connection Request" panel instead of
our pair session (#109).

Wait by yielding the queue: `removeStaleBond` re-enqueues via
`asyncAfter` and hands the re-fetched handle to a completion, moving the
pair start into `startDevicePair`. An escalated refresh now pairs blind
instead of re-probing RSSI after the teardown — the gate above it already
established reachability, and the second probe only widened the window
while also being able to abandon the attempt with the bond already gone.

Failing after a teardown also stops being silent: it leaves the
peripheral paired to nothing, which no retry cadence undoes for the user,
so it reports through the announce and watcher gates that suppress an
ordinary failure.

* fix: make a blind bond teardown recoverable before taking one on

The takeover escalation removes a local bond without an RSSI check, on
the reasoning that a peer which just released (or vanished) leaves a
device that's free but too idle to answer the probe. That holds, and it
is what unsticks a take from a locked peer — but it is a guess, and two
things stopped it from being a safe one.

The watcher's retries are what pay for a wrong guess, and `armReconnect`
preserves an existing entry's arm time so `reconnectMaxWindow` counts
from the original drop. A teardown minutes into an existing watch
therefore inherited almost no time to re-pair. `armReconnectForBondRepair`
restarts the window from the teardown, and arms a peripheral whose caller
never did; it leaves an entry's reclaim/adoption flavour alone, so the
adoption cap still applies.

Those retries also don't exist when "reconnect peripherals if they drop"
is off, so with the setting off nothing would re-pair the device at all.
Make the blind escalation conditional on it and keep the bond otherwise.
Read via `UserDefaults` rather than the `@AppStorage` wrapper, which the
Bluetooth queue can't safely touch.
@MegaManSec
MegaManSec merged commit a7e2d59 into main Aug 20, 2026
2 checks passed
@MegaManSec
MegaManSec deleted the fix/takeover-stuck-pairing branch August 20, 2026 20:17
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.25.6 🎉

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