Bound the BLE connect and file-read waits so they report instead of hanging - #546
Bound the BLE connect and file-read waits so they report instead of hanging#546dhalbert wants to merge 7 commits into
Conversation
`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 <noreply@anthropic.com>
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 (circuitpython#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 <noreply@anthropic.com>
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 <device>..." 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
|
Claude wrote this: Noting a related gap found during the same testing, not addressed in this PR.
async readOnly() {
let readonly = false;
return false;
// Check if the device is read only
console.log("Checking if device is read only");
// Attempt to write a 0-byte temp file and remove it
const testPath = '/._ble_readonly_check';
...
}Everything after the The consequence is that when the board's filesystem is read-only to CircuitPython, which is the default whenever USB MSC is active, the editor reports it as writable and goes ahead with saves that cannot succeed. The device answers This is worth mentioning here because it made the failure hard to attribute during testing: repeated save failures over BLE looked like a bug in the write path, and it took a hex dump of the result to establish that nothing had been written at all and why. The real check is not free — it writes and deletes a temp file on every connect — so short-circuiting it may well be deliberate. If so, the reporting is still worth improving: EDIT: Now filed as #551, so it does not get lost when this PR merges. Re-verified 2026-08-18 against both main and this branch: readOnly() is unchanged, and STATUS_ERROR_READONLY is still the status the device returns on the first write. Nothing in this comment is addressed by this PR. |
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 <noreply@anthropic.com>
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 circuitpython#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) <noreply@anthropic.com>
connectToBluetoothDevice() showed "Looking for <name>..." 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) <noreply@anthropic.com>
🤖 Generated with Claude Code
The BLE connect flow had several waits with no upper bound, so a failure that should have been reported instead left the editor sitting there indefinitely. This does not make the underlying failures go away — most are host-side and not ours to fix — it makes them report instead of hang.
What was unbounded
Waiting for an advertisement before connecting.
connectToBluetoothDevice()armedwatchAdvertisements()and only calledgatt.connect()from theadvertisementreceivedhandler. That event is never delivered on Linux — Chromium's BlueZ backend gates it on anEIRD-Bus property that exists only in the ChromeOS fork, Web Bluetooth on Linux is officially "partially implemented and not supported", andwatchAdvertisements()is "No longer pursuing". So on Linux the connect was never attempted at all, which is the "device chooser works, nothing happens afterwards" symptom. There is now a bounded wait, and that timer is what drives the connect on Linux.gatt.connect()itself. Chrome bounds this at roughly 41 s on Linux normally, but not while awatchAdvertisements()watch is armed — in that state the promise simply never settles. It is now raced against a timer and cancelled withgatt.disconnect(), which Chrome has honoured as a cancel since M140.The same call in
_attemptSilentReconnect(). Easy to miss and arguably worse, since CircuitPython autoreloads after every mutating file operation, so that reconnect ladder runs after every save. An unbounded connect there stalls the ladder, and the mutating op waits on it throughawaitPostOpReconnect(), so a save spins with no way out.A file read on a dead link.
readFile()andlistDir()could return a promise nobody can settle, because the library installed its reject handler after writing the request — since fixed upstream in adafruit/ble-file-transfer-js#13. Guarded here on link liveness rather than a stopwatch, since a large read over BLE can legitimately take tens of seconds while a dropped link is unambiguous. The device-info dialogs now report the rejection instead of showing a blank dialog. Picking up that library fix means bumping the pin from1.0.5to1.0.6, which is a separate change and not part of this PR; the guard is still worth having, since1.0.6does not address #11 or #12.Two smaller fixes
The advertisement watch is aborted before
gatt.connect()rather than after. The reason is Chrome's per-devicewatchAdvertisements()quota (#410) — leaving losers armed piles up against it. An earlier version of this PR justified the same change by claiming that connecting while a BlueZ discovery session is active fails on Linux; that was withdrawn. The failures behind it were a faulty host controller, a MediaTek MT7920 that goes 0/40 while WiFi scans and 40/40 with the radio quiet, and the same board connects 20/20 on an Intel AX210 with a watch armed or not. The code comments record what survived.The "Looking for …" status no longer names a device when several are being raced. Both reconnect paths loop over every permitted device, so the name shown was whichever was last in
getDevices()order, not the one that would connect.Testing
Feather nRF52840 Express, Metro ESP32-S3 and Circuit Playground Bluefruit; CircuitPython 10.3.0-alpha.4; Chrome 151 on Linux (Intel AX210) and macOS.
Linux connect, serial and file write were exercised repeatedly over two days, including the bounded advertisement wait and its status message, which is what makes a Linux connect complete at all — the deployed editor still hangs on the connect dialog there. A failed connect now reports an actionable error and re-enables the button. The file-read guard stops the "Current Device Info" spinner. The bounded silent reconnect is the least exercised of these.
One known gap in the ordering change:
abort()returns synchronously in page JS but the resultingStopDiscoveryreaches BlueZ asynchronously in the browser process, sogatt.connect()may still fire while the kernel is scanning. Measurement says this does not matter — the kernel disables scanning about 1.5 ms before every create-connection regardless of what BlueZ believes.Context
Chrome registers no usable BlueZ pairing agent, so on Linux the board must be paired from the desktop's Bluetooth settings before the editor can use File Transfer. That is not something this PR can fix; there is detail in adafruit/circuitpython#11178.
Separate from this PR: #545 fixes Save As silently corrupting files.