diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a3a60888..c4a01d37d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -345,6 +345,32 @@ archived by series under [docs/changelog/](docs/changelog/); see the ### Fixed +- **BLE survives an iPhone's Bluetooth being turned off and on, on both ends + of the link.** Turning an iPhone's Bluetooth off and on, or a Bluetooth + stack reset, left the pair unable to talk. On iOS, power-off removes the + GATT service, but the bridge still marked it as published, so power-on + advertised a service UUID with nothing behind it and peers connected and + dropped. The service is now published again on power-on, after clearing + whatever the stack kept, so it never appears twice. Power-on also never + reported BLE as available again, so the core stopped sending over BLE + while inbound still arrived. Power-off and reset now drop every per-link + record, because CoreBluetooth invalidates the links without a disconnect + callback for each one. That includes the mesh controller's connection + count: a device that was at its connection limit came back with no free + slots and refused its returning peers. Each dropped peer is also reported + lost, so one that does not come back produces `neighbor_lost` instead of + staying a neighbor in the core. Power-on brings the transport back to + running, and `stop()` while Bluetooth is off now stops it instead of + returning early and letting the next power-on restart a stopped transport. + On the Android end, an iPhone that came back from a new random address was + verified there, but the old address kept being redialled. When the + redialling gave up, or the old address's GATT server link dropped + uncleanly, it reported the live peer as lost and deleted the mapping that + pointed at the new link. Once the peer is live at another address, the old + address is now dropped on its own, with only its address-keyed state. This + does not add handling for an Android phone's own Bluetooth being turned + off and on. + - **React Native holds an interest declared before `start()` and applies it before the engine starts.** (#472) The engine's start-up exchange offers every held space with the interest in force at that moment, and a narrowing diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt index f74ccad01..7c3a39e73 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt @@ -554,6 +554,15 @@ class BleTransportFacade( if (shuttingDown) return dropStagedPeerMtu(address, peerId) } + + override fun onStaleAddressDropped(address: String) { + // The address-keyed staged slots only. A null device id + // keeps the per-device slots, which the peer's live link + // at its new address owns, and the Rust-side MTU stays + // because no blePeerLost was sent. + if (shuttingDown) return + dropStagedPeerMtu(address, null) + } }, diagnosticEmitter = { level, message, ctx -> emitDiagnostic(level, message, ctx) }, ) @@ -4148,6 +4157,22 @@ class BleTransportFacade( if (!isCleanDisconnect) { lastSeenRssi.remove(address) connections.deviceIdForAddress(address)?.let { peerId -> + if (connections.hasOtherLiveLink(peerId, address)) { + // The peer is live at another address (an iPhone rotates + // its random address across a Bluetooth power-cycle), so + // only this address is gone. Reporting the peer lost here + // would be a false neighbor_lost and would drop the live + // link's role and MTU, as on the central path. Drop only + // what is keyed by this address. + dropStagedPeerMtu(address, null) + connections.removeIdentifiersForAddress(address) + centralClient.clearResolutionAttempt(address) + emitDiagnostic("info", "Dropped stale server address for a peer with a live link", mapOf( + "address" to address, + "peerId" to peerId, + )) + return + } try { protocol.blePeerLost(peerId) } catch (e: Exception) { diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt index 2a3f6bea3..1dbe30c39 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt @@ -140,6 +140,14 @@ internal class CentralGattClient( * per-peer MTU entry). */ fun onPeerGivenUp(address: String, peerId: String) + /** Notify the facade that [address] was dropped while its peer stays + * live at another address (an iPhone rotates its random address + * across a Bluetooth power-cycle). Called from [finalizeGivenUpPeer] + * on the BLE thread instead of [onPeerGivenUp]: the facade drops only + * what it keys by [address]. Anything keyed by the peer's device id + * belongs to the live link and must survive. */ + fun onStaleAddressDropped(address: String) + /** Entry point used by the retry-on-disconnect path to re-attempt * connecting to a known address. Facade enforces the per-device * RSSI / capacity / cooldown gating inside connectToDevice. */ @@ -932,6 +940,16 @@ internal class CentralGattClient( host.clearRssi(address) } + // The peer already reconnected from another address: this one is + // stale, so drop it instead of redialling it and later reporting the + // (live) peer as lost. + val stalePeerId = host.connections.deviceIdForAddress(address) + if (stalePeerId != null && host.connections.hasOtherLiveLink(stalePeerId, address)) { + connectionRetryCount.remove(address) + bleHandler.post { finalizeGivenUpPeer(address, stalePeerId) } + return + } + if (wasConnected && host.isRunning()) { // Increment retry count and calculate backoff val retryCount = (connectionRetryCount[address] ?: 0) + 1 @@ -1016,6 +1034,21 @@ internal class CentralGattClient( // through close first. cancelMtuWatchdog(address) clearServiceInstanceSelection(address) + if (host.connections.hasOtherLiveLink(peerId, address)) { + // Only this address is gone, not the peer: skip the peer-level + // teardown (peer lost, role, outbound queue) the live link needs, + // and drop only what is keyed by the dead address. The outbound + // queue is keyed by peer id, so it stays for the live link. + host.connections.removeIdentifiersForAddress(address) + host.pendingInbound.removeAll(address) + deviceIdResolutionAttempts.remove(address) + host.onStaleAddressDropped(address) + diagnosticEmitter("info", "Dropped stale address for a peer with a live link", mapOf( + "address" to address, + "peerId" to peerId, + )) + return + } try { host.protocol.blePeerLost(peerId) } catch (e: Exception) { diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/MeshConnectionRegistry.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/MeshConnectionRegistry.kt index 5c66c30cb..35bd612ca 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/MeshConnectionRegistry.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/MeshConnectionRegistry.kt @@ -51,10 +51,20 @@ class MeshConnectionRegistry { fun removeIdentifiersForAddress(address: String) { val deviceId = addressToDevice.remove(address) if (deviceId != null) { - deviceToAddress.remove(deviceId) + // Only if it still points here: a peer that came back from a new + // address (iOS rotates its random address across a Bluetooth + // power-cycle) has already re-pointed it to the live link. + deviceToAddress.remove(deviceId, address) } } + /** True when [deviceId] is reachable over a live link at an address other than [excluding]. */ + fun hasOtherLiveLink(deviceId: String, excluding: String): Boolean { + val address = deviceToAddress[deviceId] ?: return false + return address != excluding && + (gattClients.containsKey(address) || serverConnections.contains(address)) + } + fun removeIdentifiersForDevice(deviceId: String) { val address = deviceToAddress.remove(deviceId) if (address != null) { diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/CentralGattClientInstanceSelectionTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/CentralGattClientInstanceSelectionTest.kt index fd5701a54..8361f8951 100644 --- a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/CentralGattClientInstanceSelectionTest.kt +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/CentralGattClientInstanceSelectionTest.kt @@ -376,6 +376,7 @@ class CentralGattClientInstanceSelectionTest { override fun onPeerMtuNegotiated(address: String, maxPayload: Int) {} override fun onDeviceIdResolved(address: String, deviceId: String) {} override fun onPeerGivenUp(address: String, peerId: String) {} + override fun onStaleAddressDropped(address: String) {} override fun connectToDevice(device: BluetoothDevice) {} } } diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressDisconnectTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressDisconnectTest.kt new file mode 100644 index 000000000..17b5d3f99 --- /dev/null +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressDisconnectTest.kt @@ -0,0 +1,143 @@ +package com.offlineprotocol.ble + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothProfile +import android.os.Handler +import android.os.Looper +import com.offlineprotocol.BleAppTag +import com.offlineprotocol.mesh.MeshController +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowBluetoothDevice +import org.robolectric.shadows.ShadowBluetoothGatt +import uniffi.offline_protocol.OfflineProtocol +import java.time.Duration +import java.util.UUID + +/** + * Drives the real [CentralGattClient] disconnect path for a peer that came + * back from a new BLE address (iOS rotates its random address across a + * Bluetooth power-cycle). The old address dropping must neither redial it nor + * give up on the peer: giving up reports a false `neighbor_lost` for a peer + * that is live at the new address. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class StaleAddressDisconnectTest { + + private val uuid = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") + private val old = "AA:BB:CC:DD:EE:01" + private val new = "AA:BB:CC:DD:EE:02" + private val host = FakeHost() + + private val client = CentralGattClient( + bleHandler = Handler(Looper.getMainLooper()), + serviceUuid = uuid, + messageCharUuid = uuid, + deviceIdCharUuid = uuid, + identityCharUuid = uuid, + appTagCharUuid = uuid, + appTag = BleAppTag.compute("our-app"), + host = host, + diagnosticEmitter = { _, _, _ -> }, + ) + + private fun gatt(address: String): BluetoothGatt = + ShadowBluetoothGatt.newInstance(ShadowBluetoothDevice.newInstance(address)) + + /** Delivers the disconnect, then runs past every reconnect backoff. */ + private fun disconnect(gatt: BluetoothGatt) { + client.callback.onConnectionStateChange(gatt, 8, BluetoothProfile.STATE_DISCONNECTED) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMinutes(2)) + } + + @Test + fun `the old address dropping neither redials it nor gives up on the live peer`() { + val oldGatt = gatt(old) + host.connections.registerGatt(old, oldGatt) + host.connections.setDeviceIdentifier(old, "peerA") + host.connections.registerGatt(new, gatt(new)) + host.connections.setDeviceIdentifier(new, "peerA") + host.pendingInbound.enqueue(old, byteArrayOf(1)) + + disconnect(oldGatt) + + assertEquals("no false peer loss", emptyList(), host.givenUp) + assertEquals("the stale address is not redialled", emptyList(), host.dialed) + assertEquals(new, host.connections.addressForDevice("peerA")) + assertNull(host.connections.deviceIdForAddress(old)) + assertEquals("the facade drops the dead address's state", listOf(old), host.staleDropped) + assertFalse("the dead address's inbound is dropped", host.pendingInbound.hasPending(old)) + } + + @Test + fun `a peer that comes back while the give-up is queued is not given up`() { + // A stale callback with no other link yet posts the give-up. The new + // link lands before it runs, so only the check inside the give-up + // itself can keep the live peer. + host.connections.setDeviceIdentifier(old, "peerC") + + client.callback.onConnectionStateChange(gatt(old), 8, BluetoothProfile.STATE_DISCONNECTED) + host.connections.registerGatt(new, gatt(new)) + host.connections.setDeviceIdentifier(new, "peerC") + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMinutes(2)) + + assertEquals("no false peer loss", emptyList(), host.givenUp) + assertEquals(listOf(old), host.staleDropped) + assertEquals(new, host.connections.addressForDevice("peerC")) + } + + @Test + fun `a peer with no other link is still given up`() { + // Not registered as a gatt: the stale-callback branch gives up at once. + host.connections.setDeviceIdentifier(old, "peerB") + + disconnect(gatt(old)) + + assertEquals(listOf("peerB"), host.givenUp) + assertEquals(emptyList(), host.staleDropped) + assertNull(host.connections.addressForDevice("peerB")) + } + + private class FakeHost : CentralGattClient.Host { + val givenUp = mutableListOf() + val dialed = mutableListOf() + val staleDropped = mutableListOf() + + // `finalizeGivenUpPeer` catches what this throws, so the test records + // peer loss through `onPeerGivenUp`, which runs right after it. + override val protocol: OfflineProtocol + get() = throw IllegalStateException("no core in this test") + override val connections = MeshConnectionRegistry() + override val pendingInbound = InboundFragmentBuffer(bleThreadCheck = {}) + override val outboundQueue = OutboundFragmentQueue(bleThreadCheck = {}) + override val meshController = MeshController("self", MeshController.MeshConfig(maxConnections = 4)) + @Suppress("DEPRECATION") + override val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() + override val selfDeviceId = "self" + override fun isShuttingDown() = false + override fun isRunning() = true + override fun rssiFor(address: String): Short? = null + override fun clearRssi(address: String) {} + override fun markNonMeshDevice(address: String) {} + override fun refreshAdvertising(reason: String) {} + override fun refreshSelfMetrics() {} + override fun maybeHandleRebalance(trigger: String) {} + override fun drainAndSendFragments() {} + override fun onWriteCompleted(address: String) {} + override fun handleInboundFragment(address: String, data: ByteArray) {} + override fun onPeerMtuNegotiated(address: String, maxPayload: Int) {} + override fun onDeviceIdResolved(address: String, deviceId: String) {} + override fun onPeerGivenUp(address: String, peerId: String) { givenUp += peerId } + override fun onStaleAddressDropped(address: String) { staleDropped += address } + override fun connectToDevice(device: BluetoothDevice) { dialed += device.address } + } +} diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressRegistryTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressRegistryTest.kt new file mode 100644 index 000000000..90137a3bb --- /dev/null +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressRegistryTest.kt @@ -0,0 +1,38 @@ +package com.offlineprotocol.ble + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A peer that reconnects from a new BLE address (iOS rotates its random address + * across a Bluetooth power-cycle) leaves its old address behind. Giving up on the + * stale address must not orphan the live one or report the live peer as lost. + */ +class StaleAddressRegistryTest { + @Test + fun `dropping a stale address keeps the live mapping for the same peer`() { + val registry = MeshConnectionRegistry() + registry.setDeviceIdentifier("old", "peerA") + registry.setDeviceIdentifier("new", "peerA") + registry.trackServerConnection("new") + + assertTrue(registry.hasOtherLiveLink("peerA", excluding = "old")) + registry.removeIdentifiersForAddress("old") + + assertEquals("new", registry.addressForDevice("peerA")) + assertEquals("peerA", registry.deviceIdForAddress("new")) + } + + @Test + fun `a peer with no other live link is not kept alive`() { + val registry = MeshConnectionRegistry() + registry.setDeviceIdentifier("only", "peerB") + registry.trackServerConnection("only") + + assertFalse(registry.hasOtherLiveLink("peerB", excluding = "only")) + registry.removeIdentifiersForAddress("only") + assertEquals(null, registry.addressForDevice("peerB")) + } +} diff --git a/bindings/react-native/ios/BleManager.swift b/bindings/react-native/ios/BleManager.swift index 1279c22fa..35e9ad4da 100644 --- a/bindings/react-native/ios/BleManager.swift +++ b/bindings/react-native/ios/BleManager.swift @@ -708,7 +708,10 @@ public class BleManager: NSObject, TransportManager { } private func stopUnsafe() { - guard state == .running || state == .starting else { + // `.unavailable` too: the managers are still alive with Bluetooth off, + // and returning early here would let the next power-on move a stopped + // transport back to `.running` and report BLE available to the core. + guard state == .running || state == .starting || state == .unavailable else { return } @@ -724,7 +727,61 @@ public class BleManager: NSObject, TransportManager { for peripheral in connections.allPeripherals() { centralManager?.cancelPeripheralConnection(peripheral) } + clearLinkState() + pendingAdvertiseRestart?.cancel() + pendingAdvertiseRestart = nil + lastAdvertiseRestartAt = nil + transportStartAt = nil + lastProactiveScanRefresh = nil + lastForcedBleRefresh = nil + aggressiveDiscoveryStarted = nil + + // Clean up managers + centralManager = nil + peripheralManager = nil + + centralReady = false + peripheralReady = false + isGattServiceReady = false + pendingAdvertiseAfterServiceReady = false + + updateState(.stopped) + emitDiagnostic("info", "BLE transport stopped") + } + + /// Bluetooth powered off or reset: report every identified peer lost, then + /// drop the link state. No disconnect callback arrives for these links, and + /// `bleStatusChanged(false)` clears only the Rust transport's peer map, so + /// without this a peer that does not come back stays a neighbor in the core + /// and never produces `neighbor_lost`. Peers that do come back are announced + /// again on the verified path. Not folded into `clearLinkState()`: `stop()` + /// reaches that from `deinit`, where `notifyBlePeerLost`'s `[weak self]` + /// capture is a hard abort. The second manager's callback finds the + /// registry already empty, so each peer is reported once. + private func dropLinksAfterRadioLoss() { + for deviceId in Set(connections.allPeripheralDeviceIds()) { + notifyBlePeerLost(deviceId: deviceId) + } + clearLinkState() + } + + /// Drops every piece of per-link state: connections, fragments, GATT + /// subscribers, bootstrap and service-instance bookkeeping. Used by + /// `stop()` and when the radio powers off, because CoreBluetooth then + /// invalidates every connection and published service without delivering + /// disconnect callbacks; stale entries would keep routing sends into dead + /// links after Bluetooth comes back. + private func clearLinkState() { connections.reset() + // The handshake state too. `didDisconnectPeripheral` normally clears + // it per link; without that callback a peer returning under the same + // identifier hits the `announcedPeripherals` guard, skips the identity + // reads and is never announced again. + advertisedDeviceIds.removeAll() + verifiedPeerAddresses.removeAll() + announcedPeripherals.removeAll() + // The mesh counts the same links; without this it stays full. + meshController.registerAllDisconnected() discoveredPeripherals.removeAll() peripheralRSSI.removeAll() inboundFragments.clear() @@ -753,13 +810,6 @@ public class BleManager: NSObject, TransportManager { unknownBootstrapAttempts.removeAll() verifiedNonMeshDevices.removeAll() recentAdvertisementHashes.removeAll() - pendingAdvertiseRestart?.cancel() - pendingAdvertiseRestart = nil - lastAdvertiseRestartAt = nil - transportStartAt = nil - lastProactiveScanRefresh = nil - lastForcedBleRefresh = nil - aggressiveDiscoveryStarted = nil notifyLock.lock() subscribedCentralsById.removeAll() notifyLock.unlock() @@ -772,18 +822,6 @@ public class BleManager: NSObject, TransportManager { serviceInstanceLock.lock() serviceInstanceBindings.removeAll() serviceInstanceLock.unlock() - - // Clean up managers - centralManager = nil - peripheralManager = nil - - centralReady = false - peripheralReady = false - isGattServiceReady = false - pendingAdvertiseAfterServiceReady = false - - updateState(.stopped) - emitDiagnostic("info", "BLE transport stopped") } public func pause() { @@ -1456,6 +1494,15 @@ public class BleManager: NSObject, TransportManager { let service = CBMutableService(type: SERVICE_UUID, primary: true) service.characteristics = [messageCharacteristic!, deviceIdCharacteristic!, identityCharacteristic!, appTagCharacteristic!] + // Clear this app's published services first, so this add is the only + // instance. CoreBluetooth documents the local GATT database as cleared + // only below `.poweredOff`, so after a plain power-off a build may keep + // the old service, and a relaunch restores it with every characteristic + // reference here nil. Adding on top of either publishes the service + // twice, and a central then sees two instances behind one link. Only + // this app's services are removed: other SDK apps on the phone + // publish their own. + peripheral.removeAllServices() // Add service to peripheral manager (asynchronous - callback in peripheralManager(_:didAdd:error:)) peripheral.add(service) print("[BleManager] GATT server setup initiated, waiting for service registration callback...") @@ -2898,7 +2945,10 @@ extension BleManager: CBCentralManagerDelegate { drainAndSendFragments() // If both central and peripheral are ready, mark as running - if peripheralReady && state == .starting { + // `.unavailable` too: after Bluetooth is powered off and back on, the + // core must hear bleStatusChanged(true) again or it never routes + // outbound traffic to BLE (inbound still arrives via the delegates). + if peripheralReady && (state == .starting || state == .unavailable) { updateState(.running) print("[BleManager] ✅ BLE Manager ready - dispatching bleStatusChanged(true)") // "dispatched", not "called": the FFI now runs on the protocol @@ -2908,13 +2958,16 @@ extension BleManager: CBCentralManagerDelegate { emitDiagnostic("info", "Dispatched protocol.bleStatusChanged(true)") } - case .poweredOff: - print("[BleManager] ⚠️ Bluetooth is powered off") + // `.resetting` too: any state below poweredOff invalidates every + // CBPeripheral, and a reset can return straight to poweredOn. + case .poweredOff, .resetting: + print("[BleManager] ⚠️ Bluetooth is \(stateString)") centralReady = false stopScanning(reason: "central_powered_off") + dropLinksAfterRadioLoss() updateState(.unavailable) notifyBleStatus(false) - emitDiagnostic("warning", "Bluetooth is powered off", context: ["state": stateString]) + emitDiagnostic("warning", "Bluetooth is powered off or resetting", context: ["state": stateString]) case .unauthorized: print("[BleManager] ⚠️ Bluetooth is unauthorized") @@ -2932,10 +2985,6 @@ extension BleManager: CBCentralManagerDelegate { notifyBleStatus(false) emitDiagnostic("error", "Bluetooth is not supported", context: ["state": stateString]) - case .resetting: - print("[BleManager] 🔄 Bluetooth is resetting...") - emitDiagnostic("info", "Bluetooth is resetting", context: ["state": stateString]) - case .unknown: print("[BleManager] ❓ Bluetooth state is unknown") emitDiagnostic("info", "Bluetooth state is unknown", context: ["state": stateString]) @@ -4087,7 +4136,10 @@ extension BleManager: CBPeripheralManagerDelegate { emitDiagnostic("info", "Peripheral manager powered on and ready") // If both central and peripheral are ready, mark as running - if centralReady && state == .starting { + // `.unavailable` too: after Bluetooth is powered off and back on, the + // core must hear bleStatusChanged(true) again or it never routes + // outbound traffic to BLE (inbound still arrives via the delegates). + if centralReady && (state == .starting || state == .unavailable) { updateState(.running) print("[BleManager] ✅ BLE Manager ready (peripheral) - dispatching bleStatusChanged(true)") // See the central-side note: dispatched, not completed. @@ -4095,13 +4147,21 @@ extension BleManager: CBPeripheralManagerDelegate { emitDiagnostic("info", "Dispatched protocol.bleStatusChanged(true) from peripheral") } - case .poweredOff: - print("[BleManager] ⚠️ Bluetooth peripheral is powered off") + // `.resetting` too: any state below poweredOff clears the local GATT + // database, and a reset can return straight to poweredOn. + case .poweredOff, .resetting: + print("[BleManager] ⚠️ Bluetooth peripheral is \(stateString)") peripheralReady = false stopAdvertising() + // Powering off unpublishes our GATT service. Without this, power-on + // advertises the UUID with no service behind it (setupGattServer + // sees the stale flag and skips re-adding), so peers connect and drop. + isGattServiceReady = false + pendingAdvertiseAfterServiceReady = false + dropLinksAfterRadioLoss() updateState(.unavailable) notifyBleStatus(false) - emitDiagnostic("warning", "Bluetooth peripheral is powered off", context: ["state": stateString]) + emitDiagnostic("warning", "Bluetooth peripheral is powered off or resetting", context: ["state": stateString]) case .unauthorized: print("[BleManager] ⚠️ Bluetooth peripheral is unauthorized") @@ -4119,10 +4179,6 @@ extension BleManager: CBPeripheralManagerDelegate { notifyBleStatus(false) emitDiagnostic("error", "Bluetooth peripheral is not supported", context: ["state": stateString]) - case .resetting: - print("[BleManager] 🔄 Bluetooth peripheral is resetting...") - emitDiagnostic("info", "Bluetooth peripheral is resetting", context: ["state": stateString]) - case .unknown: print("[BleManager] ❓ Bluetooth peripheral state is unknown") emitDiagnostic("info", "Bluetooth peripheral state is unknown", context: ["state": stateString]) diff --git a/bindings/react-native/ios/ble/MeshConnectionRegistry.swift b/bindings/react-native/ios/ble/MeshConnectionRegistry.swift index 194f6a132..15a0c6373 100644 --- a/bindings/react-native/ios/ble/MeshConnectionRegistry.swift +++ b/bindings/react-native/ios/ble/MeshConnectionRegistry.swift @@ -112,6 +112,13 @@ final class MeshConnectionRegistry: @unchecked Sendable { return has } + func allPeripheralDeviceIds() -> [String] { + lock.lock() + let ids = Array(peripheralDeviceIds.values) + lock.unlock() + return ids + } + func discoveredPeerCount() -> Int { lock.lock() let count = peripheralDeviceIds.count + centralDeviceIds.count diff --git a/bindings/react-native/ios/mesh/Mesh.swift b/bindings/react-native/ios/mesh/Mesh.swift index f5707b374..b3dafe0f2 100644 --- a/bindings/react-native/ios/mesh/Mesh.swift +++ b/bindings/react-native/ios/mesh/Mesh.swift @@ -645,6 +645,22 @@ final class MeshController: @unchecked Sendable { } } + /// Forgets every active link at once. For when the radio drops them all + /// without a disconnect callback per link (Bluetooth off, a stack reset, + /// `stop()`): the stale entries would keep the degree at its old value, so + /// a device that was full comes back with no free slots, refuses its own + /// returning peers and advertises itself as full. + func registerAllDisconnected() { + queue.async(flags: .barrier) { + let now = self.timeProvider() + for peerId in self.activeConnections.keys { + self.peersById[peerId]?.lastActivity = now + } + self.activeConnections.removeAll() + self.updateClusterSignature() + } + } + func markPeerActive(_ peerId: String) { queue.async(flags: .barrier) { let now = self.timeProvider() diff --git a/bindings/react-native/ios/tests/MeshControllerTests.swift b/bindings/react-native/ios/tests/MeshControllerTests.swift index 1b2e45844..ea379ecd2 100644 --- a/bindings/react-native/ios/tests/MeshControllerTests.swift +++ b/bindings/react-native/ios/tests/MeshControllerTests.swift @@ -130,5 +130,19 @@ final class MeshControllerTests: XCTestCase { XCTAssertEqual(decision.evictPeerId, "weak") XCTAssertEqual(decision.reason, "swap_bridge_capacity") } + + func testDroppingEveryLinkFreesEverySlot() { + // Bluetooth off drops every link with no disconnect callback per link. + // A mesh still counting them comes back full and refuses its peers. + let controller = MeshController(selfId: "self", config: .init(maxConnections: 2)) + controller.registerConnection(peerId: "a", role: .member) + controller.registerConnection(peerId: "b", role: .member) + XCTAssertFalse(controller.connectionBudgetAvailable()) + + controller.registerAllDisconnected() + + XCTAssertTrue(controller.connectionBudgetAvailable()) + XCTAssertEqual(controller.advertisement().freeSlotEstimate, 2) + } } diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 37be25851..90565a7b8 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -13677,6 +13677,153 @@ mod tests { } } + /// Dropping every link at once (Bluetooth off, a stack reset, `stop()`) + /// clears the handshake state with it. + /// + /// `didDisconnectPeripheral` clears `announcedPeripherals` per link, and + /// the identity reads are skipped for any peripheral still in it. Power-off + /// delivers no disconnect callbacks, so without the clear in + /// `clearLinkState` a peer that comes back under the same identifier (its + /// address did not rotate) is reported lost and then never announced + /// again: no device id, no MTU, no route. No Swift test can reach + /// `BleManager`, and a device only shows it with a peer that did not + /// power-cycle. + #[test] + fn react_native_ios_link_loss_clears_handshake_state() { + let swift = rn_source_code_only("ios/BleManager.swift"); + let start = swift + .find("private func clearLinkState() {") + .expect("BleManager.swift must drop per-link state in clearLinkState"); + let end = start + + swift[start..] + .find("public func pause() {") + .expect("pause() must follow clearLinkState so the slice is its body"); + let body = &swift[start..end]; + for clear in [ + "advertisedDeviceIds.removeAll()", + "verifiedPeerAddresses.removeAll()", + "announcedPeripherals.removeAll()", + ] { + assert!( + body.contains(clear), + "clearLinkState must call {clear}: a peer returning after a Bluetooth \ + power-cycle under the same identifier is otherwise never announced again" + ); + } + } + + /// A Bluetooth power-off or stack reset drops every link and the published + /// service, and power-on brings the transport back. Each piece is a + /// one-token edit that compiles, `BleManager` has no unit coverage, and + /// the power-cycle path has no automated device run, so this is what + /// holds them: + /// + /// - both managers treat `.resetting` as `.poweredOff`, and both arms drop + /// the links (no per-link disconnect callback arrives, so without it the + /// mesh stays full and a peer that never returns stays a neighbor); + /// - the peripheral arm forgets the published service, or power-on + /// advertises a UUID with nothing behind it; + /// - `setupGattServer` clears this app's services before adding, so the + /// service is never published twice; + /// - both power-on arms recover from `.unavailable`, or the core never + /// hears BLE is back and outbound stays off; + /// - `stop()` stops from `.unavailable`, or the next power-on revives a + /// stopped transport. + #[test] + fn react_native_ios_bluetooth_power_cycle_drops_and_restores_the_transport() { + let swift = rn_source_code_only("ios/BleManager.swift"); + let arm = |delegate: &str| -> &str { + let start = swift + .find(delegate) + .unwrap_or_else(|| panic!("BleManager.swift must implement {delegate}")); + let arm_start = start + + swift[start..] + .find("case .poweredOff, .resetting:") + .unwrap_or_else(|| { + panic!("{delegate} must handle .resetting together with .poweredOff") + }); + let arm_end = arm_start + + swift[arm_start..] + .find("case .unauthorized:") + .expect("the .unauthorized arm must follow the power-off arm"); + &swift[arm_start..arm_end] + }; + let central = arm("public func centralManagerDidUpdateState("); + let peripheral = arm("public func peripheralManagerDidUpdateState("); + for (name, body) in [("central", central), ("peripheral", peripheral)] { + assert!( + body.contains("dropLinksAfterRadioLoss()"), + "the {name} power-off arm must drop every link: no disconnect callback \ + arrives for them" + ); + } + assert!( + peripheral.contains("isGattServiceReady = false"), + "the peripheral power-off arm must forget the published service, or power-on \ + advertises a UUID with nothing behind it" + ); + + let setup_start = swift + .find("private func setupGattServer() -> Bool {") + .expect("BleManager.swift must publish its service in setupGattServer"); + let setup = &swift[setup_start..]; + let remove = setup + .find("peripheral.removeAllServices()") + .expect("setupGattServer must clear this app's services before adding"); + let add = setup + .find("peripheral.add(service)") + .expect("setupGattServer must add the service"); + assert!( + remove < add, + "removeAllServices must run before add, or a kept service is published twice" + ); + + assert_eq!( + swift + .matches("Ready && (state == .starting || state == .unavailable) {") + .count(), + 2, + "both power-on arms must recover from .unavailable, or the core never hears \ + bleStatusChanged(true) after a power-cycle" + ); + assert!( + swift.contains( + "guard state == .running || state == .starting || state == .unavailable else {" + ), + "stop() must stop from .unavailable, or the next power-on revives a stopped \ + transport" + ); + } + + /// An unclean server-side disconnect on Android keeps a peer that is live + /// at another address. An iPhone rotates its random address across a + /// Bluetooth power-cycle, and its old link to our GATT server can drop + /// uncleanly after the new one is up. Reporting the peer lost there is a + /// false `neighbor_lost` and drops the live link's role and MTU. The + /// central-role path has Robolectric coverage; `BleTransportFacade` has no + /// test harness, so this pins the check ahead of the peer-lost call. + #[test] + fn react_native_android_server_disconnect_keeps_a_peer_live_elsewhere() { + let kotlin = rn_source_code_only( + "android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt", + ); + let start = kotlin + .find("private fun handleCentralDisconnectedOnBleThread(") + .expect("BleTransportFacade.kt must handle a server-side disconnect"); + let body = &kotlin[start..]; + let guard = body + .find("if (connections.hasOtherLiveLink(peerId, address)) {") + .expect("the server-side disconnect must check for a live link elsewhere"); + let lost = body + .find("protocol.blePeerLost(peerId)") + .expect("the server-side disconnect still reports a peer with no other link lost"); + assert!( + guard < lost, + "the live-link check must come before blePeerLost, or a peer live at a new \ + address is reported lost" + ); + } + /// The buffered-inbound event set agrees across TypeScript, Kotlin and /// Swift, and each layer's hold is wired to a flush. ///