Skip to content

Bound the BLE connect and file-read waits so they report instead of hanging - #546

Open
dhalbert wants to merge 7 commits into
circuitpython:mainfrom
dhalbert:bound-ble-connect-timeouts
Open

Bound the BLE connect and file-read waits so they report instead of hanging#546
dhalbert wants to merge 7 commits into
circuitpython:mainfrom
dhalbert:bound-ble-connect-timeouts

Conversation

@dhalbert

@dhalbert dhalbert commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

🤖 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() armed watchAdvertisements() and only called gatt.connect() from the advertisementreceived handler. That event is never delivered on Linux — Chromium's BlueZ backend gates it on an EIR D-Bus property that exists only in the ChromeOS fork, Web Bluetooth on Linux is officially "partially implemented and not supported", and watchAdvertisements() 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 a watchAdvertisements() watch is armed — in that state the promise simply never settles. It is now raced against a timer and cancelled with gatt.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 through awaitPostOpReconnect(), so a save spins with no way out.

A file read on a dead link. readFile() and listDir() 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 from 1.0.5 to 1.0.6, which is a separate change and not part of this PR; the guard is still worth having, since 1.0.6 does 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-device watchAdvertisements() 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 resulting StopDiscovery reaches BlueZ asynchronously in the browser process, so gatt.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.

dhalbert and others added 4 commits August 8, 2026 16:46
`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>
@dhalbert

dhalbert commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Claude wrote this:

Noting a related gap found during the same testing, not addressed in this PR.

FileTransferClient.readOnly() in js/common/ble-file-transfer.js never detects anything — it returns before its own check:

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 return false is unreachable. The other transports implement this for real — fsapi-file-transfer.js and repl-file-transfer.js both have working versions — so BLE is the exception rather than this being a convention.

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 STATUS_ERROR_READONLY, the save retry loop runs its attempts, and the user eventually gets "Saving file ... failed after multiple attempts. Check your connection and try again." — which points at the connection, when the actual cause is that the drive is mounted over USB.

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: STATUS_ERROR_READONLY already comes back from the device on the first write attempt, so the message could name the real cause without any probing.

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>
dhalbert and others added 2 commits August 10, 2026 22:44
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant