-
-
Notifications
You must be signed in to change notification settings - Fork 61
Make Android write-completion delivery thread-safe #268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,12 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), | |
| private val advertisePermissionRequestCode = 2342616 | ||
| private var permissionHandler: PermissionHandler? = null | ||
| private var callbackChannel: UniversalBleCallbackChannel? = null | ||
|
|
||
| // Read on Bluetooth binder threads (from GATT callbacks) but assigned on the | ||
| // main thread in onAttachedToEngine/onDetachedFromEngine; @Volatile makes | ||
| // those writes visible so a binder thread never sees a stale null and drops | ||
| // a completion. | ||
| @Volatile | ||
| private var mainThreadHandler: Handler? = null | ||
| private lateinit var context: Context | ||
| private var activity: Activity? = null | ||
|
|
@@ -724,8 +730,12 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), | |
| callback | ||
| ) | ||
|
|
||
| // Wait for the result | ||
| writeResultFutureList.add(writeFuture) | ||
| // Wait for the result. The list is mutated both here (main thread) | ||
| // and in onCharacteristicWrite / cleanUpConnection (Bluetooth | ||
| // binder threads), so every access must hold the lock. | ||
| synchronized(writeResultFutureList) { | ||
| writeResultFutureList.add(writeFuture) | ||
| } | ||
|
|
||
| val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { | ||
| gatt.writeCharacteristic(gattCharacteristic, value, writeType) | ||
|
|
@@ -739,7 +749,9 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), | |
| } | ||
|
|
||
| if (result != BluetoothGatt.GATT_SUCCESS) { | ||
| writeResultFutureList.remove(writeFuture) | ||
| synchronized(writeResultFutureList) { | ||
| writeResultFutureList.remove(writeFuture) | ||
| } | ||
| callback( | ||
| Result.failure( | ||
| createFlutterError( | ||
|
|
@@ -755,35 +767,59 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), | |
| } | ||
| } | ||
|
|
||
| // Deliver on the main looper. Platform-channel replies must be sent there, | ||
| // and because matching futures are removed from their list before delivery, | ||
| // a completion must never be dropped just because the cached handler was | ||
| // cleared (a GATT callback racing onDetachedFromEngine) — that would leak | ||
| // the pending Dart await. Fall back to a fresh main-looper handler so the | ||
| // completion is always attempted. | ||
| private fun postToMainLooper(action: () -> Unit) { | ||
| (mainThreadHandler ?: Handler(Looper.getMainLooper())).post(action) | ||
| } | ||
|
|
||
| override fun onCharacteristicWrite( | ||
| gatt: BluetoothGatt?, | ||
| characteristic: BluetoothGattCharacteristic, | ||
| status: Int, | ||
| ) { | ||
| writeResultFutureList.removeAll { | ||
| if (it.deviceId == gatt?.device?.address && | ||
| it.characteristicId == characteristic.uuid.toString() && | ||
| it.serviceId == characteristic.service.uuid.toString() | ||
| ) { | ||
| if (status == BluetoothGatt.GATT_SUCCESS) { | ||
| it.result(Result.success(Unit)) | ||
| } else { | ||
| UniversalBleLogger.logError( | ||
| "WRITE_FAILED <- ${gatt?.device?.address} ${characteristic.uuid} status=$status" | ||
| ) | ||
| it.result( | ||
| Result.failure( | ||
| createFlutterError( | ||
| gattStatusToUniversalBleErrorCode(status), | ||
| "Failed to write", | ||
| status.toString() | ||
| // Runs on a binder thread: snapshot and remove matches under the lock, | ||
| // then complete each future on the main thread (platform-channel | ||
| // replies must be sent there). Completing outside the removal loop, | ||
| // with each completion individually contained, means one bad callback | ||
| // can neither corrupt the list nor block later completions. | ||
| val matches: List<WriteResultFuture> | ||
| synchronized(writeResultFutureList) { | ||
| matches = writeResultFutureList.filter { | ||
| it.deviceId == gatt?.device?.address && | ||
| it.characteristicId == characteristic.uuid.toString() && | ||
| it.serviceId == characteristic.service?.uuid?.toString() | ||
| } | ||
| writeResultFutureList.removeAll(matches) | ||
| } | ||
| if (status != BluetoothGatt.GATT_SUCCESS) { | ||
| UniversalBleLogger.logError( | ||
| "WRITE_FAILED <- ${gatt?.device?.address} ${characteristic.uuid} status=$status" | ||
| ) | ||
| } | ||
| for (future in matches) { | ||
| postToMainLooper { | ||
| try { | ||
| if (status == BluetoothGatt.GATT_SUCCESS) { | ||
| future.result(Result.success(Unit)) | ||
|
fotiDim marked this conversation as resolved.
|
||
| } else { | ||
| future.result( | ||
| Result.failure( | ||
| createFlutterError( | ||
| gattStatusToUniversalBleErrorCode(status), | ||
| "Failed to write", | ||
| status.toString() | ||
| ) | ||
| ) | ||
| ) | ||
| ) | ||
| } | ||
| } catch (e: Exception) { | ||
| UniversalBleLogger.logError("Write completion delivery failed: $e") | ||
| } | ||
| true | ||
| } else { | ||
| false | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -1104,12 +1140,18 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), | |
| false | ||
| } | ||
| } | ||
| writeResultFutureList.removeAll { | ||
| if (it.deviceId == deviceId) { | ||
| it.result(Result.failure(deviceDisconnectedError)) | ||
| true | ||
| } else { | ||
| false | ||
| val pendingWrites: List<WriteResultFuture> | ||
| synchronized(writeResultFutureList) { | ||
| pendingWrites = writeResultFutureList.filter { it.deviceId == deviceId } | ||
| writeResultFutureList.removeAll(pendingWrites) | ||
| } | ||
| for (future in pendingWrites) { | ||
| postToMainLooper { | ||
| try { | ||
| future.result(Result.failure(deviceDisconnectedError)) | ||
| } catch (e: Exception) { | ||
| UniversalBleLogger.logError("Write completion delivery failed: $e") | ||
| } | ||
| } | ||
| } | ||
| subscriptionResultFutureList.removeAll { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
To prevent similar race conditions, timeouts, and latched failures, these lists should be refactored using the same synchronized snapshot-and-remove pattern, with completions posted to the main thread.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — readResultFutureList, subscriptionResultFutureList, mtuResultFutureList, discoverServicesFutureList, and rssiResultFutureList do share the same race, off-thread reply, and callbacks-inside-removeAll pattern. I've kept this PR scoped to the write path since that's the one we could tie to a concrete field failure with a verifiable device test. It's probably better to create other PRs to fix other lists with some on-device tests. |
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.