@onekeyfe/hd-transport-react-native 1.2.0-alpha.52 → 1.2.0-alpha.54

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/dist/index.d.ts CHANGED
@@ -35,6 +35,17 @@ type ResolvedBleCharacteristics = {
35
35
  notifyCharacteristic: Characteristic;
36
36
  };
37
37
  declare const getFirmwareUploadWriteRetryType: (error: unknown) => FirmwareUploadWriteRetryType | null;
38
+ /**
39
+ * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
40
+ * reports the peripheral ready again; a peripheral wedged by its own firmware reboot
41
+ * stops reporting ready while staying connected, so the write promise never settles.
42
+ * Response timeouts cannot cover that — they are armed after the writes complete —
43
+ * and an unbounded write leaves the whole transport unusable until the process dies.
44
+ * A healthy packet completes in milliseconds, so this only fires on a dead link.
45
+ */
46
+ declare const BLE_WRITE_PACKET_TIMEOUT_MS = 10000;
47
+ /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
48
+ declare const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
38
49
  type ProtocolV2BleTuning = {
39
50
  iosPacketLength?: number;
40
51
  androidPacketLength?: number;
@@ -61,6 +72,15 @@ declare class ReactNativeBleTransport {
61
72
  firmwareUploadWriteRecoveryIds: Set<string>;
62
73
  /** Per-device protocol type detected by active wire-level probe after connect. */
63
74
  private deviceProtocol;
75
+ /**
76
+ * Protocol a probe is currently trying, before the device has confirmed it. Calls
77
+ * must route with it, but acquire() must not treat it as a detected protocol: a
78
+ * probe that never answers would otherwise leave the reuse fast path handing out a
79
+ * transport that was never validated.
80
+ */
81
+ private probingProtocols;
82
+ /** Consecutive write timeouts per device; reset by any write that completes. */
83
+ private writeTimeoutCounts;
64
84
  private deviceProtocolHints;
65
85
  private protocolV2Assemblers;
66
86
  private protocolV2FrameQueues;
@@ -100,9 +120,26 @@ declare class ReactNativeBleTransport {
100
120
  disconnect(session: string): Promise<void>;
101
121
  cancel(): void;
102
122
  private getCachedTransport;
123
+ /**
124
+ * Write one packet under a bounded budget. A write that never settles means the
125
+ * peripheral is wedged even though the GATT link still reports connected, so the
126
+ * link is torn down: releasing JS state alone would leave the poisoned peripheral
127
+ * cached and every later call would hang on it again.
128
+ */
129
+ private writeBlePacket;
130
+ /**
131
+ * Drop a link whose writes stopped completing. The JS state is purged synchronously
132
+ * so the next acquire() cannot reuse the dead transport, while the native teardown is
133
+ * intentionally NOT awaited: it talks to the very layer that just stopped settling
134
+ * promises, so awaiting it could hang exactly like the write it is recovering from.
135
+ */
136
+ private tearDownWedgedLink;
137
+ private resetPlxManager;
103
138
  private createProtocolMismatchError;
104
139
  private createProtocolDetectionError;
105
140
  private clearProbeProtocol;
141
+ /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
142
+ private getActiveProtocol;
106
143
  private detectProtocol;
107
144
  private resetProbeStateAfterProtocolProbe;
108
145
  private probeProtocolV1;
@@ -120,4 +157,4 @@ declare class ReactNativeBleTransport {
120
157
  getProtocolType(path: string): ProtocolType | undefined;
121
158
  }
122
159
 
123
- export { IOneKeyDevice, ProtocolV2BleTuning, configureProtocolV2BleTuning, ReactNativeBleTransport as default, getFirmwareUploadWriteRetryType, getProtocolV2BleTuning, resetProtocolV2BleTuning };
160
+ export { BLE_WRITE_PACKET_TIMEOUT_MS, BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD, IOneKeyDevice, ProtocolV2BleTuning, configureProtocolV2BleTuning, ReactNativeBleTransport as default, getFirmwareUploadWriteRetryType, getProtocolV2BleTuning, resetProtocolV2BleTuning };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAEA,OAAO,EAIL,UAAU,IAAI,aAAa,EAE5B,MAAM,sBAAsB,CAAC;AAE9B,OAAO,SAAS,EAAE,EAEhB,KAAK,oBAAoB,EAIzB,KAAK,YAAY,EAKjB,KAAK,oBAAoB,EAG1B,MAAM,wBAAwB,CAAC;AAmBhC,OAAO,YAAY,MAAM,gBAAgB,CAAC;AAK1C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACjF,OAAO,KAAK,YAAY,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAgBjE,KAAK,4BAA4B,GAAG,WAAW,CAAC;AAChD,KAAK,0BAA0B,GAAG;IAChC,mBAAmB,EAAE,cAAc,CAAC;IACpC,oBAAoB,EAAE,cAAc,CAAC;CACtC,CAAC;AAOF,eAAO,MAAM,+BAA+B,UACnC,OAAO,KACb,4BAA4B,GAAG,IAsBjC,CAAC;AASF,MAAM,MAAM,mBAAmB,GAAG;IAChC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B,CAAC;AAiBF,wBAAgB,4BAA4B,CAAC,MAAM,GAAE,mBAAwB,QAY5E;AAED,wBAAgB,wBAAwB,SAGvC;AAED,wBAAgB,sBAAsB;;;EAErC;AAkBD,MAAM,MAAM,aAAa,GAAG,oBAAoB,GAAG,MAAM,CAAC;AA4D1D,MAAM,CAAC,OAAO,OAAO,uBAAuB;IAC1C,aAAa,EAAE,aAAa,GAAG,SAAS,CAAC;IAEzC,SAAS,EAAE,UAAU,CAAC,OAAO,SAAS,CAAC,cAAc,CAAC,GAAG,SAAS,CAAC;IAEnE,WAAW,EAAE,UAAU,CAAC,OAAO,SAAS,CAAC,cAAc,CAAC,GAAG,SAAS,CAAC;IAErE,OAAO,CAAC,6BAA6B,CAAqB;IAE1D,IAAI,SAA6B;IAEjC,UAAU,UAAS;IAEnB,OAAO,UAAS;IAEhB,WAAW,SAA0B;IAErC,UAAU,EAAE,QAAQ,CAAC,GAAG,CAAC,GAAG,IAAI,CAAQ;IAExC,OAAO,CAAC,kBAAkB,CAAuB;IAEjD,OAAO,CAAC,EAAE,YAAY,CAAC;IAEvB,8BAA8B,cAAqB;IAGnD,OAAO,CAAC,cAAc,CAAwC;IAE9D,OAAO,CAAC,mBAAmB,CAAwC;IAEnE,OAAO,CAAC,oBAAoB,CAAoD;IAEhF,OAAO,CAAC,qBAAqB,CAAwC;IAErE,OAAO,CAAC,uBAAuB,CAAgD;IAE/E,OAAO,CAAC,eAAe,CAmBpB;IAEH,OAAO,CAAC,aAAa,CAAkC;IAEvD,OAAO,CAAC,qBAAqB,CAAkC;IAE/D,OAAO,CAAC,gBAAgB,CAAK;gBAEjB,OAAO,EAAE,gBAAgB;IAIrC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,YAAY;IAKvC,SAAS,CAAC,UAAU,EAAE,GAAG;IAMzB,mBAAmB,CAAC,UAAU,EAAE,GAAG;IAgBnC,MAAM;IAIN,aAAa,IAAI,OAAO,CAAC,aAAa,CAAC;IAMjC,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,0BAA0B,CAAC;IAmFjF,4BAA4B,CAAC,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;IAgClF,OAAO,CAAC,oBAAoB;IAgBtB,gCAAgC,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,YAAY;IAuDtE,SAAS;YAmID,0BAA0B;IAwClC,OAAO,CAAC,KAAK,EAAE,eAAe;;;;IAsKpC,sBAAsB,CACpB,cAAc,EAAE,cAAc,EAC9B,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,EACpB,mBAAmB,EAAE,MAAM,GAC1B,YAAY;IAsIT,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,UAAQ;YAK7B,aAAa;IA8DrB,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAIjE,IAAI,CACR,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,OAAO,CAAC,EAAE,oBAAoB;YA+BlB,cAAc;IA0N5B,IAAI;IAIE,UAAU,CAAC,OAAO,EAAE,MAAM;IA8EhC,MAAM;IASN,OAAO,CAAC,kBAAkB;IAQ1B,OAAO,CAAC,2BAA2B;IAOnC,OAAO,CAAC,4BAA4B;IAOpC,OAAO,CAAC,kBAAkB;YAMZ,cAAc;YAkEd,iCAAiC;YAoDjC,eAAe;YAkBf,eAAe;IAwB7B,OAAO,CAAC,4BAA4B;IA2BpC,OAAO,CAAC,uBAAuB;IAS/B,OAAO,CAAC,sBAAsB;IAU9B,OAAO,CAAC,qBAAqB;IAI7B,OAAO,CAAC,sBAAsB;YAShB,mBAAmB;YAiBnB,qBAAqB;YAmCrB,oBAAoB;YAiCpB,cAAc;IAoC5B,OAAO,CAAC,uBAAuB;IA4C/B,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS;CAGxD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -244,6 +244,12 @@ const getFirmwareUploadWriteRetryType = (error) => {
244
244
  const resolveFirmwareUploadRetryDelay = (attempt, baseDelayMs = 200, maxDelayMs = 1200) => Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
245
245
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
246
246
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10000;
247
+ const BLE_WRITE_PACKET_TIMEOUT_MS = 10000;
248
+ const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
249
+ const isWedgedWriteError = (error) => (error === null || error === void 0 ? void 0 : error.errorCode) === hdShared.HardwareErrorCode.BleWriteCharacteristicError &&
250
+ typeof (error === null || error === void 0 ? void 0 : error.message) === 'string' &&
251
+ error.message.startsWith(WEDGED_WRITE_MESSAGE);
252
+ const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
247
253
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
248
254
  const IOS_NOTIFY_READY_DELAY_MS = 150;
249
255
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
@@ -341,6 +347,8 @@ class ReactNativeBleTransport {
341
347
  this.runPromiseDeviceId = null;
342
348
  this.firmwareUploadWriteRecoveryIds = new Set();
343
349
  this.deviceProtocol = new Map();
350
+ this.probingProtocols = new Map();
351
+ this.writeTimeoutCounts = new Map();
344
352
  this.deviceProtocolHints = new Map();
345
353
  this.protocolV2Assemblers = new Map();
346
354
  this.protocolV2FrameQueues = new Map();
@@ -852,7 +860,7 @@ class ReactNativeBleTransport {
852
860
  Log === null || Log === void 0 ? void 0 : Log.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
853
861
  return;
854
862
  }
855
- if (this.deviceProtocol.get(uuid) === 'V2') {
863
+ if (this.getActiveProtocol(uuid) === 'V2') {
856
864
  let errorCode = hdShared.HardwareErrorCode.BleCharacteristicNotifyError;
857
865
  if ((_a = error.reason) === null || _a === void 0 ? void 0 : _a.includes('The connection has timed out unexpectedly')) {
858
866
  errorCode = hdShared.HardwareErrorCode.BleTimeoutError;
@@ -903,7 +911,7 @@ class ReactNativeBleTransport {
903
911
  }
904
912
  try {
905
913
  const data = buffer.Buffer.from(c.value, 'base64');
906
- const protocol = this.deviceProtocol.get(uuid);
914
+ const protocol = this.getActiveProtocol(uuid);
907
915
  if (!protocol) {
908
916
  Log === null || Log === void 0 ? void 0 : Log.debug('monitor data ignored before protocol detection: ', uuid);
909
917
  return;
@@ -931,7 +939,7 @@ class ReactNativeBleTransport {
931
939
  catch (error) {
932
940
  Log === null || Log === void 0 ? void 0 : Log.debug('monitor data error: ', error);
933
941
  const notifyError = hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleWriteCharacteristicError);
934
- if (this.deviceProtocol.get(uuid) === 'V2') {
942
+ if (this.getActiveProtocol(uuid) === 'V2') {
935
943
  this.rejectProtocolV2Frames(uuid, notifyError);
936
944
  }
937
945
  else if (this.runPromiseDeviceId === uuid) {
@@ -987,6 +995,7 @@ class ReactNativeBleTransport {
987
995
  delete transportCache[uuid];
988
996
  }
989
997
  this.deviceProtocol.delete(uuid);
998
+ this.probingProtocols.delete(uuid);
990
999
  (_f = this.protocolV2Assemblers.get(uuid)) === null || _f === void 0 ? void 0 : _f.reset();
991
1000
  this.protocolV2Assemblers.delete(uuid);
992
1001
  this.resetProtocolV2Frames(uuid);
@@ -1047,6 +1056,7 @@ class ReactNativeBleTransport {
1047
1056
  this.runPromiseDeviceId = null;
1048
1057
  }
1049
1058
  };
1059
+ const isCurrentOwner = () => this.runPromise === runPromise;
1050
1060
  const messages = this._messages;
1051
1061
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1052
1062
  let timeout;
@@ -1067,6 +1077,9 @@ class ReactNativeBleTransport {
1067
1077
  }
1068
1078
  catch (e) {
1069
1079
  onError(e);
1080
+ if (isWedgedWriteError(e)) {
1081
+ throw e;
1082
+ }
1070
1083
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleWriteCharacteristicError);
1071
1084
  }
1072
1085
  }
@@ -1094,6 +1107,9 @@ class ReactNativeBleTransport {
1094
1107
  }
1095
1108
  catch (e) {
1096
1109
  onError(e);
1110
+ if (isWedgedWriteError(e)) {
1111
+ throw e;
1112
+ }
1097
1113
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleWriteCharacteristicError);
1098
1114
  }
1099
1115
  }
@@ -1104,7 +1120,7 @@ class ReactNativeBleTransport {
1104
1120
  });
1105
1121
  }
1106
1122
  if (name === 'EmmcFileWrite') {
1107
- yield writeChunkedData(buffers, data => transport.writeWithRetry(data), e => {
1123
+ yield writeChunkedData(buffers, data => this.writeBlePacket(uuid, data, payload => transport.writeWithRetry(payload), isCurrentOwner), e => {
1108
1124
  releaseOwnershipIfCurrent();
1109
1125
  Log === null || Log === void 0 ? void 0 : Log.error('writeCharacteristic write error: ', e);
1110
1126
  });
@@ -1121,7 +1137,7 @@ class ReactNativeBleTransport {
1121
1137
  let attempt = 0;
1122
1138
  while (true) {
1123
1139
  try {
1124
- yield transport.writeCharacteristic.writeWithoutResponse(data);
1140
+ yield this.writeBlePacket(uuid, data, payload => transport.writeCharacteristic.writeWithoutResponse(payload), isCurrentOwner);
1125
1141
  return;
1126
1142
  }
1127
1143
  catch (error) {
@@ -1148,11 +1164,14 @@ class ReactNativeBleTransport {
1148
1164
  for (const o of buffers) {
1149
1165
  const outData = o.toString('base64');
1150
1166
  try {
1151
- yield transport.writeCharacteristic.writeWithoutResponse(outData);
1167
+ yield this.writeBlePacket(uuid, outData, payload => transport.writeCharacteristic.writeWithoutResponse(payload), isCurrentOwner);
1152
1168
  }
1153
1169
  catch (e) {
1154
1170
  Log === null || Log === void 0 ? void 0 : Log.debug('writeCharacteristic write error: ', e);
1155
1171
  releaseOwnershipIfCurrent();
1172
+ if (isWedgedWriteError(e)) {
1173
+ throw e;
1174
+ }
1156
1175
  if (e.errorCode === reactNativeBlePlx.BleErrorCode.DeviceDisconnected) {
1157
1176
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleDeviceNotBonded);
1158
1177
  }
@@ -1265,6 +1284,7 @@ class ReactNativeBleTransport {
1265
1284
  delete transportCache[session];
1266
1285
  }
1267
1286
  this.deviceProtocol.delete(session);
1287
+ this.probingProtocols.delete(session);
1268
1288
  this.deviceProtocolHints.delete(session);
1269
1289
  this.protocolV2Assemblers.delete(session);
1270
1290
  this.resetProtocolV2Frames(session);
@@ -1293,6 +1313,80 @@ class ReactNativeBleTransport {
1293
1313
  }
1294
1314
  return transport;
1295
1315
  }
1316
+ writeBlePacket(uuid, data, write, isCurrentOwner) {
1317
+ return __awaiter(this, void 0, void 0, function* () {
1318
+ let timer;
1319
+ let timedOut = false;
1320
+ try {
1321
+ yield Promise.race([
1322
+ write(data),
1323
+ new Promise((_, reject) => {
1324
+ timer = setTimeout(() => {
1325
+ timedOut = true;
1326
+ reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleWriteCharacteristicError, `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`));
1327
+ }, BLE_WRITE_PACKET_TIMEOUT_MS);
1328
+ }),
1329
+ ]);
1330
+ this.writeTimeoutCounts.delete(uuid);
1331
+ }
1332
+ catch (error) {
1333
+ if (timedOut) {
1334
+ if (isCurrentOwner && !isCurrentOwner()) {
1335
+ Log === null || Log === void 0 ? void 0 : Log.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1336
+ }
1337
+ else {
1338
+ this.tearDownWedgedLink(uuid);
1339
+ }
1340
+ }
1341
+ throw error;
1342
+ }
1343
+ finally {
1344
+ if (timer)
1345
+ clearTimeout(timer);
1346
+ }
1347
+ });
1348
+ }
1349
+ tearDownWedgedLink(uuid) {
1350
+ var _a;
1351
+ const timeouts = ((_a = this.writeTimeoutCounts.get(uuid)) !== null && _a !== void 0 ? _a : 0) + 1;
1352
+ this.writeTimeoutCounts.set(uuid, timeouts);
1353
+ Log === null || Log === void 0 ? void 0 : Log.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1354
+ consecutiveWriteTimeouts: timeouts,
1355
+ });
1356
+ const wedged = transportCache[uuid];
1357
+ this.disconnect(uuid).catch(error => {
1358
+ Log === null || Log === void 0 ? void 0 : Log.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1359
+ });
1360
+ if (wedged && transportCache[uuid] === wedged) {
1361
+ delete transportCache[uuid];
1362
+ }
1363
+ this.deviceProtocol.delete(uuid);
1364
+ this.probingProtocols.delete(uuid);
1365
+ this.protocolV2Assemblers.delete(uuid);
1366
+ this.resetProtocolV2Frames(uuid);
1367
+ if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1368
+ Log === null || Log === void 0 ? void 0 : Log.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1369
+ this.resetPlxManager();
1370
+ this.writeTimeoutCounts.delete(uuid);
1371
+ }
1372
+ }
1373
+ resetPlxManager() {
1374
+ const manager = this.blePlxManager;
1375
+ this.blePlxManager = undefined;
1376
+ Object.keys(transportCache).forEach(key => {
1377
+ delete transportCache[key];
1378
+ });
1379
+ this.deviceProtocol.clear();
1380
+ this.probingProtocols.clear();
1381
+ this.monitorTokens.clear();
1382
+ this.protocolV2Assemblers.clear();
1383
+ try {
1384
+ manager === null || manager === void 0 ? void 0 : manager.destroy();
1385
+ }
1386
+ catch (error) {
1387
+ Log === null || Log === void 0 ? void 0 : Log.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1388
+ }
1389
+ }
1296
1390
  createProtocolMismatchError(expected) {
1297
1391
  return hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, `Device protocol mismatch: expected ${expected}, but device did not respond to expected protocol`);
1298
1392
  }
@@ -1300,10 +1394,17 @@ class ReactNativeBleTransport {
1300
1394
  return hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleTimeoutError, 'Unable to detect BLE protocol: device did not respond to Protocol V1 GetFeatures or Protocol V2 Ping');
1301
1395
  }
1302
1396
  clearProbeProtocol(uuid, protocol) {
1397
+ if (this.probingProtocols.get(uuid) === protocol) {
1398
+ this.probingProtocols.delete(uuid);
1399
+ }
1303
1400
  if (this.deviceProtocol.get(uuid) === protocol) {
1304
1401
  this.deviceProtocol.delete(uuid);
1305
1402
  }
1306
1403
  }
1404
+ getActiveProtocol(uuid) {
1405
+ var _a;
1406
+ return (_a = this.deviceProtocol.get(uuid)) !== null && _a !== void 0 ? _a : this.probingProtocols.get(uuid);
1407
+ }
1307
1408
  detectProtocol(uuid, expectedProtocol, protocolHint, rebuildTransport) {
1308
1409
  return __awaiter(this, void 0, void 0, function* () {
1309
1410
  if (expectedProtocol === 'V1') {
@@ -1354,6 +1455,7 @@ class ReactNativeBleTransport {
1354
1455
  }
1355
1456
  }
1356
1457
  this.deviceProtocol.delete(uuid);
1458
+ this.probingProtocols.delete(uuid);
1357
1459
  throw this.createProtocolDetectionError();
1358
1460
  });
1359
1461
  }
@@ -1405,13 +1507,17 @@ class ReactNativeBleTransport {
1405
1507
  return false;
1406
1508
  }
1407
1509
  try {
1408
- this.deviceProtocol.set(uuid, 'V1');
1510
+ this.probingProtocols.set(uuid, 'V1');
1409
1511
  yield this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1512
+ this.probingProtocols.delete(uuid);
1410
1513
  return true;
1411
1514
  }
1412
1515
  catch (error) {
1413
1516
  this.clearProbeProtocol(uuid, 'V1');
1414
1517
  Log === null || Log === void 0 ? void 0 : Log.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1518
+ if (isWedgedWriteError(error)) {
1519
+ throw error;
1520
+ }
1415
1521
  return false;
1416
1522
  }
1417
1523
  });
@@ -1422,7 +1528,7 @@ class ReactNativeBleTransport {
1422
1528
  if (!this._messages || !this._messagesV2) {
1423
1529
  return false;
1424
1530
  }
1425
- this.deviceProtocol.set(uuid, 'V2');
1531
+ this.probingProtocols.set(uuid, 'V2');
1426
1532
  (_a = this.protocolV2Assemblers.get(uuid)) === null || _a === void 0 ? void 0 : _a.reset();
1427
1533
  const detected = yield transport.probeProtocolV2({
1428
1534
  call: (name, data, options) => this.callProtocolV2(uuid, name, data, options),
@@ -1438,6 +1544,9 @@ class ReactNativeBleTransport {
1438
1544
  if (!detected) {
1439
1545
  this.clearProbeProtocol(uuid, 'V2');
1440
1546
  }
1547
+ else {
1548
+ this.probingProtocols.delete(uuid);
1549
+ }
1441
1550
  return detected;
1442
1551
  });
1443
1552
  }
@@ -1509,7 +1618,7 @@ class ReactNativeBleTransport {
1509
1618
  }
1510
1619
  });
1511
1620
  }
1512
- writeProtocolV2Packet(transport, base64, context, assertCurrentGeneration) {
1621
+ writeProtocolV2Packet(uuid, transport, base64, context, assertCurrentGeneration) {
1513
1622
  return __awaiter(this, void 0, void 0, function* () {
1514
1623
  let attempt = 0;
1515
1624
  for (;;) {
@@ -1518,7 +1627,15 @@ class ReactNativeBleTransport {
1518
1627
  throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
1519
1628
  }
1520
1629
  try {
1521
- yield transport.writeCharacteristic.writeWithoutResponse(base64);
1630
+ yield this.writeBlePacket(uuid, base64, payload => transport.writeCharacteristic.writeWithoutResponse(payload), () => {
1631
+ try {
1632
+ assertCurrentGeneration();
1633
+ return !context.signal.aborted;
1634
+ }
1635
+ catch (_a) {
1636
+ return false;
1637
+ }
1638
+ });
1522
1639
  assertCurrentGeneration();
1523
1640
  return;
1524
1641
  }
@@ -1539,7 +1656,7 @@ class ReactNativeBleTransport {
1539
1656
  }
1540
1657
  });
1541
1658
  }
1542
- writeProtocolV2Frame(transport$1, frame, context, assertCurrentGeneration) {
1659
+ writeProtocolV2Frame(uuid, transport$1, frame, context, assertCurrentGeneration) {
1543
1660
  return __awaiter(this, void 0, void 0, function* () {
1544
1661
  const tuning = getProtocolV2BleTuning();
1545
1662
  const packetCapacity = resolveProtocolV2PacketCapacity({
@@ -1558,7 +1675,7 @@ class ReactNativeBleTransport {
1558
1675
  burstPauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
1559
1676
  flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
1560
1677
  wait: delay,
1561
- writePacket: packet => this.writeProtocolV2Packet(transport$1, buffer.Buffer.from(packet).toString('base64'), context, assertCurrentGeneration),
1678
+ writePacket: packet => this.writeProtocolV2Packet(uuid, transport$1, buffer.Buffer.from(packet).toString('base64'), context, assertCurrentGeneration),
1562
1679
  });
1563
1680
  });
1564
1681
  }
@@ -1607,7 +1724,7 @@ class ReactNativeBleTransport {
1607
1724
  writeFrame: (frame, context) => __awaiter(this, void 0, void 0, function* () {
1608
1725
  assertCurrentGeneration();
1609
1726
  const currentTransport = this.getCachedTransport(uuid);
1610
- yield this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
1727
+ yield this.writeProtocolV2Frame(uuid, currentTransport, frame, context, assertCurrentGeneration);
1611
1728
  }),
1612
1729
  readFrame: () => __awaiter(this, void 0, void 0, function* () {
1613
1730
  assertCurrentGeneration();
@@ -1628,10 +1745,12 @@ class ReactNativeBleTransport {
1628
1745
  };
1629
1746
  }
1630
1747
  getProtocolType(path) {
1631
- return this.deviceProtocol.get(path);
1748
+ return this.getActiveProtocol(path);
1632
1749
  }
1633
1750
  }
1634
1751
 
1752
+ exports.BLE_WRITE_PACKET_TIMEOUT_MS = BLE_WRITE_PACKET_TIMEOUT_MS;
1753
+ exports.BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD;
1635
1754
  exports.configureProtocolV2BleTuning = configureProtocolV2BleTuning;
1636
1755
  exports["default"] = ReactNativeBleTransport;
1637
1756
  exports.getFirmwareUploadWriteRetryType = getFirmwareUploadWriteRetryType;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport-react-native",
3
- "version": "1.2.0-alpha.52",
3
+ "version": "1.2.0-alpha.54",
4
4
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -20,11 +20,11 @@
20
20
  "lint:fix": "eslint . --fix"
21
21
  },
22
22
  "dependencies": {
23
- "@onekeyfe/hd-core": "1.2.0-alpha.52",
24
- "@onekeyfe/hd-shared": "1.2.0-alpha.52",
25
- "@onekeyfe/hd-transport": "1.2.0-alpha.52",
23
+ "@onekeyfe/hd-core": "1.2.0-alpha.54",
24
+ "@onekeyfe/hd-shared": "1.2.0-alpha.54",
25
+ "@onekeyfe/hd-transport": "1.2.0-alpha.54",
26
26
  "@onekeyfe/react-native-ble-utils": "^0.1.6",
27
27
  "react-native-ble-plx": "3.5.1"
28
28
  },
29
- "gitHead": "d8fd8f3d30c4ff3653d0379d5c3e1c100414b61e"
29
+ "gitHead": "a382a4719286e0df04fe706ed13099020eb8e13f"
30
30
  }
@@ -482,6 +482,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
482
482
  configureProtocolV2BleTuning({ iosPacketLength: 20 });
483
483
 
484
484
  await transport.writeProtocolV2Frame(
485
+ 'device-uuid',
485
486
  bleTransport,
486
487
  new Uint8Array(30),
487
488
  context,
@@ -513,7 +514,13 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
513
514
  configureProtocolV2BleTuning({ iosPacketLength: 20 });
514
515
 
515
516
  await expect(
516
- transport.writeProtocolV2Frame(bleTransport, new Uint8Array(30), context, jest.fn())
517
+ transport.writeProtocolV2Frame(
518
+ 'device-uuid',
519
+ bleTransport,
520
+ new Uint8Array(30),
521
+ context,
522
+ jest.fn()
523
+ )
517
524
  ).rejects.toMatchObject({ errorCode: 205 });
518
525
  expect(writeWithoutResponse).toHaveBeenCalledTimes(1);
519
526
  });
@@ -0,0 +1,306 @@
1
+ import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+
3
+ import ReactNativeBleTransport, {
4
+ BLE_WRITE_PACKET_TIMEOUT_MS,
5
+ BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD,
6
+ } from '../index';
7
+
8
+ import messages from '@onekeyfe/hd-transport/messages.json';
9
+
10
+ jest.mock(
11
+ 'react-native',
12
+ () => ({
13
+ Platform: { OS: 'ios', select: (spec: Record<string, unknown>) => spec.ios },
14
+ PermissionsAndroid: {
15
+ PERMISSIONS: {},
16
+ RESULTS: {},
17
+ request: jest.fn(),
18
+ requestMultiple: jest.fn(),
19
+ },
20
+ }),
21
+ { virtual: true }
22
+ );
23
+
24
+ jest.mock('react-native-ble-plx', () => ({
25
+ BleATTErrorCode: { InvalidHandle: 1 },
26
+ BleError: Error,
27
+ BleErrorCode: { DeviceDisconnected: 201, OperationStartFailed: 601 },
28
+ BleManager: jest.fn(),
29
+ ScanMode: { LowLatency: 2 },
30
+ }));
31
+
32
+ jest.mock('@onekeyfe/react-native-ble-utils', () => ({
33
+ __esModule: true,
34
+ default: {
35
+ getConnectedPeripherals: jest.fn(() => Promise.resolve([])),
36
+ getBondedPeripherals: jest.fn(() => Promise.resolve([])),
37
+ pairDevice: jest.fn(() => Promise.resolve()),
38
+ },
39
+ }));
40
+
41
+ const UUID = 'wedged-write-device';
42
+
43
+ const flush = () =>
44
+ new Promise(resolve => {
45
+ setImmediate(resolve);
46
+ });
47
+
48
+ /** Drive fake timers forward in slices until `settled()` reports done. */
49
+ async function advanceUntil(settled: () => boolean, totalMs: number, stepMs = 500) {
50
+ for (let elapsed = 0; elapsed < totalMs; elapsed += stepMs) {
51
+ jest.advanceTimersByTime(stepMs);
52
+ // eslint-disable-next-line no-await-in-loop
53
+ await flush();
54
+ if (settled()) return;
55
+ }
56
+ throw new Error(`fake timers exhausted after ${totalMs}ms before the call settled`);
57
+ }
58
+
59
+ /** Drive fake timers forward without requiring the work to settle. */
60
+ async function drain(totalMs: number, stepMs = 500) {
61
+ for (let elapsed = 0; elapsed < totalMs; elapsed += stepMs) {
62
+ jest.advanceTimersByTime(stepMs);
63
+ // eslint-disable-next-line no-await-in-loop
64
+ await flush();
65
+ }
66
+ }
67
+
68
+ function createHarness(writeImpl: () => Promise<void>) {
69
+ const t = new ReactNativeBleTransport({});
70
+ t.configure(messages);
71
+ (t as any).deviceProtocol.set(UUID, 'V1');
72
+ const writeWithoutResponse = jest.fn(writeImpl);
73
+ const fakeBleTransport = {
74
+ writeCharacteristic: { writeWithoutResponse },
75
+ writeWithRetry: jest.fn(writeImpl),
76
+ };
77
+ (t as any).getCachedTransport = () => fakeBleTransport;
78
+ const disconnectSpy = jest.spyOn(t, 'disconnect').mockResolvedValue(undefined);
79
+ return { t, disconnectSpy, writeWithoutResponse };
80
+ }
81
+
82
+ describe('BLE packet write timeout', () => {
83
+ beforeAll(() => {
84
+ jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] });
85
+ });
86
+
87
+ afterAll(() => {
88
+ jest.useRealTimers();
89
+ });
90
+
91
+ afterEach(() => {
92
+ jest.clearAllTimers();
93
+ jest.restoreAllMocks();
94
+ });
95
+
96
+ test('a never-settling write rejects instead of hanging the call forever', async () => {
97
+ // iOS never resolves writeWithoutResponse when the peripheral stops reporting
98
+ // "ready to send"; without a bounded write the response timeout is never armed.
99
+ const { t } = createHarness(
100
+ () =>
101
+ new Promise<void>(() => {
102
+ // never settles
103
+ })
104
+ );
105
+
106
+ const errors: Array<{ errorCode?: unknown }> = [];
107
+ let settled = false;
108
+ const call = t.call(UUID, 'Initialize', {}, { timeoutMs: 25000 });
109
+ call.catch(e => {
110
+ errors.push(e);
111
+ settled = true;
112
+ });
113
+ await flush();
114
+
115
+ await advanceUntil(() => settled, 30000);
116
+
117
+ expect(errors).toHaveLength(1);
118
+ expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleWriteCharacteristicError);
119
+ });
120
+
121
+ test('a wedged write tears the BLE link down so the next call reconnects', async () => {
122
+ const { t, disconnectSpy } = createHarness(
123
+ () =>
124
+ new Promise<void>(() => {
125
+ // never settles
126
+ })
127
+ );
128
+
129
+ let settled = false;
130
+ const call = t.call(UUID, 'Initialize', {}, { timeoutMs: 25000 });
131
+ call.catch(() => {
132
+ settled = true;
133
+ });
134
+ await flush();
135
+
136
+ await advanceUntil(() => settled, 30000);
137
+
138
+ expect(disconnectSpy).toHaveBeenCalledWith(UUID);
139
+ });
140
+
141
+ test('the write budget is per packet, not per call', async () => {
142
+ // Five packets at 4s each: the call's write phase far outlives one packet budget,
143
+ // but no single packet does, so nothing may be torn down.
144
+ const { t, disconnectSpy, writeWithoutResponse } = createHarness(
145
+ () =>
146
+ new Promise<void>(resolve => {
147
+ setTimeout(resolve, 4000);
148
+ })
149
+ );
150
+
151
+ const errors: Array<{ errorCode?: unknown }> = [];
152
+ let settled = false;
153
+ const call = t.call(UUID, 'Ping', { message: 'x'.repeat(300) }, { timeoutMs: 120000 });
154
+ call.catch(e => {
155
+ errors.push(e);
156
+ settled = true;
157
+ });
158
+ await flush();
159
+
160
+ await advanceUntil(() => settled, 200000);
161
+
162
+ // Every packet was written: none was aborted even though the write phase as a
163
+ // whole ran far past a single packet budget.
164
+ expect(writeWithoutResponse.mock.calls.length).toBeGreaterThan(1);
165
+ expect(4000 * writeWithoutResponse.mock.calls.length).toBeGreaterThan(
166
+ BLE_WRITE_PACKET_TIMEOUT_MS
167
+ );
168
+ // Fails on the RESPONSE timeout the device never answered, not on a write timeout.
169
+ expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleTimeoutError);
170
+ });
171
+
172
+ test('a superseded call whose write wedges must not tear down the successor link', async () => {
173
+ // Only the first call's write wedges; the successor writes normally.
174
+ let writes = 0;
175
+ const { t, disconnectSpy } = createHarness(() => {
176
+ writes += 1;
177
+ return writes === 1
178
+ ? new Promise<void>(() => {
179
+ // never settles
180
+ })
181
+ : Promise.resolve();
182
+ });
183
+
184
+ const first = t.call(UUID, 'Initialize', {}, { timeoutMs: 25000 });
185
+ first.catch(() => undefined);
186
+ await flush();
187
+
188
+ // A forceRun call supersedes it and becomes the transport owner.
189
+ const second = t.call(UUID, 'Initialize', {}, { timeoutMs: 25000 });
190
+ second.catch(() => undefined);
191
+ await flush();
192
+
193
+ await drain(BLE_WRITE_PACKET_TIMEOUT_MS + 2000);
194
+
195
+ expect(disconnectSpy).not.toHaveBeenCalled();
196
+ expect(t.runPromise).not.toBeNull();
197
+ });
198
+
199
+ test('repeated wedged writes recreate the BLE manager', async () => {
200
+ const { t, disconnectSpy } = createHarness(
201
+ () =>
202
+ new Promise<void>(() => {
203
+ // never settles
204
+ })
205
+ );
206
+ const destroy = jest.fn();
207
+ (t as any).blePlxManager = { destroy };
208
+
209
+ for (let attempt = 0; attempt < BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD; attempt += 1) {
210
+ (t as any).deviceProtocol.set(UUID, 'V1');
211
+ let settled = false;
212
+ const call = t.call(UUID, 'Initialize', {}, { timeoutMs: 25000 });
213
+ call.catch(() => {
214
+ settled = true;
215
+ });
216
+ // eslint-disable-next-line no-await-in-loop
217
+ await flush();
218
+ // eslint-disable-next-line no-await-in-loop
219
+ await advanceUntil(() => settled, BLE_WRITE_PACKET_TIMEOUT_MS + 2000);
220
+ }
221
+
222
+ expect(disconnectSpy).toHaveBeenCalledTimes(BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD);
223
+ expect(destroy).toHaveBeenCalledTimes(1);
224
+ expect((t as any).blePlxManager).toBeUndefined();
225
+ });
226
+
227
+ test('slow but progressing writes are not aborted', async () => {
228
+ // Each packet takes a while but keeps completing: this must not be mistaken
229
+ // for a wedged link, even when the whole call outlives a single packet budget.
230
+ const { t, writeWithoutResponse } = createHarness(
231
+ () =>
232
+ new Promise<void>(resolve => {
233
+ setTimeout(resolve, 4000);
234
+ })
235
+ );
236
+
237
+ const errors: unknown[] = [];
238
+ let settled = false;
239
+ const call = t.call(UUID, 'Initialize', {}, { timeoutMs: 60000 });
240
+ call.catch(e => {
241
+ errors.push(e);
242
+ settled = true;
243
+ });
244
+ await flush();
245
+
246
+ // Let every packet drain; the call then waits for a device response that never
247
+ // comes, and must fail with the RESPONSE timeout, not a write timeout.
248
+ await advanceUntil(() => settled, 90000);
249
+
250
+ expect(writeWithoutResponse).toHaveBeenCalled();
251
+ expect(errors).toHaveLength(1);
252
+ expect((errors[0] as { errorCode?: unknown })?.errorCode).toBe(
253
+ HardwareErrorCode.BleTimeoutError
254
+ );
255
+ });
256
+ });
257
+
258
+ describe('protocol probe state visibility', () => {
259
+ beforeAll(() => {
260
+ jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] });
261
+ });
262
+
263
+ afterAll(() => {
264
+ jest.useRealTimers();
265
+ });
266
+
267
+ afterEach(() => {
268
+ jest.clearAllTimers();
269
+ jest.restoreAllMocks();
270
+ });
271
+
272
+ test('an in-flight probe does not publish its protocol as confirmed', async () => {
273
+ const { t } = createHarness(
274
+ () =>
275
+ new Promise<void>(() => {
276
+ // never settles
277
+ })
278
+ );
279
+ (t as any).deviceProtocol.delete(UUID);
280
+
281
+ const probe = (t as any).probeProtocolV1(UUID) as Promise<boolean>;
282
+ probe.catch(() => undefined);
283
+ await flush();
284
+
285
+ // The call itself must still route as V1 while probing...
286
+ expect(t.getProtocolType(UUID)).toBe('V1');
287
+ // ...but acquire's cached-transport reuse gate must not treat it as detected.
288
+ expect((t as any).deviceProtocol.get(UUID)).toBeUndefined();
289
+
290
+ await drain(BLE_WRITE_PACKET_TIMEOUT_MS + 2000);
291
+ });
292
+
293
+ test('a probe that confirms its protocol leaves no probing entry behind', async () => {
294
+ const { t } = createHarness(() => Promise.resolve());
295
+ (t as any).deviceProtocol.delete(UUID);
296
+
297
+ const probe = (t as any).probeProtocolV1(UUID) as Promise<boolean>;
298
+ probe.catch(() => undefined);
299
+ await flush();
300
+ // The device answers the probe.
301
+ t.runPromise?.resolve('deadbeef');
302
+ await flush();
303
+
304
+ expect((t as any).probingProtocols.size).toBe(0);
305
+ });
306
+ });
package/src/index.ts CHANGED
@@ -106,6 +106,22 @@ const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, max
106
106
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
107
107
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
108
108
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
109
+ /**
110
+ * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
111
+ * reports the peripheral ready again; a peripheral wedged by its own firmware reboot
112
+ * stops reporting ready while staying connected, so the write promise never settles.
113
+ * Response timeouts cannot cover that — they are armed after the writes complete —
114
+ * and an unbounded write leaves the whole transport unusable until the process dies.
115
+ * A healthy packet completes in milliseconds, so this only fires on a dead link.
116
+ */
117
+ export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_000;
118
+ const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
119
+ const isWedgedWriteError = (error: unknown): boolean =>
120
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
121
+ typeof (error as { message?: unknown })?.message === 'string' &&
122
+ (error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
123
+ /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
124
+ export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
109
125
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
110
126
  const IOS_NOTIFY_READY_DELAY_MS = 150;
111
127
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
@@ -256,6 +272,17 @@ export default class ReactNativeBleTransport {
256
272
  /** Per-device protocol type detected by active wire-level probe after connect. */
257
273
  private deviceProtocol: Map<string, ProtocolType> = new Map();
258
274
 
275
+ /**
276
+ * Protocol a probe is currently trying, before the device has confirmed it. Calls
277
+ * must route with it, but acquire() must not treat it as a detected protocol: a
278
+ * probe that never answers would otherwise leave the reuse fast path handing out a
279
+ * transport that was never validated.
280
+ */
281
+ private probingProtocols: Map<string, ProtocolType> = new Map();
282
+
283
+ /** Consecutive write timeouts per device; reset by any write that completes. */
284
+ private writeTimeoutCounts: Map<string, number> = new Map();
285
+
259
286
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
260
287
 
261
288
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
@@ -879,7 +906,7 @@ export default class ReactNativeBleTransport {
879
906
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
880
907
  return;
881
908
  }
882
- if (this.deviceProtocol.get(uuid) === 'V2') {
909
+ if (this.getActiveProtocol(uuid) === 'V2') {
883
910
  let errorCode:
884
911
  | typeof HardwareErrorCode.BleDeviceBondError
885
912
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -949,7 +976,7 @@ export default class ReactNativeBleTransport {
949
976
 
950
977
  try {
951
978
  const data = Buffer.from(c.value as string, 'base64');
952
- const protocol = this.deviceProtocol.get(uuid);
979
+ const protocol = this.getActiveProtocol(uuid);
953
980
  if (!protocol) {
954
981
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
955
982
  return;
@@ -983,7 +1010,7 @@ export default class ReactNativeBleTransport {
983
1010
  } catch (error) {
984
1011
  Log?.debug('monitor data error: ', error);
985
1012
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
986
- if (this.deviceProtocol.get(uuid) === 'V2') {
1013
+ if (this.getActiveProtocol(uuid) === 'V2') {
987
1014
  this.rejectProtocolV2Frames(uuid, notifyError);
988
1015
  } else if (this.runPromiseDeviceId === uuid) {
989
1016
  this.runPromise?.reject(notifyError);
@@ -1047,6 +1074,7 @@ export default class ReactNativeBleTransport {
1047
1074
  }
1048
1075
 
1049
1076
  this.deviceProtocol.delete(uuid);
1077
+ this.probingProtocols.delete(uuid);
1050
1078
  // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1051
1079
  this.protocolV2Assemblers.get(uuid)?.reset();
1052
1080
  this.protocolV2Assemblers.delete(uuid);
@@ -1131,6 +1159,7 @@ export default class ReactNativeBleTransport {
1131
1159
  this.runPromiseDeviceId = null;
1132
1160
  }
1133
1161
  };
1162
+ const isCurrentOwner = () => this.runPromise === runPromise;
1134
1163
  const messages = this._messages;
1135
1164
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1136
1165
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1156,6 +1185,9 @@ export default class ReactNativeBleTransport {
1156
1185
  chunk = ByteBuffer.allocate(packetCapacity);
1157
1186
  } catch (e) {
1158
1187
  onError(e);
1188
+ if (isWedgedWriteError(e)) {
1189
+ throw e;
1190
+ }
1159
1191
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1160
1192
  }
1161
1193
  }
@@ -1187,6 +1219,9 @@ export default class ReactNativeBleTransport {
1187
1219
  }
1188
1220
  } catch (e) {
1189
1221
  onError(e);
1222
+ if (isWedgedWriteError(e)) {
1223
+ throw e;
1224
+ }
1190
1225
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1191
1226
  }
1192
1227
  }
@@ -1200,7 +1235,13 @@ export default class ReactNativeBleTransport {
1200
1235
  if (name === 'EmmcFileWrite') {
1201
1236
  await writeChunkedData(
1202
1237
  buffers,
1203
- data => transport.writeWithRetry(data),
1238
+ data =>
1239
+ this.writeBlePacket(
1240
+ uuid,
1241
+ data,
1242
+ payload => transport.writeWithRetry(payload),
1243
+ isCurrentOwner
1244
+ ),
1204
1245
  e => {
1205
1246
  releaseOwnershipIfCurrent();
1206
1247
  Log?.error('writeCharacteristic write error: ', e);
@@ -1223,7 +1264,12 @@ export default class ReactNativeBleTransport {
1223
1264
  // eslint-disable-next-line no-constant-condition
1224
1265
  while (true) {
1225
1266
  try {
1226
- await transport.writeCharacteristic.writeWithoutResponse(data);
1267
+ await this.writeBlePacket(
1268
+ uuid,
1269
+ data,
1270
+ payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1271
+ isCurrentOwner
1272
+ );
1227
1273
  return;
1228
1274
  } catch (error) {
1229
1275
  const retryType = getFirmwareUploadWriteRetryType(error);
@@ -1251,10 +1297,18 @@ export default class ReactNativeBleTransport {
1251
1297
  const outData = o.toString('base64');
1252
1298
  // Upload resources on low-end phones may OOM
1253
1299
  try {
1254
- await transport.writeCharacteristic.writeWithoutResponse(outData);
1300
+ await this.writeBlePacket(
1301
+ uuid,
1302
+ outData,
1303
+ payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1304
+ isCurrentOwner
1305
+ );
1255
1306
  } catch (e) {
1256
1307
  Log?.debug('writeCharacteristic write error: ', e);
1257
1308
  releaseOwnershipIfCurrent();
1309
+ if (isWedgedWriteError(e)) {
1310
+ throw e;
1311
+ }
1258
1312
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1259
1313
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1260
1314
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1383,6 +1437,7 @@ export default class ReactNativeBleTransport {
1383
1437
  delete transportCache[session];
1384
1438
  }
1385
1439
  this.deviceProtocol.delete(session);
1440
+ this.probingProtocols.delete(session);
1386
1441
  this.deviceProtocolHints.delete(session);
1387
1442
  this.protocolV2Assemblers.delete(session);
1388
1443
  this.resetProtocolV2Frames(session);
@@ -1417,6 +1472,105 @@ export default class ReactNativeBleTransport {
1417
1472
  return transport;
1418
1473
  }
1419
1474
 
1475
+ /**
1476
+ * Write one packet under a bounded budget. A write that never settles means the
1477
+ * peripheral is wedged even though the GATT link still reports connected, so the
1478
+ * link is torn down: releasing JS state alone would leave the poisoned peripheral
1479
+ * cached and every later call would hang on it again.
1480
+ */
1481
+ private async writeBlePacket(
1482
+ uuid: string,
1483
+ data: string,
1484
+ write: (payload: string) => Promise<unknown>,
1485
+ isCurrentOwner?: () => boolean
1486
+ ) {
1487
+ let timer: ReturnType<typeof setTimeout> | undefined;
1488
+ let timedOut = false;
1489
+ try {
1490
+ await Promise.race([
1491
+ write(data),
1492
+ new Promise<never>((_, reject) => {
1493
+ timer = setTimeout(() => {
1494
+ timedOut = true;
1495
+ reject(
1496
+ ERRORS.TypedError(
1497
+ HardwareErrorCode.BleWriteCharacteristicError,
1498
+ `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1499
+ )
1500
+ );
1501
+ }, BLE_WRITE_PACKET_TIMEOUT_MS);
1502
+ }),
1503
+ ]);
1504
+ this.writeTimeoutCounts.delete(uuid);
1505
+ } catch (error) {
1506
+ if (timedOut) {
1507
+ // A superseded call's late write must not tear down the link the current
1508
+ // call is using; only the owner of the transport may declare it dead.
1509
+ if (isCurrentOwner && !isCurrentOwner()) {
1510
+ Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1511
+ } else {
1512
+ this.tearDownWedgedLink(uuid);
1513
+ }
1514
+ }
1515
+ throw error;
1516
+ } finally {
1517
+ if (timer) clearTimeout(timer);
1518
+ }
1519
+ }
1520
+
1521
+ /**
1522
+ * Drop a link whose writes stopped completing. The JS state is purged synchronously
1523
+ * so the next acquire() cannot reuse the dead transport, while the native teardown is
1524
+ * intentionally NOT awaited: it talks to the very layer that just stopped settling
1525
+ * promises, so awaiting it could hang exactly like the write it is recovering from.
1526
+ */
1527
+ private tearDownWedgedLink(uuid: string) {
1528
+ const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1529
+ this.writeTimeoutCounts.set(uuid, timeouts);
1530
+ Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1531
+ consecutiveWriteTimeouts: timeouts,
1532
+ });
1533
+
1534
+ const wedged = transportCache[uuid];
1535
+ this.disconnect(uuid).catch(error => {
1536
+ Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1537
+ });
1538
+ if (wedged && transportCache[uuid] === wedged) {
1539
+ delete transportCache[uuid];
1540
+ }
1541
+ this.deviceProtocol.delete(uuid);
1542
+ this.probingProtocols.delete(uuid);
1543
+ this.protocolV2Assemblers.delete(uuid);
1544
+ this.resetProtocolV2Frames(uuid);
1545
+
1546
+ if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1547
+ // Reconnecting reuses the same native peripheral object. When it stays wedged
1548
+ // across attempts the poison lives in the BLE manager itself, and only a fresh
1549
+ // manager drops every cached peripheral — the JS equivalent of restarting the app.
1550
+ Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1551
+ this.resetPlxManager();
1552
+ this.writeTimeoutCounts.delete(uuid);
1553
+ }
1554
+ }
1555
+
1556
+ private resetPlxManager() {
1557
+ const manager = this.blePlxManager;
1558
+ this.blePlxManager = undefined;
1559
+ // Every cached transport belongs to the destroyed manager's peripherals.
1560
+ Object.keys(transportCache).forEach(key => {
1561
+ delete transportCache[key];
1562
+ });
1563
+ this.deviceProtocol.clear();
1564
+ this.probingProtocols.clear();
1565
+ this.monitorTokens.clear();
1566
+ this.protocolV2Assemblers.clear();
1567
+ try {
1568
+ manager?.destroy();
1569
+ } catch (error) {
1570
+ Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1571
+ }
1572
+ }
1573
+
1420
1574
  private createProtocolMismatchError(expected: ProtocolType) {
1421
1575
  return ERRORS.TypedError(
1422
1576
  HardwareErrorCode.RuntimeError,
@@ -1432,11 +1586,19 @@ export default class ReactNativeBleTransport {
1432
1586
  }
1433
1587
 
1434
1588
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1589
+ if (this.probingProtocols.get(uuid) === protocol) {
1590
+ this.probingProtocols.delete(uuid);
1591
+ }
1435
1592
  if (this.deviceProtocol.get(uuid) === protocol) {
1436
1593
  this.deviceProtocol.delete(uuid);
1437
1594
  }
1438
1595
  }
1439
1596
 
1597
+ /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1598
+ private getActiveProtocol(uuid: string): ProtocolType | undefined {
1599
+ return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1600
+ }
1601
+
1440
1602
  private async detectProtocol(
1441
1603
  uuid: string,
1442
1604
  expectedProtocol?: ProtocolType,
@@ -1500,6 +1662,7 @@ export default class ReactNativeBleTransport {
1500
1662
  }
1501
1663
 
1502
1664
  this.deviceProtocol.delete(uuid);
1665
+ this.probingProtocols.delete(uuid);
1503
1666
  throw this.createProtocolDetectionError();
1504
1667
  }
1505
1668
 
@@ -1561,14 +1724,20 @@ export default class ReactNativeBleTransport {
1561
1724
  }
1562
1725
 
1563
1726
  try {
1564
- this.deviceProtocol.set(uuid, 'V1');
1727
+ this.probingProtocols.set(uuid, 'V1');
1565
1728
  // GetFeatures identifies Protocol V1 without resetting an existing wallet
1566
1729
  // session before Core has a chance to restore a hidden wallet.
1567
1730
  await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1731
+ this.probingProtocols.delete(uuid);
1568
1732
  return true;
1569
1733
  } catch (error) {
1570
1734
  this.clearProbeProtocol(uuid, 'V1');
1571
1735
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1736
+ // A wedged write already dropped the link, so probing another protocol on it
1737
+ // would only fail against a torn-down transport: surface the real cause.
1738
+ if (isWedgedWriteError(error)) {
1739
+ throw error;
1740
+ }
1572
1741
  return false;
1573
1742
  }
1574
1743
  }
@@ -1578,7 +1747,7 @@ export default class ReactNativeBleTransport {
1578
1747
  return false;
1579
1748
  }
1580
1749
 
1581
- this.deviceProtocol.set(uuid, 'V2');
1750
+ this.probingProtocols.set(uuid, 'V2');
1582
1751
  this.protocolV2Assemblers.get(uuid)?.reset();
1583
1752
  const detected = await probeProtocolV2Helper({
1584
1753
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1593,6 +1762,8 @@ export default class ReactNativeBleTransport {
1593
1762
  });
1594
1763
  if (!detected) {
1595
1764
  this.clearProbeProtocol(uuid, 'V2');
1765
+ } else {
1766
+ this.probingProtocols.delete(uuid);
1596
1767
  }
1597
1768
  return detected;
1598
1769
  }
@@ -1674,6 +1845,7 @@ export default class ReactNativeBleTransport {
1674
1845
  }
1675
1846
 
1676
1847
  private async writeProtocolV2Packet(
1848
+ uuid: string,
1677
1849
  transport: BleTransport,
1678
1850
  base64: string,
1679
1851
  context: ProtocolV2CallContext,
@@ -1686,7 +1858,21 @@ export default class ReactNativeBleTransport {
1686
1858
  throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
1687
1859
  }
1688
1860
  try {
1689
- await transport.writeCharacteristic.writeWithoutResponse(base64);
1861
+ await this.writeBlePacket(
1862
+ uuid,
1863
+ base64,
1864
+ payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1865
+ // Same rule as Protocol V1: a write from a superseded generation must not
1866
+ // tear down the link that the current generation is using.
1867
+ () => {
1868
+ try {
1869
+ assertCurrentGeneration();
1870
+ return !context.signal.aborted;
1871
+ } catch {
1872
+ return false;
1873
+ }
1874
+ }
1875
+ );
1690
1876
  assertCurrentGeneration();
1691
1877
  return;
1692
1878
  } catch (error) {
@@ -1709,6 +1895,7 @@ export default class ReactNativeBleTransport {
1709
1895
  }
1710
1896
 
1711
1897
  private async writeProtocolV2Frame(
1898
+ uuid: string,
1712
1899
  transport: BleTransport,
1713
1900
  frame: Uint8Array,
1714
1901
  context: ProtocolV2CallContext,
@@ -1733,6 +1920,7 @@ export default class ReactNativeBleTransport {
1733
1920
  wait: delay,
1734
1921
  writePacket: packet =>
1735
1922
  this.writeProtocolV2Packet(
1923
+ uuid,
1736
1924
  transport,
1737
1925
  Buffer.from(packet).toString('base64'),
1738
1926
  context,
@@ -1797,7 +1985,13 @@ export default class ReactNativeBleTransport {
1797
1985
  writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
1798
1986
  assertCurrentGeneration();
1799
1987
  const currentTransport = this.getCachedTransport(uuid);
1800
- await this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
1988
+ await this.writeProtocolV2Frame(
1989
+ uuid,
1990
+ currentTransport,
1991
+ frame,
1992
+ context,
1993
+ assertCurrentGeneration
1994
+ );
1801
1995
  },
1802
1996
  readFrame: async () => {
1803
1997
  assertCurrentGeneration();
@@ -1822,6 +2016,6 @@ export default class ReactNativeBleTransport {
1822
2016
  }
1823
2017
 
1824
2018
  getProtocolType(path: string): ProtocolType | undefined {
1825
- return this.deviceProtocol.get(path);
2019
+ return this.getActiveProtocol(path);
1826
2020
  }
1827
2021
  }