Skip to content

fix(bindings): BLE survives an iPhone's Bluetooth power-cycle on both ends of the link - #501

Merged
bahdotsh merged 14 commits into
mainfrom
fix/ble-reliability
Oct 2, 2026
Merged

bahdotsh merged 14 commits into
mainfrom
fix/ble-reliability

Conversation

@mizanisoffline

@mizanisoffline mizanisoffline commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

What this does

Bluetooth bugs on both platforms left a peer pair unable to talk after an iPhone's Bluetooth was turned off and on, or after a Bluetooth stack reset. One commit per fix.

iOS recovers after Bluetooth is turned off and on, or reset.

  • Powering off unpublishes the GATT service, but isGattServiceReady stayed true, so power-on advertised the service UUID with no service behind it. Peers connected, found nothing and dropped. The poweredOff branches now clear the flag and the per-link state (connections, fragments, subscribers), which CoreBluetooth invalidates without disconnect callbacks. That cleanup is factored out of stop() as clearLinkState().
  • Power-on only reported bleStatusChanged(true) from .starting. After a power-off the state is .unavailable, so the core never heard BLE was back and drainAndSendFragments (gated on .running) never sent again, while inbound kept working. Power-on now also recovers from .unavailable.
  • .resetting is handled as .poweredOff: any state below poweredOff clears the local GATT database and invalidates every CBPeripheral, and a reset can go straight back to poweredOn.
  • setupGattServer clears this app's published services before adding, so the service is never published twice: CoreBluetooth only documents a cleared database below poweredOff, and a relaunch restores the service with nil characteristic references.
  • stop() while Bluetooth is off used to return early in .unavailable, so the next power-on brought a stopped transport back to .running. It now stops.
  • clearLinkState() also clears the mesh controller's connection count (MeshController.registerAllDisconnected()). Before this, connections was emptied but the mesh still counted every pre-power-off link, so a device that was at maxConnections came back with no free slots, refused its returning peers and advertised itself as full.
  • Power-off and reset also report every identified peer lost (blePeerLost) before clearing link state. bleStatusChanged(false) only clears the Rust transport's peer map, so a peer that did not come back stayed a neighbor in the core and never produced neighbor_lost. stop() does not do this: it is reachable from deinit, where notifyBlePeerLost's [weak self] capture aborts.

Android keeps an iPhone that reconnected from a new address. This is the iPhone's power-cycle seen from Android. Nothing here handles an Android phone's own Bluetooth being turned off and on.

iOS rotates its random BLE address across a power-cycle. Android verified the peer at the new address but kept redialling the old one, and when that gave up it ran the peer-level teardown: blePeerLost (a false neighbor_lost), dropped the connection role and outbound queue, and removeIdentifiersForAddress deleted the device-to-address mapping that by then pointed at the live link.

  • removeIdentifiersForAddress only drops the reverse mapping if it still points at the removed address.
  • MeshConnectionRegistry.hasOtherLiveLink(deviceId, excluding).
  • handleDisconnected stops redialling a stale address once the peer is live elsewhere, and finalizeGivenUpPeer then only cleans up that address: its mapping, buffered inbound, resolution throttle, and the facade's address-keyed staged MTU slots (new Host.onStaleAddressDropped). Peer-keyed state stays with the live link.
  • The GATT server's unclean disconnect (BleTransportFacade) gets the same hasOtherLiveLink guard and address-only cleanup.

Verification

  • StaleAddressRegistryTest covers the Android registry behaviour.
  • StaleAddressDisconnectTest drives the real CentralGattClient disconnect path: the stale address is neither redialled nor reported as a lost peer and its address-keyed state is dropped, a give-up queued before the new link lands still keeps the peer, and a peer with no other link is still given up.
  • Two Rust source guards in the uniffi crate: react_native_ios_bluetooth_power_cycle_drops_and_restores_the_transport pins the iOS power-off arms, the GATT flag reset, the clear before the add, the .unavailable recovery and stop(); react_native_android_server_disconnect_keeps_a_peer_live_elsewhere pins the facade guard. Every pinned piece was mutated out and the guard or test failed.
  • MeshControllerTests.testDroppingEveryLinkFreesEverySlot covers the mesh slot release (runs in the Swift Package job).
  • The iOS bridge typecheck passes, including the peer-lost change. Not yet checked on a device: with Bluetooth off on the iPhone and the peer moved out of range, neighbor_lost should fire. BleManager.swift has no unit coverage, so the power-cycle path itself is not covered by an automated test.
  • No engine and no UDL change, so no bindings were regenerated. The only Rust is the source guards in the uniffi test module.
  • CHANGELOG.md has an entry under Unreleased / Fixed.

Not in this PR

  • Handling of an Android phone's own Bluetooth adapter being turned off and on.
  • The iOS mirror of the Android fix: an unbonded Android peer whose address rotates appears to iOS under a new peripheral.identifier.

Two bugs left an iPhone unable to send after a Bluetooth power-cycle:

- Powering off unpublishes the GATT service, but isGattServiceReady stayed
  true, so power-on advertised the service UUID with no service behind it.
  Peers connected, found nothing and dropped. The poweredOff branches now
  clear the flag and the per-link state (connections, fragments,
  subscribers), which CoreBluetooth invalidates without disconnect callbacks.
  That cleanup is factored out of stop() as clearLinkState().
- Power-on only reported bleStatusChanged(true) from the .starting state.
  After a power-off the state is .unavailable, so the core never heard BLE
  was back and drainAndSendFragments (gated on .running) never sent again,
  while inbound kept working. Power-on now also recovers from .unavailable.

Known, not fixed: stop() returns early in .unavailable, so stopping while
Bluetooth is off skips cleanup.
iOS rotates its random BLE address across a Bluetooth power-cycle. Android
verified the peer at the new address but kept redialling the old one, and
when that gave up it ran the peer-level teardown: blePeerLost (a false
neighbor_lost), dropped the connection role and outbound queue, and
removeIdentifiersForAddress deleted the device->address mapping that by then
pointed at the live link.

- removeIdentifiersForAddress only drops the reverse mapping if it still
  points at the removed address.
- MeshConnectionRegistry.hasOtherLiveLink(deviceId, excluding).
- handleDisconnected stops redialling a stale address once the peer is live
  elsewhere, and finalizeGivenUpPeer then only cleans up that address.

StaleAddressRegistryTest covers the registry behaviour.
@mizanisoffline
mizanisoffline marked this pull request as draft October 2, 2026 08:19
stop() returned early in .unavailable, leaving both CoreBluetooth managers
alive. Now that power-on recovers from .unavailable, the next power-on would
move the stopped transport back to .running and report BLE available,
overriding disableTransport("ble").
…r-off

Any CoreBluetooth state below poweredOff invalidates every CBPeripheral and
clears the local GATT database, and a reset can return straight to poweredOn.
.resetting only logged, so isGattServiceReady and the per-link state survived
it: power-on advertised the service UUID with nothing behind it. It now takes
the poweredOff branch on both managers.
…drops the peer

Drives the real CentralGattClient disconnect path through Robolectric: the
old address of a peer live at a new one is not redialled and the peer is not
given up, while a peer with no other link still is.
Power-off, a stack reset and stop() empty the BLE connection registry,
but the mesh controller kept counting every link it had before. A device
that was at maxConnections came back with no free slots, refused its own
returning peers and advertised itself as full. clearLinkState() now
forgets the mesh's active links too.
CoreBluetooth drops every link on power-off or reset without a disconnect
callback per link, and bleStatusChanged(false) clears only the Rust
transport's peer map. A peer that did not come back stayed a neighbor in
the core and never produced neighbor_lost. The power-off and reset
branches now report each identified peer lost before clearing link state.
stop() is left alone: it is reachable from deinit, where
notifyBlePeerLost's weak-self capture aborts.
…wer-cycle

clearLinkState dropped the link registry but not the handshake state.
didDisconnectPeripheral clears announcedPeripherals per link, and power-off
delivers no disconnect callbacks, so a peer returning under the same
identifier skipped the identity reads and was never announced again after
being reported lost. Clear advertisedDeviceIds, verifiedPeerAddresses and
announcedPeripherals with the rest of the per-link state, and pin it with a
source guard since no Swift test reaches BleManager.
@mizanisoffline
mizanisoffline marked this pull request as ready for review October 2, 2026 08:57
The power-cycle fix resets the "service published" flag on
power-off, so power-on adds the service again. That is right only
if the stack actually threw the old one away. CoreBluetooth
promises a cleared GATT database for states *below* poweredOff,
not for poweredOff itself. A build that keeps it gets a second
identical service instance on every power-cycle, and the central
then has to pick between two of our services behind one link.

State restoration has the same hole already: a relaunch restores
the published service while every characteristic reference in the
manager is nil, so setup walks straight past its early return and
adds a duplicate.

Clear this app's own services right before the add. It only
touches what this process published, so the other SDK apps on the
phone keep theirs.

While at it, pin the whole power-cycle path with a source guard.
BleManager has no unit coverage and the path has never run on a
device in CI, and every piece of it is a one-token edit that still
compiles: the reset arm folded into power-off, both arms dropping
the links, the peripheral arm forgetting the service, the clear
before the add, both power-on arms recovering from unavailable,
and stop() working from unavailable. Each of those was mutated and
the guard fails on every one.
…e peer

An iPhone comes back from a Bluetooth power-cycle on a new random
address. The central path already learned to drop the old address
instead of giving up on the peer. Two things were left half done.

First, the stale branch skipped *all* cleanup, not just the
peer-level part. The inbound fragments buffered for the dead
address, its device-id resolution throttle and the facade's
address-keyed staged MTU slots all sat there until some idle sweep
got around to them. They are keyed by an address that will never
carry traffic again, so drop them on the spot. Everything keyed by
the device id stays: that belongs to the live link. The facade gets
a separate hook for this, because the existing give-up hook also
wipes the per-device MTU slots, which is exactly what must not
happen here.

Second, the GATT server side never got the guard at all. When the
old address's link to our server drops uncleanly, the facade still
reported the peer lost, dropped its role and its MTU, and deleted
the mapping that by then pointed at the new link. The window is
only the supervision timeout, but a stack reset can reconnect well
inside it. Same check, same address-only cleanup.

The new Robolectric case covers the give-up that is queued before
the new link lands, which only the check inside the give-up itself
can catch. The facade has no test harness, so its guard is pinned
by a source guard. Each change was mutated out and a test failed.
@bahdotsh bahdotsh changed the title fix(bindings): BLE survives a Bluetooth power-cycle on iOS and Android fix(bindings): BLE survives an iPhone's Bluetooth power-cycle on both ends of the link Oct 2, 2026
@bahdotsh
bahdotsh merged commit a790e54 into main Oct 2, 2026
24 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 2, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants