Skip to content

fix: stop the unbond settle from stalling every other peripheral's connect - #111

Merged
MegaManSec merged 2 commits into
fix/takeover-stuck-pairingfrom
fix/nonblocking-bond-refresh
Aug 20, 2026
Merged

fix: stop the unbond settle from stalling every other peripheral's connect#111
MegaManSec merged 2 commits into
fix/takeover-stuck-pairingfrom
fix/nonblocking-bond-refresh

Conversation

@MegaManSec

@MegaManSec MegaManSec commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Stacked on #108 — review that first; this targets its branch and will retarget to main when it merges.

Unbond settle stops stalling the queue

removeStaleBond slept 0.5s on bluetoothQueue to let -remove settle. 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 spent longest bonded to no Mac at all — the window in which macOS answers the peripheral's own reconnect with the "Connection Request" panel from #109 instead of our pair session.

  • Wait by yielding the queue: removeStaleBond re-enqueues via asyncAfter and hands the re-fetched handle to a completion. The pair start moves into startDevicePair so both paths share it, and the completion re-checks isCurrentAttempt since the gap is now supersedable.
  • An escalated refresh pairs blind instead of re-probing RSSI after the teardown. The gate above it already established reachability, and the second probe both widened the window and could abandon the attempt with the bond already gone.

The blind teardown becomes recoverable

#108's takeover escalation removes a bond with no RSSI check, reasoning that a peer which just released (or vanished) leaves a device that's free but too idle to answer the probe. That's right, and it's what unsticks a take from a locked peer — but it's a guess, and two things stopped it being a safe one.

  • The watcher's retries are what pay for a wrong guess, but armReconnect preserves an existing entry's arm time, so reconnectMaxWindow counts from the original drop. A teardown minutes into an existing watch inherited almost no time to re-pair. armReconnectForBondRepair restarts the window from the teardown, and arms a peripheral whose caller never did. It leaves the entry's reclaim/adoption flavour alone, so the adoption attempt cap still applies.
  • Those retries don't exist at all when "reconnect peripherals if they drop" is off. The blind escalation is now conditional on it; otherwise the unanswered probe keeps its bond. Read via UserDefaults rather than the @AppStorage wrapper, which the Bluetooth queue can't safely touch.
  • A failure after a teardown 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.

Verified with swift format only — no Xcode on this machine, so this hasn't been built or run against hardware.

…nnect

`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.
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 b1fc6f2 into fix/takeover-stuck-pairing Aug 20, 2026
@MegaManSec
MegaManSec deleted the fix/nonblocking-bond-refresh branch August 20, 2026 20:17
MegaManSec added a commit that referenced this pull request Aug 20, 2026
* fix: unstick taking peripherals from an absent peer

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.

* fix: stop the unbond settle from stalling every other peripheral's connect (#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant