Skip to content

Bluetooth workflow disconnects after every write/rename/delete operation (UX paper cut, fixable editor-side) #509

@makermelissa-piclaw

Description

@makermelissa-piclaw

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

  1. Connect a CLUE (or any BLE-capable CP board) using the Bluetooth workflow at https://code.circuitpython.org.
  2. Open the file dialog, rename any file (or save code.py, or create a folder).
  3. The rename succeeds on-device, but the editor immediately drops to "Disconnected" and shows the reconnect button.
  4. 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:

  1. 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).
  2. 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.
  3. Calls connectToBluetoothDevice on the same device handle we already have permission for (no requestDevice prompt needed because we still have bleDevice).
  4. Retries with backoff (e.g. 3 attempts at 1 s / 2 s / 4 s) before giving up and showing the manual reconnect UI.
  5. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions