Skip to content

Fix duplicate BLE connect/disconnect messages and 3rd-reconnect failure#512

Merged
makermelissa merged 1 commit into
circuitpython:mainfrom
makermelissa-piclaw:fix/issue-410-ble-listener-leaks
Jun 8, 2026
Merged

Fix duplicate BLE connect/disconnect messages and 3rd-reconnect failure#512
makermelissa merged 1 commit into
circuitpython:mainfrom
makermelissa-piclaw:fix/issue-410-ble-listener-leaks

Conversation

@makermelissa-piclaw

Copy link
Copy Markdown
Contributor

Closes #410.

Symptoms

In the BLE workflow, repeated connect/disconnect cycles would:

  • Log multiple Connected to ... and disconnected lines per single user action (1 → 2 → 3 → ... per cycle).
  • After ~3 reconnect cycles, the Reconnect button would log Attempting to connect to ... for each paired device but never actually establish a connection, with no error.

Reproduces on Feather S3 Reverse TFT (reporter's hardware) and CLUE nRF52840 (my test rig) running CircuitPython 10.x. Chrome latest.

Root causes

Five overlapping bugs in js/workflows/ble.js:

1. .bind(this) returning a fresh function each call

this.bleDevice.removeEventListener("gattserverdisconnected", this.onDisconnected.bind(this));
this.bleDevice.addEventListener("gattserverdisconnected", this.onDisconnected.bind(this));

removeEventListener only matches by reference, so the remove was a no-op. Every switchToDevice / _rebindAfterSilentReconnect / _loadDom call added another listener on top of the old ones.

Fix: Cache each bound handler on the instance in the constructor and reuse the same reference for add/remove. Pattern already used in usb.js.

2. disconnectButtonHandler double-firing onDisconnected

this.bleDevice.gatt.disconnect();   // fires gattserverdisconnected -> onDisconnected
await this.onDisconnected(e, false); // ALSO calls onDisconnected manually

Two trips through onDisconnected per disconnect click → two log lines, two UI updates, two connect() recursions.

Fix: Only call onDisconnected manually when the GATT layer was already torn down (!gatt.connected); otherwise let the GATT event drive cleanup.

3. onAdvertisementReceived not guarded against re-entry

BLE peripherals can deliver several ads in the same event-loop tick before abortController.abort() actually detaches the listener. The handler would run multiple times per device, calling switchToDevice and onConnected multiple times.

Fix: Closure-local advHandled boolean; bail on subsequent entries.

4. Stale watchAdvertisements exhausting Chrome's per-device quota

reconnectButtonHandler iterates ALL paired devices and starts watchAdvertisements on each. Only one device responds; the others keep watching indefinitely. After several reconnect cycles, each device has multiple background watches, and Chrome silently stops delivering advertisementreceived events.

This is why the 3rd reconnect failed: the watches were active per Chrome's bookkeeping, but no events were being routed to our handlers.

Fix: Track every in-flight AbortController in this._pendingAdvAborts (Set). When any device wins, abort all of them (winner + losers). Also clear the Set on disconnect.

5. BLE-specific onDisconnected to detach lingering listeners

Even with fix #1, the device handle itself persists across reconnect cycles, and stale gattserverdisconnected listeners attached to old transitions could fire spuriously.

Fix: Add a BLE-specific onDisconnected override that detaches gattserverdisconnected from bleDevice and characteristicvaluechanged from txCharacteristic before delegating to super.onDisconnected.

Test protocol

On a CLUE running CircuitPython 10.0.0, with 3 paired BLE devices visible to Chrome:

Before:

Attempting to connect to CIRCUITPY732a...
Connected to CIRCUITPY732a
disconnected
disconnected
Attempting to connect to CIRCUITPY732a...
Attempting to connect to CIRCUITPY732a...
Connected to CIRCUITPY732a
Connected to CIRCUITPY732a
disconnected
disconnected
disconnected
disconnected
... (3rd reconnect attempt logs "Attempting..." x3 but never connects)

After (verified by @makermelissa on Feather S3 Reverse TFT, 4+ cycles):

Attempting to connect to CIRCUITPY732a...
Attempting to connect to CIRCUITPY88bc...
Attempting to connect to CIRCUITPY4841...
Connected to CIRCUITPY732a
disconnected
Attempting to connect to CIRCUITPY732a...
Attempting to connect to CIRCUITPY88bc...
Attempting to connect to CIRCUITPY4841...
Connected to CIRCUITPY732a
disconnected
... (reliably reconnects every cycle, single log line per event)

(The N "Attempting" lines per cycle are expected: reconnectButtonHandler checks every paired device because we can't know in advance which one is on. Only the responsive one produces a Connected.)

Out of scope

cc @makermelissa

…loses circuitpython#410)

Several BLE workflow bugs caused log lines to pile up across
connect/disconnect cycles, and made subsequent reconnects fail
after a few attempts:

1. addEventListener/removeEventListener pairs used inline
   .bind(this), which creates a new function each call. The
   remove was a no-op, so listeners accumulated on every cycle.
   Cache bound handlers on the instance so the remove actually
   matches.

2. disconnectButtonHandler explicitly called onDisconnected(e,
   false) AFTER gatt.disconnect(), which itself fires
   gattserverdisconnected. That meant two trips through
   onDisconnected per click (two 'disconnected' log lines, two
   UI updates). Only call onDisconnected manually when GATT was
   already torn down.

3. onAdvertisementReceived wasn't guarded against re-entry. BLE
   peripherals can deliver multiple ads in the same event-loop
   tick before abortController.abort() takes effect on the
   listener, so the handler ran more than once per device.

4. reconnectButtonHandler iterates all paired devices and starts
   watchAdvertisements on each. Only one wins; the others kept
   watching in the background. After a few reconnect cycles,
   Chrome's per-device watch quota was exhausted and no further
   advertisementreceived events fired -- which manifested as
   'logged Attempting... but never connected' on ~3rd reconnect.
   Track every in-flight AbortController in a Set so the winner
   can cancel the others, and clear the Set on disconnect.

5. Add a BLE-specific onDisconnected override that detaches the
   gattserverdisconnected and characteristicvaluechanged
   listeners on tear-down, preventing stale device handles from
   firing onDisconnected later (the root cause of accumulating
   'disconnected' lines in earlier sessions).

@makermelissa makermelissa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

@makermelissa makermelissa merged commit 52e2f20 into circuitpython:main Jun 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants