Skip to content

Make Android write-completion delivery thread-safe - #268

Merged
fotiDim merged 3 commits into
Navideck:mainfrom
liyuqian:thread_fix
Jul 26, 2026
Merged

Make Android write-completion delivery thread-safe#268
fotiDim merged 3 commits into
Navideck:mainfrom
liyuqian:thread_fix

Conversation

@liyuqian

Copy link
Copy Markdown
Contributor

Field logs showed writes completing fast (<100ms) or never (timeouts at both 100ms and 1s deadlines, zero GATT-busy errors), sometimes latching into 100% loss until the session ended. The write-completion plumbing can lose acks between onCharacteristicWrite and Dart:

  1. writeResultFutureList is an unsynchronized ArrayList mutated from the main thread (writeValue) and Bluetooth binder threads (onCharacteristicWrite / cleanUpConnection) - connectGatt registers no Handler, so callbacks arrive on binder threads. Concurrent add/removeAll can silently drop a pending completion.
  2. Pigeon replies were invoked directly on the binder thread; platform channel replies must be sent from the main thread.
  3. removeAll invoked completion callbacks inside its predicate: one throwing completion aborts the loop mid-iteration, leaves its entry in the list (double-completed on the next ack), and permanently blocks all later completions - the latched failure mode.

Fix: guard every writeResultFutureList access with synchronized; in onCharacteristicWrite and cleanUpConnection, snapshot+remove matches under the lock, then post each completion to the main thread individually wrapped in try/catch. Write-await semantics are unchanged.

Field logs showed writes completing fast (<100ms) or never (timeouts at
both 100ms and 1s deadlines, zero GATT-busy errors), sometimes latching
into 100% loss until the session ended. The write-completion plumbing
can lose acks between onCharacteristicWrite and Dart:

1. writeResultFutureList is an unsynchronized ArrayList mutated from the
   main thread (writeValue) and Bluetooth binder threads
   (onCharacteristicWrite / cleanUpConnection) - connectGatt registers no
   Handler, so callbacks arrive on binder threads. Concurrent add/removeAll
   can silently drop a pending completion.
2. Pigeon replies were invoked directly on the binder thread; platform
   channel replies must be sent from the main thread.
3. removeAll invoked completion callbacks inside its predicate: one throwing
   completion aborts the loop mid-iteration, leaves its entry in the list
   (double-completed on the next ack), and permanently blocks all later
   completions - the latched failure mode.

Fix: guard every writeResultFutureList access with synchronized; in
onCharacteristicWrite and cleanUpConnection, snapshot+remove matches under
the lock, then post each completion to the main thread individually wrapped
in try/catch. Write-await semantics are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request improves thread safety in UniversalBlePlugin.kt by synchronizing access to writeResultFutureList and dispatching future completions to the main thread. The review comments point out critical improvements, including marking mainThreadHandler as @Volatile to avoid thread visibility issues, using safe calls on characteristic.service to prevent potential null pointer exceptions, and applying similar synchronization patterns to other pending future lists.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

)
}
for (future in matches) {
mainThreadHandler?.post {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The mainThreadHandler variable is mutated on the main thread (in onAttachedToEngine and onDetachedFromEngine) but read here on a Bluetooth binder thread without any synchronization or volatile guarantees. This can lead to thread visibility issues (data races) where the binder thread reads a stale null value even after initialization, causing write completions to be silently dropped and resulting in timeouts.

To ensure proper visibility across threads, please mark mainThreadHandler with the @Volatile annotation at its declaration on line 45:

@Volatile
private var mainThreadHandler: Handler? = null

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Volatile added.

}
}
}
subscriptionResultFutureList.removeAll {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The subscriptionResultFutureList (and similarly readResultFutureList, mtuResultFutureList, discoverServicesFutureList, and rssiResultFutureList) suffers from the exact same thread-safety and callback-delivery issues that were just fixed for writeResultFutureList:

  1. It is mutated concurrently from the main thread (in setNotifiable) and Bluetooth binder threads (in updateSubscriptionState and cleanUpConnection) without synchronization.
  2. Pigeon/platform-channel replies are invoked directly on the binder thread instead of the main thread.
  3. removeAll invokes completion callbacks inside its predicate, which can throw and abort the loop mid-iteration, leaving entries in the list and permanently blocking later completions.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

@fotiDim

fotiDim commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

@liyuqian thanks for the PR please check the gemini-code-assist comments. Also, are we sure this fix doesn't degrade performance?

Addresses review feedback on the write-completion thread-safety fix:

- mainThreadHandler is read on Bluetooth binder threads but assigned on the
  main thread; mark it @volatile so a binder thread cannot observe a stale
  null and silently drop a completion.
- characteristic.service can be null on the binder thread; use a safe call
  when matching pending futures so an NPE cannot abort the matching loop and
  leave futures permanently hung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@liyuqian

Copy link
Copy Markdown
Contributor Author

@liyuqian thanks for the PR please check the gemini-code-assist comments. Also, are we sure this fix doesn't degrade performance?

@fotiDim No measurable degrade in my local tests.

Before this PR:

Session Timeout cfg BlueCommands Timeouts P50 P90 P99
fvy-unag-nrw/2026-07-18-22-15.pb 100 ms 2263 81 (3.6%) 11 16 32 ms

After this PR:

Session Timeout cfg BlueCommands Timeouts P50 P90 P99
nir-eafa-gqs/2026-07-19-23-26.pb (none recorded) 2658 0 (0.0%) 11 17 32 ms

@tanguy-penfen

Copy link
Copy Markdown

A little bump because we were getting OTA updates failure (a lot of succesive write operations) and this PR fixes it completely. We hope to see it published soon. Thank you !

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens Android BLE write-completion delivery against concurrency hazards between Bluetooth GATT binder-thread callbacks and Flutter/Dart completion handling, aiming to prevent lost acks and latched “no completions” failure modes observed in field logs.

Changes:

  • Adds synchronization around writeResultFutureList mutations to avoid concurrent modification issues across threads.
  • Reworks onCharacteristicWrite and disconnect cleanup to snapshot/remove matching write futures under a lock, then complete them on the main thread with per-callback exception isolation.
  • Marks mainThreadHandler as @Volatile to improve cross-thread visibility of its assignment/clearing.
Comments suppressed due to low confidence (1)

android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt:1142

  • In cleanUpConnection, pending write futures are removed from writeResultFutureList and then completed only via mainThreadHandler?.post { ... }. If mainThreadHandler is null, those futures are never completed (and now cannot be retried because they were removed from the list). Use a main-looper handler fallback here as well so disconnect cleanup doesn't drop write completions.
        for (future in pendingWrites) {
            mainThreadHandler?.post {
                try {
                    future.result(Result.failure(deviceDisconnectedError))
                } catch (e: Exception) {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@liyuqian

Copy link
Copy Markdown
Contributor Author

@fotiDim how does the latest change look?

@fotiDim

fotiDim commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

@fotiDim how does the latest change look?

@liyuqian looks good. Thanks for the PR!

@fotiDim
fotiDim merged commit ecf387b into Navideck:main Jul 26, 2026
2 checks passed
@fotiDim

fotiDim commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

It is out in universal_ble: ^2.1.1 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants