@@ -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