disconnect() is easy after a connection exists. The interesting case is cancelling one that never finished.
Status: ๐ Landed
The Spec Said Cancel
The first step of Web Bluetooth's disconnect() clears the active algorithms associated with the GATT server. In practical terms, calling disconnect() while gatt.connect() is pending should reject that promise promptly with AbortError.
That did not reliably happen. If a known peripheral became unavailable, gatt.connect() could remain pending. Calling disconnect() did not unwind every platform backend, and a page could be left with a connection attempt that neither succeeded nor failed.
The desired sequence is:
- Select a peripheral while it is advertising.
- Make it unavailable and begin
gatt.connect(). - Call
disconnect(). - The pending promise rejects with
AbortError. - A new device request and connection works without reloading the page.
The issue dates back to 2017: 40502943.
The Core Behavior
The cross-platform layer first learned that GATT disconnect cancels an ongoing connect attempt:
That defines the browser behavior, but cancellation still has to travel through each native backend. Windows and Android fail in different ways.
Windows: Fail the Pending Work
On Windows, cancellation has two parts: fail callbacks waiting for the GATT connection and treat a cancelled WinRT service-discovery operation as an expected result rather than tripping the async-results DCHECK.
The important distinction is between a failed connection and a connection the caller deliberately cancelled. Both complete the pending work, but only one is an error in the backend.
Android: The Callback That Never Arrived
Android's platform stack does not reliably deliver a disconnect callback when a connection is cancelled while it is still waiting for client registration. Chromium could request cancellation and then wait forever for notification that never came.
The practical fix is in Chromium's Android backend: when the platform does not report the cancellation, synthesize the disconnect callback and complete the pending Web Bluetooth operation.
That works on Android versions already in users' hands. An OS-level fix would only help devices that later receive an updated Bluetooth module.
The AOSP Detour
An OS-level version of the fix was also prototyped in Android's Bluetooth module:
The patch made BluetoothGatt.disconnect() cancel a request still waiting for client registration and report it through onConnectionStateChange(). Review confirmed the platform gap, but the OS module was not the best deployment point for this Web API behavior. Handling the missing callback in Chromium also fixes devices already in use.
That detour was still useful. It located the exact layer where the cancellation signal disappeared and made the trade-off explicit: platform purity versus a browser fix that reaches the installed base.
A Peripheral Built to Disappear
Testing this requires more than a Bluetooth mock. The connection must start against a real device and then stop making progress on demand.
The sampler uses ESP32-C3 firmware advertising as dino c(h)ancler. Serial commands start and stop advertising, disconnect the central, or enter deep sleep. That makes the pending-connect state reproducible instead of timing-dependent.
The manual test verifies not only that the promise rejects, but that cancellation leaves the adapter usable: select another peripheral and connect again without reloading.
The project page includes firmware and a patched ARM64 ChromePublic APK.
The Takeaway
A cancellation API is not complete when it sets a flag. Every asynchronous layer must finish its pending work exactly once, even when a native platform omits the callback the browser expected.
Windows needed cancelled native work to become an ordinary completion. Android needed Chromium to synthesize the completion the OS did not send. The Web API behavior is the same; the correct backend fix is platform-specific.