Skip to content

Commit 52e2f20

Browse files
authored
Merge pull request #512 from makermelissa-piclaw/fix/issue-410-ble-listener-leaks
Fix duplicate BLE connect/disconnect messages and 3rd-reconnect failure
2 parents 4fa7932 + dadf00a commit 52e2f20

1 file changed

Lines changed: 73 additions & 16 deletions

File tree

js/workflows/ble.js

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ class BLEWorkflow extends Workflow {
4949
this._lastMutatingOpAt = 0;
5050
this._silentReconnectInFlight = false;
5151
this._silentReconnectPromise = null;
52+
53+
// Cached bound handlers so add/remove use the same reference.
54+
// Without this, .bind() returns a fresh function every call and
55+
// removeEventListener becomes a no-op, leaking listeners on every
56+
// connect/reconnect cycle. See #410.
57+
this._onRequestBluetoothDeviceClick = this.onRequestBluetoothDeviceButtonClick.bind(this);
58+
this._onReconnectClick = this.reconnectButtonHandler.bind(this);
59+
this._onSerialReceiveBound = this.onSerialReceive.bind(this);
60+
this._onDisconnectedBound = this.onDisconnected.bind(this);
61+
62+
// Track in-flight watchAdvertisements abort controllers so we can
63+
// cancel them when any device wins or when we tear down (#410).
64+
this._pendingAdvAborts = new Set();
5265
}
5366

5467
// Called by the FileTransferClient wrapper right before any mutating
@@ -93,14 +106,38 @@ class BLEWorkflow extends Workflow {
93106
async disconnectButtonHandler(e) {
94107
await super.disconnectButtonHandler(e);
95108
if (this.connectionStatus()) {
96-
// Disconnect BlueTooth and Reset things
97109
if (this.bleDevice !== undefined && this.bleDevice.gatt.connected) {
110+
// gatt.disconnect() fires gattserverdisconnected which calls
111+
// onDisconnected via our listener — don't call onDisconnected
112+
// again here or we double-log and double-fire UI updates.
113+
// See #410.
98114
this.bleDevice.gatt.disconnect();
115+
} else {
116+
// Already torn down at the GATT layer; run our cleanup directly.
117+
await this.onDisconnected(e, false);
99118
}
100-
await this.onDisconnected(e, false);
101119
}
102120
}
103121

122+
async onDisconnected(e, reconnect = true) {
123+
// Detach the gattserverdisconnected listener so a stale device handle
124+
// can't fire onDisconnected later (the cause of the duplicate log
125+
// accumulation in #410).
126+
if (this.bleDevice) {
127+
this.bleDevice.removeEventListener('gattserverdisconnected', this._onDisconnectedBound);
128+
}
129+
if (this.txCharacteristic) {
130+
this.txCharacteristic.removeEventListener('characteristicvaluechanged', this._onSerialReceiveBound);
131+
}
132+
// Cancel any in-flight watchAdvertisements so a subsequent reconnect
133+
// doesn't pile up Chrome's per-device watch quota (#410).
134+
for (const ctrl of this._pendingAdvAborts) {
135+
ctrl.abort();
136+
}
137+
this._pendingAdvAborts.clear();
138+
await super.onDisconnected(e, reconnect);
139+
}
140+
104141
async showConnect(documentState) {
105142
let p = this.connectDialog.open();
106143
let modal = this.connectDialog.getModal();
@@ -114,8 +151,10 @@ class BLEWorkflow extends Workflow {
114151
request: btnRequestBluetoothDevice
115152
};
116153

117-
btnRequestBluetoothDevice.addEventListener('click', this.onRequestBluetoothDeviceButtonClick.bind(this));
118-
btnReconnect.addEventListener('click', this.reconnectButtonHandler.bind(this));
154+
btnRequestBluetoothDevice.removeEventListener('click', this._onRequestBluetoothDeviceClick);
155+
btnRequestBluetoothDevice.addEventListener('click', this._onRequestBluetoothDeviceClick);
156+
btnReconnect.removeEventListener('click', this._onReconnectClick);
157+
btnReconnect.addEventListener('click', this._onReconnectClick);
119158

120159
// Check if Web Bluetooth is available
121160
if (!(await this.available() instanceof Error)) {
@@ -155,9 +194,9 @@ class BLEWorkflow extends Workflow {
155194
this.txCharacteristic = await this.serialService.getCharacteristic(bleNusCharTXUUID);
156195
this.rxCharacteristic = await this.serialService.getCharacteristic(bleNusCharRXUUID);
157196

158-
// Remove any existing event listeners to prevent multiple reads
159-
this.txCharacteristic.removeEventListener('characteristicvaluechanged', this.onSerialReceive.bind(this));
160-
this.txCharacteristic.addEventListener('characteristicvaluechanged', this.onSerialReceive.bind(this));
197+
// Use cached bound handler so removeEventListener actually matches.
198+
this.txCharacteristic.removeEventListener('characteristicvaluechanged', this._onSerialReceiveBound);
199+
this.txCharacteristic.addEventListener('characteristicvaluechanged', this._onSerialReceiveBound);
161200
await this.txCharacteristic.startNotifications();
162201
return true;
163202
} catch (e) {
@@ -197,11 +236,25 @@ class BLEWorkflow extends Workflow {
197236

198237
async connectToBluetoothDevice(device) {
199238
const abortController = new AbortController();
239+
this._pendingAdvAborts.add(abortController);
240+
let advHandled = false;
200241

201242
async function onAdvertisementReceived(event) {
243+
// Multiple ads can land in the same event-loop tick before
244+
// abortController.abort() takes effect on the listener. Guard
245+
// so we only run the connect flow once per device. See #410.
246+
if (advHandled) {
247+
return;
248+
}
249+
advHandled = true;
202250
console.log('> Received advertisement from "' + device.name + '"...');
203-
// Stop watching advertisements to conserve battery life.
204-
abortController.abort();
251+
// This device won. Abort ALL pending watchAdvertisements
252+
// (including this one) so other paired devices stop scanning
253+
// and don't pile up Chrome's per-device watch quota.
254+
for (const ctrl of this._pendingAdvAborts) {
255+
ctrl.abort();
256+
}
257+
this._pendingAdvAborts.clear();
205258
console.log('Connecting to GATT Server from "' + device.name + '"...');
206259
try {
207260
this.bleServer = await device.gatt.connect();
@@ -220,8 +273,12 @@ class BLEWorkflow extends Workflow {
220273
}
221274
}
222275

223-
device.removeEventListener('advertisementreceived', onAdvertisementReceived.bind(this));
224-
device.addEventListener('advertisementreceived', onAdvertisementReceived.bind(this));
276+
// Use the abortController signal so we don't need to manage the
277+
// handler reference manually — the listener is auto-removed when
278+
// onAdvertisementReceived calls abortController.abort().
279+
device.addEventListener('advertisementreceived',
280+
onAdvertisementReceived.bind(this),
281+
{signal: abortController.signal});
225282

226283
this.debugLog("Attempting to connect to " + device.name + "...");
227284
try {
@@ -253,8 +310,8 @@ class BLEWorkflow extends Workflow {
253310

254311
async switchToDevice(device) {
255312
this.bleDevice = device;
256-
this.bleDevice.removeEventListener("gattserverdisconnected", this.onDisconnected.bind(this));
257-
this.bleDevice.addEventListener("gattserverdisconnected", this.onDisconnected.bind(this));
313+
this.bleDevice.removeEventListener("gattserverdisconnected", this._onDisconnectedBound);
314+
this.bleDevice.addEventListener("gattserverdisconnected", this._onDisconnectedBound);
258315
console.log("connected", this.bleServer);
259316

260317
try {
@@ -368,9 +425,9 @@ class BLEWorkflow extends Workflow {
368425

369426
// Rebind characteristics after silent reconnect without rebuilding fileHelper.
370427
async _rebindAfterSilentReconnect() {
371-
// Re-attach disconnect listener (idempotent).
372-
this.bleDevice.removeEventListener('gattserverdisconnected', this.onDisconnected.bind(this));
373-
this.bleDevice.addEventListener('gattserverdisconnected', this.onDisconnected.bind(this));
428+
// Re-attach disconnect listener (idempotent thanks to cached bound ref).
429+
this.bleDevice.removeEventListener('gattserverdisconnected', this._onDisconnectedBound);
430+
this.bleDevice.addEventListener('gattserverdisconnected', this._onDisconnectedBound);
374431

375432
// NUS serial chars need re-fetch; BLE-FT chars re-fetched lazily by checkConnection().
376433
await this.connectToSerial();

0 commit comments

Comments
 (0)