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 4 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: |
🤖 Generated with Claude Code
Draft: two of the four commits are not yet verified against hardware. See Testing.
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 any of the underlying failures go away — most of them 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: measured 0 events in 45 s while BlueZ concurrently received 38 advertising reports from the same board. It is unimplemented rather than broken — Chromium's BlueZ backend gates advertisement delivery on anEIRD-Bus property that exists only in the ChromeOS fork, Web Bluetooth on Linux is officially "partially implemented and not supported", andwatchAdvertisements()is marked "No longer pursuing". So on Linux the connect was never attempted at all, which is the "device chooser works, nothing happens afterwards" symptom.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, observed over two minutes with no connection attempt in progress at the BlueZ level. 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 the worse of the two, since CircuitPython autoreloads after every mutating file operation and that reconnect ladder therefore 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 that nobody can settle, because upstream installs the reject handler after writing the request — see adafruit/ble-file-transfer-js#13. Guarded here on link liveness rather than a stopwatch, since a large file read over BLE can legitimately take tens of seconds while a dropped link is unambiguous. The device-info dialogs also now catch the rejection and say something, instead of leaving a blank dialog that reads as "the device answered with nothing".Deliberate choice worth reviewing
The advertisement watch is now left armed across the connect and aborted only afterwards, rather than aborted first. On Linux the kernel only takes its working connect path while a discovery session is active —
hci_update_passive_scan_sync()returns early whendiscovery.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.This leans on Chrome-on-Linux implementation detail, not specified behaviour. It is commented as such.
Testing
Feather nRF52840 Express, CircuitPython 10.3.0-alpha.4, Chrome 151.
Verified: the bounded connect on Linux, where a failed attempt now reports an actionable error and re-enables the button instead of hanging; and the file-read guard, which stops the "Current Device Info" spinner. Both reproduced before and after.
Not verified against hardware: the shorter advertisement wait with its status message, and the bounded silent reconnect. Linux connects succeed on roughly a third of attempts for unrelated host reasons, which makes a latency change hard to observe deliberately, and the silent-reconnect path needs a specific failure to exercise. Hence draft.
Context
The Linux connect unreliability underneath all of this is a host defect, not something the editor can fix: the kernel frequently issues no create-connection at all for an unbonded peripheral, succeeding on about a third of attempts. Reported upstream without a fix in bluez#2309, bluez#2356, bleak#1244 and kernel bugzilla 199111, open since 2018. Chrome also registers no BlueZ pairing agent, so pairing cannot complete on Linux without one supplied externally; there is more detail in adafruit/circuitpython#11178.
Separate from this PR: #545 fixes Save As silently corrupting files, found during the same testing.