@onekeyfe/hd-transport-react-native 1.2.0-alpha.66 → 1.2.0-alpha.68

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.
package/src/index.ts CHANGED
@@ -135,6 +135,22 @@ const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, max
135
135
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
136
136
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
137
137
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
138
+ /**
139
+ * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
140
+ * reports the peripheral ready again; a peripheral wedged by its own firmware reboot
141
+ * stops reporting ready while staying connected, so the write promise never settles.
142
+ * Response timeouts cannot cover that — they are armed after the writes complete —
143
+ * and an unbounded write leaves the whole transport unusable until the process dies.
144
+ * A healthy packet completes in milliseconds, so this only fires on a dead link.
145
+ */
146
+ export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_000;
147
+ const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
148
+ const isWedgedWriteError = (error: unknown): boolean =>
149
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
150
+ typeof (error as { message?: unknown })?.message === 'string' &&
151
+ (error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
152
+ /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
153
+ export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
138
154
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
139
155
  const IOS_NOTIFY_READY_DELAY_MS = 150;
140
156
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
@@ -191,12 +207,52 @@ function getDeviceDisplayName(device?: Device | null) {
191
207
 
192
208
  const ANDROID_REQUEST_MTU = 256;
193
209
 
210
+ const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
211
+
194
212
  const connectOptions: Record<string, unknown> = {
195
213
  requestMTU: ANDROID_REQUEST_MTU,
196
- timeout: 3000,
214
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
197
215
  refreshGatt: 'OnConnected',
198
216
  };
199
217
 
218
+ /** Fallback connect options: drops requestMTU (the thing being worked around) but keeps the native budget. */
219
+ const fallbackConnectOptions: Record<string, unknown> = {
220
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
221
+ };
222
+
223
+ /**
224
+ * JS backstop for connect. The native adapter applies its own 3s budget, but it
225
+ * schedules that timeout on its serial queue, so a busy queue (e.g. right after a
226
+ * firmware install tears the link down) can leave the promise unsettled — observed
227
+ * blocking a reconnect for 61s until the app-level timeout. Healthy connects finish
228
+ * inside the native budget, so this only fires when the native timeout did not.
229
+ */
230
+ export const BLE_CONNECT_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS * 2 + 2000;
231
+ /**
232
+ * Service discovery and characteristic resolution run after connect() succeeds, but
233
+ * CoreBluetooth schedules them on the same serial queue. If that queue is wedged by a
234
+ * device reboot, these calls can remain pending forever unless they have their own
235
+ * budget.
236
+ */
237
+ export const BLE_GATT_SETUP_TIMEOUT_MS = 10_000;
238
+ /**
239
+ * How many times a known device may fail its own protocol before we probe the others
240
+ * again. Reconnect polling during a device reboot repeats this every few seconds, and
241
+ * probing Protocol V2 costs a 10s Ping timeout, so paying it on every attempt for a
242
+ * device we just spoke V1 to dominates the wait. A firmware update can legitimately
243
+ * change a device's protocol, so the shortcut has to expire rather than stick.
244
+ */
245
+ export const PROTOCOL_REPROBE_FALLBACK_ATTEMPTS = 3;
246
+ /** BLE setup timeouts since the last successful setup before the manager is recreated. */
247
+ export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
248
+ const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
249
+ const isConnectTimeoutError = (error: unknown): boolean =>
250
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
251
+ typeof (error as { message?: unknown })?.message === 'string' &&
252
+ (error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
253
+ const isNativeOperationTimeoutError = (error: unknown): boolean =>
254
+ (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
255
+
200
256
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
201
257
 
202
258
  const tryToGetConfiguration = (device: Device) => {
@@ -285,8 +341,28 @@ export default class ReactNativeBleTransport {
285
341
  /** Per-device protocol type detected by active wire-level probe after connect. */
286
342
  private deviceProtocol: Map<string, ProtocolType> = new Map();
287
343
 
344
+ /**
345
+ * Protocol a probe is currently trying, before the device has confirmed it. Calls
346
+ * must route with it, but acquire() must not treat it as a detected protocol: a
347
+ * probe that never answers would otherwise leave the reuse fast path handing out a
348
+ * transport that was never validated.
349
+ */
350
+ private probingProtocols: Map<string, ProtocolType> = new Map();
351
+
352
+ /** Consecutive write timeouts per device; reset by any write that completes. */
353
+ private writeTimeoutCounts: Map<string, number> = new Map();
354
+
355
+ /** BLE setup timeouts per device since the last complete characteristic resolution. */
356
+ private connectionSetupTimeoutCounts: Map<string, number> = new Map();
357
+
288
358
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
289
359
 
360
+ /** Protocol this device actually answered on, kept across reconnects of one session. */
361
+ private sessionProtocols: Map<string, ProtocolType> = new Map();
362
+
363
+ /** Consecutive detections that failed while trusting sessionProtocols. */
364
+ private protocolReprobeFailures: Map<string, number> = new Map();
365
+
290
366
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
291
367
 
292
368
  private protocolV2FrameQueues: Map<string, Uint8Array[]> = new Map();
@@ -504,22 +580,21 @@ export default class ReactNativeBleTransport {
504
580
  const isConnected = await device.isConnected().catch(() => false);
505
581
  if (!isConnected) {
506
582
  try {
507
- device = await device.connect(connectOptions);
583
+ device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
508
584
  } catch (e) {
509
585
  if (
510
586
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
511
587
  e.errorCode === BleErrorCode.OperationCancelled
512
588
  ) {
513
- device = await device.connect();
589
+ device = await this.connectWithTimeout(uuid, () => device.connect());
514
590
  } else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
515
591
  throw e;
516
592
  }
517
593
  }
518
594
  }
519
595
 
520
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
521
- device
522
- );
596
+ const { writeCharacteristic, notifyCharacteristic } =
597
+ await this.resolveCharacteristicsWithTimeout(uuid, device);
523
598
 
524
599
  transport.device = device;
525
600
  transport.writeCharacteristic = writeCharacteristic;
@@ -698,7 +773,7 @@ export default class ReactNativeBleTransport {
698
773
  characteristics?: ResolvedBleCharacteristics
699
774
  ) {
700
775
  const { writeCharacteristic, notifyCharacteristic } =
701
- characteristics ?? (await this.resolveCharacteristics(device));
776
+ characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
702
777
  const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
703
778
  if (Platform.OS === 'android') {
704
779
  transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
@@ -802,15 +877,22 @@ export default class ReactNativeBleTransport {
802
877
  if (!device) {
803
878
  Log?.debug('try to connect to device: ', uuid);
804
879
  try {
805
- device = await blePlxManager.connectToDevice(uuid, connectOptions);
880
+ device = await this.connectWithTimeout(uuid, () =>
881
+ blePlxManager.connectToDevice(uuid, connectOptions)
882
+ );
806
883
  } catch (e) {
807
884
  Log?.debug('try to connect to device has error: ', e);
885
+ if (isConnectTimeoutError(e)) {
886
+ throw e;
887
+ }
808
888
  if (
809
889
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
810
890
  e.errorCode === BleErrorCode.OperationCancelled
811
891
  ) {
812
892
  Log?.debug('first try to reconnect without params');
813
- device = await blePlxManager.connectToDevice(uuid);
893
+ device = await this.connectWithTimeout(uuid, () =>
894
+ blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
895
+ );
814
896
  } else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
815
897
  Log?.debug('device already connected');
816
898
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
@@ -826,26 +908,36 @@ export default class ReactNativeBleTransport {
826
908
 
827
909
  if (!(await device.isConnected())) {
828
910
  Log?.debug('not connected, try to connect to device: ', uuid);
911
+ const disconnectedDevice = device;
829
912
 
830
913
  try {
831
- device = await device.connect(connectOptions);
914
+ device = await this.connectWithTimeout(uuid, () =>
915
+ disconnectedDevice.connect(connectOptions)
916
+ );
832
917
  } catch (e) {
833
918
  Log?.debug('not connected, try to connect to device has error: ', e);
919
+ if (isConnectTimeoutError(e)) {
920
+ throw e;
921
+ }
834
922
  if (
835
923
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
836
924
  e.errorCode === BleErrorCode.OperationCancelled
837
925
  ) {
838
926
  Log?.debug('second try to reconnect without params');
839
927
  try {
840
- device = await device.connect();
928
+ device = await this.connectWithTimeout(uuid, () =>
929
+ disconnectedDevice.connect(fallbackConnectOptions)
930
+ );
841
931
  } catch (e) {
842
932
  Log?.debug('last try to reconnect error: ', e);
843
933
  // last try to reconnect device if this issue exists
844
934
  // https://github.com/dotintent/react-native-ble-plx/issues/426
845
935
  if (e.errorCode === BleErrorCode.OperationCancelled) {
846
936
  Log?.debug('last try to reconnect');
847
- await device.cancelConnection();
848
- device = await device.connect();
937
+ await disconnectedDevice.cancelConnection();
938
+ device = await this.connectWithTimeout(uuid, () =>
939
+ disconnectedDevice.connect(fallbackConnectOptions)
940
+ );
849
941
  }
850
942
  }
851
943
  } else {
@@ -856,9 +948,8 @@ export default class ReactNativeBleTransport {
856
948
 
857
949
  device = await requestAndroidMtu(device);
858
950
  const acquiredDevice = device;
859
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
860
- acquiredDevice
861
- );
951
+ const { writeCharacteristic, notifyCharacteristic } =
952
+ await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
862
953
 
863
954
  const protocolHint = expectedProtocol
864
955
  ? undefined
@@ -922,7 +1013,7 @@ export default class ReactNativeBleTransport {
922
1013
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
923
1014
  return;
924
1015
  }
925
- if (this.deviceProtocol.get(uuid) === 'V2') {
1016
+ if (this.getActiveProtocol(uuid) === 'V2') {
926
1017
  let errorCode:
927
1018
  | typeof HardwareErrorCode.BleDeviceBondError
928
1019
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -992,7 +1083,7 @@ export default class ReactNativeBleTransport {
992
1083
 
993
1084
  try {
994
1085
  const data = Buffer.from(c.value as string, 'base64');
995
- const protocol = this.deviceProtocol.get(uuid);
1086
+ const protocol = this.getActiveProtocol(uuid);
996
1087
  if (!protocol) {
997
1088
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
998
1089
  return;
@@ -1026,7 +1117,7 @@ export default class ReactNativeBleTransport {
1026
1117
  } catch (error) {
1027
1118
  Log?.debug('monitor data error: ', error);
1028
1119
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1029
- if (this.deviceProtocol.get(uuid) === 'V2') {
1120
+ if (this.getActiveProtocol(uuid) === 'V2') {
1030
1121
  this.rejectProtocolV2Frames(uuid, notifyError);
1031
1122
  } else if (this.runPromiseDeviceId === uuid) {
1032
1123
  this.runPromise?.reject(notifyError);
@@ -1090,6 +1181,7 @@ export default class ReactNativeBleTransport {
1090
1181
  }
1091
1182
 
1092
1183
  this.deviceProtocol.delete(uuid);
1184
+ this.probingProtocols.delete(uuid);
1093
1185
  // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1094
1186
  this.protocolV2Assemblers.get(uuid)?.reset();
1095
1187
  this.protocolV2Assemblers.delete(uuid);
@@ -1156,8 +1248,25 @@ export default class ReactNativeBleTransport {
1156
1248
  const transport = this.getCachedTransport(uuid);
1157
1249
  const runPromise = createDeferred<string>();
1158
1250
  runPromise.promise.catch(() => undefined);
1251
+ const supersededRunPromise = this.runPromise;
1252
+ if (supersededRunPromise) {
1253
+ // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1254
+ // the superseded deferred now so its response race resolves and its finally block
1255
+ // clears its timeout timer; an orphaned timer would otherwise fire much later and
1256
+ // tear down the shared connection while another call is using it.
1257
+ supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1258
+ }
1159
1259
  this.runPromise = runPromise;
1160
1260
  this.runPromiseDeviceId = uuid;
1261
+ // A superseded call's late write failure must not clear the successor's ownership;
1262
+ // only the call that still owns the slot may release it.
1263
+ const releaseOwnershipIfCurrent = () => {
1264
+ if (this.runPromise === runPromise) {
1265
+ this.runPromise = null;
1266
+ this.runPromiseDeviceId = null;
1267
+ }
1268
+ };
1269
+ const isCurrentOwner = () => this.runPromise === runPromise;
1161
1270
  const messages = this._messages;
1162
1271
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1163
1272
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1183,6 +1292,9 @@ export default class ReactNativeBleTransport {
1183
1292
  chunk = ByteBuffer.allocate(packetCapacity);
1184
1293
  } catch (e) {
1185
1294
  onError(e);
1295
+ if (isWedgedWriteError(e)) {
1296
+ throw e;
1297
+ }
1186
1298
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1187
1299
  }
1188
1300
  }
@@ -1214,6 +1326,9 @@ export default class ReactNativeBleTransport {
1214
1326
  }
1215
1327
  } catch (e) {
1216
1328
  onError(e);
1329
+ if (isWedgedWriteError(e)) {
1330
+ throw e;
1331
+ }
1217
1332
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1218
1333
  }
1219
1334
  }
@@ -1227,9 +1342,15 @@ export default class ReactNativeBleTransport {
1227
1342
  if (name === 'EmmcFileWrite') {
1228
1343
  await writeChunkedData(
1229
1344
  buffers,
1230
- data => transport.writeWithRetry(data),
1345
+ data =>
1346
+ this.writeBlePacket(
1347
+ uuid,
1348
+ data,
1349
+ payload => transport.writeWithRetry(payload),
1350
+ isCurrentOwner
1351
+ ),
1231
1352
  e => {
1232
- this.runPromise = null;
1353
+ releaseOwnershipIfCurrent();
1233
1354
  Log?.error('writeCharacteristic write error: ', e);
1234
1355
  }
1235
1356
  );
@@ -1250,7 +1371,12 @@ export default class ReactNativeBleTransport {
1250
1371
  // eslint-disable-next-line no-constant-condition
1251
1372
  while (true) {
1252
1373
  try {
1253
- await transport.writeWithRetry(data);
1374
+ await this.writeBlePacket(
1375
+ uuid,
1376
+ data,
1377
+ payload => transport.writeWithRetry(payload),
1378
+ isCurrentOwner
1379
+ );
1254
1380
  return;
1255
1381
  } catch (error) {
1256
1382
  const retryType = getFirmwareUploadWriteRetryType(error);
@@ -1269,7 +1395,7 @@ export default class ReactNativeBleTransport {
1269
1395
  }
1270
1396
  },
1271
1397
  e => {
1272
- this.runPromise = null;
1398
+ releaseOwnershipIfCurrent();
1273
1399
  Log?.error('writeCharacteristic write error: ', e);
1274
1400
  }
1275
1401
  );
@@ -1280,14 +1406,21 @@ export default class ReactNativeBleTransport {
1280
1406
  try {
1281
1407
  const shouldUseWriteWithResponse =
1282
1408
  Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1283
- if (shouldUseWriteWithResponse) {
1284
- await transport.writeCharacteristic.writeWithResponse(outData);
1285
- } else {
1286
- await transport.writeCharacteristic.writeWithoutResponse(outData);
1287
- }
1409
+ await this.writeBlePacket(
1410
+ uuid,
1411
+ outData,
1412
+ payload =>
1413
+ shouldUseWriteWithResponse
1414
+ ? transport.writeCharacteristic.writeWithResponse(payload)
1415
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
1416
+ isCurrentOwner
1417
+ );
1288
1418
  } catch (e) {
1289
1419
  Log?.debug('writeCharacteristic write error: ', e);
1290
- this.runPromise = null;
1420
+ releaseOwnershipIfCurrent();
1421
+ if (isWedgedWriteError(e)) {
1422
+ throw e;
1423
+ }
1291
1424
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1292
1425
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1293
1426
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1330,8 +1463,13 @@ export default class ReactNativeBleTransport {
1330
1463
  }
1331
1464
  const isProbeTimeout =
1332
1465
  name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1466
+ // A call that has been superseded (forceRun) or cleaned up no longer owns the
1467
+ // transport; its late timeout must not tear down the connection the current
1468
+ // call is actively using.
1469
+ const isStaleCall = this.runPromise !== runPromise;
1333
1470
  if (
1334
1471
  !isProbeTimeout &&
1472
+ !isStaleCall &&
1335
1473
  (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1336
1474
  ) {
1337
1475
  await this.disconnect(uuid);
@@ -1411,7 +1549,10 @@ export default class ReactNativeBleTransport {
1411
1549
  delete transportCache[session];
1412
1550
  }
1413
1551
  this.deviceProtocol.delete(session);
1552
+ this.probingProtocols.delete(session);
1414
1553
  this.deviceProtocolHints.delete(session);
1554
+ this.sessionProtocols.delete(session);
1555
+ this.protocolReprobeFailures.delete(session);
1415
1556
  this.protocolV2Assemblers.delete(session);
1416
1557
  this.resetProtocolV2Frames(session);
1417
1558
 
@@ -1437,6 +1578,113 @@ export default class ReactNativeBleTransport {
1437
1578
  this.runPromiseDeviceId = null;
1438
1579
  }
1439
1580
 
1581
+ /** Run a native connect under the JS backstop budget. */
1582
+ private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1583
+ let timer: ReturnType<typeof setTimeout> | undefined;
1584
+ let timedOut = false;
1585
+ const pending = connect();
1586
+ // The abandoned attempt keeps running; swallow its late outcome so it cannot
1587
+ // surface as an unhandled rejection after we have already given up on it.
1588
+ pending.catch(() => undefined);
1589
+ try {
1590
+ const result = await Promise.race([
1591
+ pending,
1592
+ new Promise<never>((_, reject) => {
1593
+ timer = setTimeout(() => {
1594
+ timedOut = true;
1595
+ reject(
1596
+ ERRORS.TypedError(
1597
+ HardwareErrorCode.BleConnectedError,
1598
+ `BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
1599
+ )
1600
+ );
1601
+ }, BLE_CONNECT_TIMEOUT_MS);
1602
+ }),
1603
+ ]);
1604
+ return result;
1605
+ } catch (error) {
1606
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1607
+ this.abandonStalledConnection(uuid, timedOut ? 'connect-backstop' : 'connect-native');
1608
+ }
1609
+ throw error;
1610
+ } finally {
1611
+ if (timer) clearTimeout(timer);
1612
+ }
1613
+ }
1614
+
1615
+ /** Resolve the complete GATT shape under a budget so acquire() always settles. */
1616
+ private async resolveCharacteristicsWithTimeout(
1617
+ uuid: string,
1618
+ device: Device
1619
+ ): Promise<ResolvedBleCharacteristics> {
1620
+ let timer: ReturnType<typeof setTimeout> | undefined;
1621
+ let timedOut = false;
1622
+ const pending = this.resolveCharacteristics(device);
1623
+ pending.catch(() => undefined);
1624
+ try {
1625
+ const result = await Promise.race([
1626
+ pending,
1627
+ new Promise<never>((_, reject) => {
1628
+ timer = setTimeout(() => {
1629
+ timedOut = true;
1630
+ reject(
1631
+ ERRORS.TypedError(
1632
+ HardwareErrorCode.BleConnectedError,
1633
+ `BLE GATT setup timeout after ${BLE_GATT_SETUP_TIMEOUT_MS}ms for ${uuid}`
1634
+ )
1635
+ );
1636
+ }, BLE_GATT_SETUP_TIMEOUT_MS);
1637
+ }),
1638
+ ]);
1639
+ this.connectionSetupTimeoutCounts.delete(uuid);
1640
+ return result;
1641
+ } catch (error) {
1642
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1643
+ this.abandonStalledConnection(uuid, timedOut ? 'gatt-backstop' : 'gatt-native');
1644
+ }
1645
+ throw error;
1646
+ } finally {
1647
+ if (timer) clearTimeout(timer);
1648
+ }
1649
+ }
1650
+
1651
+ /**
1652
+ * Give up on a BLE setup operation the native layer did not settle. The abandoned
1653
+ * operation still owns native connection/GATT state that can poison the next attempt,
1654
+ * so it is cleared here without awaiting the same queue that stopped responding.
1655
+ */
1656
+ private abandonStalledConnection(
1657
+ uuid: string,
1658
+ stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
1659
+ ) {
1660
+ const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
1661
+ this.connectionSetupTimeoutCounts.set(uuid, timeouts);
1662
+ Log?.error('[ReactNativeBleTransport] BLE setup timed out:', uuid, {
1663
+ stage,
1664
+ setupTimeoutsSinceSuccess: timeouts,
1665
+ });
1666
+
1667
+ this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
1668
+ // Rejects with "Operation was cancelled" while merely connecting — expected.
1669
+ });
1670
+ const stalled = transportCache[uuid];
1671
+ if (stalled) {
1672
+ delete transportCache[uuid];
1673
+ }
1674
+ this.deviceProtocol.delete(uuid);
1675
+ this.probingProtocols.delete(uuid);
1676
+ this.protocolV2Assemblers.delete(uuid);
1677
+ this.resetProtocolV2Frames(uuid);
1678
+
1679
+ if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1680
+ // BleManager.destroy() force-rejects every promise the native queue abandoned —
1681
+ // the only JS-reachable way to settle them — and drops all cached peripherals.
1682
+ Log?.error('[ReactNativeBleTransport] BLE setup wedged repeatedly, resetting BLE manager');
1683
+ this.resetPlxManager();
1684
+ this.connectionSetupTimeoutCounts.delete(uuid);
1685
+ }
1686
+ }
1687
+
1440
1688
  private getCachedTransport(uuid: string) {
1441
1689
  const transport = transportCache[uuid];
1442
1690
  if (!transport) {
@@ -1445,6 +1693,107 @@ export default class ReactNativeBleTransport {
1445
1693
  return transport;
1446
1694
  }
1447
1695
 
1696
+ /**
1697
+ * Write one packet under a bounded budget. A write that never settles means the
1698
+ * peripheral is wedged even though the GATT link still reports connected, so the
1699
+ * link is torn down: releasing JS state alone would leave the poisoned peripheral
1700
+ * cached and every later call would hang on it again.
1701
+ */
1702
+ private async writeBlePacket(
1703
+ uuid: string,
1704
+ data: string,
1705
+ write: (payload: string) => Promise<unknown>,
1706
+ isCurrentOwner?: () => boolean
1707
+ ) {
1708
+ let timer: ReturnType<typeof setTimeout> | undefined;
1709
+ let timedOut = false;
1710
+ try {
1711
+ await Promise.race([
1712
+ write(data),
1713
+ new Promise<never>((_, reject) => {
1714
+ timer = setTimeout(() => {
1715
+ timedOut = true;
1716
+ reject(
1717
+ ERRORS.TypedError(
1718
+ HardwareErrorCode.BleWriteCharacteristicError,
1719
+ `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1720
+ )
1721
+ );
1722
+ }, BLE_WRITE_PACKET_TIMEOUT_MS);
1723
+ }),
1724
+ ]);
1725
+ this.writeTimeoutCounts.delete(uuid);
1726
+ } catch (error) {
1727
+ if (timedOut) {
1728
+ // A superseded call's late write must not tear down the link the current
1729
+ // call is using; only the owner of the transport may declare it dead.
1730
+ if (isCurrentOwner && !isCurrentOwner()) {
1731
+ Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1732
+ } else {
1733
+ this.tearDownWedgedLink(uuid);
1734
+ }
1735
+ }
1736
+ throw error;
1737
+ } finally {
1738
+ if (timer) clearTimeout(timer);
1739
+ }
1740
+ }
1741
+
1742
+ /**
1743
+ * Drop a link whose writes stopped completing. The JS state is purged synchronously
1744
+ * so the next acquire() cannot reuse the dead transport, while the native teardown is
1745
+ * intentionally NOT awaited: it talks to the very layer that just stopped settling
1746
+ * promises, so awaiting it could hang exactly like the write it is recovering from.
1747
+ */
1748
+ private tearDownWedgedLink(uuid: string) {
1749
+ const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1750
+ this.writeTimeoutCounts.set(uuid, timeouts);
1751
+ Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1752
+ consecutiveWriteTimeouts: timeouts,
1753
+ });
1754
+
1755
+ const wedged = transportCache[uuid];
1756
+ this.disconnect(uuid).catch(error => {
1757
+ Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1758
+ });
1759
+ if (wedged && transportCache[uuid] === wedged) {
1760
+ delete transportCache[uuid];
1761
+ }
1762
+ this.deviceProtocol.delete(uuid);
1763
+ this.probingProtocols.delete(uuid);
1764
+ this.protocolV2Assemblers.delete(uuid);
1765
+ this.resetProtocolV2Frames(uuid);
1766
+
1767
+ if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1768
+ // Reconnecting reuses the same native peripheral object. When it stays wedged
1769
+ // across attempts the poison lives in the BLE manager itself, and only a fresh
1770
+ // manager drops every cached peripheral — the JS equivalent of restarting the app.
1771
+ Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1772
+ this.resetPlxManager();
1773
+ this.writeTimeoutCounts.delete(uuid);
1774
+ }
1775
+ }
1776
+
1777
+ private resetPlxManager() {
1778
+ const manager = this.blePlxManager;
1779
+ this.blePlxManager = undefined;
1780
+ // Every cached transport belongs to the destroyed manager's peripherals.
1781
+ Object.keys(transportCache).forEach(key => {
1782
+ delete transportCache[key];
1783
+ });
1784
+ this.deviceProtocol.clear();
1785
+ this.probingProtocols.clear();
1786
+ this.sessionProtocols.clear();
1787
+ this.protocolReprobeFailures.clear();
1788
+ this.monitorTokens.clear();
1789
+ this.protocolV2Assemblers.clear();
1790
+ try {
1791
+ manager?.destroy();
1792
+ } catch (error) {
1793
+ Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1794
+ }
1795
+ }
1796
+
1448
1797
  private createProtocolMismatchError(expected: ProtocolType) {
1449
1798
  return ERRORS.TypedError(
1450
1799
  HardwareErrorCode.RuntimeError,
@@ -1460,11 +1809,19 @@ export default class ReactNativeBleTransport {
1460
1809
  }
1461
1810
 
1462
1811
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1812
+ if (this.probingProtocols.get(uuid) === protocol) {
1813
+ this.probingProtocols.delete(uuid);
1814
+ }
1463
1815
  if (this.deviceProtocol.get(uuid) === protocol) {
1464
1816
  this.deviceProtocol.delete(uuid);
1465
1817
  }
1466
1818
  }
1467
1819
 
1820
+ /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1821
+ private getActiveProtocol(uuid: string): ProtocolType | undefined {
1822
+ return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1823
+ }
1824
+
1468
1825
  private async detectProtocol(
1469
1826
  uuid: string,
1470
1827
  expectedProtocol?: ProtocolType,
@@ -1484,6 +1841,7 @@ export default class ReactNativeBleTransport {
1484
1841
  if (expectedProtocol === 'V1') {
1485
1842
  if (await this.probeProtocolV1(uuid)) {
1486
1843
  this.deviceProtocol.set(uuid, 'V1');
1844
+ this.sessionProtocols.set(uuid, 'V1');
1487
1845
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1488
1846
  deviceId: uuid,
1489
1847
  protocol: 'V1',
@@ -1497,6 +1855,7 @@ export default class ReactNativeBleTransport {
1497
1855
  if (expectedProtocol === 'V2') {
1498
1856
  if (await this.probeProtocolV2(uuid)) {
1499
1857
  this.deviceProtocol.set(uuid, 'V2');
1858
+ this.sessionProtocols.set(uuid, 'V2');
1500
1859
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1501
1860
  deviceId: uuid,
1502
1861
  protocol: 'V2',
@@ -1509,8 +1868,18 @@ export default class ReactNativeBleTransport {
1509
1868
 
1510
1869
  // Protocol must be actively probed after connection. Name, PID, and descriptors only
1511
1870
  // influence probe order; a V2 hint probes V2 first and falls back to V1.
1512
- const probeOrder: ProtocolType[] =
1871
+ const sessionProtocol = this.sessionProtocols.get(uuid);
1872
+ const reprobeFailures = this.protocolReprobeFailures.get(uuid) ?? 0;
1873
+ const fullProbeOrder: ProtocolType[] =
1513
1874
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
1875
+ // A device that already answered on a protocol in this session keeps answering on
1876
+ // it; while it is rebooting nothing answers at all, so probing the other protocol
1877
+ // only adds its timeout to every poll.
1878
+ const trustSessionProtocol =
1879
+ sessionProtocol !== undefined &&
1880
+ !protocolHint &&
1881
+ reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
1882
+ const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
1514
1883
 
1515
1884
  for (let i = 0; i < probeOrder.length; i += 1) {
1516
1885
  const protocol = probeOrder[i];
@@ -1528,6 +1897,8 @@ export default class ReactNativeBleTransport {
1528
1897
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1529
1898
  if (detected) {
1530
1899
  this.deviceProtocol.set(uuid, protocol);
1900
+ this.sessionProtocols.set(uuid, protocol);
1901
+ this.protocolReprobeFailures.delete(uuid);
1531
1902
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1532
1903
  deviceId: uuid,
1533
1904
  protocol,
@@ -1537,7 +1908,16 @@ export default class ReactNativeBleTransport {
1537
1908
  }
1538
1909
  }
1539
1910
 
1911
+ if (trustSessionProtocol) {
1912
+ // Still silent on its own protocol: count it, and let the streak expire the
1913
+ // shortcut so a device that genuinely switched protocols is found again.
1914
+ this.protocolReprobeFailures.set(uuid, reprobeFailures + 1);
1915
+ } else {
1916
+ this.protocolReprobeFailures.delete(uuid);
1917
+ }
1918
+
1540
1919
  this.deviceProtocol.delete(uuid);
1920
+ this.probingProtocols.delete(uuid);
1541
1921
  throw this.createProtocolDetectionError();
1542
1922
  }
1543
1923
 
@@ -1599,14 +1979,20 @@ export default class ReactNativeBleTransport {
1599
1979
  }
1600
1980
 
1601
1981
  try {
1602
- this.deviceProtocol.set(uuid, 'V1');
1982
+ this.probingProtocols.set(uuid, 'V1');
1603
1983
  // GetFeatures identifies Protocol V1 without resetting an existing wallet
1604
1984
  // session before Core has a chance to restore a hidden wallet.
1605
1985
  await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1986
+ this.probingProtocols.delete(uuid);
1606
1987
  return true;
1607
1988
  } catch (error) {
1608
1989
  this.clearProbeProtocol(uuid, 'V1');
1609
1990
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1991
+ // A wedged write already dropped the link, so probing another protocol on it
1992
+ // would only fail against a torn-down transport: surface the real cause.
1993
+ if (isWedgedWriteError(error)) {
1994
+ throw error;
1995
+ }
1610
1996
  return false;
1611
1997
  }
1612
1998
  }
@@ -1616,7 +2002,7 @@ export default class ReactNativeBleTransport {
1616
2002
  return false;
1617
2003
  }
1618
2004
 
1619
- this.deviceProtocol.set(uuid, 'V2');
2005
+ this.probingProtocols.set(uuid, 'V2');
1620
2006
  this.protocolV2Assemblers.get(uuid)?.reset();
1621
2007
  const detected = await probeProtocolV2Helper({
1622
2008
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1631,6 +2017,8 @@ export default class ReactNativeBleTransport {
1631
2017
  });
1632
2018
  if (!detected) {
1633
2019
  this.clearProbeProtocol(uuid, 'V2');
2020
+ } else {
2021
+ this.probingProtocols.delete(uuid);
1634
2022
  }
1635
2023
  return detected;
1636
2024
  }
@@ -1712,6 +2100,7 @@ export default class ReactNativeBleTransport {
1712
2100
  }
1713
2101
 
1714
2102
  private async writeProtocolV2Packet(
2103
+ uuid: string,
1715
2104
  transport: BleTransport,
1716
2105
  base64: string,
1717
2106
  context: ProtocolV2CallContext,
@@ -1727,11 +2116,24 @@ export default class ReactNativeBleTransport {
1727
2116
  throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
1728
2117
  }
1729
2118
  try {
1730
- if (shouldUseWriteWithResponse) {
1731
- await transport.writeCharacteristic.writeWithResponse(base64);
1732
- } else {
1733
- await transport.writeCharacteristic.writeWithoutResponse(base64);
1734
- }
2119
+ await this.writeBlePacket(
2120
+ uuid,
2121
+ base64,
2122
+ payload =>
2123
+ shouldUseWriteWithResponse
2124
+ ? transport.writeCharacteristic.writeWithResponse(payload)
2125
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
2126
+ // Same rule as Protocol V1: a write from a superseded generation must not
2127
+ // tear down the link that the current generation is using.
2128
+ () => {
2129
+ try {
2130
+ assertCurrentGeneration();
2131
+ return !context.signal.aborted;
2132
+ } catch {
2133
+ return false;
2134
+ }
2135
+ }
2136
+ );
1735
2137
  assertCurrentGeneration();
1736
2138
  return;
1737
2139
  } catch (error) {
@@ -1754,6 +2156,7 @@ export default class ReactNativeBleTransport {
1754
2156
  }
1755
2157
 
1756
2158
  private async writeProtocolV2Frame(
2159
+ uuid: string,
1757
2160
  transport: BleTransport,
1758
2161
  frame: Uint8Array,
1759
2162
  context: ProtocolV2CallContext,
@@ -1785,6 +2188,7 @@ export default class ReactNativeBleTransport {
1785
2188
  wait: delay,
1786
2189
  writePacket: packet =>
1787
2190
  this.writeProtocolV2Packet(
2191
+ uuid,
1788
2192
  transport,
1789
2193
  Buffer.from(packet).toString('base64'),
1790
2194
  context,
@@ -1849,7 +2253,13 @@ export default class ReactNativeBleTransport {
1849
2253
  writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
1850
2254
  assertCurrentGeneration();
1851
2255
  const currentTransport = this.getCachedTransport(uuid);
1852
- await this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
2256
+ await this.writeProtocolV2Frame(
2257
+ uuid,
2258
+ currentTransport,
2259
+ frame,
2260
+ context,
2261
+ assertCurrentGeneration
2262
+ );
1853
2263
  },
1854
2264
  readFrame: async () => {
1855
2265
  assertCurrentGeneration();
@@ -1874,6 +2284,6 @@ export default class ReactNativeBleTransport {
1874
2284
  }
1875
2285
 
1876
2286
  getProtocolType(path: string): ProtocolType | undefined {
1877
- return this.deviceProtocol.get(path);
2287
+ return this.getActiveProtocol(path);
1878
2288
  }
1879
2289
  }