From cb1a134637759c883a9490d1e28e0317189750a9 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Sat, 8 Aug 2026 16:46:10 -0400 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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); From 2bd1935b44612455191f1b46c011f7ed3fa24a37 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Tue, 18 Aug 2026 17:45:59 -0400 Subject: [PATCH 7/9] Don't name a device in the status while racing several connectToBluetoothDevice() showed "Looking for ..." for its own device. Both reconnect paths call it in a loop over every permitted device, so each call overwrote the last and the message the user was left looking at named whichever device happened to be last in getDevices() order -- not the one that would win the race and get connected. Observed with two boards permitted for the same origin and only one of them plugged in: the dialog said it was looking for the absent board for the whole advertisement wait, then connected to the present one and correctly named that one instead. Move the message to the callers, which know how many devices are in play, and say "Looking for N previously connected boards..." when there is more than one. _connectToGattServer() already names the actual winner once a connect starts. Co-Authored-By: Claude Opus 5 (1M context) --- js/workflows/ble.js | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/js/workflows/ble.js b/js/workflows/ble.js index 710f38e..6100487 100644 --- a/js/workflows/ble.js +++ b/js/workflows/ble.js @@ -240,6 +240,7 @@ class BLEWorkflow extends Workflow { const devices = await navigator.bluetooth.getDevices(); console.log('> Found ' + devices.length + ' Bluetooth device(s).'); + this._showSearchingStatus(devices); // These devices may not be powered on or in range, so scan for // advertisement packets from them before connecting. for (const device of devices) { @@ -261,6 +262,20 @@ class BLEWorkflow extends Workflow { }); } + // Say something during the advertisement wait. On Linux it always runs to the + // full timeout, and silence looks like a hang. Naming a device is only honest + // when there is one: the reconnect paths race every permitted device and + // connect to whichever answers first, which need not be the one named. + _showSearchingStatus(devices) { + if (devices.length === 0) { + return; + } + this.clearConnectStatus(); + this.showConnectStatus(devices.length === 1 + ? "Looking for " + devices[0].name + "..." + : "Looking for " + devices.length + " previously connected boards..."); + } + // Abort pending advertisement watches, optionally sparing one. Deleting // while iterating a Set is safe. _abortAdvWatches(keep = null) { @@ -332,10 +347,10 @@ 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 + "..."); + // No status message here. The caller has already said what it is + // looking for, and naming this device would be wrong: the reconnect + // paths arm a watch on every permitted device at once, so each call + // would overwrite the last and leave a loser's name on screen. 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}); @@ -405,6 +420,7 @@ class BLEWorkflow extends Workflow { let device = await this.requestDevice(); console.log('> Requested ' + device.name); + this._showSearchingStatus([device]); await this.connectToBluetoothDevice(device); } @@ -487,6 +503,7 @@ class BLEWorkflow extends Workflow { if (!this.bleDevice) { try { let devices = await navigator.bluetooth.getDevices(); + this._showSearchingStatus(devices); for (const device of devices) { await this.connectToBluetoothDevice(device); } From e6d68ce8e27adcca7166b339df368b934da73dbd Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Wed, 19 Aug 2026 17:09:27 -0400 Subject: [PATCH 8/9] Take ble-file-transfer-js 1.1.0 and drop the local read guard 1.1.0 rejects readFile()/listDir() when the link is down or drops mid-request: checkConnection() rethrows, and the response promise is installed before the request is written. That is what the _whileConnected() wrapper existed to do, so it goes. Co-Authored-By: Claude Opus 5 (1M context) --- js/common/ble-file-transfer.js | 46 ---------------------------------- package-lock.json | 8 +++--- package.json | 2 +- 3 files changed, 4 insertions(+), 52 deletions(-) diff --git a/js/common/ble-file-transfer.js b/js/common/ble-file-transfer.js index f36e9c3..6b16775 100644 --- a/js/common/ble-file-transfer.js +++ b/js/common/ble-file-transfer.js @@ -8,52 +8,6 @@ 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/package-lock.json b/package-lock.json index 4257cf2..05303f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "web-editor", "version": "0.0.0", "dependencies": { - "@adafruit/ble-file-transfer-js": "adafruit/ble-file-transfer-js#1.0.5", + "@adafruit/ble-file-transfer-js": "adafruit/ble-file-transfer-js#1.1.0", "@adafruit/circuitpython-repl-js": "adafruit/circuitpython-repl-js#3.4.0", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.12", @@ -18,7 +18,6 @@ "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-xml": "^6.1.0", "@fortawesome/fontawesome-free": "^7.3.1", - "@rollup/rollup-linux-x64-gnu": "4.62.4", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", @@ -39,9 +38,8 @@ } }, "node_modules/@adafruit/ble-file-transfer-js": { - "name": "@adafruit/ble-file-transfer", - "version": "1.0.2", - "resolved": "git+ssh://git@github.com/adafruit/ble-file-transfer-js.git#09864e281e77310817b7cb6932f260db1b9581a0", + "version": "1.1.0", + "resolved": "git+ssh://git@github.com/adafruit/ble-file-transfer-js.git#cfdbef2902a0db559e03b6253123d0f9adf3cf4e", "license": "MIT" }, "node_modules/@adafruit/circuitpython-repl-js": { diff --git a/package.json b/package.json index 1284019..daef8a8 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "vite-plugin-mkcert": "^2.1.0" }, "dependencies": { - "@adafruit/ble-file-transfer-js": "adafruit/ble-file-transfer-js#1.0.5", + "@adafruit/ble-file-transfer-js": "adafruit/ble-file-transfer-js#1.1.0", "@adafruit/circuitpython-repl-js": "adafruit/circuitpython-repl-js#3.4.0", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.12", From 4a89ca5aa042f1366bb164f73371cb25e60872bc Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Thu, 20 Aug 2026 16:31:36 -0400 Subject: [PATCH 9/9] Force a CCCD write when subscribing to BLE serial startNotifications() on a reconnect to a bonded board can return without writing the descriptor, so the board keeps notifications disabled and the terminal stays silent for the rest of the session, while file transfer -- which goes through its own subscribe -- keeps working. Stop before starting. Reproduced on a Feather nRF52840 and a Metro ESP32-S3, from Chrome on Linux and Windows 11: after a disconnect and reconnect, the board answers reads but sends no notifications until a stop/start pair forces the descriptor write. Co-Authored-By: Claude Opus 5 (1M context) --- js/workflows/ble.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/js/workflows/ble.js b/js/workflows/ble.js index 6100487..615e7b7 100644 --- a/js/workflows/ble.js +++ b/js/workflows/ble.js @@ -224,6 +224,21 @@ class BLEWorkflow extends Workflow { // Use cached bound handler so removeEventListener actually matches. this.txCharacteristic.removeEventListener('characteristicvaluechanged', this._onSerialReceiveBound); this.txCharacteristic.addEventListener('characteristicvaluechanged', this._onSerialReceiveBound); + + // Stop before starting, so a CCCD write actually goes out. Reconnecting to a + // bonded board, startNotifications() can return without writing the + // descriptor, leaving the board with notifications disabled -- the terminal + // then stays silent for the rest of the session while file transfer works. + // Measured on a Feather nRF52840 and a Metro ESP32-S3, on Linux and Windows. + // + // No read is needed first here, unlike the file transfer client's own + // subscribe: switchToDevice() bonds through the file transfer client before + // calling this, so the link is already encrypted by now. + try { + await this.txCharacteristic.stopNotifications(); + } catch (e) { + // Nothing was subscribed yet, which is the ordinary first connect. + } await this.txCharacteristic.startNotifications(); return true; } catch (e) {