@cero-base/core 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,15 +3,14 @@ import b4a from 'b4a'
3
3
  import { hash, randomBytes } from 'hypercore-crypto'
4
4
  import safetyCatch from 'safety-catch'
5
5
 
6
- import { GattStream } from './gatt-stream.js'
6
+ import { GattStream } from './gatt.js'
7
7
 
8
- // One data characteristic (write + notify) carries a framed byte stream both
9
- // ways. iOS never answers an L2CAP channel opened by a Mac central and gives no
10
- // L2CAP disconnect signal; GATT with app-level framing is the proven transport
11
- // (the bitchat mesh app uses exactly this on iOS and Android).
8
+ // iOS never answers an L2CAP channel opened by a Mac central and gives no L2CAP
9
+ // disconnect signal, so one data characteristic (write + notify) carries a
10
+ // framed byte stream both ways instead.
12
11
  const DATA_UUID = 'ce1a0004-0000-1000-8000-00805f9b34fb'
13
12
 
14
- // Wire frame on the characteristic (both directions): [type:1][sessionId:8][payload].
13
+ // Wire frame both directions: [type:1][sessionId:8][payload].
15
14
  const TYPE_OPEN = 1
16
15
  const TYPE_DATA = 2
17
16
  const TYPE_CLOSE = 3
@@ -19,23 +18,23 @@ const TYPE_HELLO = 4
19
18
  const SID_LEN = 8
20
19
  const HEADER = 1 + SID_LEN
21
20
 
22
- const DEFAULT_CAP = 4
21
+ const DEFAULT_MAX_OUTBOUND = 4
22
+ const DEFAULT_MAX_INBOUND = 8
23
23
  const CONNECT_TIMEOUT = 15000
24
- // per-peer dial backoff bases (bitchat model): eager while unlinked, patient once
25
- // we have a link. The actual cooldown grows exponentially per consecutive failure.
24
+ // per-peer dial backoff: eager while unlinked, patient once linked; the cooldown
25
+ // grows exponentially per consecutive failure.
26
26
  const DIAL_COOLDOWN_BASE = 8000
27
27
  const DIAL_COOLDOWN_BASE_LONELY = 2000
28
28
  const DIAL_COOLDOWN_MAX = 30000
29
- // no two dials globally closer than this — one radio can't usefully dial faster
29
+ // one radio can't usefully dial faster than this
30
30
  const DIAL_MIN_INTERVAL = 500
31
- // Lonely: scan continuously, cycling every SCAN_RESTART_LONELY so a re-advertised
32
- // peer is re-reported (iOS suppresses duplicate discoveries within a scan session).
31
+ // iOS reports a peripheral once per scan session; restart to re-report a
32
+ // re-advertised peer.
33
33
  const SCAN_RESTART_LONELY = 5000
34
- // Linked: continuous scanning is the dominant BLE battery cost, so duty-cycle it
35
- // (bitchat model) — scan for SCAN_DUTY_ON, then stay dark for SCAN_DUTY_OFF.
34
+ // linked: continuous scanning is the dominant battery cost, so duty-cycle it.
36
35
  const SCAN_DUTY_ON = 5000
37
36
  const SCAN_DUTY_OFF = 25000
38
- // suspend() drain window: how long to let goodbye frames flush before hanging up.
37
+ // suspend() drain window: let goodbye frames flush before hanging up.
39
38
  const DRAIN_MS = 300
40
39
 
41
40
  const EMPTY = b4a.alloc(0)
@@ -57,7 +56,7 @@ function frame(type, sid, payload = EMPTY) {
57
56
  }
58
57
 
59
58
  /**
60
- * Parse a wire frame. Short/empty buffers → null (the caller logs + drops).
59
+ * Parse a wire frame. Short/empty buffers → null (caller drops).
61
60
  *
62
61
  * @param {Uint8Array} buf
63
62
  * @returns {{ type: number, sid: Uint8Array, sidHex: string, payload: Uint8Array } | null}
@@ -112,26 +111,24 @@ const findByUUID = (items, uuid) => (items || []).find((i) => uuidEq(i.uuid, uui
112
111
  * byte-stream to each discovered peer, and feeds it into `network.inject`. From
113
112
  * there replication and pairing are transport-agnostic (see Network.inject).
114
113
  *
115
- * Choreography: the server adds one data characteristic (write + notify) and
116
- * advertises. The central connects, discovers the characteristic, subscribes,
117
- * then framed bytes flow both ways — central→server as GATT writes, server→
118
- * central as notifications — each tagged with an 8-byte session id (bitchat
119
- * model). `backend` is bare-bluetooth in production and a mock in tests.
120
- *
121
- * ponytail: capability-handshake DoS link-scoring is deferred — it needs a
122
- * replication-progress signal (design §4b). v1 caps links + times out dials.
114
+ * The server adds one data characteristic (write + notify) and advertises. The
115
+ * central connects, discovers the characteristic, subscribes, then framed bytes
116
+ * flow both ways — central→server as GATT writes, server→central as
117
+ * notifications — each tagged with an 8-byte session id. `backend` is
118
+ * bare-bluetooth in production and a mock in tests.
123
119
  *
124
120
  * @extends ReadyResource
125
121
  */
126
- export class BluetoothTransport extends ReadyResource {
122
+ export class BLETransport extends ReadyResource {
127
123
  /**
128
124
  * @param {object} opts
129
125
  * @param {any} opts.backend bare-bluetooth-shaped module (Central, Server, Service, Characteristic).
130
- * @param {import('./index.js').Network} opts.network
126
+ * @param {import('../index.js').Network} opts.network
131
127
  * @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
132
128
  * @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
133
129
  * @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
134
- * @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
130
+ * @param {number} [opts.maxOutbound] Max concurrent outbound dials/links; gossip covers the rest.
131
+ * @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
135
132
  * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
136
133
  * @param {boolean} [opts.keepLinks] On close, stop the radio but leave established links alive (invite rendezvous: the link outlives the QR and carries the initial replication).
137
134
  * @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
@@ -142,7 +139,8 @@ export class BluetoothTransport extends ReadyResource {
142
139
  uuid,
143
140
  nodeId,
144
141
  tag = 'cero-ble',
145
- cap = DEFAULT_CAP,
142
+ maxOutbound = DEFAULT_MAX_OUTBOUND,
143
+ maxInbound = DEFAULT_MAX_INBOUND,
146
144
  scanOptions,
147
145
  keepLinks = false,
148
146
  name
@@ -154,7 +152,8 @@ export class BluetoothTransport extends ReadyResource {
154
152
  this.nodeId = nodeId
155
153
  this.nodeHex = b4a.toString(nodeId, 'hex')
156
154
  this.serviceUUID = toServiceUUID(uuid, tag)
157
- this.cap = cap
155
+ this.maxOutbound = maxOutbound
156
+ this.maxInbound = maxInbound
158
157
  this.scanOptions = scanOptions
159
158
  this.keepLinks = keepLinks
160
159
 
@@ -169,19 +168,8 @@ export class BluetoothTransport extends ReadyResource {
169
168
  this._scanning = false
170
169
  this._advertising = false
171
170
  this._serviceAdded = false
172
- /** peripheral id being dialed its connect-timeout timer */
173
- this._dialing = new Map()
174
- /** peripheral ids that carry a live channel — never re-dialed (a second
175
- * dial's failure would disconnect the peripheral and kill the good link) */
176
- this._linked = new Set()
177
- /** peripheral id → retry-after timestamp; failed dials back off */
178
- this._coolUntil = new Map()
179
- /** peripheral id → consecutive failure count; drives exponential backoff */
180
- this._failures = new Map()
181
- /** peripheral id → remote peer key, learned at handshake — dial guard */
182
- this._peerByPeripheral = new Map()
183
- /** live central-side peripheral wrappers — for goodbye + physical hang-up on suspend */
184
- this._connectedPeripherals = new Set()
171
+ /** peripheral id per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
172
+ this._devices = new Map()
185
173
  /** last central.connect timestamp — global inter-dial rate limit */
186
174
  this._lastDial = 0
187
175
  this._scanTimer = null
@@ -206,6 +194,22 @@ export class BluetoothTransport extends ReadyResource {
206
194
  return this.peers.size
207
195
  }
208
196
 
197
+ _device(id) {
198
+ let d = this._devices.get(id)
199
+ if (!d) {
200
+ d = { timer: null, linked: false, coolUntil: 0, failures: 0, peerKey: null, peripheral: null }
201
+ this._devices.set(id, d)
202
+ }
203
+ return d
204
+ }
205
+
206
+ _prune(id) {
207
+ const d = this._devices.get(id)
208
+ if (d && !d.timer && !d.linked && !d.coolUntil && !d.failures && !d.peerKey && !d.peripheral) {
209
+ this._devices.delete(id)
210
+ }
211
+ }
212
+
209
213
  async _open() {
210
214
  const { Central, Server, Service, Characteristic } = this.backend
211
215
 
@@ -219,13 +223,9 @@ export class BluetoothTransport extends ReadyResource {
219
223
  this._maybeAdvertise()
220
224
  })
221
225
  this.server.on('writeRequest', (reqs) => this._onWriteRequests(reqs))
222
- // Notify queue drain signal: the last updateValue was refused (queue full);
223
- // retry the head frame now that the peripheral can accept more.
224
226
  this.server.on('readyToUpdate', () => this._drainNotify())
225
- // WriteRequests carry no central identifier (see peripheral-manager
226
- // _onwriterequests), so sessions can't be mapped to the unsubscribing
227
- // central; teardown is left to the per-link keepalive/timeout liveness in
228
- // _onChannel (15s).
227
+ // writeRequests carry no central identifier, so an unsubscribe can't be
228
+ // mapped to a session; teardown is left to _onChannel's keepalive/timeout.
229
229
  this.server.on('unsubscribe', () => {})
230
230
  this.server.on('error', safetyCatch)
231
231
 
@@ -245,7 +245,7 @@ export class BluetoothTransport extends ReadyResource {
245
245
  }
246
246
 
247
247
  _startServer(Service, Characteristic) {
248
- if (this.server.state !== 'poweredOn') return // wait for the adapter (stateChange)
248
+ if (this.server.state !== 'poweredOn') return
249
249
  if (!this._serviceAdded) {
250
250
  this._dataChar = new Characteristic(DATA_UUID, { write: true, notify: true })
251
251
  this.server.addService(new Service(this.serviceUUID, [this._dataChar]))
@@ -263,20 +263,24 @@ export class BluetoothTransport extends ReadyResource {
263
263
  _onWriteRequests(requests) {
264
264
  const ok = this.server.constructor.ATT_SUCCESS ?? 0
265
265
  for (const req of requests) {
266
- // respond within ms or the central times out — before any parsing
266
+ // must respond within ms or the central times out — before any parsing
267
267
  if (req.responseNeeded !== false) this.server.respondToRequest(req, ok)
268
- if (this._suspended) continue // off means off: acknowledge but drop
268
+ if (this._suspended) continue
269
269
  this._onServerFrame(req.data)
270
270
  }
271
271
  }
272
272
 
273
273
  _onServerFrame(data) {
274
274
  const f = parseFrame(data)
275
- if (!f) {
276
- return
277
- }
275
+ if (!f) return
278
276
  if (f.type === TYPE_OPEN) {
279
- if (this._sessions.has(f.sidHex)) return // duplicate open
277
+ if (this._sessions.has(f.sidHex)) return
278
+ if (this._sessions.size >= this.maxInbound) {
279
+ // established links win: refuse newcomers with a CLOSE so the dialer's
280
+ // stream ends cleanly and backs off — the mesh converges transitively.
281
+ this._enqueueNotify(frame(TYPE_CLOSE, f.sid)).catch(safetyCatch)
282
+ return
283
+ }
280
284
  const sid = b4a.from(f.sid) // copy: f.sid views the transient request buffer
281
285
  const stream = new GattStream({
282
286
  send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
@@ -285,7 +289,6 @@ export class BluetoothTransport extends ReadyResource {
285
289
  const session = { stream, sid, conn: null, name: null }
286
290
  this._sessions.set(f.sidHex, session)
287
291
  session.conn = this._onChannel(stream, false, null)
288
- // greet the peer with our app-user name so it can label this link
289
292
  this._enqueueNotify(frame(TYPE_HELLO, sid, this._helloPayload())).catch(safetyCatch)
290
293
  } else if (f.type === TYPE_DATA) {
291
294
  const s = this._sessions.get(f.sidHex)
@@ -299,29 +302,21 @@ export class BluetoothTransport extends ReadyResource {
299
302
  this._sessions.delete(f.sidHex)
300
303
  s.stream.remoteEnd()
301
304
  }
302
- } else {
303
305
  }
304
306
  }
305
307
 
306
308
  _closeServerSession(sidHex, sid) {
307
- if (!this._sessions.has(sidHex)) return // already torn down (peer sent close)
309
+ if (!this._sessions.has(sidHex)) return
308
310
  this._sessions.delete(sidHex)
309
- this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch) // best effort
311
+ this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
310
312
  }
311
313
 
312
314
  // ─── peer display name (hello frame) ──────────────────────────────────────
313
315
 
314
- /** Our hello payload: the local app-user name the peer labels this link with. */
315
316
  _helloPayload() {
316
317
  return b4a.from(JSON.stringify({ n: this.name || '' }))
317
318
  }
318
319
 
319
- /**
320
- * Parse a hello payload. Malformed → null (the caller ignores it).
321
- *
322
- * @param {Uint8Array} payload
323
- * @returns {string | null}
324
- */
325
320
  _parseHello(payload) {
326
321
  try {
327
322
  const { n } = JSON.parse(b4a.toString(payload))
@@ -331,18 +326,17 @@ export class BluetoothTransport extends ReadyResource {
331
326
  }
332
327
  }
333
328
 
334
- /** Stash a peer's name onto a server session + its conn, then refresh mirrors. */
335
329
  _applyPeerName(session, payload) {
336
330
  const name = this._parseHello(payload)
337
- if (name === null) return // malformed hello — ignore
331
+ if (name === null) return
338
332
  session.name = name
339
333
  if (session.conn) session.conn._peerName = name
340
334
  this.emit('update')
341
335
  }
342
336
 
343
337
  // Serialize notifications through the single characteristic: updateValue
344
- // returns false when the peripheral's queue is full — hold the frame and retry
345
- // on the next 'readyToUpdate', preserving order.
338
+ // returns false when the peripheral's queue is full — hold the head frame and
339
+ // retry on the next 'readyToUpdate', preserving order.
346
340
  _enqueueNotify(f) {
347
341
  return new Promise((resolve, reject) => {
348
342
  this._notifyQueue.push({ frame: f, resolve, reject })
@@ -361,7 +355,7 @@ export class BluetoothTransport extends ReadyResource {
361
355
  item.reject(err)
362
356
  continue
363
357
  }
364
- if (!ok) return // queue full — wait for 'readyToUpdate', retry same frame
358
+ if (!ok) return
365
359
  this._notifyQueue.shift()
366
360
  item.resolve()
367
361
  }
@@ -374,11 +368,9 @@ export class BluetoothTransport extends ReadyResource {
374
368
  this._armScanRestart()
375
369
  }
376
370
 
377
- // Scan scheduler (bitchat model). Lonely: cycle the scan every
378
- // SCAN_RESTART_LONELY so a re-advertised peer is re-reported (iOS suppresses
379
- // duplicate discoveries within a scan session). Linked: duty-cycle to save the
380
- // battery — scan SCAN_DUTY_ON, then go dark SCAN_DUTY_OFF, repeat. The next
381
- // delay is derived from the current phase, so one timer drives the whole cycle.
371
+ // Lonely: cycle the scan every SCAN_RESTART_LONELY so a re-advertised peer is
372
+ // re-reported. Linked: duty-cycle SCAN_DUTY_ON on / SCAN_DUTY_OFF dark to save
373
+ // battery. One timer drives the whole cycle; the next delay derives from phase.
382
374
  _armScanRestart() {
383
375
  if (this._scanTimer) clearTimeout(this._scanTimer)
384
376
  const delay =
@@ -387,19 +379,19 @@ export class BluetoothTransport extends ReadyResource {
387
379
  this._scanTimer = null
388
380
  if (this.closing || this.closed || this._suspended) return
389
381
  // never toggle the scan mid-dial (would kill the connect) — defer a phase
390
- if (this._dialing.size > 0) {
382
+ if (this._isDialing()) {
391
383
  this._armScanRestart()
392
384
  return
393
385
  }
394
386
  if (this.linkCount > 0) {
395
387
  if (this._scanning) {
396
- this._stopScan() // enter the dark half of the duty cycle
388
+ this._stopScan()
397
389
  this._armScanRestart()
398
390
  } else {
399
- this._startScan() // wake up and re-arm for the next dark window
391
+ this._startScan()
400
392
  }
401
393
  } else {
402
- this._stopScan() // lonely: cycle so re-advertised peers re-report
394
+ this._stopScan()
403
395
  this._startScan()
404
396
  }
405
397
  }, delay)
@@ -424,23 +416,21 @@ export class BluetoothTransport extends ReadyResource {
424
416
  }
425
417
 
426
418
  _onDiscover(peripheral) {
427
- if (this.closing || this.closed) return
428
- if (this._suspended) return
429
- if (this._linked.has(peripheral.id)) return // already linked
430
- const knownPeer = this._peerByPeripheral.get(peripheral.id)
431
- if (knownPeer && this.peers.has(knownPeer)) return // device already linked via another channel
432
- const cool = this._coolUntil.get(peripheral.id)
433
- if (cool && cool > Date.now()) return // failed recently back off
434
- if (this._dialing.has(peripheral.id)) return // already connecting to this one
435
- if (this.linkCount >= this.cap) return // gossip covers the rest
436
- // global rate limit: one radio can't usefully dial faster than this, and
437
- // back-to-back connects thrash CoreBluetooth (bitchat connect hygiene)
419
+ if (this.closing || this.closed || this._suspended) return
420
+ const d = this._devices.get(peripheral.id)
421
+ if (d) {
422
+ if (d.linked) return
423
+ if (d.peerKey && this.peers.has(d.peerKey)) return // linked via another channel
424
+ if (d.coolUntil > Date.now()) return // failed recently — back off
425
+ if (d.timer) return // already connecting to this one
426
+ }
427
+ if (this.linkCount >= this.maxOutbound) return // gossip covers the rest
438
428
  if (Date.now() - this._lastDial < DIAL_MIN_INTERVAL) return
439
- // no tie-break: dial every discovery and open a session (bitchat model);
440
- // a redundant link to the same peer is dropped by _track's dedup
429
+ // dial every discovery and open a session; a redundant link is dropped by
430
+ // _track's dedup
441
431
  this._lastDial = Date.now()
442
432
  const timer = setTimeout(() => this._abortDial(peripheral, 'timeout'), CONNECT_TIMEOUT)
443
- this._dialing.set(peripheral.id, timer)
433
+ this._device(peripheral.id).timer = timer
444
434
  try {
445
435
  this._stopScan()
446
436
  this.central.connect(peripheral)
@@ -450,7 +440,7 @@ export class BluetoothTransport extends ReadyResource {
450
440
  }
451
441
 
452
442
  _onConnect(peripheral) {
453
- this._connectedPeripherals.add(peripheral)
443
+ this._device(peripheral.id).peripheral = peripheral
454
444
  peripheral.on('error', () => this._abortDial(peripheral, 'peripheral-error'))
455
445
  peripheral.once('servicesDiscover', (services) => {
456
446
  const svc = findByUUID(services, this.serviceUUID)
@@ -469,10 +459,9 @@ export class BluetoothTransport extends ReadyResource {
469
459
  }
470
460
  this._startCentralSession(peripheral, char)
471
461
  })
472
- // No tie-break: both sides open a session (bitchat model). On iOS the peer
473
- // id isn't known until after connect, so a tie-break can only yield
474
- // post-handshake which deadlocks when the other side never dials back.
475
- // Redundant links are tolerated; _track keeps the first and drops the dup.
462
+ // both sides open a session: on iOS the peer id isn't known until after
463
+ // connect, so a tie-break yields only post-handshake and deadlocks if the
464
+ // other side never dials back. _track keeps the first, drops the dup.
476
465
  peripheral.discoverServices([this.serviceUUID])
477
466
  }
478
467
 
@@ -488,7 +477,8 @@ export class BluetoothTransport extends ReadyResource {
488
477
  send: (payload) => this._centralSend(peripheral, char, frame(TYPE_DATA, sid, payload)),
489
478
  onclose: () => {
490
479
  this._centralSend(peripheral, char, frame(TYPE_CLOSE, sid)).catch(safetyCatch)
491
- this._connectedPeripherals.delete(peripheral)
480
+ const d = this._devices.get(peripheral.id)
481
+ if (d) d.peripheral = null
492
482
  try {
493
483
  this.central.disconnect(peripheral)
494
484
  } catch (err) {
@@ -499,7 +489,6 @@ export class BluetoothTransport extends ReadyResource {
499
489
  peripheral._stream = stream
500
490
  // open frame first: it registers the session on the server before any data
501
491
  this._centralSend(peripheral, char, frame(TYPE_OPEN, sid)).catch(safetyCatch)
502
- // greet the peer with our app-user name so it can label this link
503
492
  this._centralSend(peripheral, char, frame(TYPE_HELLO, sid, this._helloPayload())).catch(
504
493
  safetyCatch
505
494
  )
@@ -511,14 +500,12 @@ export class BluetoothTransport extends ReadyResource {
511
500
  const sess = peripheral._session
512
501
  if (!sess || !peripheral._stream) return
513
502
  const f = parseFrame(data)
514
- if (!f) {
515
- return
516
- }
517
- if (f.sidHex !== sess.sidHex) return // not our session (broadcast to others)
503
+ if (!f) return
504
+ if (f.sidHex !== sess.sidHex) return // not our session
518
505
  if (f.type === TYPE_DATA) peripheral._stream.receive(b4a.from(f.payload))
519
506
  else if (f.type === TYPE_HELLO) {
520
507
  const name = this._parseHello(f.payload)
521
- if (name === null) return // malformed hello — ignore
508
+ if (name === null) return
522
509
  peripheral._peerName = name
523
510
  if (peripheral._conn) peripheral._conn._peerName = name
524
511
  this.emit('update')
@@ -564,17 +551,15 @@ export class BluetoothTransport extends ReadyResource {
564
551
 
565
552
  _abortDial(peripheral, _reason) {
566
553
  const id = peripheral?.id
567
- // eager when unlinked, patient once linked; per-peer exponential backoff so a
568
- // peer that keeps failing is retried ever less often (bitchat connect hygiene)
569
554
  if (id != null) {
570
- const count = (this._failures.get(id) || 0) + 1
571
- this._failures.set(id, count)
555
+ const d = this._device(id)
556
+ d.failures += 1
572
557
  const base = this.linkCount === 0 ? DIAL_COOLDOWN_BASE_LONELY : DIAL_COOLDOWN_BASE
573
- const cooldown = Math.min(DIAL_COOLDOWN_MAX, base * 2 ** Math.min(4, count - 1))
574
- this._coolUntil.set(id, Date.now() + cooldown)
558
+ d.coolUntil =
559
+ Date.now() + Math.min(DIAL_COOLDOWN_MAX, base * 2 ** Math.min(4, d.failures - 1))
560
+ d.peripheral = null
575
561
  }
576
562
  this._clearDial(id)
577
- this._connectedPeripherals.delete(peripheral)
578
563
  try {
579
564
  this.central.disconnect(peripheral)
580
565
  } catch (err) {
@@ -585,9 +570,16 @@ export class BluetoothTransport extends ReadyResource {
585
570
 
586
571
  _clearDial(id) {
587
572
  if (id == null) return
588
- const timer = this._dialing.get(id)
589
- if (timer) clearTimeout(timer)
590
- this._dialing.delete(id) // free the slot — rediscovery can re-dial
573
+ const d = this._devices.get(id)
574
+ if (!d) return
575
+ if (d.timer) clearTimeout(d.timer)
576
+ d.timer = null
577
+ this._prune(id)
578
+ }
579
+
580
+ _isDialing() {
581
+ for (const d of this._devices.values()) if (d.timer) return true
582
+ return false
591
583
  }
592
584
 
593
585
  // iOS & Android report errored connects as 'error' with a code, not
@@ -596,51 +588,54 @@ export class BluetoothTransport extends ReadyResource {
596
588
  safetyCatch(err)
597
589
  const code = err && err.code
598
590
  if (code === 'CONNECTION_FAILED' || code === 'DISCONNECT') {
599
- for (const id of [...this._dialing.keys()]) this._clearDial(id)
591
+ for (const [id, d] of this._devices) if (d.timer) this._clearDial(id)
600
592
  }
601
593
  }
602
594
 
603
- _onChannel(l2cap, isInitiator, peripheralId) {
595
+ _onChannel(stream, isInitiator, peripheralId) {
604
596
  if (this.closing || this.closed || this._suspended) {
605
- l2cap.destroy()
597
+ stream.destroy()
606
598
  return
607
599
  }
608
- const conn = this.network.inject(l2cap, { isInitiator })
609
- // Liveness: iOS never signals an L2CAP disconnect, so ping the peer and
610
- // drop the link if we stop hearing back (keepalive refreshes the timeout).
600
+ const conn = this.network.inject(stream, { isInitiator })
601
+ // iOS never signals a disconnect for a vanished peer: keepalive pings and the
602
+ // timeout are the liveness detector (keepalive refreshes the timeout).
611
603
  conn.setKeepAlive(5000)
612
604
  conn.setTimeout(15000)
613
- l2cap.on('error', safetyCatch)
605
+ stream.on('error', safetyCatch)
614
606
  // marked at channel-open (not handshake-open): rediscovery must not dial a
615
607
  // peripheral whose channel is still handshaking
616
608
  if (peripheralId != null) {
617
- this._linked.add(peripheralId)
618
- this._coolUntil.delete(peripheralId)
619
- this._failures.delete(peripheralId) // reached a live session — reset backoff
620
- conn.once('close', () => this._linked.delete(peripheralId))
609
+ const d = this._device(peripheralId)
610
+ d.linked = true
611
+ d.coolUntil = 0
612
+ d.failures = 0 // reached a live session — reset backoff
613
+ conn.once('close', () => {
614
+ const rec = this._devices.get(peripheralId)
615
+ if (rec) {
616
+ rec.linked = false
617
+ this._prune(peripheralId)
618
+ }
619
+ })
621
620
  }
622
621
  conn.on('open', () => this._track(conn, peripheralId, isInitiator))
623
- conn.on('close', () => this._untrack(conn, peripheralId))
622
+ conn.on('close', () => this._untrack(conn))
624
623
  this._startScan()
625
624
  return conn
626
625
  }
627
626
 
628
627
  _track(conn, peripheralId, isInitiator) {
629
628
  if (peripheralId != null && conn.remotePublicKey) {
630
- this._peerByPeripheral.set(peripheralId, b4a.toString(conn.remotePublicKey, 'hex'))
629
+ this._device(peripheralId).peerKey = b4a.toString(conn.remotePublicKey, 'hex')
631
630
  }
632
631
  if (this.closing || this.closed) return
633
632
  const key = b4a.toString(conn.remotePublicKey, 'hex')
634
633
  const existing = this.peers.get(key)
635
634
  if (existing && existing !== conn) {
636
- // Both sides dial (bitchat model) so a pair links twice two separate
637
- // channels, each with an initiator end on one device and a responder end
638
- // on the other. Dropping a channel closes it for BOTH devices, so the two
639
- // devices MUST retire the same channel or one tears down the peer's kept
640
- // link. Deterministic winner: keep the channel whose initiator has the
641
- // smaller static key. Both peers compute it identically (initiator end:
642
- // our key smaller; responder end: remote key smaller), so the loser
643
- // channel is dropped on both ends and never cascades onto the survivor.
635
+ // Both sides dial, so a pair links twice. Dropping a channel closes it for
636
+ // BOTH devices, so both must retire the SAME channel. Deterministic winner:
637
+ // keep the channel whose initiator has the smaller static key computed
638
+ // identically on both ends, so the loser drops on both and never cascades.
644
639
  const initiatorIsUsSmaller = b4a.compare(conn.publicKey, conn.remotePublicKey) < 0
645
640
  const preferred = isInitiator === initiatorIsUsSmaller
646
641
  if (!preferred) {
@@ -665,21 +660,17 @@ export class BluetoothTransport extends ReadyResource {
665
660
  }
666
661
  }
667
662
 
668
- /**
669
- * Best-effort TYPE_CLOSE to every live session server sessions over the
670
- * notify path, central sessions over the write path reusing the same helpers
671
- * a normal stream close uses. Waits up to DRAIN_MS for the frames to flush,
672
- * then resolves regardless: suspend must never hang on a wedged radio.
673
- *
674
- * @returns {Promise<void>}
675
- */
663
+ // Best-effort TYPE_CLOSE to every live session — server sessions over notify,
664
+ // central sessions over write then wait up to DRAIN_MS for the frames to
665
+ // flush. Resolves regardless: suspend must never hang on a wedged radio.
676
666
  async _sayGoodbye() {
677
667
  const sent = []
678
668
  for (const { sid } of this._sessions.values()) {
679
669
  sent.push(this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch))
680
670
  }
681
- for (const peripheral of this._connectedPeripherals) {
682
- const sess = peripheral._session
671
+ for (const d of this._devices.values()) {
672
+ const peripheral = d.peripheral
673
+ const sess = peripheral && peripheral._session
683
674
  if (!sess || !peripheral._char) continue
684
675
  const f = frame(TYPE_CLOSE, sess.sid)
685
676
  sent.push(this._centralSend(peripheral, peripheral._char, f).catch(safetyCatch))
@@ -691,29 +682,27 @@ export class BluetoothTransport extends ReadyResource {
691
682
  /**
692
683
  * Pause radio activity but KEEP the Server/Central instances and the
693
684
  * registered GATT service alive — the toggle-friendly counterpart to _close.
694
- * iOS CoreBluetooth managers can't be destroy()ed (native double-free), so a
695
- * fresh transport per toggle leaks a manager whose stale peripheral-manager
696
- * keeps a duplicate GATT service registered; remote centrals then subscribe to
697
- * the dead service and hear silence. Reuse one instance instead. Idempotent.
685
+ * CoreBluetooth managers can't be destroy()ed (native double-free), so one
686
+ * transport is reused across toggles rather than recreated. Idempotent.
698
687
  */
699
688
  async suspend() {
700
689
  this._suspended = true
701
690
  if (this._scanTimer) clearTimeout(this._scanTimer)
702
691
  this._scanTimer = null
703
- for (const timer of this._dialing.values()) clearTimeout(timer)
704
- this._dialing.clear()
705
- // Say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
706
- // out the 15s keepalive: close frames, a short drain, then drop the physical
707
- // links (an ACL disconnect is an instant OS-level signal on both roles).
692
+ for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
693
+ // say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
694
+ // out the 15s keepalive: close frames, a short drain, then an ACL disconnect
695
+ // (an instant OS-level signal on both roles).
708
696
  await this._sayGoodbye()
709
- for (const peripheral of this._connectedPeripherals) {
697
+ for (const d of this._devices.values()) {
698
+ if (!d.peripheral) continue
710
699
  try {
711
- this.central.disconnect(peripheral)
700
+ this.central.disconnect(d.peripheral)
712
701
  } catch (err) {
713
702
  safetyCatch(err)
714
703
  }
715
704
  }
716
- this._connectedPeripherals.clear()
705
+ this._devices.clear()
717
706
  try {
718
707
  this._stopScan()
719
708
  } catch (err) {
@@ -743,9 +732,6 @@ export class BluetoothTransport extends ReadyResource {
743
732
  this._sessions.clear()
744
733
  for (const item of this._notifyQueue) item.reject(new Error('suspended'))
745
734
  this._notifyQueue = []
746
- this._linked.clear()
747
- this._coolUntil.clear()
748
- this._failures.clear()
749
735
  this.state = 'off'
750
736
  this.emit('update')
751
737
  }
@@ -769,10 +755,10 @@ export class BluetoothTransport extends ReadyResource {
769
755
  async _close() {
770
756
  if (this._scanTimer) clearTimeout(this._scanTimer)
771
757
  this._scanTimer = null
772
- // doctor-app rule: never call central/server.destroy() — it double-frees in
773
- // the native teardown; stop advertising/scanning and let the runtime reclaim.
774
- for (const timer of this._dialing.values()) clearTimeout(timer)
775
- this._dialing.clear()
758
+ // never call central/server.destroy() — it double-frees in native teardown;
759
+ // stop advertising/scanning and let the runtime reclaim.
760
+ for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
761
+ this._devices.clear()
776
762
  try {
777
763
  this.central?.stopScan()
778
764
  } catch (err) {
@@ -784,7 +770,6 @@ export class BluetoothTransport extends ReadyResource {
784
770
  safetyCatch(err)
785
771
  }
786
772
  if (!this.keepLinks) {
787
- // channel mesh: toggling nearby off means stop syncing nearby
788
773
  for (const conn of this.peers.values()) {
789
774
  try {
790
775
  conn.destroy()