@onekeyfe/hd-transport-web-device 1.2.8-alpha.101 → 1.2.8-alpha.103

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/webusb.ts CHANGED
@@ -45,6 +45,8 @@ const HEADER_LENGTH = PROTOCOL_V1_MESSAGE_HEADER_SIZE;
45
45
  const PACKET_IO_MAX_RETRIES = 3;
46
46
  const PACKET_IO_RETRY_DELAY = 300;
47
47
  const USB_DEVICE_IN_USE = 'USB device is already in use';
48
+ const WEBUSB_LIFECYCLE_LOG_PREFIX = '[WebUsbTransport][lifecycle]';
49
+ const WEBUSB_ERROR_MESSAGE_LIMIT = 240;
48
50
  // Legacy devices can take longer to answer Initialize after a WebUSB reset.
49
51
  // Keep this aligned with Node USB so a slow Protocol V1 response is not
50
52
  // misreported as a protocol mismatch during acquire.
@@ -74,6 +76,23 @@ interface TransferCancelToken {
74
76
 
75
77
  type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
76
78
 
79
+ type WebUsbHandleOperation =
80
+ | 'open'
81
+ | 'reset'
82
+ | 'selectConfiguration'
83
+ | 'claimInterface'
84
+ | 'clearHaltIn'
85
+ | 'clearHaltOut'
86
+ | 'releaseInterface'
87
+ | 'close';
88
+
89
+ type WebUsbLifecyclePhase = 'start' | 'success' | 'error' | 'skipped';
90
+
91
+ interface WebUsbConnectOptions {
92
+ expectedProtocol?: ProtocolType;
93
+ forceProtocolDetection?: boolean;
94
+ }
95
+
77
96
  /**
78
97
  * The navigator.usb disconnect listener is module-scoped and attached at most
79
98
  * once: listeners on navigator.usb are global and never garbage collected, so a
@@ -83,6 +102,7 @@ type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
83
102
  */
84
103
  let activeWebUsbTransport: WebUsbTransport | undefined;
85
104
  let usbDisconnectListenerAttached = false;
105
+ let nextWebUsbTransportInstanceId = 0;
86
106
  // Only isolated discovery owns these handles. Normal calls must never close or
87
107
  // open them, including while native open() is still pending.
88
108
  const identityProbeHandles = new WeakSet<USBDevice>();
@@ -107,6 +127,14 @@ function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
107
127
  }
108
128
 
109
129
  export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
130
+ private readonly transportInstanceId = ++nextWebUsbTransportInstanceId;
131
+
132
+ private handleOperationSequence = 0;
133
+
134
+ private deviceLogSequence = 0;
135
+
136
+ private deviceLogIds = new WeakMap<USBDevice, number>();
137
+
110
138
  messages: ReturnType<typeof transport.parseConfigure> | undefined;
111
139
 
112
140
  /** Protobuf schema for Protocol V2 transports. */
@@ -204,15 +232,30 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
204
232
  }
205
233
 
206
234
  stop(): Promise<void> {
235
+ const startedAt = Date.now();
207
236
  this.stopped = true;
208
237
  this.acquiredPaths.clear();
209
238
  if (activeWebUsbTransport === this) activeWebUsbTransport = undefined;
210
239
  if (!this.stopPromise) {
211
- this.stopPromise = this.stopResources().catch(error => {
212
- // A later discovery can retry closure without dropping the handle guard.
213
- this.stopPromise = undefined;
214
- throw error;
215
- });
240
+ this.logLifecycle('stop', { phase: 'start' });
241
+ this.stopPromise = this.stopResources().then(
242
+ () => {
243
+ this.logLifecycle('stop', {
244
+ phase: 'success',
245
+ durationMs: Date.now() - startedAt,
246
+ });
247
+ },
248
+ error => {
249
+ this.logLifecycle('stop', {
250
+ phase: 'error',
251
+ durationMs: Date.now() - startedAt,
252
+ ...this.getDiagnosticError(error),
253
+ });
254
+ // A later discovery can retry closure without dropping the handle guard.
255
+ this.stopPromise = undefined;
256
+ throw error;
257
+ }
258
+ );
216
259
  }
217
260
  return this.stopPromise;
218
261
  }
@@ -237,12 +280,34 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
237
280
  try {
238
281
  if (device.opened) {
239
282
  const iface = this.deviceEndpoints.get(path)?.interfaceNumber ?? this.interfaceId;
240
- try {
241
- await device.releaseInterface(iface);
242
- } catch (error) {
243
- this.Log?.debug('[WebUsbTransport] stop releaseInterface failed:', error);
283
+ if (this.getInterfaceClaimed(device, iface) === false) {
284
+ this.logSkippedHandleOperation(
285
+ 'releaseInterface',
286
+ device,
287
+ 'interface-not-claimed',
288
+ iface
289
+ );
290
+ } else {
291
+ try {
292
+ await this.runLoggedHandleOperation({
293
+ operation: 'releaseInterface',
294
+ device,
295
+ path,
296
+ interfaceNumber: iface,
297
+ allowStopped: true,
298
+ task: () => device.releaseInterface(iface),
299
+ });
300
+ } catch (error) {
301
+ this.Log?.debug('[WebUsbTransport] stop releaseInterface failed:', error);
302
+ }
244
303
  }
245
- await device.close();
304
+ await this.runLoggedHandleOperation({
305
+ operation: 'close',
306
+ device,
307
+ path,
308
+ allowStopped: true,
309
+ task: () => device.close(),
310
+ });
246
311
  if (device.opened) throw new Error('WebUSB handle remains open after stop');
247
312
  }
248
313
  if (this.identityProbe) {
@@ -293,6 +358,122 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
293
358
  }
294
359
  }
295
360
 
361
+ private getInterfaceClaimed(device: USBDevice, interfaceNumber: number) {
362
+ return device.configuration?.interfaces?.find(
363
+ usbInterface => usbInterface.interfaceNumber === interfaceNumber
364
+ )?.claimed;
365
+ }
366
+
367
+ private getDeviceLogId(device: USBDevice) {
368
+ const existing = this.deviceLogIds.get(device);
369
+ if (existing) return existing;
370
+ const next = ++this.deviceLogSequence;
371
+ this.deviceLogIds.set(device, next);
372
+ return next;
373
+ }
374
+
375
+ private getHandleLogState(device: USBDevice, interfaceNumber?: number) {
376
+ const interfaceClaimed =
377
+ interfaceNumber === undefined ? undefined : this.getInterfaceClaimed(device, interfaceNumber);
378
+ return {
379
+ deviceRefId: this.getDeviceLogId(device),
380
+ opened: device.opened,
381
+ configurationValue: device.configuration?.configurationValue ?? null,
382
+ interfaceNumber: interfaceNumber ?? null,
383
+ interfaceClaimed: interfaceClaimed ?? null,
384
+ identityProbe: this.identityProbe,
385
+ stopped: this.stopped,
386
+ acquiredPathCount: this.acquiredPaths.size,
387
+ normalHandleCount: this.normalDevices.size,
388
+ identityProbeHandleCount: this.identityProbeDevices.size,
389
+ pendingHandleOperationCount: this.pendingHandleOperations.size,
390
+ };
391
+ }
392
+
393
+ private getDiagnosticError(error: unknown, path?: string) {
394
+ const errorMessage = this.getErrorMessage(error);
395
+ const redactedMessage = path ? errorMessage.split(path).join('<device>') : errorMessage;
396
+ return {
397
+ errorName: this.getErrorName(error) || 'Error',
398
+ errorMessage: redactedMessage.slice(0, WEBUSB_ERROR_MESSAGE_LIMIT),
399
+ errorCode: error instanceof HardwareError ? error.errorCode : undefined,
400
+ };
401
+ }
402
+
403
+ private logLifecycle(event: string, payload: Record<string, unknown> = {}) {
404
+ this.Log?.debug(WEBUSB_LIFECYCLE_LOG_PREFIX, {
405
+ event,
406
+ transportInstanceId: this.transportInstanceId,
407
+ ...payload,
408
+ });
409
+ }
410
+
411
+ private logSkippedHandleOperation(
412
+ operation: WebUsbHandleOperation,
413
+ device: USBDevice,
414
+ reason: string,
415
+ interfaceNumber?: number
416
+ ) {
417
+ const operationId = ++this.handleOperationSequence;
418
+ this.logLifecycle('handle-operation', {
419
+ operationId,
420
+ operation,
421
+ phase: 'skipped' satisfies WebUsbLifecyclePhase,
422
+ reason,
423
+ ...this.getHandleLogState(device, interfaceNumber),
424
+ });
425
+ }
426
+
427
+ private async runLoggedHandleOperation<T>({
428
+ operation,
429
+ device,
430
+ task,
431
+ path,
432
+ interfaceNumber,
433
+ endpointNumber,
434
+ allowStopped = false,
435
+ }: {
436
+ operation: WebUsbHandleOperation;
437
+ device: USBDevice;
438
+ task: () => Promise<T>;
439
+ path?: string;
440
+ interfaceNumber?: number;
441
+ endpointNumber?: number;
442
+ allowStopped?: boolean;
443
+ }): Promise<T> {
444
+ const operationId = ++this.handleOperationSequence;
445
+ const startedAt = Date.now();
446
+ const details = {
447
+ operationId,
448
+ operation,
449
+ ...(endpointNumber === undefined ? {} : { endpointNumber }),
450
+ };
451
+ this.logLifecycle('handle-operation', {
452
+ ...details,
453
+ phase: 'start' satisfies WebUsbLifecyclePhase,
454
+ ...this.getHandleLogState(device, interfaceNumber),
455
+ });
456
+ try {
457
+ const result = allowStopped ? await task() : await this.runHandleOperation(task);
458
+ this.logLifecycle('handle-operation', {
459
+ ...details,
460
+ phase: 'success' satisfies WebUsbLifecyclePhase,
461
+ durationMs: Date.now() - startedAt,
462
+ ...this.getHandleLogState(device, interfaceNumber),
463
+ });
464
+ return result;
465
+ } catch (error) {
466
+ this.logLifecycle('handle-operation', {
467
+ ...details,
468
+ phase: 'error' satisfies WebUsbLifecyclePhase,
469
+ durationMs: Date.now() - startedAt,
470
+ ...this.getHandleLogState(device, interfaceNumber),
471
+ ...this.getDiagnosticError(error, path),
472
+ });
473
+ throw error;
474
+ }
475
+ }
476
+
296
477
  /**
297
478
  * Announce that a USB device left. Called from the module-scoped disconnect
298
479
  * listener, which is why it is public rather than inlined.
@@ -356,10 +537,22 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
356
537
  */
357
538
  async promptDeviceAccess() {
358
539
  if (!this.usb) return null;
540
+ const startedAt = Date.now();
541
+ this.logLifecycle('prompt-device-access', { phase: 'start' });
359
542
  try {
360
543
  const device = await this.usb.requestDevice({ filters: ONEKEY_WEBUSB_FILTER });
544
+ this.logLifecycle('prompt-device-access', {
545
+ phase: 'success',
546
+ durationMs: Date.now() - startedAt,
547
+ selected: Boolean(device),
548
+ });
361
549
  return device;
362
550
  } catch (e) {
551
+ this.logLifecycle('prompt-device-access', {
552
+ phase: 'error',
553
+ durationMs: Date.now() - startedAt,
554
+ ...this.getDiagnosticError(e),
555
+ });
363
556
  this.Log.debug(
364
557
  'requestDevice error: ',
365
558
  e instanceof Error ? `${e.name}: ${e.message}` : String(e)
@@ -374,16 +567,35 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
374
567
  * It does NOT prompt the user to select a device
375
568
  */
376
569
  async enumerate() {
377
- // Retry only our failed cleanup before Core skips already-open USB handles.
378
- for (const path of this.identityProbeCleanupPaths) {
379
- try {
380
- await this.release(path);
381
- } catch {
382
- // Keep ownership while native close still fails; retry on the next probe.
570
+ const startedAt = Date.now();
571
+ this.logLifecycle('enumerate', {
572
+ phase: 'start',
573
+ pendingProbeCleanupCount: this.identityProbeCleanupPaths.size,
574
+ });
575
+ try {
576
+ // Retry only our failed cleanup before Core skips already-open USB handles.
577
+ for (const path of this.identityProbeCleanupPaths) {
578
+ try {
579
+ await this.release(path);
580
+ } catch {
581
+ // Keep ownership while native close still fails; retry on the next probe.
582
+ }
383
583
  }
584
+ await this.getConnectedDevices();
585
+ this.logLifecycle('enumerate', {
586
+ phase: 'success',
587
+ durationMs: Date.now() - startedAt,
588
+ deviceCount: this.deviceList.length,
589
+ });
590
+ return this.deviceList;
591
+ } catch (error) {
592
+ this.logLifecycle('enumerate', {
593
+ phase: 'error',
594
+ durationMs: Date.now() - startedAt,
595
+ ...this.getDiagnosticError(error),
596
+ });
597
+ throw error;
384
598
  }
385
- await this.getConnectedDevices();
386
- return this.deviceList;
387
599
  }
388
600
 
389
601
  /**
@@ -393,7 +605,24 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
393
605
  this.assertRunning();
394
606
  if (!this.usb) return [];
395
607
 
396
- const devices = await this.usb.getDevices();
608
+ const startedAt = Date.now();
609
+ this.logLifecycle('get-devices', { phase: 'start' });
610
+ let devices: USBDevice[];
611
+ try {
612
+ devices = await this.usb.getDevices();
613
+ this.logLifecycle('get-devices', {
614
+ phase: 'success',
615
+ durationMs: Date.now() - startedAt,
616
+ authorizedDeviceCount: devices.length,
617
+ });
618
+ } catch (error) {
619
+ this.logLifecycle('get-devices', {
620
+ phase: 'error',
621
+ durationMs: Date.now() - startedAt,
622
+ ...this.getDiagnosticError(error),
623
+ });
624
+ throw error;
625
+ }
397
626
  this.assertRunning();
398
627
  for (const owner of new Set(devices.map(device => normalHandleOwners.get(device)))) {
399
628
  if (owner?.stopped) {
@@ -439,16 +668,28 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
439
668
  async acquire(input: AcquireInput) {
440
669
  this.assertRunning();
441
670
  if (!input.path) return;
442
- if (this.identityProbe) {
443
- await this.getConnectedDevices();
444
- this.claimIdentityProbeHandle(input.path, await this.findDevice(input.path));
445
- } else {
446
- this.claimNormalHandle(input.path, await this.findDevice(input.path));
447
- }
671
+ const startedAt = Date.now();
672
+ this.logLifecycle('acquire', {
673
+ phase: 'start',
674
+ expectedProtocol: input.expectedProtocol ?? null,
675
+ protocolHint: input.protocolHint ?? null,
676
+ forceProtocolDetection: Boolean(input.forceProtocolDetection),
677
+ skipProtocolProbe: Boolean(input.skipProtocolProbe),
678
+ hasCachedProtocol: this.deviceProtocol.has(input.path),
679
+ });
448
680
  try {
681
+ if (this.identityProbe) {
682
+ await this.getConnectedDevices();
683
+ this.claimIdentityProbeHandle(input.path, await this.findDevice(input.path));
684
+ } else {
685
+ this.claimNormalHandle(input.path, await this.findDevice(input.path));
686
+ }
449
687
  await this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
450
688
  await this.closeOpenDevice(input.path);
451
- await this.connect(input.path ?? '', true);
689
+ await this.connect(input.path ?? '', true, {
690
+ expectedProtocol: input.expectedProtocol,
691
+ forceProtocolDetection: input.forceProtocolDetection,
692
+ });
452
693
  if (input.skipProtocolProbe) {
453
694
  if (!input.expectedProtocol) {
454
695
  throw ERRORS.TypedError(
@@ -526,8 +767,18 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
526
767
  }
527
768
  this.assertRunning();
528
769
  this.acquiredPaths.add(input.path);
770
+ this.logLifecycle('acquire', {
771
+ phase: 'success',
772
+ durationMs: Date.now() - startedAt,
773
+ protocol: this.deviceProtocol.get(input.path) ?? null,
774
+ });
529
775
  return await Promise.resolve(input.path);
530
776
  } catch (e) {
777
+ this.logLifecycle('acquire', {
778
+ phase: 'error',
779
+ durationMs: Date.now() - startedAt,
780
+ ...this.getDiagnosticError(e, input.path),
781
+ });
531
782
  this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
532
783
  this.acquiredPaths.delete(input.path);
533
784
  try {
@@ -653,18 +904,55 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
653
904
  /**
654
905
  * Connect to device with retry mechanism
655
906
  */
656
- async connect(path: string, first: boolean) {
907
+ async connect(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
657
908
  const maxRetries = 5;
658
909
  for (let i = 0; i < maxRetries; i++) {
659
910
  this.assertRunning();
911
+ const attempt = i + 1;
912
+ const startedAt = Date.now();
913
+ const connectionStrategy = this.shouldUseLegacyV1Connection(path, options)
914
+ ? 'protocol-v1-legacy-1.1.31'
915
+ : 'current';
916
+ this.logLifecycle('connect-attempt', {
917
+ phase: 'start',
918
+ attempt,
919
+ maxAttempts: maxRetries,
920
+ first,
921
+ connectionStrategy,
922
+ });
660
923
  try {
661
- return await this.connectToDevice(path, first);
924
+ const result = await this.connectToDevice(path, first, options);
925
+ this.logLifecycle('connect-attempt', {
926
+ phase: 'success',
927
+ attempt,
928
+ maxAttempts: maxRetries,
929
+ first,
930
+ connectionStrategy,
931
+ durationMs: Date.now() - startedAt,
932
+ });
933
+ return result;
662
934
  } catch (e) {
663
935
  this.assertRunning();
664
- if (e instanceof HardwareError && e.errorCode === HardwareErrorCode.BridgeNeedsPermission) {
936
+ const isTerminalError =
937
+ this.isHandleOwnershipError(e) ||
938
+ (e instanceof HardwareError &&
939
+ (e.errorCode === HardwareErrorCode.BridgeNeedsPermission ||
940
+ e.errorCode === HardwareErrorCode.WebUsbDeviceAccessError));
941
+ const willRetry = !isTerminalError && attempt < maxRetries;
942
+ this.logLifecycle('connect-attempt', {
943
+ phase: 'error',
944
+ attempt,
945
+ maxAttempts: maxRetries,
946
+ first,
947
+ connectionStrategy,
948
+ durationMs: Date.now() - startedAt,
949
+ willRetry,
950
+ ...this.getDiagnosticError(e, path),
951
+ });
952
+ if (isTerminalError) {
665
953
  throw e;
666
954
  }
667
- if (i === maxRetries - 1) {
955
+ if (!willRetry) {
668
956
  throw e;
669
957
  }
670
958
  await wait(i * 200);
@@ -672,6 +960,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
672
960
  }
673
961
  }
674
962
 
963
+ private shouldUseLegacyV1Connection(path: string, options: WebUsbConnectOptions) {
964
+ if (options.expectedProtocol) return options.expectedProtocol === 'V1';
965
+ return !options.forceProtocolDetection && this.deviceProtocol.get(path) === 'V1';
966
+ }
967
+
675
968
  /**
676
969
  * Discover vendor-class (0xFF) interface and its IN/OUT endpoint numbers from USB descriptors.
677
970
  * Falls back to legacy hardcoded values if no vendor interface is found.
@@ -709,7 +1002,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
709
1002
  * Connect to specific device.
710
1003
  * Discovers interface/endpoint numbers from USB descriptors on first connection.
711
1004
  */
712
- async connectToDevice(path: string, first: boolean) {
1005
+ async connectToDevice(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
713
1006
  this.assertRunning();
714
1007
  let device: USBDevice = await this.findDevice(path);
715
1008
  if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
@@ -722,21 +1015,63 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
722
1015
  if (!device.opened) {
723
1016
  openingHandles.add(device);
724
1017
  try {
725
- await this.runHandleOperation(() => device.open());
1018
+ await this.runLoggedHandleOperation({
1019
+ operation: 'open',
1020
+ device,
1021
+ path,
1022
+ task: () => device.open(),
1023
+ });
726
1024
  } catch (error) {
727
1025
  return await this.throwWebUsbIoError(path, 'open', error);
728
1026
  } finally {
729
1027
  openingHandles.delete(device);
730
1028
  }
731
1029
  }
1030
+
1031
+ if (this.shouldUseLegacyV1Connection(path, options)) {
1032
+ // Match the last V1-only WebUSB lifecycle from SDK 1.1.31. Legacy devices
1033
+ // configure before reset and keep the same USBDevice object afterwards.
1034
+ if (first) {
1035
+ await this.runLoggedHandleOperation({
1036
+ operation: 'selectConfiguration',
1037
+ device,
1038
+ path,
1039
+ task: () => device.selectConfiguration(this.configurationId),
1040
+ });
1041
+ try {
1042
+ await this.runLoggedHandleOperation({
1043
+ operation: 'reset',
1044
+ device,
1045
+ path,
1046
+ task: () => device.reset(),
1047
+ });
1048
+ } catch {
1049
+ this.assertRunning();
1050
+ // Reset is best-effort in 1.1.31; runLoggedHandleOperation already recorded the error.
1051
+ }
1052
+ }
1053
+
1054
+ // Descriptor lookup is local-only and does not alter the 1.1.31 USB
1055
+ // operation order. Defaults remain available for legacy descriptors.
1056
+ const legacyEndpoints = this.discoverEndpoints(device);
1057
+ this.deviceEndpoints.set(path, legacyEndpoints);
1058
+ await this.claimDeviceInterface(path, device, legacyEndpoints);
1059
+ return;
1060
+ }
1061
+
732
1062
  // A V1 packet retry continues an in-flight exchange. Preserve its endpoint
733
1063
  // state as in the legacy transport; probing and V2 recovery still reset.
734
1064
  if (first || this.deviceProtocol.get(path) !== 'V1') {
735
1065
  try {
736
- await this.runHandleOperation(() => device.reset());
737
- } catch (error) {
1066
+ await this.runLoggedHandleOperation({
1067
+ operation: 'reset',
1068
+ device,
1069
+ path,
1070
+ task: () => device.reset(),
1071
+ });
1072
+ } catch {
738
1073
  this.assertRunning();
739
- this.Log?.debug('[WebUsbTransport] reset before claim failed, continuing:', error);
1074
+ // Reset remains best-effort; runLoggedHandleOperation already recorded the error.
740
1075
  }
741
1076
  }
742
1077
  await this.getConnectedDevices();
@@ -751,7 +1086,12 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
751
1086
  if (!device.opened) {
752
1087
  openingHandles.add(device);
753
1088
  try {
754
- await this.runHandleOperation(() => device.open());
1089
+ await this.runLoggedHandleOperation({
1090
+ operation: 'open',
1091
+ device,
1092
+ path,
1093
+ task: () => device.open(),
1094
+ });
755
1095
  } catch (error) {
756
1096
  return await this.throwWebUsbIoError(path, 'open', error);
757
1097
  } finally {
@@ -764,19 +1104,34 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
764
1104
  !device.configuration ||
765
1105
  device.configuration.configurationValue !== this.configurationId
766
1106
  ) {
767
- await this.runHandleOperation(() => device.selectConfiguration(this.configurationId));
1107
+ await this.runLoggedHandleOperation({
1108
+ operation: 'selectConfiguration',
1109
+ device,
1110
+ path,
1111
+ task: () => device.selectConfiguration(this.configurationId),
1112
+ });
768
1113
  }
769
1114
 
770
1115
  // Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
771
1116
  const endpoints = this.discoverEndpoints(device);
772
1117
  this.deviceEndpoints.set(path, endpoints);
1118
+ await this.claimDeviceInterface(path, device, endpoints);
1119
+ await this.clearEndpointHalt(path, device, 'in', endpoints.endpointIn);
1120
+ await this.clearEndpointHalt(path, device, 'out', endpoints.endpointOut);
1121
+ }
1122
+
1123
+ private async claimDeviceInterface(path: string, device: USBDevice, endpoints: DeviceEndpoints) {
773
1124
  try {
774
- await this.runHandleOperation(() => device.claimInterface(endpoints.interfaceNumber));
1125
+ await this.runLoggedHandleOperation({
1126
+ operation: 'claimInterface',
1127
+ device,
1128
+ path,
1129
+ interfaceNumber: endpoints.interfaceNumber,
1130
+ task: () => device.claimInterface(endpoints.interfaceNumber),
1131
+ });
775
1132
  } catch (error) {
776
1133
  return this.throwWebUsbIoError(path, 'claimInterface', error);
777
1134
  }
778
- await this.clearEndpointHalt(device, 'in', endpoints.endpointIn);
779
- await this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
780
1135
  }
781
1136
 
782
1137
  private assertHandleAvailable(device: USBDevice) {
@@ -800,7 +1155,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
800
1155
  private releaseNormalHandles(path: string) {
801
1156
  if (this.stopped) return;
802
1157
  for (const [device, ownedPath] of this.normalDevices) {
803
- if (ownedPath === path) {
1158
+ if (ownedPath === path && !device.opened) {
1159
+ // Do not expose a native handle to another transport while close failed.
1160
+ // A later release/stop can retry cleanup through normalDevices.
804
1161
  normalHandleOwners.delete(device);
805
1162
  this.normalDevices.delete(device);
806
1163
  }
@@ -844,36 +1201,83 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
844
1201
 
845
1202
  private async closeOpenDevice(path: string) {
846
1203
  if (this.stopped) return;
847
- const current = this.identityProbe
848
- ? this.identityProbeDevices.get(path)
849
- : this.deviceList.find(device => device.path === path)?.device;
850
- if (!current?.opened) return;
1204
+ const devices = this.identityProbe
1205
+ ? [this.identityProbeDevices.get(path)].filter(
1206
+ (device): device is USBDevice => device !== undefined
1207
+ )
1208
+ : [...this.normalDevices]
1209
+ .filter(([, ownedPath]) => ownedPath === path)
1210
+ .map(([device]) => device);
1211
+ let firstCloseError: unknown;
1212
+ for (const device of devices) {
1213
+ try {
1214
+ await this.closeDeviceHandle(path, device);
1215
+ } catch (error) {
1216
+ firstCloseError ??= error;
1217
+ }
1218
+ }
1219
+ if (firstCloseError) {
1220
+ throw firstCloseError instanceof Error
1221
+ ? firstCloseError
1222
+ : new Error(this.getErrorMessage(firstCloseError));
1223
+ }
1224
+ }
1225
+
1226
+ private async closeDeviceHandle(path: string, current: USBDevice) {
1227
+ if (!current.opened) return;
851
1228
  this.assertHandleAvailable(current);
852
1229
 
853
1230
  const endpoints = this.deviceEndpoints.get(path);
854
1231
  const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
855
- try {
856
- await this.runHandleOperation(() => current.releaseInterface(ifaceNum));
857
- } catch (error) {
858
- this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
1232
+ if (this.getInterfaceClaimed(current, ifaceNum) === false) {
1233
+ this.logSkippedHandleOperation(
1234
+ 'releaseInterface',
1235
+ current,
1236
+ 'interface-not-claimed',
1237
+ ifaceNum
1238
+ );
1239
+ } else {
1240
+ try {
1241
+ await this.runLoggedHandleOperation({
1242
+ operation: 'releaseInterface',
1243
+ device: current,
1244
+ path,
1245
+ interfaceNumber: ifaceNum,
1246
+ task: () => current.releaseInterface(ifaceNum),
1247
+ });
1248
+ } catch (error) {
1249
+ this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
1250
+ }
859
1251
  }
860
1252
  this.assertHandleAvailable(current);
861
1253
  try {
862
- await this.runHandleOperation(() => current.close());
1254
+ await this.runLoggedHandleOperation({
1255
+ operation: 'close',
1256
+ device: current,
1257
+ path,
1258
+ task: () => current.close(),
1259
+ });
863
1260
  } catch (error) {
864
1261
  this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
865
- if (this.identityProbe) throw error;
1262
+ throw error;
866
1263
  }
867
1264
  }
868
1265
 
869
1266
  private async clearEndpointHalt(
1267
+ path: string,
870
1268
  device: USBDevice,
871
1269
  direction: USBDirection,
872
1270
  endpointNumber: number
873
1271
  ) {
874
1272
  this.assertHandleAvailable(device);
875
1273
  try {
876
- await this.runHandleOperation(() => device.clearHalt(direction, endpointNumber));
1274
+ await this.runLoggedHandleOperation({
1275
+ operation: direction === 'in' ? 'clearHaltIn' : 'clearHaltOut',
1276
+ device,
1277
+ path,
1278
+ endpointNumber,
1279
+ task: () => device.clearHalt(direction, endpointNumber),
1280
+ });
877
1281
  } catch (error) {
878
1282
  this.assertRunning();
879
1283
  this.Log?.debug(
@@ -1030,13 +1434,33 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
1030
1434
  if (currentDevice.opened) {
1031
1435
  const endpoints = this.deviceEndpoints.get(path);
1032
1436
  const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
1033
- try {
1034
- await this.runHandleOperation(() => currentDevice.releaseInterface(ifaceNum));
1035
- } catch (releaseError) {
1036
- this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
1437
+ if (this.getInterfaceClaimed(currentDevice, ifaceNum) === false) {
1438
+ this.logSkippedHandleOperation(
1439
+ 'releaseInterface',
1440
+ currentDevice,
1441
+ 'interface-not-claimed',
1442
+ ifaceNum
1443
+ );
1444
+ } else {
1445
+ try {
1446
+ await this.runLoggedHandleOperation({
1447
+ operation: 'releaseInterface',
1448
+ device: currentDevice,
1449
+ path,
1450
+ interfaceNumber: ifaceNum,
1451
+ task: () => currentDevice.releaseInterface(ifaceNum),
1452
+ });
1453
+ } catch (releaseError) {
1454
+ this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
1455
+ }
1037
1456
  }
1038
1457
  this.assertHandleAvailable(currentDevice);
1039
- await this.runHandleOperation(() => currentDevice.close());
1458
+ await this.runLoggedHandleOperation({
1459
+ operation: 'close',
1460
+ device: currentDevice,
1461
+ path,
1462
+ task: () => currentDevice.close(),
1463
+ });
1040
1464
  }
1041
1465
  } catch (closeError) {
1042
1466
  this.assertRunning();
@@ -1401,10 +1825,23 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
1401
1825
  * Release device
1402
1826
  */
1403
1827
  async release(path: string) {
1828
+ const startedAt = Date.now();
1829
+ this.logLifecycle('release', { phase: 'start' });
1404
1830
  this.acquiredPaths.delete(path);
1405
1831
  try {
1406
1832
  await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1407
1833
  await this.closeOpenDevice(path);
1834
+ this.logLifecycle('release', {
1835
+ phase: 'success',
1836
+ durationMs: Date.now() - startedAt,
1837
+ });
1838
+ } catch (error) {
1839
+ this.logLifecycle('release', {
1840
+ phase: 'error',
1841
+ durationMs: Date.now() - startedAt,
1842
+ ...this.getDiagnosticError(error, path),
1843
+ });
1844
+ throw error;
1408
1845
  } finally {
1409
1846
  this.releaseIdentityProbeHandle(path);
1410
1847
  this.releaseNormalHandles(path);