fix(bindings): BLE survives an iPhone's Bluetooth power-cycle on both ends of the link - #501
Merged
Merged
Conversation
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
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
marked this pull request as ready for review
October 2, 2026 08:57
# Conflicts: # CHANGELOG.md
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
approved these changes
Oct 2, 2026
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
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.
isGattServiceReadystayed true, so power-on advertised the service UUID with no service behind it. Peers connected, found nothing and dropped. ThepoweredOffbranches now clear the flag and the per-link state (connections, fragments, subscribers), which CoreBluetooth invalidates without disconnect callbacks. That cleanup is factored out ofstop()asclearLinkState().bleStatusChanged(true)from.starting. After a power-off the state is.unavailable, so the core never heard BLE was back anddrainAndSendFragments(gated on.running) never sent again, while inbound kept working. Power-on now also recovers from.unavailable..resettingis handled as.poweredOff: any state belowpoweredOffclears the local GATT database and invalidates everyCBPeripheral, and a reset can go straight back topoweredOn.setupGattServerclears this app's published services before adding, so the service is never published twice: CoreBluetooth only documents a cleared database belowpoweredOff, 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,connectionswas emptied but the mesh still counted every pre-power-off link, so a device that was atmaxConnectionscame back with no free slots, refused its returning peers and advertised itself as full.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 producedneighbor_lost.stop()does not do this: it is reachable fromdeinit, wherenotifyBlePeerLost'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 falseneighbor_lost), dropped the connection role and outbound queue, andremoveIdentifiersForAddressdeleted the device-to-address mapping that by then pointed at the live link.removeIdentifiersForAddressonly drops the reverse mapping if it still points at the removed address.MeshConnectionRegistry.hasOtherLiveLink(deviceId, excluding).handleDisconnectedstops redialling a stale address once the peer is live elsewhere, andfinalizeGivenUpPeerthen only cleans up that address: its mapping, buffered inbound, resolution throttle, and the facade's address-keyed staged MTU slots (newHost.onStaleAddressDropped). Peer-keyed state stays with the live link.BleTransportFacade) gets the samehasOtherLiveLinkguard and address-only cleanup.Verification
StaleAddressRegistryTestcovers the Android registry behaviour.StaleAddressDisconnectTestdrives the realCentralGattClientdisconnect 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.react_native_ios_bluetooth_power_cycle_drops_and_restores_the_transportpins the iOS power-off arms, the GATT flag reset, the clear before the add, the.unavailablerecovery andstop();react_native_android_server_disconnect_keeps_a_peer_live_elsewherepins the facade guard. Every pinned piece was mutated out and the guard or test failed.MeshControllerTests.testDroppingEveryLinkFreesEverySlotcovers the mesh slot release (runs in the Swift Package job).neighbor_lostshould fire.BleManager.swifthas no unit coverage, so the power-cycle path itself is not covered by an automated test.CHANGELOG.mdhas an entry under Unreleased / Fixed.Not in this PR
peripheral.identifier.