@onekeyfe/hd-transport-react-native 1.2.0-alpha.75 → 1.2.0-alpha.76

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
@@ -142,6 +142,22 @@ const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, max
142
142
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
143
143
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
144
144
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
145
+ /**
146
+ * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
147
+ * reports the peripheral ready again; a peripheral wedged by its own firmware reboot
148
+ * stops reporting ready while staying connected, so the write promise never settles.
149
+ * Response timeouts cannot cover that — they are armed after the writes complete —
150
+ * and an unbounded write leaves the whole transport unusable until the process dies.
151
+ * A healthy packet completes in milliseconds, so this only fires on a dead link.
152
+ */
153
+ export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_000;
154
+ const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
155
+ const isWedgedWriteError = (error: unknown): boolean =>
156
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
157
+ typeof (error as { message?: unknown })?.message === 'string' &&
158
+ (error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
159
+ /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
160
+ export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
145
161
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
146
162
  const IOS_NOTIFY_READY_DELAY_MS = 150;
147
163
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
@@ -204,12 +220,52 @@ const ANDROID_HIGH_PRIORITY_IDLE_MS = 1000;
204
220
  const getRequestedBleMtu = () =>
205
221
  Platform.OS === 'android' ? ANDROID_REQUEST_MTU : IOS_REQUEST_MTU;
206
222
 
223
+ const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
224
+
207
225
  const connectOptions: Record<string, unknown> = {
208
226
  requestMTU: getRequestedBleMtu(),
209
- timeout: 3000,
227
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
210
228
  refreshGatt: 'OnConnected',
211
229
  };
212
230
 
231
+ /** Fallback connect options: drops requestMTU (the thing being worked around) but keeps the native budget. */
232
+ const fallbackConnectOptions: Record<string, unknown> = {
233
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
234
+ };
235
+
236
+ /**
237
+ * JS backstop for connect. The native adapter applies its own 3s budget, but it
238
+ * schedules that timeout on its serial queue, so a busy queue (e.g. right after a
239
+ * firmware install tears the link down) can leave the promise unsettled — observed
240
+ * blocking a reconnect for 61s until the app-level timeout. Healthy connects finish
241
+ * inside the native budget, so this only fires when the native timeout did not.
242
+ */
243
+ export const BLE_CONNECT_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS * 2 + 2000;
244
+ /**
245
+ * Service discovery and characteristic resolution run after connect() succeeds, but
246
+ * CoreBluetooth schedules them on the same serial queue. If that queue is wedged by a
247
+ * device reboot, these calls can remain pending forever unless they have their own
248
+ * budget.
249
+ */
250
+ export const BLE_GATT_SETUP_TIMEOUT_MS = 10_000;
251
+ /**
252
+ * How many times a known device may fail its own protocol before we probe the others
253
+ * again. Reconnect polling during a device reboot repeats this every few seconds, and
254
+ * probing Protocol V2 costs a 10s Ping timeout, so paying it on every attempt for a
255
+ * device we just spoke V1 to dominates the wait. A firmware update can legitimately
256
+ * change a device's protocol, so the shortcut has to expire rather than stick.
257
+ */
258
+ export const PROTOCOL_REPROBE_FALLBACK_ATTEMPTS = 3;
259
+ /** BLE setup timeouts since the last successful setup before the manager is recreated. */
260
+ export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
261
+ const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
262
+ const isConnectTimeoutError = (error: unknown): boolean =>
263
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
264
+ typeof (error as { message?: unknown })?.message === 'string' &&
265
+ (error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
266
+ const isNativeOperationTimeoutError = (error: unknown): boolean =>
267
+ (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
268
+
213
269
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
214
270
 
215
271
  const tryToGetConfiguration = (device: Device) => {
@@ -307,8 +363,28 @@ export default class ReactNativeBleTransport {
307
363
  /** Per-device protocol type detected by active wire-level probe after connect. */
308
364
  private deviceProtocol: Map<string, ProtocolType> = new Map();
309
365
 
366
+ /**
367
+ * Protocol a probe is currently trying, before the device has confirmed it. Calls
368
+ * must route with it, but acquire() must not treat it as a detected protocol: a
369
+ * probe that never answers would otherwise leave the reuse fast path handing out a
370
+ * transport that was never validated.
371
+ */
372
+ private probingProtocols: Map<string, ProtocolType> = new Map();
373
+
374
+ /** Consecutive write timeouts per device; reset by any write that completes. */
375
+ private writeTimeoutCounts: Map<string, number> = new Map();
376
+
377
+ /** BLE setup timeouts per device since the last complete characteristic resolution. */
378
+ private connectionSetupTimeoutCounts: Map<string, number> = new Map();
379
+
310
380
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
311
381
 
382
+ /** Protocol this device actually answered on, kept across reconnects of one session. */
383
+ private sessionProtocols: Map<string, ProtocolType> = new Map();
384
+
385
+ /** Consecutive detections that failed while trusting sessionProtocols. */
386
+ private protocolReprobeFailures: Map<string, number> = new Map();
387
+
312
388
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
313
389
 
314
390
  private protocolV2FrameQueues: Map<string, Uint8Array[]> = new Map();
@@ -532,22 +608,21 @@ export default class ReactNativeBleTransport {
532
608
  const isConnected = await device.isConnected().catch(() => false);
533
609
  if (!isConnected) {
534
610
  try {
535
- device = await device.connect(connectOptions);
611
+ device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
536
612
  } catch (e) {
537
613
  if (
538
614
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
539
615
  e.errorCode === BleErrorCode.OperationCancelled
540
616
  ) {
541
- device = await device.connect();
617
+ device = await this.connectWithTimeout(uuid, () => device.connect());
542
618
  } else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
543
619
  throw e;
544
620
  }
545
621
  }
546
622
  }
547
623
 
548
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
549
- device
550
- );
624
+ const { writeCharacteristic, notifyCharacteristic } =
625
+ await this.resolveCharacteristicsWithTimeout(uuid, device);
551
626
 
552
627
  transport.device = device;
553
628
  transport.writeCharacteristic = writeCharacteristic;
@@ -726,7 +801,7 @@ export default class ReactNativeBleTransport {
726
801
  characteristics?: ResolvedBleCharacteristics
727
802
  ) {
728
803
  const { writeCharacteristic, notifyCharacteristic } =
729
- characteristics ?? (await this.resolveCharacteristics(device));
804
+ characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
730
805
  const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
731
806
  transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
732
807
  const monitorToken = this.nextMonitorToken;
@@ -863,15 +938,22 @@ export default class ReactNativeBleTransport {
863
938
  if (!device) {
864
939
  Log?.debug('try to connect to device: ', uuid);
865
940
  try {
866
- device = await blePlxManager.connectToDevice(uuid, connectOptions);
941
+ device = await this.connectWithTimeout(uuid, () =>
942
+ blePlxManager.connectToDevice(uuid, connectOptions)
943
+ );
867
944
  } catch (e) {
868
945
  Log?.debug('try to connect to device has error: ', e);
946
+ if (isConnectTimeoutError(e)) {
947
+ throw e;
948
+ }
869
949
  if (
870
950
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
871
951
  e.errorCode === BleErrorCode.OperationCancelled
872
952
  ) {
873
953
  Log?.debug('first try to reconnect without params');
874
- device = await blePlxManager.connectToDevice(uuid);
954
+ device = await this.connectWithTimeout(uuid, () =>
955
+ blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
956
+ );
875
957
  } else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
876
958
  Log?.debug('device already connected');
877
959
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
@@ -887,26 +969,36 @@ export default class ReactNativeBleTransport {
887
969
 
888
970
  if (!(await device.isConnected())) {
889
971
  Log?.debug('not connected, try to connect to device: ', uuid);
972
+ const disconnectedDevice = device;
890
973
 
891
974
  try {
892
- device = await device.connect(connectOptions);
975
+ device = await this.connectWithTimeout(uuid, () =>
976
+ disconnectedDevice.connect(connectOptions)
977
+ );
893
978
  } catch (e) {
894
979
  Log?.debug('not connected, try to connect to device has error: ', e);
980
+ if (isConnectTimeoutError(e)) {
981
+ throw e;
982
+ }
895
983
  if (
896
984
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
897
985
  e.errorCode === BleErrorCode.OperationCancelled
898
986
  ) {
899
987
  Log?.debug('second try to reconnect without params');
900
988
  try {
901
- device = await device.connect();
989
+ device = await this.connectWithTimeout(uuid, () =>
990
+ disconnectedDevice.connect(fallbackConnectOptions)
991
+ );
902
992
  } catch (e) {
903
993
  Log?.debug('last try to reconnect error: ', e);
904
994
  // last try to reconnect device if this issue exists
905
995
  // https://github.com/dotintent/react-native-ble-plx/issues/426
906
996
  if (e.errorCode === BleErrorCode.OperationCancelled) {
907
997
  Log?.debug('last try to reconnect');
908
- await device.cancelConnection();
909
- device = await device.connect();
998
+ await disconnectedDevice.cancelConnection();
999
+ device = await this.connectWithTimeout(uuid, () =>
1000
+ disconnectedDevice.connect(fallbackConnectOptions)
1001
+ );
910
1002
  }
911
1003
  }
912
1004
  } else {
@@ -917,9 +1009,8 @@ export default class ReactNativeBleTransport {
917
1009
 
918
1010
  device = await resolveNegotiatedMtu(device);
919
1011
  const acquiredDevice = device;
920
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
921
- acquiredDevice
922
- );
1012
+ const { writeCharacteristic, notifyCharacteristic } =
1013
+ await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
923
1014
 
924
1015
  const protocolHint = expectedProtocol
925
1016
  ? undefined
@@ -983,7 +1074,7 @@ export default class ReactNativeBleTransport {
983
1074
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
984
1075
  return;
985
1076
  }
986
- if (this.deviceProtocol.get(uuid) === 'V2') {
1077
+ if (this.getActiveProtocol(uuid) === 'V2') {
987
1078
  let errorCode:
988
1079
  | typeof HardwareErrorCode.BleDeviceBondError
989
1080
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -1053,7 +1144,7 @@ export default class ReactNativeBleTransport {
1053
1144
 
1054
1145
  try {
1055
1146
  const data = Buffer.from(c.value as string, 'base64');
1056
- const protocol = this.deviceProtocol.get(uuid);
1147
+ const protocol = this.getActiveProtocol(uuid);
1057
1148
  if (!protocol) {
1058
1149
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
1059
1150
  return;
@@ -1087,7 +1178,7 @@ export default class ReactNativeBleTransport {
1087
1178
  } catch (error) {
1088
1179
  Log?.debug('monitor data error: ', error);
1089
1180
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1090
- if (this.deviceProtocol.get(uuid) === 'V2') {
1181
+ if (this.getActiveProtocol(uuid) === 'V2') {
1091
1182
  this.rejectProtocolV2Frames(uuid, notifyError);
1092
1183
  } else if (this.runPromiseDeviceId === uuid) {
1093
1184
  this.runPromise?.reject(notifyError);
@@ -1155,6 +1246,7 @@ export default class ReactNativeBleTransport {
1155
1246
  this.protocolV2HighVolumeLogSignatures.delete(uuid);
1156
1247
 
1157
1248
  this.deviceProtocol.delete(uuid);
1249
+ this.probingProtocols.delete(uuid);
1158
1250
  // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1159
1251
  this.protocolV2Assemblers.get(uuid)?.reset();
1160
1252
  this.protocolV2Assemblers.delete(uuid);
@@ -1221,8 +1313,25 @@ export default class ReactNativeBleTransport {
1221
1313
  const transport = this.getCachedTransport(uuid);
1222
1314
  const runPromise = createDeferred<string>();
1223
1315
  runPromise.promise.catch(() => undefined);
1316
+ const supersededRunPromise = this.runPromise;
1317
+ if (supersededRunPromise) {
1318
+ // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1319
+ // the superseded deferred now so its response race resolves and its finally block
1320
+ // clears its timeout timer; an orphaned timer would otherwise fire much later and
1321
+ // tear down the shared connection while another call is using it.
1322
+ supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1323
+ }
1224
1324
  this.runPromise = runPromise;
1225
1325
  this.runPromiseDeviceId = uuid;
1326
+ // A superseded call's late write failure must not clear the successor's ownership;
1327
+ // only the call that still owns the slot may release it.
1328
+ const releaseOwnershipIfCurrent = () => {
1329
+ if (this.runPromise === runPromise) {
1330
+ this.runPromise = null;
1331
+ this.runPromiseDeviceId = null;
1332
+ }
1333
+ };
1334
+ const isCurrentOwner = () => this.runPromise === runPromise;
1226
1335
  const messages = this._messages;
1227
1336
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1228
1337
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1248,6 +1357,9 @@ export default class ReactNativeBleTransport {
1248
1357
  chunk = ByteBuffer.allocate(packetCapacity);
1249
1358
  } catch (e) {
1250
1359
  onError(e);
1360
+ if (isWedgedWriteError(e)) {
1361
+ throw e;
1362
+ }
1251
1363
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1252
1364
  }
1253
1365
  }
@@ -1279,6 +1391,9 @@ export default class ReactNativeBleTransport {
1279
1391
  }
1280
1392
  } catch (e) {
1281
1393
  onError(e);
1394
+ if (isWedgedWriteError(e)) {
1395
+ throw e;
1396
+ }
1282
1397
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1283
1398
  }
1284
1399
  }
@@ -1292,9 +1407,15 @@ export default class ReactNativeBleTransport {
1292
1407
  if (name === 'EmmcFileWrite') {
1293
1408
  await writeChunkedData(
1294
1409
  buffers,
1295
- data => transport.writeWithRetry(data),
1410
+ data =>
1411
+ this.writeBlePacket(
1412
+ uuid,
1413
+ data,
1414
+ payload => transport.writeWithRetry(payload),
1415
+ isCurrentOwner
1416
+ ),
1296
1417
  e => {
1297
- this.runPromise = null;
1418
+ releaseOwnershipIfCurrent();
1298
1419
  Log?.error('writeCharacteristic write error: ', e);
1299
1420
  }
1300
1421
  );
@@ -1315,7 +1436,12 @@ export default class ReactNativeBleTransport {
1315
1436
  // eslint-disable-next-line no-constant-condition
1316
1437
  while (true) {
1317
1438
  try {
1318
- await transport.writeWithRetry(data);
1439
+ await this.writeBlePacket(
1440
+ uuid,
1441
+ data,
1442
+ payload => transport.writeWithRetry(payload),
1443
+ isCurrentOwner
1444
+ );
1319
1445
  return;
1320
1446
  } catch (error) {
1321
1447
  const retryType = getFirmwareUploadWriteRetryType(error);
@@ -1334,7 +1460,7 @@ export default class ReactNativeBleTransport {
1334
1460
  }
1335
1461
  },
1336
1462
  e => {
1337
- this.runPromise = null;
1463
+ releaseOwnershipIfCurrent();
1338
1464
  Log?.error('writeCharacteristic write error: ', e);
1339
1465
  }
1340
1466
  );
@@ -1345,14 +1471,21 @@ export default class ReactNativeBleTransport {
1345
1471
  try {
1346
1472
  const shouldUseWriteWithResponse =
1347
1473
  Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1348
- if (shouldUseWriteWithResponse) {
1349
- await transport.writeCharacteristic.writeWithResponse(outData);
1350
- } else {
1351
- await transport.writeCharacteristic.writeWithoutResponse(outData);
1352
- }
1474
+ await this.writeBlePacket(
1475
+ uuid,
1476
+ outData,
1477
+ payload =>
1478
+ shouldUseWriteWithResponse
1479
+ ? transport.writeCharacteristic.writeWithResponse(payload)
1480
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
1481
+ isCurrentOwner
1482
+ );
1353
1483
  } catch (e) {
1354
1484
  Log?.debug('writeCharacteristic write error: ', e);
1355
- this.runPromise = null;
1485
+ releaseOwnershipIfCurrent();
1486
+ if (isWedgedWriteError(e)) {
1487
+ throw e;
1488
+ }
1356
1489
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1357
1490
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1358
1491
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1395,8 +1528,13 @@ export default class ReactNativeBleTransport {
1395
1528
  }
1396
1529
  const isProbeTimeout =
1397
1530
  name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1531
+ // A call that has been superseded (forceRun) or cleaned up no longer owns the
1532
+ // transport; its late timeout must not tear down the connection the current
1533
+ // call is actively using.
1534
+ const isStaleCall = this.runPromise !== runPromise;
1398
1535
  if (
1399
1536
  !isProbeTimeout &&
1537
+ !isStaleCall &&
1400
1538
  (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1401
1539
  ) {
1402
1540
  await this.disconnect(uuid);
@@ -1476,7 +1614,10 @@ export default class ReactNativeBleTransport {
1476
1614
  delete transportCache[session];
1477
1615
  }
1478
1616
  this.deviceProtocol.delete(session);
1617
+ this.probingProtocols.delete(session);
1479
1618
  this.deviceProtocolHints.delete(session);
1619
+ this.sessionProtocols.delete(session);
1620
+ this.protocolReprobeFailures.delete(session);
1480
1621
  this.protocolV2Assemblers.delete(session);
1481
1622
  this.resetProtocolV2Frames(session);
1482
1623
 
@@ -1502,6 +1643,113 @@ export default class ReactNativeBleTransport {
1502
1643
  this.runPromiseDeviceId = null;
1503
1644
  }
1504
1645
 
1646
+ /** Run a native connect under the JS backstop budget. */
1647
+ private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1648
+ let timer: ReturnType<typeof setTimeout> | undefined;
1649
+ let timedOut = false;
1650
+ const pending = connect();
1651
+ // The abandoned attempt keeps running; swallow its late outcome so it cannot
1652
+ // surface as an unhandled rejection after we have already given up on it.
1653
+ pending.catch(() => undefined);
1654
+ try {
1655
+ const result = await Promise.race([
1656
+ pending,
1657
+ new Promise<never>((_, reject) => {
1658
+ timer = setTimeout(() => {
1659
+ timedOut = true;
1660
+ reject(
1661
+ ERRORS.TypedError(
1662
+ HardwareErrorCode.BleConnectedError,
1663
+ `BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
1664
+ )
1665
+ );
1666
+ }, BLE_CONNECT_TIMEOUT_MS);
1667
+ }),
1668
+ ]);
1669
+ return result;
1670
+ } catch (error) {
1671
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1672
+ this.abandonStalledConnection(uuid, timedOut ? 'connect-backstop' : 'connect-native');
1673
+ }
1674
+ throw error;
1675
+ } finally {
1676
+ if (timer) clearTimeout(timer);
1677
+ }
1678
+ }
1679
+
1680
+ /** Resolve the complete GATT shape under a budget so acquire() always settles. */
1681
+ private async resolveCharacteristicsWithTimeout(
1682
+ uuid: string,
1683
+ device: Device
1684
+ ): Promise<ResolvedBleCharacteristics> {
1685
+ let timer: ReturnType<typeof setTimeout> | undefined;
1686
+ let timedOut = false;
1687
+ const pending = this.resolveCharacteristics(device);
1688
+ pending.catch(() => undefined);
1689
+ try {
1690
+ const result = await Promise.race([
1691
+ pending,
1692
+ new Promise<never>((_, reject) => {
1693
+ timer = setTimeout(() => {
1694
+ timedOut = true;
1695
+ reject(
1696
+ ERRORS.TypedError(
1697
+ HardwareErrorCode.BleConnectedError,
1698
+ `BLE GATT setup timeout after ${BLE_GATT_SETUP_TIMEOUT_MS}ms for ${uuid}`
1699
+ )
1700
+ );
1701
+ }, BLE_GATT_SETUP_TIMEOUT_MS);
1702
+ }),
1703
+ ]);
1704
+ this.connectionSetupTimeoutCounts.delete(uuid);
1705
+ return result;
1706
+ } catch (error) {
1707
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1708
+ this.abandonStalledConnection(uuid, timedOut ? 'gatt-backstop' : 'gatt-native');
1709
+ }
1710
+ throw error;
1711
+ } finally {
1712
+ if (timer) clearTimeout(timer);
1713
+ }
1714
+ }
1715
+
1716
+ /**
1717
+ * Give up on a BLE setup operation the native layer did not settle. The abandoned
1718
+ * operation still owns native connection/GATT state that can poison the next attempt,
1719
+ * so it is cleared here without awaiting the same queue that stopped responding.
1720
+ */
1721
+ private abandonStalledConnection(
1722
+ uuid: string,
1723
+ stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
1724
+ ) {
1725
+ const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
1726
+ this.connectionSetupTimeoutCounts.set(uuid, timeouts);
1727
+ Log?.error('[ReactNativeBleTransport] BLE setup timed out:', uuid, {
1728
+ stage,
1729
+ setupTimeoutsSinceSuccess: timeouts,
1730
+ });
1731
+
1732
+ this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
1733
+ // Rejects with "Operation was cancelled" while merely connecting — expected.
1734
+ });
1735
+ const stalled = transportCache[uuid];
1736
+ if (stalled) {
1737
+ delete transportCache[uuid];
1738
+ }
1739
+ this.deviceProtocol.delete(uuid);
1740
+ this.probingProtocols.delete(uuid);
1741
+ this.protocolV2Assemblers.delete(uuid);
1742
+ this.resetProtocolV2Frames(uuid);
1743
+
1744
+ if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1745
+ // BleManager.destroy() force-rejects every promise the native queue abandoned —
1746
+ // the only JS-reachable way to settle them — and drops all cached peripherals.
1747
+ Log?.error('[ReactNativeBleTransport] BLE setup wedged repeatedly, resetting BLE manager');
1748
+ this.resetPlxManager();
1749
+ this.connectionSetupTimeoutCounts.delete(uuid);
1750
+ }
1751
+ }
1752
+
1505
1753
  private getCachedTransport(uuid: string) {
1506
1754
  const transport = transportCache[uuid];
1507
1755
  if (!transport) {
@@ -1510,6 +1758,107 @@ export default class ReactNativeBleTransport {
1510
1758
  return transport;
1511
1759
  }
1512
1760
 
1761
+ /**
1762
+ * Write one packet under a bounded budget. A write that never settles means the
1763
+ * peripheral is wedged even though the GATT link still reports connected, so the
1764
+ * link is torn down: releasing JS state alone would leave the poisoned peripheral
1765
+ * cached and every later call would hang on it again.
1766
+ */
1767
+ private async writeBlePacket(
1768
+ uuid: string,
1769
+ data: string,
1770
+ write: (payload: string) => Promise<unknown>,
1771
+ isCurrentOwner?: () => boolean
1772
+ ) {
1773
+ let timer: ReturnType<typeof setTimeout> | undefined;
1774
+ let timedOut = false;
1775
+ try {
1776
+ await Promise.race([
1777
+ write(data),
1778
+ new Promise<never>((_, reject) => {
1779
+ timer = setTimeout(() => {
1780
+ timedOut = true;
1781
+ reject(
1782
+ ERRORS.TypedError(
1783
+ HardwareErrorCode.BleWriteCharacteristicError,
1784
+ `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1785
+ )
1786
+ );
1787
+ }, BLE_WRITE_PACKET_TIMEOUT_MS);
1788
+ }),
1789
+ ]);
1790
+ this.writeTimeoutCounts.delete(uuid);
1791
+ } catch (error) {
1792
+ if (timedOut) {
1793
+ // A superseded call's late write must not tear down the link the current
1794
+ // call is using; only the owner of the transport may declare it dead.
1795
+ if (isCurrentOwner && !isCurrentOwner()) {
1796
+ Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1797
+ } else {
1798
+ this.tearDownWedgedLink(uuid);
1799
+ }
1800
+ }
1801
+ throw error;
1802
+ } finally {
1803
+ if (timer) clearTimeout(timer);
1804
+ }
1805
+ }
1806
+
1807
+ /**
1808
+ * Drop a link whose writes stopped completing. The JS state is purged synchronously
1809
+ * so the next acquire() cannot reuse the dead transport, while the native teardown is
1810
+ * intentionally NOT awaited: it talks to the very layer that just stopped settling
1811
+ * promises, so awaiting it could hang exactly like the write it is recovering from.
1812
+ */
1813
+ private tearDownWedgedLink(uuid: string) {
1814
+ const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1815
+ this.writeTimeoutCounts.set(uuid, timeouts);
1816
+ Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1817
+ consecutiveWriteTimeouts: timeouts,
1818
+ });
1819
+
1820
+ const wedged = transportCache[uuid];
1821
+ this.disconnect(uuid).catch(error => {
1822
+ Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1823
+ });
1824
+ if (wedged && transportCache[uuid] === wedged) {
1825
+ delete transportCache[uuid];
1826
+ }
1827
+ this.deviceProtocol.delete(uuid);
1828
+ this.probingProtocols.delete(uuid);
1829
+ this.protocolV2Assemblers.delete(uuid);
1830
+ this.resetProtocolV2Frames(uuid);
1831
+
1832
+ if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1833
+ // Reconnecting reuses the same native peripheral object. When it stays wedged
1834
+ // across attempts the poison lives in the BLE manager itself, and only a fresh
1835
+ // manager drops every cached peripheral — the JS equivalent of restarting the app.
1836
+ Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1837
+ this.resetPlxManager();
1838
+ this.writeTimeoutCounts.delete(uuid);
1839
+ }
1840
+ }
1841
+
1842
+ private resetPlxManager() {
1843
+ const manager = this.blePlxManager;
1844
+ this.blePlxManager = undefined;
1845
+ // Every cached transport belongs to the destroyed manager's peripherals.
1846
+ Object.keys(transportCache).forEach(key => {
1847
+ delete transportCache[key];
1848
+ });
1849
+ this.deviceProtocol.clear();
1850
+ this.probingProtocols.clear();
1851
+ this.sessionProtocols.clear();
1852
+ this.protocolReprobeFailures.clear();
1853
+ this.monitorTokens.clear();
1854
+ this.protocolV2Assemblers.clear();
1855
+ try {
1856
+ manager?.destroy();
1857
+ } catch (error) {
1858
+ Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1859
+ }
1860
+ }
1861
+
1513
1862
  private createProtocolMismatchError(expected: ProtocolType) {
1514
1863
  return ERRORS.TypedError(
1515
1864
  HardwareErrorCode.RuntimeError,
@@ -1525,11 +1874,19 @@ export default class ReactNativeBleTransport {
1525
1874
  }
1526
1875
 
1527
1876
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1877
+ if (this.probingProtocols.get(uuid) === protocol) {
1878
+ this.probingProtocols.delete(uuid);
1879
+ }
1528
1880
  if (this.deviceProtocol.get(uuid) === protocol) {
1529
1881
  this.deviceProtocol.delete(uuid);
1530
1882
  }
1531
1883
  }
1532
1884
 
1885
+ /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1886
+ private getActiveProtocol(uuid: string): ProtocolType | undefined {
1887
+ return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1888
+ }
1889
+
1533
1890
  private async detectProtocol(
1534
1891
  uuid: string,
1535
1892
  expectedProtocol?: ProtocolType,
@@ -1549,6 +1906,7 @@ export default class ReactNativeBleTransport {
1549
1906
  if (expectedProtocol === 'V1') {
1550
1907
  if (await this.probeProtocolV1(uuid)) {
1551
1908
  this.deviceProtocol.set(uuid, 'V1');
1909
+ this.sessionProtocols.set(uuid, 'V1');
1552
1910
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1553
1911
  deviceId: uuid,
1554
1912
  protocol: 'V1',
@@ -1562,6 +1920,7 @@ export default class ReactNativeBleTransport {
1562
1920
  if (expectedProtocol === 'V2') {
1563
1921
  if (await this.probeProtocolV2(uuid)) {
1564
1922
  this.deviceProtocol.set(uuid, 'V2');
1923
+ this.sessionProtocols.set(uuid, 'V2');
1565
1924
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1566
1925
  deviceId: uuid,
1567
1926
  protocol: 'V2',
@@ -1574,8 +1933,18 @@ export default class ReactNativeBleTransport {
1574
1933
 
1575
1934
  // Protocol must be actively probed after connection. Name, PID, and descriptors only
1576
1935
  // influence probe order; a V2 hint probes V2 first and falls back to V1.
1577
- const probeOrder: ProtocolType[] =
1936
+ const sessionProtocol = this.sessionProtocols.get(uuid);
1937
+ const reprobeFailures = this.protocolReprobeFailures.get(uuid) ?? 0;
1938
+ const fullProbeOrder: ProtocolType[] =
1578
1939
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
1940
+ // A device that already answered on a protocol in this session keeps answering on
1941
+ // it; while it is rebooting nothing answers at all, so probing the other protocol
1942
+ // only adds its timeout to every poll.
1943
+ const trustSessionProtocol =
1944
+ sessionProtocol !== undefined &&
1945
+ !protocolHint &&
1946
+ reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
1947
+ const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
1579
1948
 
1580
1949
  for (let i = 0; i < probeOrder.length; i += 1) {
1581
1950
  const protocol = probeOrder[i];
@@ -1593,6 +1962,8 @@ export default class ReactNativeBleTransport {
1593
1962
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1594
1963
  if (detected) {
1595
1964
  this.deviceProtocol.set(uuid, protocol);
1965
+ this.sessionProtocols.set(uuid, protocol);
1966
+ this.protocolReprobeFailures.delete(uuid);
1596
1967
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1597
1968
  deviceId: uuid,
1598
1969
  protocol,
@@ -1602,7 +1973,16 @@ export default class ReactNativeBleTransport {
1602
1973
  }
1603
1974
  }
1604
1975
 
1976
+ if (trustSessionProtocol) {
1977
+ // Still silent on its own protocol: count it, and let the streak expire the
1978
+ // shortcut so a device that genuinely switched protocols is found again.
1979
+ this.protocolReprobeFailures.set(uuid, reprobeFailures + 1);
1980
+ } else {
1981
+ this.protocolReprobeFailures.delete(uuid);
1982
+ }
1983
+
1605
1984
  this.deviceProtocol.delete(uuid);
1985
+ this.probingProtocols.delete(uuid);
1606
1986
  throw this.createProtocolDetectionError();
1607
1987
  }
1608
1988
 
@@ -1664,14 +2044,20 @@ export default class ReactNativeBleTransport {
1664
2044
  }
1665
2045
 
1666
2046
  try {
1667
- this.deviceProtocol.set(uuid, 'V1');
2047
+ this.probingProtocols.set(uuid, 'V1');
1668
2048
  // GetFeatures identifies Protocol V1 without resetting an existing wallet
1669
2049
  // session before Core has a chance to restore a hidden wallet.
1670
2050
  await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2051
+ this.probingProtocols.delete(uuid);
1671
2052
  return true;
1672
2053
  } catch (error) {
1673
2054
  this.clearProbeProtocol(uuid, 'V1');
1674
2055
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
2056
+ // A wedged write already dropped the link, so probing another protocol on it
2057
+ // would only fail against a torn-down transport: surface the real cause.
2058
+ if (isWedgedWriteError(error)) {
2059
+ throw error;
2060
+ }
1675
2061
  return false;
1676
2062
  }
1677
2063
  }
@@ -1681,7 +2067,7 @@ export default class ReactNativeBleTransport {
1681
2067
  return false;
1682
2068
  }
1683
2069
 
1684
- this.deviceProtocol.set(uuid, 'V2');
2070
+ this.probingProtocols.set(uuid, 'V2');
1685
2071
  this.protocolV2Assemblers.get(uuid)?.reset();
1686
2072
  const detected = await probeProtocolV2Helper({
1687
2073
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1696,6 +2082,8 @@ export default class ReactNativeBleTransport {
1696
2082
  });
1697
2083
  if (!detected) {
1698
2084
  this.clearProbeProtocol(uuid, 'V2');
2085
+ } else {
2086
+ this.probingProtocols.delete(uuid);
1699
2087
  }
1700
2088
  return detected;
1701
2089
  }
@@ -1777,6 +2165,7 @@ export default class ReactNativeBleTransport {
1777
2165
  }
1778
2166
 
1779
2167
  private async writeProtocolV2Packet(
2168
+ uuid: string,
1780
2169
  transport: BleTransport,
1781
2170
  base64: string,
1782
2171
  context: ProtocolV2CallContext,
@@ -1795,11 +2184,24 @@ export default class ReactNativeBleTransport {
1795
2184
  throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
1796
2185
  }
1797
2186
  try {
1798
- if (shouldUseWriteWithResponse) {
1799
- await transport.writeCharacteristic.writeWithResponse(base64);
1800
- } else {
1801
- await transport.writeCharacteristic.writeWithoutResponse(base64);
1802
- }
2187
+ await this.writeBlePacket(
2188
+ uuid,
2189
+ base64,
2190
+ payload =>
2191
+ shouldUseWriteWithResponse
2192
+ ? transport.writeCharacteristic.writeWithResponse(payload)
2193
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
2194
+ // Same rule as Protocol V1: a write from a superseded generation must not
2195
+ // tear down the link that the current generation is using.
2196
+ () => {
2197
+ try {
2198
+ assertCurrentGeneration();
2199
+ return !context.signal.aborted;
2200
+ } catch {
2201
+ return false;
2202
+ }
2203
+ }
2204
+ );
1803
2205
  assertCurrentGeneration();
1804
2206
  return;
1805
2207
  } catch (error) {
@@ -1822,6 +2224,7 @@ export default class ReactNativeBleTransport {
1822
2224
  }
1823
2225
 
1824
2226
  private async writeProtocolV2Frame(
2227
+ uuid: string,
1825
2228
  transport: BleTransport,
1826
2229
  frame: Uint8Array,
1827
2230
  context: ProtocolV2CallContext,
@@ -1843,6 +2246,7 @@ export default class ReactNativeBleTransport {
1843
2246
  wait: delay,
1844
2247
  writePacket: packet =>
1845
2248
  this.writeProtocolV2Packet(
2249
+ uuid,
1846
2250
  transport,
1847
2251
  Buffer.from(packet).toString('base64'),
1848
2252
  context,
@@ -2020,7 +2424,13 @@ export default class ReactNativeBleTransport {
2020
2424
  writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
2021
2425
  assertCurrentGeneration();
2022
2426
  const currentTransport = this.getCachedTransport(uuid);
2023
- await this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
2427
+ await this.writeProtocolV2Frame(
2428
+ uuid,
2429
+ currentTransport,
2430
+ frame,
2431
+ context,
2432
+ assertCurrentGeneration
2433
+ );
2024
2434
  },
2025
2435
  readFrame: async () => {
2026
2436
  assertCurrentGeneration();
@@ -2045,6 +2455,6 @@ export default class ReactNativeBleTransport {
2045
2455
  }
2046
2456
 
2047
2457
  getProtocolType(path: string): ProtocolType | undefined {
2048
- return this.deviceProtocol.get(path);
2458
+ return this.getActiveProtocol(path);
2049
2459
  }
2050
2460
  }