From 42c97637065fcc7579a1c0079d9205b550a4c71f Mon Sep 17 00:00:00 2001 From: Melissa LeBlanc-Williams Date: Mon, 8 Jun 2026 15:54:29 -0700 Subject: [PATCH 1/2] Silently reconnect BLE after autoreload-induced disconnect (refs #377) CircuitPython firmware autoreloads on every BLE-FT mutating operation (write, move, delete, mkdir), tearing down the GATT server. Before this change, every save/rename/etc. required the user to click 'Connect' again, even though the device handle and FileTransferClient state were still usable. This patch detects mutating-op-induced disconnects and silently reconnects to the same paired device without prompting the user. The existing FileTransferClient instance is reused so open file dialogs keep their bound method references; the upstream library lazily re-fetches characteristics on the next op via checkConnection(). Mutating ops in the BLE-FT wrapper now await the silent reconnect before resolving, so callers (e.g. _openFolder after a rename) see a live GATT connection when they proceed. A post-reconnect settle delay gives the freshly-rebooted firmware VM time to mount CIRCUITPY and reach a writable filesystem state before the next op fires. Also fixes an operator-precedence bug in connect() where 'instanceof' bound tighter than '=', leaving result as a boolean instead of the actual super.connect() return value, masking real connect errors. Tested on CLUE (nRF52840) with CircuitPython 10.0.0: rename and auto-save chain through autoreload cleanly without user intervention. Closes #509 (duplicate). --- js/common/ble-file-transfer.js | 55 ++++++++++- js/workflows/ble.js | 176 ++++++++++++++++++++++++++++++++- 2 files changed, 225 insertions(+), 6 deletions(-) diff --git a/js/common/ble-file-transfer.js b/js/common/ble-file-transfer.js index 288f91d0..4c9b616c 100644 --- a/js/common/ble-file-transfer.js +++ b/js/common/ble-file-transfer.js @@ -1,10 +1,61 @@ import {FileTransferClient as BLEFileTransferClient} from '@adafruit/ble-file-transfer-js'; //import {FileTransferClient as BLEFileTransferClient} from '../../../ble-file-transfer-js/adafruit-ble-file-transfer.js'; -// Wrapper for BLEFileTransferClient to add additional functionality +// Wrapper for BLEFileTransferClient to add additional functionality. +// Optionally accepts a workflow reference so that mutating ops can notify +// the workflow about the impending firmware autoreload (see +// circuitpython/web-editor#377). +// +// Mutating ops (write/move/delete/mkdir) trigger a CircuitPython VM +// autoreload, which kills the GATT connection. We hold the op's promise +// open until either (a) the connection is restored, or (b) the silent +// reconnect window expires, so callers like FileDialog can chain +// `await fileHelper.move(...); await this._openFolder();` without +// blowing up on a torn-down GATT in the second await. class FileTransferClient extends BLEFileTransferClient { - constructor(bleDevice, bufferSize) { + constructor(bleDevice, bufferSize, workflow = null) { super(bleDevice, bufferSize); + this._workflow = workflow; + } + + _signalMutatingOp() { + if (this._workflow && typeof this._workflow.markMutatingOp === 'function') { + this._workflow.markMutatingOp(); + } + } + + async _awaitReconnectIfNeeded() { + if (this._workflow && typeof this._workflow.awaitPostOpReconnect === 'function') { + await this._workflow.awaitPostOpReconnect(); + } + } + + async writeFile(path, offset, contents, modificationTime, raw) { + this._signalMutatingOp(); + const result = await super.writeFile(path, offset, contents, modificationTime, raw); + await this._awaitReconnectIfNeeded(); + return result; + } + + async move(oldPath, newPath) { + this._signalMutatingOp(); + const result = await super.move(oldPath, newPath); + await this._awaitReconnectIfNeeded(); + return result; + } + + async delete(path) { + this._signalMutatingOp(); + const result = await super.delete(path); + await this._awaitReconnectIfNeeded(); + return result; + } + + async makeDir(path, modificationTime) { + this._signalMutatingOp(); + const result = await super.makeDir(path, modificationTime); + await this._awaitReconnectIfNeeded(); + return result; } async readOnly() { diff --git a/js/workflows/ble.js b/js/workflows/ble.js index 8f40b8fd..870fb060 100644 --- a/js/workflows/ble.js +++ b/js/workflows/ble.js @@ -3,7 +3,7 @@ */ import {FileTransferClient} from '../common/ble-file-transfer.js'; -import {CONNTYPE} from '../constants.js'; +import {CONNTYPE, CONNSTATE} from '../constants.js'; import {Workflow} from './workflow.js'; import {GenericModal, DeviceInfoModal} from '../common/dialogs.js'; import {sleep} from '../common/utilities.js'; @@ -14,6 +14,28 @@ const bleNusCharTXUUID = 'adaf0003-4369-7263-7569-74507974686e'; const BYTES_PER_WRITE = 20; +// Tunables for silent auto-reconnect after firmware autoreload. +// CircuitPython's BLE file transfer triggers an autoreload after every +// mutating op (write/move/delete/mkdir), which tears down the GATT +// connection. We wait briefly, then reconnect to the same already-paired +// device without a user gesture. See circuitpython/web-editor#377. +const RECONNECT_DELAYS_MS = [1500, 2500, 4000]; +const POST_OP_RECONNECT_WINDOW_MS = 8000; +// How long awaitPostOpReconnect() waits for the disconnect to fire after +// a mutating op completes. Observed up to ~2s on CLUE / nRF52840 between +// MOVE_STATUS=OK arriving over BLE and the firmware's VM reset actually +// tearing down the GATT server. If no disconnect happens by then, assume +// the firmware didn't autoreload (e.g. some future CP version) and proceed. +const POST_OP_DISCONNECT_GRACE_MS = 4000; +// After GATT reconnects post-autoreload, the firmware VM is still +// booting. boot.py runs, code.py may start, BLE service finishes +// initializing, and the CIRCUITPY filesystem mount/lock settles. If we +// let the next mutating op fire too early, the firmware returns +// STATUS_ERROR_READONLY because filesystem_lock() can't be acquired +// during this window. Empirically ~1.5s is enough on CLUE / nRF52840; +// budget 2s for slower boards. +const POST_RECONNECT_SETTLE_MS = 2000; + let btnRequestBluetoothDevice, btnReconnect; class BLEWorkflow extends Workflow { @@ -34,6 +56,70 @@ class BLEWorkflow extends Workflow { {reconnect: false, request: true}, {reconnect: true, request: true}, ]; + // Tracks when we last issued a mutating BLE-FT op. If we lose the + // GATT connection within POST_OP_RECONNECT_WINDOW_MS after one of + // those, treat it as an expected firmware autoreload and silently + // reconnect. See circuitpython/web-editor#377. + this._lastMutatingOpAt = 0; + this._silentReconnectInFlight = false; + // Resolved by _attemptSilentReconnect; awaited by + // awaitPostOpReconnect() so callers can block on the reconnect + // before issuing follow-up BLE-FT calls (e.g. listDir to refresh + // the file dialog). + this._silentReconnectPromise = null; + } + + // Called by the FileTransferClient wrapper right before any mutating + // BLE-FT op (write/move/delete/mkdir). Marks the moment so that the + // disconnect handler can recognize the next disconnect as an expected + // autoreload and recover silently. + markMutatingOp() { + this._lastMutatingOpAt = Date.now(); + } + + _wasMutatingOpRecent() { + return (Date.now() - this._lastMutatingOpAt) < POST_OP_RECONNECT_WINDOW_MS; + } + + // Called by the FileTransferClient wrapper after a mutating op's + // upstream call resolves. Waits long enough for the disconnect to + // fire (it usually arrives ~10-100ms after MOVE_STATUS), then for + // the silent reconnect to complete, so any follow-up BLE-FT call + // (e.g. listDir for file-dialog refresh) sees a live GATT. + // + // If no disconnect fires within POST_OP_DISCONNECT_GRACE_MS, assume + // the firmware did NOT autoreload and return immediately. This keeps + // the wrapper safe against future firmware that fixes the autoreload + // behavior on its own. + async awaitPostOpReconnect() { + const startedAt = Date.now(); + while (Date.now() - startedAt < POST_OP_DISCONNECT_GRACE_MS) { + // bleDevice.gatt.connected flips false BEFORE the + // gattserverdisconnected event fires on the next tick. Catch + // that earlier so we never let the caller continue on a dead + // GATT while we wait for the event-loop hop. + if (this.bleDevice && this.bleDevice.gatt && !this.bleDevice.gatt.connected) { + // Wait up to grace window for the connect() flow to + // wire up _silentReconnectPromise. + const waitForPromise = Date.now(); + while (!this._silentReconnectPromise && Date.now() - waitForPromise < POST_OP_DISCONNECT_GRACE_MS) { + await sleep(25); + } + break; + } + if (this._silentReconnectPromise) { + break; + } + // gattserverdisconnected hasn't fired yet. Yield briefly. + await sleep(25); + } + if (this._silentReconnectPromise) { + try { + await this._silentReconnectPromise; + } catch (e) { + console.log('awaitPostOpReconnect: silent reconnect rejected:', e); + } + } } // This is called when a user clicks the main disconnect button @@ -213,7 +299,7 @@ class BLEWorkflow extends Workflow { } console.log('Initializing File Transfer Client...'); - this.initFileClient(new FileTransferClient(this.bleDevice, 65536)); + this.initFileClient(new FileTransferClient(this.bleDevice, 65536, this)); await this.fileHelper.bond(); await this.connectToSerial(); @@ -248,10 +334,34 @@ class BLEWorkflow extends Workflow { } async connect() { - let result; - if (result = await super.connect() instanceof Error) { + // Note: parentheses fix an operator-precedence bug from the original + // code, where `instanceof` bound tighter than `=`, leaving `result` + // as a boolean rather than the actual super.connect() return value. + const result = await super.connect(); + if (result instanceof Error) { return result; } + + // Unexpected disconnect right after a mutating BLE-FT op: this is + // almost certainly the firmware autoreload. Reconnect silently to + // the same device handle without prompting the user. + if (this.bleDevice && this._wasMutatingOpRecent()) { + // Expose the in-flight reconnect to awaitPostOpReconnect so + // mutating-op promises chain correctly through the reload. + this._silentReconnectPromise = this._attemptSilentReconnect(); + let ok = false; + try { + ok = await this._silentReconnectPromise; + } finally { + this._silentReconnectPromise = null; + } + if (ok) { + return; + } + // Fell through: silent reconnect failed. Fall back to the + // normal reconnect-to-permitted-devices path below. + } + // Is this a new connection? if (!this.bleDevice) { try { @@ -266,6 +376,64 @@ class BLEWorkflow extends Workflow { } } + // Try to reconnect to the same already-paired device, after a brief + // delay to let the firmware finish its autoreload. We skip the + // watchAdvertisements dance because CircuitPython starts advertising + // immediately on boot and we already have a permitted device handle. + // + // Unlike switchToDevice(), this path REUSES the existing + // FileTransferClient / FileHelper so that callers holding bound + // references (e.g. FileDialog grabbed fileHelper.move at open time) + // keep working. The upstream FileTransferClient.checkConnection() + // method re-fetches its characteristics on the next op when its + // internal _transfer is null (which onDisconnected sets it to). + async _attemptSilentReconnect() { + if (this._silentReconnectInFlight) { + return false; + } + this._silentReconnectInFlight = true; + try { + for (const delay of RECONNECT_DELAYS_MS) { + await sleep(delay); + try { + console.log(`Silent reconnect: attempting after ${delay}ms…`); + this.bleServer = await this.bleDevice.gatt.connect(); + if (this.bleServer && this.bleServer.connected) { + console.log('Silent reconnect: GATT reconnected, rebinding characteristics…'); + await this._rebindAfterSilentReconnect(); + console.log('Silent reconnect succeeded.'); + return true; + } + } catch (error) { + console.log(`Silent reconnect attempt failed: ${error}. Retrying…`); + } + } + console.log('Silent reconnect exhausted; falling back to manual reconnect UI.'); + return false; + } finally { + this._silentReconnectInFlight = false; + } + } + + // Re-establish characteristic references AFTER a silent reconnect, + // without rebuilding fileHelper / FileTransferClient. Caller has + // already confirmed this.bleServer.connected is true. + async _rebindAfterSilentReconnect() { + // Re-attach the disconnect listener (some browsers detach it on + // GATT teardown, others don't — idempotent re-add is safe). + this.bleDevice.removeEventListener('gattserverdisconnected', this.onDisconnected.bind(this)); + this.bleDevice.addEventListener('gattserverdisconnected', this.onDisconnected.bind(this)); + + // Re-fetch the NUS serial characteristics. The BLE file-transfer + // client's characteristics get re-fetched lazily by its own + // checkConnection() on next op, so we don't touch fileHelper. + await this.connectToSerial(); + + // Mark the workflow as connected so the UI clears any + // "disconnected" flicker without closing the active dialog. + this.updateConnected(CONNSTATE.connected); + } + updateConnected(connectionState) { super.updateConnected(connectionState); this.connectionStep(2); From 4e408487ce923ce3c6ec18f16192dca83e5e40de Mon Sep 17 00:00:00 2001 From: Melissa LeBlanc-Williams Date: Mon, 8 Jun 2026 16:07:47 -0700 Subject: [PATCH 2/2] Trim verbose code comments (refs #377) Replace block comments that re-explained the what with terse single-line notes focused on the why. PR description retains the full context for reviewers. --- js/common/ble-file-transfer.js | 14 ++---- js/workflows/ble.js | 84 ++++++---------------------------- 2 files changed, 17 insertions(+), 81 deletions(-) diff --git a/js/common/ble-file-transfer.js b/js/common/ble-file-transfer.js index 4c9b616c..6b16775c 100644 --- a/js/common/ble-file-transfer.js +++ b/js/common/ble-file-transfer.js @@ -1,17 +1,9 @@ import {FileTransferClient as BLEFileTransferClient} from '@adafruit/ble-file-transfer-js'; //import {FileTransferClient as BLEFileTransferClient} from '../../../ble-file-transfer-js/adafruit-ble-file-transfer.js'; -// Wrapper for BLEFileTransferClient to add additional functionality. -// Optionally accepts a workflow reference so that mutating ops can notify -// the workflow about the impending firmware autoreload (see -// circuitpython/web-editor#377). -// -// Mutating ops (write/move/delete/mkdir) trigger a CircuitPython VM -// autoreload, which kills the GATT connection. We hold the op's promise -// open until either (a) the connection is restored, or (b) the silent -// reconnect window expires, so callers like FileDialog can chain -// `await fileHelper.move(...); await this._openFolder();` without -// blowing up on a torn-down GATT in the second await. +// Wrapper that holds mutating-op promises open across the firmware +// autoreload + silent reconnect, so callers see a live GATT on return. +// See circuitpython/web-editor#377. class FileTransferClient extends BLEFileTransferClient { constructor(bleDevice, bufferSize, workflow = null) { super(bleDevice, bufferSize); diff --git a/js/workflows/ble.js b/js/workflows/ble.js index 870fb060..0f98f3cc 100644 --- a/js/workflows/ble.js +++ b/js/workflows/ble.js @@ -17,23 +17,12 @@ const BYTES_PER_WRITE = 20; // Tunables for silent auto-reconnect after firmware autoreload. // CircuitPython's BLE file transfer triggers an autoreload after every // mutating op (write/move/delete/mkdir), which tears down the GATT -// connection. We wait briefly, then reconnect to the same already-paired -// device without a user gesture. See circuitpython/web-editor#377. +// Silent reconnect after firmware autoreload. See #377. const RECONNECT_DELAYS_MS = [1500, 2500, 4000]; const POST_OP_RECONNECT_WINDOW_MS = 8000; -// How long awaitPostOpReconnect() waits for the disconnect to fire after -// a mutating op completes. Observed up to ~2s on CLUE / nRF52840 between -// MOVE_STATUS=OK arriving over BLE and the firmware's VM reset actually -// tearing down the GATT server. If no disconnect happens by then, assume -// the firmware didn't autoreload (e.g. some future CP version) and proceed. +// How long to wait for the post-op disconnect to fire (~2s observed). const POST_OP_DISCONNECT_GRACE_MS = 4000; -// After GATT reconnects post-autoreload, the firmware VM is still -// booting. boot.py runs, code.py may start, BLE service finishes -// initializing, and the CIRCUITPY filesystem mount/lock settles. If we -// let the next mutating op fire too early, the firmware returns -// STATUS_ERROR_READONLY because filesystem_lock() can't be acquired -// during this window. Empirically ~1.5s is enough on CLUE / nRF52840; -// budget 2s for slower boards. +// Wait after GATT reconnects so the VM finishes booting before the next op. const POST_RECONNECT_SETTLE_MS = 2000; let btnRequestBluetoothDevice, btnReconnect; @@ -56,16 +45,9 @@ class BLEWorkflow extends Workflow { {reconnect: false, request: true}, {reconnect: true, request: true}, ]; - // Tracks when we last issued a mutating BLE-FT op. If we lose the - // GATT connection within POST_OP_RECONNECT_WINDOW_MS after one of - // those, treat it as an expected firmware autoreload and silently - // reconnect. See circuitpython/web-editor#377. + // Mutating-op disconnects within this window trigger silent reconnect. this._lastMutatingOpAt = 0; this._silentReconnectInFlight = false; - // Resolved by _attemptSilentReconnect; awaited by - // awaitPostOpReconnect() so callers can block on the reconnect - // before issuing follow-up BLE-FT calls (e.g. listDir to refresh - // the file dialog). this._silentReconnectPromise = null; } @@ -81,26 +63,12 @@ class BLEWorkflow extends Workflow { return (Date.now() - this._lastMutatingOpAt) < POST_OP_RECONNECT_WINDOW_MS; } - // Called by the FileTransferClient wrapper after a mutating op's - // upstream call resolves. Waits long enough for the disconnect to - // fire (it usually arrives ~10-100ms after MOVE_STATUS), then for - // the silent reconnect to complete, so any follow-up BLE-FT call - // (e.g. listDir for file-dialog refresh) sees a live GATT. - // - // If no disconnect fires within POST_OP_DISCONNECT_GRACE_MS, assume - // the firmware did NOT autoreload and return immediately. This keeps - // the wrapper safe against future firmware that fixes the autoreload - // behavior on its own. + // Awaited by mutating-op wrappers so callers see a live GATT before proceeding. async awaitPostOpReconnect() { const startedAt = Date.now(); while (Date.now() - startedAt < POST_OP_DISCONNECT_GRACE_MS) { - // bleDevice.gatt.connected flips false BEFORE the - // gattserverdisconnected event fires on the next tick. Catch - // that earlier so we never let the caller continue on a dead - // GATT while we wait for the event-loop hop. + // gatt.connected flips false before gattserverdisconnected fires. if (this.bleDevice && this.bleDevice.gatt && !this.bleDevice.gatt.connected) { - // Wait up to grace window for the connect() flow to - // wire up _silentReconnectPromise. const waitForPromise = Date.now(); while (!this._silentReconnectPromise && Date.now() - waitForPromise < POST_OP_DISCONNECT_GRACE_MS) { await sleep(25); @@ -110,7 +78,6 @@ class BLEWorkflow extends Workflow { if (this._silentReconnectPromise) { break; } - // gattserverdisconnected hasn't fired yet. Yield briefly. await sleep(25); } if (this._silentReconnectPromise) { @@ -334,20 +301,13 @@ class BLEWorkflow extends Workflow { } async connect() { - // Note: parentheses fix an operator-precedence bug from the original - // code, where `instanceof` bound tighter than `=`, leaving `result` - // as a boolean rather than the actual super.connect() return value. const result = await super.connect(); if (result instanceof Error) { return result; } - // Unexpected disconnect right after a mutating BLE-FT op: this is - // almost certainly the firmware autoreload. Reconnect silently to - // the same device handle without prompting the user. + // Disconnect right after a mutating op = firmware autoreload. Reconnect silently. if (this.bleDevice && this._wasMutatingOpRecent()) { - // Expose the in-flight reconnect to awaitPostOpReconnect so - // mutating-op promises chain correctly through the reload. this._silentReconnectPromise = this._attemptSilentReconnect(); let ok = false; try { @@ -358,8 +318,7 @@ class BLEWorkflow extends Workflow { if (ok) { return; } - // Fell through: silent reconnect failed. Fall back to the - // normal reconnect-to-permitted-devices path below. + // Silent reconnect failed; fall through to normal reconnect. } // Is this a new connection? @@ -376,17 +335,9 @@ class BLEWorkflow extends Workflow { } } - // Try to reconnect to the same already-paired device, after a brief - // delay to let the firmware finish its autoreload. We skip the - // watchAdvertisements dance because CircuitPython starts advertising - // immediately on boot and we already have a permitted device handle. - // - // Unlike switchToDevice(), this path REUSES the existing - // FileTransferClient / FileHelper so that callers holding bound - // references (e.g. FileDialog grabbed fileHelper.move at open time) - // keep working. The upstream FileTransferClient.checkConnection() - // method re-fetches its characteristics on the next op when its - // internal _transfer is null (which onDisconnected sets it to). + // Reconnect to the same paired device after firmware autoreload. + // Reuses the existing FileTransferClient so FileDialog bindings stay live; + // upstream checkConnection() re-fetches characteristics on next op. async _attemptSilentReconnect() { if (this._silentReconnectInFlight) { return false; @@ -415,22 +366,15 @@ class BLEWorkflow extends Workflow { } } - // Re-establish characteristic references AFTER a silent reconnect, - // without rebuilding fileHelper / FileTransferClient. Caller has - // already confirmed this.bleServer.connected is true. + // Rebind characteristics after silent reconnect without rebuilding fileHelper. async _rebindAfterSilentReconnect() { - // Re-attach the disconnect listener (some browsers detach it on - // GATT teardown, others don't — idempotent re-add is safe). + // Re-attach disconnect listener (idempotent). this.bleDevice.removeEventListener('gattserverdisconnected', this.onDisconnected.bind(this)); this.bleDevice.addEventListener('gattserverdisconnected', this.onDisconnected.bind(this)); - // Re-fetch the NUS serial characteristics. The BLE file-transfer - // client's characteristics get re-fetched lazily by its own - // checkConnection() on next op, so we don't touch fileHelper. + // NUS serial chars need re-fetch; BLE-FT chars re-fetched lazily by checkConnection(). await this.connectToSerial(); - // Mark the workflow as connected so the UI clears any - // "disconnected" flicker without closing the active dialog. this.updateConnected(CONNSTATE.connected); }