Skip to content

Commit 939132f

Browse files
jamesarichCopilotgarthvh
authored
fix(ble): cleanup races discovered while reviewing #5207 (#5221)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
1 parent 35e9ea6 commit 939132f

2 files changed

Lines changed: 32 additions & 6 deletions

File tree

core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,17 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
139139
meshtasticDevice.advertisement?.let { adv -> Peripheral(adv) { commonConfig() } }
140140
?: createPeripheral(device.address) { commonConfig() }
141141

142-
cleanUpPeripheral(device.address)
143-
peripheral = p
144-
145-
ActiveBleConnection.active = ActiveConnection(p, device.address)
142+
// Install ownership of the new peripheral atomically. Cancellation between
143+
// peripheral construction and field assignment would strand `p` (Kable allocates
144+
// a per-peripheral scope + Bluetooth-state observer eagerly), so the cleanup,
145+
// assignment, and ActiveBleConnection update must complete as a single unit.
146+
// _deviceFlow.emit() is intentionally outside this block — making it
147+
// non-cancellable could hang teardown on a slow collector.
148+
withContext(NonCancellable) {
149+
cleanUpPeripheral(device.address)
150+
peripheral = p
151+
ActiveBleConnection.active = ActiveConnection(p, device.address)
152+
}
146153

147154
_deviceFlow.emit(device)
148155

@@ -212,11 +219,18 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
212219
stateJob?.cancel()
213220
stateJob = null
214221

222+
// Capture the peripheral we own before clearing it so we can identity-check
223+
// ActiveBleConnection below. A stale disconnect from an earlier connection
224+
// attempt's exception handler must not clobber a newer connection that has
225+
// already installed itself as active.
226+
val owned = peripheral
215227
safeClosePeripheral("disconnect")
216228
peripheral = null
217229
connectionScope = null
218230

219-
ActiveBleConnection.active = null
231+
if (owned != null && ActiveBleConnection.active?.peripheral === owned) {
232+
ActiveBleConnection.active = null
233+
}
220234

221235
_deviceFlow.emit(null)
222236
}

core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,17 @@ class BleRadioTransport(
103103
internal val address: String,
104104
) : RadioTransport {
105105

106+
// Detached cleanup scope for last-ditch GATT teardown from the exception handler.
107+
// Must NOT be a child of `scope`: when an uncaught exception fires in connectionScope,
108+
// upper layers often tear down `scope` immediately. Launching cleanup on `scope` then
109+
// races the cancellation and may never start, leaking BluetoothGatt (status 133 on
110+
// the next reconnect). This scope is cancelled in close() once our own disconnect
111+
// has completed and the safety net is no longer needed.
112+
private val cleanupScope: CoroutineScope = CoroutineScope(SupervisorJob() + scope.coroutineContext.minusKey(Job))
113+
106114
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
107115
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
108-
scope.launch {
116+
cleanupScope.launch {
109117
try {
110118
bleConnection.disconnect()
111119
} catch (e: Exception) {
@@ -427,6 +435,10 @@ class BleRadioTransport(
427435
Logger.w(e) { "[$address] Failed to disconnect in close()" }
428436
}
429437
}
438+
// Our own disconnect succeeded — the exception-handler safety net is no longer
439+
// needed. Cancel the detached cleanup scope so it doesn't outlive us in tests
440+
// or process lifetime.
441+
cleanupScope.cancel("close() called")
430442
}
431443

432444
private fun dispatchPacket(packet: ByteArray) {

0 commit comments

Comments
 (0)