Summary
When using the BLE workflow, the editor disconnects from the device every time a write-style file operation completes (save, rename, move, delete, mkdir). The user has to manually click reconnect to keep working. This behavior is present at least as far back as CircuitPython 8.2.7 and 9.1.0 (tested on a CLUE / nRF52840), so it isn't a regression — but it is a real UX paper cut and I think it's fixable purely on the editor side.
Reproduction
- Connect a CLUE (or any BLE-capable CP board) using the Bluetooth workflow at https://code.circuitpython.org.
- Open the file dialog, rename any file (or save
code.py, or create a folder).
- The rename succeeds on-device, but the editor immediately drops to "Disconnected" and shows the reconnect button.
- Have to click Reconnect to do anything else.
Tested on CLUE running CircuitPython 8.2.7, 9.1.0, 10.0.0, 10.0.1, 10.0.3, and 10.1.1 — same disconnect behavior on all of them.
Why it disconnects (firmware side)
In supervisor/shared/bluetooth/file_transfer.c, after any mutating command completes (WRITE, WRITE_DATA, DELETE, MKDIR, MOVE) the firmware calls autoreload_trigger() to reload the running Python program. This is intentional and matches USB MSC behavior ("save a file → code.py reruns"). The problem specifically over BLE is that the BLE-FT GATT server lives inside the VM that just got reloaded, so the GATT connection drops as a side effect of the reload.
This has been the design since BLE workflow shipped, which is why it's present in 8.2.7 going forward.
Re-architecting this on the firmware side (moving BLE-FT into supervisor-only code that survives reload) is a fairly invasive change, and would need testing across every BLE-capable port. I don't think we should chase that here — it's much cleaner to handle this on the editor side.
Why the editor's existing reconnect doesn't kick in
Workflow.onDisconnected(e, reconnect = true) in js/workflows/workflow.js is supposed to attempt this.connect() when the disconnect was unexpected, and the gattserverdisconnected listener attached in ble.js invokes it with reconnect defaulting to true. But in practice no reconnect happens after the autoreload-induced drop. A few likely reasons:
-
The BLE connect() override in js/workflows/ble.js:
async connect() {
let result;
if (result = await super.connect() instanceof Error) {
return result;
}
...
}
Due to operator precedence, result ends up being true / false rather than the actual super.connect() return value, so the early-return-on-error logic isn't doing what it looks like it's doing.
-
Even without that bug, connectToBluetoothDevice is called immediately. The board is still mid-reload at that point, so gatt.connect() will fail and we silently bail.
-
connectToSerial() and initFileClient() are called unconditionally in switchToDevice, but gattserverdisconnected listeners aren't rewired with the same care on the reconnect path.
Proposed fix (editor side)
Add an automatic reconnect path that:
- Detects "expected" autoreload-induced disconnects — i.e. a disconnect that happened within ~5 seconds of completing a
writeFile / move / delete / makeDir call (or unconditionally, if simpler).
- Waits a short grace period (~1.5–2.5 s) so the firmware has time to finish the autoreload and bring the GATT service back up.
- Calls
connectToBluetoothDevice on the same device handle we already have permission for (no requestDevice prompt needed because we still have bleDevice).
- Retries with backoff (e.g. 3 attempts at 1 s / 2 s / 4 s) before giving up and showing the manual reconnect UI.
- Suppresses the "Disconnected" toast / status flicker for the duration of the silent reconnect attempt, so the UX feels like "saving…" rather than "disconnected".
While we're in there, fix the precedence bug in ble.js#connect:
- if (result = await super.connect() instanceof Error) {
- return result;
- }
+ result = await super.connect();
+ if (result instanceof Error) {
+ return result;
+ }
Why this is editor-side, not firmware-side
- The disconnect has existed since BLE workflow shipped; firmware fix is high risk, low reward.
- Web Bluetooth's
navigator.bluetooth.getDevices() lets us reconnect to an already-permitted device without a user gesture, so we have everything we need to make this transparent.
- Same approach naturally handles other autoreload-induced drops (e.g. user's
code.py calling supervisor.reload() while connected).
Happy to take a stab at the PR if there's interest.
Environment
- Board: Adafruit CLUE (nRF52840)
- CP versions tested: 8.2.7, 9.1.0, 10.0.0, 10.0.1, 10.0.3, 10.1.1
- Browser: Chrome on macOS (same behavior expected on Edge / Chromium-based browsers)
- Workflow: Bluetooth (code.circuitpython.org)
Summary
When using the BLE workflow, the editor disconnects from the device every time a write-style file operation completes (save, rename, move, delete, mkdir). The user has to manually click reconnect to keep working. This behavior is present at least as far back as CircuitPython 8.2.7 and 9.1.0 (tested on a CLUE / nRF52840), so it isn't a regression — but it is a real UX paper cut and I think it's fixable purely on the editor side.
Reproduction
code.py, or create a folder).Tested on CLUE running CircuitPython 8.2.7, 9.1.0, 10.0.0, 10.0.1, 10.0.3, and 10.1.1 — same disconnect behavior on all of them.
Why it disconnects (firmware side)
In
supervisor/shared/bluetooth/file_transfer.c, after any mutating command completes (WRITE,WRITE_DATA,DELETE,MKDIR,MOVE) the firmware callsautoreload_trigger()to reload the running Python program. This is intentional and matches USB MSC behavior ("save a file →code.pyreruns"). The problem specifically over BLE is that the BLE-FT GATT server lives inside the VM that just got reloaded, so the GATT connection drops as a side effect of the reload.This has been the design since BLE workflow shipped, which is why it's present in 8.2.7 going forward.
Re-architecting this on the firmware side (moving BLE-FT into supervisor-only code that survives reload) is a fairly invasive change, and would need testing across every BLE-capable port. I don't think we should chase that here — it's much cleaner to handle this on the editor side.
Why the editor's existing reconnect doesn't kick in
Workflow.onDisconnected(e, reconnect = true)injs/workflows/workflow.jsis supposed to attemptthis.connect()when the disconnect was unexpected, and thegattserverdisconnectedlistener attached inble.jsinvokes it withreconnectdefaulting totrue. But in practice no reconnect happens after the autoreload-induced drop. A few likely reasons:The BLE
connect()override injs/workflows/ble.js:Due to operator precedence,
resultends up beingtrue/falserather than the actualsuper.connect()return value, so the early-return-on-error logic isn't doing what it looks like it's doing.Even without that bug,
connectToBluetoothDeviceis called immediately. The board is still mid-reload at that point, sogatt.connect()will fail and we silently bail.connectToSerial()andinitFileClient()are called unconditionally inswitchToDevice, butgattserverdisconnectedlisteners aren't rewired with the same care on the reconnect path.Proposed fix (editor side)
Add an automatic reconnect path that:
writeFile/move/delete/makeDircall (or unconditionally, if simpler).connectToBluetoothDeviceon the same device handle we already have permission for (norequestDeviceprompt needed because we still havebleDevice).While we're in there, fix the precedence bug in
ble.js#connect:Why this is editor-side, not firmware-side
navigator.bluetooth.getDevices()lets us reconnect to an already-permitted device without a user gesture, so we have everything we need to make this transparent.code.pycallingsupervisor.reload()while connected).Happy to take a stab at the PR if there's interest.
Environment