From cb1a134637759c883a9490d1e28e0317189750a9 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Sat, 8 Aug 2026 16:46:10 -0400 Subject: [PATCH 1/6] Bound the BLE connect so it can't hang forever `connectToBluetoothDevice()` had two unbounded waits, and on Linux both of them hang. The connect dialog stays open with no feedback and no error. First, it waited for an `advertisementreceived` event before connecting at all. Chrome's BlueZ backend never delivers that event: measured 0 events in 45s while BlueZ concurrently received 38 advertising reports from the same device. The same page on macOS gets its first event ~30ms after arming. So on Linux the connect was never even attempted. The wait is now bounded by `ADVERTISEMENT_WAIT_MS`, and we connect anyway when it expires. Second, `gatt.connect()` itself does not always reject. Chrome bounds it at ~41s on Linux normally, but not while a `watchAdvertisements()` watch is armed -- in that state the promise simply never settles, observed over two minutes with no connection attempt in progress at the BlueZ level. It is now raced against `CONNECT_TIMEOUT_MS` and cancelled with `gatt.disconnect()`, which is the only way page JS can abort an in-flight connect. Failure produces an actionable message and re-enables the button. The watch is deliberately left armed until the connect settles, rather than aborted first as before. On Linux the kernel only takes the working connect path while a discovery session is active -- `hci_update_passive_scan_sync()` returns early when `discovery.state != DISCOVERY_STOPPED`, and otherwise installs an accept-list-filtered passive scan that never matches -- and Chrome holds a discovery session for the lifetime of the watch. Other devices' watches are still aborted immediately so Chrome's per-device watch quota is not consumed. Adds `_connectAttemptInFlight` so that several remembered devices whose advertisement waits expire together cannot all try to connect at once. None of this makes Linux reliable; that needs a host fix. It converts an indefinite silent hang into a bounded, reported failure. Co-Authored-By: Claude Opus 5 --- js/workflows/ble.js | 140 ++++++++++++++++++++++++++++++++------------ 1 file changed, 104 insertions(+), 36 deletions(-) diff --git a/js/workflows/ble.js b/js/workflows/ble.js index 9cf97d9..b1a3508 100644 --- a/js/workflows/ble.js +++ b/js/workflows/ble.js @@ -25,6 +25,19 @@ const POST_OP_DISCONNECT_GRACE_MS = 4000; // Wait after GATT reconnects so the VM finishes booting before the next op. const POST_RECONNECT_SETTLE_MS = 2000; +// How long to wait for an advertisement before connecting anyway. Chrome's +// BlueZ backend never delivers advertisementreceived, so on Linux this event +// does not arrive at all and an unbounded wait leaves the connect dialog open +// forever with no feedback. macOS delivers the first event within ~30ms, so a +// few seconds is generous everywhere it works. +const ADVERTISEMENT_WAIT_MS = 5000; +// How long to allow gatt.connect() before giving up. Chrome bounds this itself +// at ~41s on Linux, but not while a watchAdvertisements() watch is armed -- in +// that state the promise simply never settles. Successful connects have been +// measured from 0.5s (macOS, Windows) up to 26.6s (Linux), hence the generous +// ceiling. +const CONNECT_TIMEOUT_MS = 30000; + let btnRequestBluetoothDevice, btnReconnect; class BLEWorkflow extends Workflow { @@ -62,6 +75,11 @@ class BLEWorkflow extends Workflow { // Track in-flight watchAdvertisements abort controllers so we can // cancel them when any device wins or when we tear down (#410). this._pendingAdvAborts = new Set(); + + // Only one device may attempt a connection at a time. Without this, + // several remembered devices whose advertisement waits expire together + // would all try to connect at once. + this._connectAttemptInFlight = false; } // Called by the FileTransferClient wrapper right before any mutating @@ -131,10 +149,7 @@ class BLEWorkflow extends Workflow { } // Cancel any in-flight watchAdvertisements so a subsequent reconnect // doesn't pile up Chrome's per-device watch quota (#410). - for (const ctrl of this._pendingAdvAborts) { - ctrl.abort(); - } - this._pendingAdvAborts.clear(); + this._abortAdvWatches(); await super.onDisconnected(e, reconnect); } @@ -234,51 +249,59 @@ class BLEWorkflow extends Workflow { }); } + // Abort pending advertisement watches, optionally sparing one. Deleting + // while iterating a Set is safe. + _abortAdvWatches(keep = null) { + for (const ctrl of this._pendingAdvAborts) { + if (ctrl !== keep) { + ctrl.abort(); + this._pendingAdvAborts.delete(ctrl); + } + } + } + async connectToBluetoothDevice(device) { const abortController = new AbortController(); this._pendingAdvAborts.add(abortController); let advHandled = false; - async function onAdvertisementReceived(event) { - // Multiple ads can land in the same event-loop tick before - // abortController.abort() takes effect on the listener. Guard - // so we only run the connect flow once per device. See #410. - if (advHandled) { + // Runs either when an advertisement arrives or when we give up waiting + // for one. Guarded because multiple ads can land in the same event-loop + // tick before abortController.abort() takes effect on the listener, and + // because the timer can fire alongside a late advertisement. See #410. + const attemptConnect = async (reason) => { + if (advHandled || this._connectAttemptInFlight) { return; } advHandled = true; - console.log('> Received advertisement from "' + device.name + '"...'); - // This device won. Abort ALL pending watchAdvertisements - // (including this one) so other paired devices stop scanning - // and don't pile up Chrome's per-device watch quota. - for (const ctrl of this._pendingAdvAborts) { - ctrl.abort(); - } - this._pendingAdvAborts.clear(); - console.log('Connecting to GATT Server from "' + device.name + '"...'); + this._connectAttemptInFlight = true; + clearTimeout(advTimer); + + // This device won. Stop the OTHER devices' watches so they don't + // pile up Chrome's per-device watch quota. This device keeps its + // own watch until the connect settles: on Linux the kernel only + // takes the working connect path while a discovery session is + // active, and Chrome holds one for the lifetime of the watch. + this._abortAdvWatches(abortController); try { - this.bleServer = await device.gatt.connect(); - } catch (error) { - console.log(error); - // TODO(ericzundel): Add to suggestBLEConnectAction if we can determine the exception type - this.showConnectStatus("Failed to connect to device. Try forgetting device from OS bluetooth devices and try again."); - // Disable the reconnect button - this.connectionStep(1); - } - if (this.bleServer && this.bleServer.connected) { - console.log('> Bluetooth device "' + device.name + ' connected.'); - await this.switchToDevice(device); - } else { - console.log('Unable to connect to bluetooth device "' + device.name + '.'); + await this._connectToGattServer(device, reason); + } finally { + this._connectAttemptInFlight = false; + this._abortAdvWatches(); } - } + }; + + const advTimer = setTimeout( + () => attemptConnect(`no advertisement within ${ADVERTISEMENT_WAIT_MS / 1000}s`), + ADVERTISEMENT_WAIT_MS); // Use the abortController signal so we don't need to manage the // handler reference manually — the listener is auto-removed when - // onAdvertisementReceived calls abortController.abort(). - device.addEventListener('advertisementreceived', - onAdvertisementReceived.bind(this), - {signal: abortController.signal}); + // abortController.abort() is called. + device.addEventListener('advertisementreceived', () => { + console.log('> Received advertisement from "' + device.name + '"...'); + attemptConnect('advertisement received'); + }, {signal: abortController.signal}); this.debugLog("Attempting to connect to " + device.name + "..."); try { @@ -288,11 +311,56 @@ class BLEWorkflow extends Workflow { await device.watchAdvertisements({signal: abortController.signal}); } catch (error) { + clearTimeout(advTimer); console.error(error); this.showConnectStatus(this._suggestBLEConnectActions(error)); } } + // Connect with a bound. gatt.connect() does not always reject on its own -- + // on Linux with a watch armed it never settles -- so race it against a timer + // and cancel with gatt.disconnect(), which is the only way page JS can abort + // an in-flight connect. + async _connectToGattServer(device, reason) { + console.log(`Connecting to GATT Server from "${device.name}" (${reason})...`); + this.showConnectStatus("Connecting to " + device.name + "..."); + + let connectTimer; + try { + this.bleServer = await Promise.race([ + device.gatt.connect(), + new Promise((_, reject) => { + connectTimer = setTimeout(() => { + device.gatt.disconnect(); + reject(new Error( + `connect did not complete within ${CONNECT_TIMEOUT_MS / 1000}s`)); + }, CONNECT_TIMEOUT_MS); + }), + ]); + } catch (error) { + console.log(error); + // TODO(ericzundel): Add to suggestBLEConnectAction if we can determine the exception type + this.showConnectStatus( + `Could not connect to ${device.name}. Try again. If it keeps failing, forget the ` + + `device in your operating system's Bluetooth settings, then reload this page.`); + // Disable the reconnect button + this.connectionStep(1); + return; + } + finally { + clearTimeout(connectTimer); + } + + if (this.bleServer && this.bleServer.connected) { + console.log('> Bluetooth device "' + device.name + '" connected.'); + await this.switchToDevice(device); + } else { + console.log('Unable to connect to bluetooth device "' + device.name + '".'); + this.showConnectStatus(`Could not connect to ${device.name}. Try again.`); + this.connectionStep(1); + } + } + // Request Bluetooth Device async onRequestBluetoothDeviceButtonClick(e) { console.log('Requesting any Bluetooth device...'); From c84e3a786c788eedecbe5a77986608a1f763325b Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Sat, 8 Aug 2026 17:23:11 -0400 Subject: [PATCH 2/6] Report a failed BLE file read instead of hanging on it Reading device info over BLE could hang forever, leaving the editor spinning on "Current Device Info" with no way out but a reload. Reproduced on Linux by letting pairing fail: the connect succeeds, encryption then drops the link, and the device-info read is issued on a dead connection. The defect is upstream in `@adafruit/ble-file-transfer-js`. `readFile()` and `listDir()` install their promise's reject handler *after* writing the request: await this._write(header); await this._write(encoded); let p = new Promise((resolve, reject) => { this._resolve = resolve; this._reject = reject; // too late }); return p; On a dead link `_transfer` is null, so both writes throw. `_write()` swallows the error and calls `onDisconnected()`, which has no `_reject` to call yet. `checkConnection()` likewise catches its own failure and returns normally rather than rethrowing, so the read proceeds regardless. The returned promise is then never settled by anyone. Rather than patch upstream from here, our `FileTransferClient` wrapper guards the two read paths with `_whileConnected()`: reject immediately if the GATT link is already down, and reject if it drops while the read is in flight. Bounding on liveness rather than elapsed time is deliberate -- a large file read over BLE can legitimately take tens of seconds, so a stopwatch would produce false failures, while a dropped link is unambiguous. The mutating ops are left alone, since they are meant to span the autoreload disconnect (#377). That alone stops the hang, because `showBusy()` clears the spinner in a `finally`. But the rejection then escaped `_getVersionInfo()` and `_getDeviceInfo()` uncaught, leaving a blank dialog that reads as "the device answered with nothing". Both now catch and show a message, using the `#message` element the other modals already use, added to these two. Co-Authored-By: Claude Opus 5 --- index.html | 2 ++ js/common/ble-file-transfer.js | 46 ++++++++++++++++++++++++++++++++++ js/common/dialogs.js | 29 +++++++++++++++++++-- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 781dabc..c26ac2d 100644 --- a/index.html +++ b/index.html @@ -393,6 +393,7 @@

Select USB Host Folder

+

More network devices

@@ -435,6 +436,7 @@

More network devices
diff --git a/js/common/ble-file-transfer.js b/js/common/ble-file-transfer.js index 6b16775..f36e9c3 100644 --- a/js/common/ble-file-transfer.js +++ b/js/common/ble-file-transfer.js @@ -8,6 +8,52 @@ class FileTransferClient extends BLEFileTransferClient { constructor(bleDevice, bufferSize, workflow = null) { super(bleDevice, bufferSize); this._workflow = workflow; + this._bleDevice = bleDevice; + } + + // Reject a read if the GATT link is already down, or drops while it is in + // flight, instead of returning a promise that can never settle. + // + // Upstream readFile()/listDir() install their promise's reject handler + // AFTER writing the request: + // + // await this._write(header); + // await this._write(encoded); + // let p = new Promise((resolve, reject) => { + // this._resolve = resolve; + // this._reject = reject; // too late + // }); + // + // On a dead link `_transfer` is null, so both writes throw; _write() + // swallows the error and calls onDisconnected(), which has no `_reject` to + // call yet. checkConnection() likewise catches its own failure and returns + // normally rather than rethrowing, so the read proceeds regardless. The + // returned promise is then never settled by anyone and the caller hangs -- + // which is what left the editor spinning on "Current Device Info". + // + // Bound on liveness rather than elapsed time: a large file read over BLE can + // legitimately take tens of seconds, so a stopwatch would produce false + // failures, while a dropped link is unambiguous. + _whileConnected(operation) { + const device = this._bleDevice; + if (!device || !device.gatt || !device.gatt.connected) { + return Promise.reject(new Error("Bluetooth device is not connected")); + } + return new Promise((resolve, reject) => { + const onDisconnected = () => reject(new Error("Bluetooth device disconnected")); + device.addEventListener("gattserverdisconnected", onDisconnected, {once: true}); + operation().then(resolve, reject).finally(() => { + device.removeEventListener("gattserverdisconnected", onDisconnected); + }); + }); + } + + async readFile(path, raw = false) { + return await this._whileConnected(() => super.readFile(path, raw)); + } + + async listDir(path) { + return await this._whileConnected(() => super.listDir(path)); } _signalMutatingOp() { diff --git a/js/common/dialogs.js b/js/common/dialogs.js index 42e2f7d..9634676 100644 --- a/js/common/dialogs.js +++ b/js/common/dialogs.js @@ -340,9 +340,28 @@ class ButtonValueDialog extends GenericModal { } } +// Report a failed device-info read in the dialog. Without this the read's +// rejection escapes as an unhandled promise rejection and the dialog is simply +// left blank, which reads as "the device answered with nothing" rather than +// "we never reached the device". +function showDeviceInfoError(modal, error) { + console.error("Unable to read device info:", error); + const msgElement = modal.querySelector("#message"); + if (msgElement) { + msgElement.textContent = + "Could not read device information. The connection to the device was lost."; + } +} + class DiscoveryModal extends GenericModal { async _getVersionInfo() { - const deviceInfo = await this._showBusy(this._fileHelper.versionInfo()); + let deviceInfo; + try { + deviceInfo = await this._showBusy(this._fileHelper.versionInfo()); + } catch (error) { + showDeviceInfoError(this._currentModal, error); + return; + } this._currentModal.querySelector("#version").textContent = deviceInfo.version; const boardLink = this._currentModal.querySelector("#board"); boardLink.href = `https://circuitpython.org/board/${deviceInfo.board_id}/`; @@ -413,7 +432,13 @@ class DiscoveryModal extends GenericModal { class DeviceInfoModal extends GenericModal { async _getDeviceInfo() { - const deviceInfo = await this._showBusy(this._fileHelper.versionInfo()); + let deviceInfo; + try { + deviceInfo = await this._showBusy(this._fileHelper.versionInfo()); + } catch (error) { + showDeviceInfoError(this._currentModal, error); + return; + } this._currentModal.querySelector("#version").textContent = deviceInfo.version; const boardLink = this._currentModal.querySelector("#board"); boardLink.href = `https://circuitpython.org/board/${deviceInfo.board_id}/`; From f6ac81bc836375e8f216234b65e6cb3830dc8468 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Sat, 8 Aug 2026 18:10:49 -0400 Subject: [PATCH 3/6] Shorten the advertisement wait and say something during it Two follow-ups to bounding the connect, both about how the wait feels rather than what it does. `ADVERTISEMENT_WAIT_MS` drops from 5s to 2s. On Linux the event never arrives, so the wait always runs to the full timeout before the connect is attempted, and five seconds of it is pure latency. It is not wasted time though: the discovery session that `watchAdvertisements()` opens is what makes BlueZ create its device object, without which `gatt.connect()` rejects immediately as "no longer in range". A second or so is enough for that, and platforms where the event does arrive get it in about 30ms, so the constant is irrelevant to them. The wait was also completely silent, because `clearConnectStatus()` runs just before it. Two to five seconds of a blank dialog reads as a hang, which is the impression this whole change set is trying to remove, so show "Looking for ..." until the connect starts. Untested against hardware: the Linux connect only succeeds about a third of the time for unrelated host reasons, which makes the latency difference hard to observe deliberately. Co-Authored-By: Claude Opus 5 --- js/workflows/ble.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/js/workflows/ble.js b/js/workflows/ble.js index b1a3508..44f84b8 100644 --- a/js/workflows/ble.js +++ b/js/workflows/ble.js @@ -29,8 +29,11 @@ const POST_RECONNECT_SETTLE_MS = 2000; // BlueZ backend never delivers advertisementreceived, so on Linux this event // does not arrive at all and an unbounded wait leaves the connect dialog open // forever with no feedback. macOS delivers the first event within ~30ms, so a -// few seconds is generous everywhere it works. -const ADVERTISEMENT_WAIT_MS = 5000; +// couple of seconds is generous everywhere it works. On Linux the wait is not +// wasted even though nothing arrives: the discovery session that +// watchAdvertisements() opens is what makes BlueZ (re)create its device object, +// without which gatt.connect() rejects as "no longer in range". +const ADVERTISEMENT_WAIT_MS = 2000; // How long to allow gatt.connect() before giving up. Chrome bounds this itself // at ~41s on Linux, but not while a watchAdvertisements() watch is armed -- in // that state the promise simply never settles. Successful connects have been @@ -306,6 +309,9 @@ class BLEWorkflow extends Workflow { this.debugLog("Attempting to connect to " + device.name + "..."); try { this.clearConnectStatus(); + // Say something during the advertisement wait. On Linux it always + // runs to the full timeout, and silence looks like a hang. + this.showConnectStatus("Looking for " + device.name + "..."); console.log('Watching advertisements from "' + device.name + '"...'); console.log('If no advertisements are received, make sure the device is powered on and in range. You can also try resetting the device.'); await device.watchAdvertisements({signal: abortController.signal}); From 0c197d4cb0b9f9403e6c177181c8962be3d861fd Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Sat, 8 Aug 2026 18:29:44 -0400 Subject: [PATCH 4/6] Bound the silent reconnect's connect too The previous commit bounded gatt.connect() in connectToBluetoothDevice() but missed the copy in _attemptSilentReconnect(), which is arguably the worse of the two. CircuitPython autoreloads after every mutating file operation, which drops the link, so that reconnect ladder runs after every save. An unbounded connect there stalls the ladder, and the mutating op waits on it through awaitPostOpReconnect(), so a save spins with no way out. Extracts the timeout-and-cancel race into _connectWithTimeout(device, ms) and uses it in both places, rather than repeating it. The silent path gets its own shorter bound. CONNECT_TIMEOUT_MS is 30s, chosen so a slow-but-real Linux connect is not abandoned; three of those in the reconnect ladder would be 90s of apparent hang. Ten seconds is long enough for a reconnect that is going to work -- post-autoreload reconnects land in about a second -- and past that it has stopped being silent anyway, so failing over to the manual reconnect UI is the better outcome. Co-Authored-By: Claude Opus 5 --- js/workflows/ble.js | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/js/workflows/ble.js b/js/workflows/ble.js index 44f84b8..5150fc3 100644 --- a/js/workflows/ble.js +++ b/js/workflows/ble.js @@ -40,6 +40,11 @@ const ADVERTISEMENT_WAIT_MS = 2000; // measured from 0.5s (macOS, Windows) up to 26.6s (Linux), hence the generous // ceiling. const CONNECT_TIMEOUT_MS = 30000; +// Per-attempt bound for the silent reconnect after a firmware autoreload. +// Shorter than CONNECT_TIMEOUT_MS because this path runs once per entry in +// RECONNECT_DELAYS_MS, and a reconnect that has not landed within ten seconds +// has stopped being silent regardless of whether it eventually succeeds. +const SILENT_RECONNECT_TIMEOUT_MS = 10000; let btnRequestBluetoothDevice, btnReconnect; @@ -326,23 +331,33 @@ class BLEWorkflow extends Workflow { // Connect with a bound. gatt.connect() does not always reject on its own -- // on Linux with a watch armed it never settles -- so race it against a timer // and cancel with gatt.disconnect(), which is the only way page JS can abort - // an in-flight connect. - async _connectToGattServer(device, reason) { - console.log(`Connecting to GATT Server from "${device.name}" (${reason})...`); - this.showConnectStatus("Connecting to " + device.name + "..."); - + // an in-flight connect. Chrome has honoured disconnect() as a cancel since + // M140; before that the attempt is orphaned rather than aborted, so treat a + // timeout as fatal rather than assuming the adapter is left clean. + async _connectWithTimeout(device, timeoutMs) { let connectTimer; try { - this.bleServer = await Promise.race([ + return await Promise.race([ device.gatt.connect(), new Promise((_, reject) => { connectTimer = setTimeout(() => { device.gatt.disconnect(); reject(new Error( - `connect did not complete within ${CONNECT_TIMEOUT_MS / 1000}s`)); - }, CONNECT_TIMEOUT_MS); + `connect did not complete within ${timeoutMs / 1000}s`)); + }, timeoutMs); }), ]); + } finally { + clearTimeout(connectTimer); + } + } + + async _connectToGattServer(device, reason) { + console.log(`Connecting to GATT Server from "${device.name}" (${reason})...`); + this.showConnectStatus("Connecting to " + device.name + "..."); + + try { + this.bleServer = await this._connectWithTimeout(device, CONNECT_TIMEOUT_MS); } catch (error) { console.log(error); // TODO(ericzundel): Add to suggestBLEConnectAction if we can determine the exception type @@ -353,9 +368,6 @@ class BLEWorkflow extends Workflow { this.connectionStep(1); return; } - finally { - clearTimeout(connectTimer); - } if (this.bleServer && this.bleServer.connected) { console.log('> Bluetooth device "' + device.name + '" connected.'); @@ -479,7 +491,11 @@ class BLEWorkflow extends Workflow { await sleep(delay); try { console.log(`Silent reconnect: attempting after ${delay}ms…`); - this.bleServer = await this.bleDevice.gatt.connect(); + // Bounded: an unbounded connect here stalls the whole + // reconnect ladder, and every mutating op waits on it via + // awaitPostOpReconnect(), so a save appears to hang. + this.bleServer = await this._connectWithTimeout( + this.bleDevice, SILENT_RECONNECT_TIMEOUT_MS); if (this.bleServer && this.bleServer.connected) { console.log('Silent reconnect: GATT reconnected, rebinding characteristics…'); await this._rebindAfterSilentReconnect(); From f7edefeca4721abda46c94327fab9f738ce3bf81 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Sun, 9 Aug 2026 16:22:13 -0400 Subject: [PATCH 5/6] Abort the advertisement watch before connecting, not after Chrome holds a BlueZ discovery session for as long as any watchAdvertisements() watch is armed, and connecting while one is active is what fails on Linux -- the opposite of what the previous comment claimed. Driving Device1.Connect() directly: 36/36 with discovery stopped, 18/52 with it active. _abortAdvWatches() now drops this device's own watch too, and the redundant call in the finally block goes away since nothing is left pending by then. Co-Authored-By: Claude Opus 5 --- js/workflows/ble.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/js/workflows/ble.js b/js/workflows/ble.js index 5150fc3..4539a0f 100644 --- a/js/workflows/ble.js +++ b/js/workflows/ble.js @@ -285,17 +285,21 @@ class BLEWorkflow extends Workflow { this._connectAttemptInFlight = true; clearTimeout(advTimer); - // This device won. Stop the OTHER devices' watches so they don't - // pile up Chrome's per-device watch quota. This device keeps its - // own watch until the connect settles: on Linux the kernel only - // takes the working connect path while a discovery session is - // active, and Chrome holds one for the lifetime of the watch. - this._abortAdvWatches(abortController); + // This device won. Stop every pending watch, this device's + // included, BEFORE connecting -- Chrome holds a BlueZ discovery + // session for as long as any watch is armed, and connecting while + // one is active is what fails on Linux. Measured by driving + // Device1.Connect() directly: discovery stopped 36/36, discovery + // active 18/52. Intermittent -- a host suspend/resume clears the + // failing state until the next boot, so it may not reproduce. In + // the failing case the HCI create-connection is identical to a + // working one and the controller simply transmits nothing until + // the attempt is cancelled ~20s later. + this._abortAdvWatches(); try { await this._connectToGattServer(device, reason); } finally { this._connectAttemptInFlight = false; - this._abortAdvWatches(); } }; From 3113ab6472893183e2fc3a68cc7fbb383b3dda0f Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 10 Aug 2026 22:44:12 -0400 Subject: [PATCH 6/6] Correct the ble.js comments to match what the investigation established The comment on the pre-connect _abortAdvWatches() call claimed the abort was needed because connecting while a BlueZ discovery session is active fails on Linux, citing 36/36 with discovery stopped against 18/52 with it active. That did not hold. Later discovery-stopped runs measured 15/20 and 18/20, and the failures turned out to be the host Bluetooth controller -- a MediaTek MT7920, which goes 0/40 while WiFi scans and 40/40 with the radio quiet -- rather than the discovery state. The same board connects 20/20 on an Intel AX210 whether a watch is armed or not. Name the withdrawn claim in place instead of deleting it, and give the reason that does hold: the per-device watchAdvertisements() quota from #410. Also record two findings that survived, because both bear on this call site. The kernel disables scanning about 1.5ms before every create-connection regardless of what BlueZ believes, so aborting cannot change the controller's state at the moment of connect. And aborting may be mildly counterproductive on Linux, since Chrome's discovery session is what refreshes BlueZ's 30s sighting window and gatt.connect() rejects with "no longer in range" once it lapses. CONNECT_TIMEOUT_MS keeps its value but is rejustified. Its 26.6s datum came from the faulty MediaTek, so note the healthy figures alongside it -- 0.5s on macOS and Windows, 0.7s median on the AX210 -- and that the ceiling is a backstop against a promise that never settles rather than a tuned deadline. Comments only; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) --- js/workflows/ble.js | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/js/workflows/ble.js b/js/workflows/ble.js index 4539a0f..710f38e 100644 --- a/js/workflows/ble.js +++ b/js/workflows/ble.js @@ -36,9 +36,13 @@ const POST_RECONNECT_SETTLE_MS = 2000; const ADVERTISEMENT_WAIT_MS = 2000; // How long to allow gatt.connect() before giving up. Chrome bounds this itself // at ~41s on Linux, but not while a watchAdvertisements() watch is armed -- in -// that state the promise simply never settles. Successful connects have been -// measured from 0.5s (macOS, Windows) up to 26.6s (Linux), hence the generous -// ceiling. +// that state the promise simply never settles, which is the case this timeout +// exists for. The ceiling is deliberately loose rather than tuned: a healthy +// adapter connects in well under a second (0.5s on macOS and Windows, 0.7s +// median on an Intel AX210 on Linux), but a user's host controller may be far +// slower -- 26.6s was measured on a faulty MediaTek MT7920. The point is to +// convert a never-settling promise into a reportable failure, not to enforce a +// tight deadline. const CONNECT_TIMEOUT_MS = 30000; // Per-attempt bound for the silent reconnect after a firmware autoreload. // Shorter than CONNECT_TIMEOUT_MS because this path runs once per entry in @@ -285,16 +289,27 @@ class BLEWorkflow extends Workflow { this._connectAttemptInFlight = true; clearTimeout(advTimer); - // This device won. Stop every pending watch, this device's - // included, BEFORE connecting -- Chrome holds a BlueZ discovery - // session for as long as any watch is armed, and connecting while - // one is active is what fails on Linux. Measured by driving - // Device1.Connect() directly: discovery stopped 36/36, discovery - // active 18/52. Intermittent -- a host suspend/resume clears the - // failing state until the next boot, so it may not reproduce. In - // the failing case the HCI create-connection is identical to a - // working one and the controller simply transmits nothing until - // the attempt is cancelled ~20s later. + // This device won, so stop every pending watch, this device's + // included. The reason is the one from #410: Chrome enforces a + // per-device watchAdvertisements quota, and leaving the losers + // armed piles up against it. + // + // An earlier version of this comment claimed the abort was needed + // because connecting while a BlueZ discovery session is active + // fails on Linux. That was investigated at length and does not + // hold: the connect failures it described were the host Bluetooth + // controller (a MediaTek MT7920, 0/40 while WiFi scanned), not the + // discovery state, and the same board connects 20/20 on an Intel + // AX210 with a watch armed or not. The kernel also disables + // scanning ~1.5ms before every create-connection regardless of + // what BlueZ believes, so aborting the watch does not change the + // controller's state at the moment of connect. + // + // Ordering it before the connect is therefore housekeeping, not a + // workaround, and on Linux it may even cost a little: Chrome's + // discovery session is what refreshes BlueZ's 30s sighting window, + // and gatt.connect() rejects with "no longer in range" once that + // window lapses. this._abortAdvWatches(); try { await this._connectToGattServer(device, reason);