@onekeyfe/hd-transport-web-device 1.2.8-alpha.104 → 1.2.8-alpha.106

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,12 +767,27 @@ 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 {
534
785
  await this.closeOpenDevice(input.path);
786
+ } catch (cleanupError) {
787
+ this.logLifecycle('acquire-cleanup', {
788
+ phase: 'error',
789
+ ...this.getDiagnosticError(cleanupError, input.path),
790
+ });
535
791
  } finally {
536
792
  this.releaseIdentityProbeHandle(input.path);
537
793
  this.releaseNormalHandles(input.path);
@@ -653,18 +909,55 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
653
909
  /**
654
910
  * Connect to device with retry mechanism
655
911
  */
656
- async connect(path: string, first: boolean) {
912
+ async connect(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
657
913
  const maxRetries = 5;
658
914
  for (let i = 0; i < maxRetries; i++) {
659
915
  this.assertRunning();
916
+ const attempt = i + 1;
917
+ const startedAt = Date.now();
918
+ const connectionStrategy = this.shouldUseLegacyV1Connection(path, options)
919
+ ? 'protocol-v1-legacy-1.1.31'
920
+ : 'current';
921
+ this.logLifecycle('connect-attempt', {
922
+ phase: 'start',
923
+ attempt,
924
+ maxAttempts: maxRetries,
925
+ first,
926
+ connectionStrategy,
927
+ });
660
928
  try {
661
- return await this.connectToDevice(path, first);
929
+ const result = await this.connectToDevice(path, first, options);
930
+ this.logLifecycle('connect-attempt', {
931
+ phase: 'success',
932
+ attempt,
933
+ maxAttempts: maxRetries,
934
+ first,
935
+ connectionStrategy,
936
+ durationMs: Date.now() - startedAt,
937
+ });
938
+ return result;
662
939
  } catch (e) {
663
940
  this.assertRunning();
664
- if (e instanceof HardwareError && e.errorCode === HardwareErrorCode.BridgeNeedsPermission) {
941
+ const isTerminalError =
942
+ this.isHandleOwnershipError(e) ||
943
+ (e instanceof HardwareError &&
944
+ (e.errorCode === HardwareErrorCode.BridgeNeedsPermission ||
945
+ e.errorCode === HardwareErrorCode.WebUsbDeviceAccessError));
946
+ const willRetry = !isTerminalError && attempt < maxRetries;
947
+ this.logLifecycle('connect-attempt', {
948
+ phase: 'error',
949
+ attempt,
950
+ maxAttempts: maxRetries,
951
+ first,
952
+ connectionStrategy,
953
+ durationMs: Date.now() - startedAt,
954
+ willRetry,
955
+ ...this.getDiagnosticError(e, path),
956
+ });
957
+ if (isTerminalError) {
665
958
  throw e;
666
959
  }
667
- if (i === maxRetries - 1) {
960
+ if (!willRetry) {
668
961
  throw e;
669
962
  }
670
963
  await wait(i * 200);
@@ -672,6 +965,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
672
965
  }
673
966
  }
674
967
 
968
+ private shouldUseLegacyV1Connection(path: string, options: WebUsbConnectOptions) {
969
+ if (options.expectedProtocol) return options.expectedProtocol === 'V1';
970
+ return !options.forceProtocolDetection && this.deviceProtocol.get(path) === 'V1';
971
+ }
972
+
675
973
  /**
676
974
  * Discover vendor-class (0xFF) interface and its IN/OUT endpoint numbers from USB descriptors.
677
975
  * Falls back to legacy hardcoded values if no vendor interface is found.
@@ -709,7 +1007,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
709
1007
  * Connect to specific device.
710
1008
  * Discovers interface/endpoint numbers from USB descriptors on first connection.
711
1009
  */
712
- async connectToDevice(path: string, first: boolean) {
1010
+ async connectToDevice(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
713
1011
  this.assertRunning();
714
1012
  let device: USBDevice = await this.findDevice(path);
715
1013
  if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
@@ -722,21 +1020,63 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
722
1020
  if (!device.opened) {
723
1021
  openingHandles.add(device);
724
1022
  try {
725
- await this.runHandleOperation(() => device.open());
1023
+ await this.runLoggedHandleOperation({
1024
+ operation: 'open',
1025
+ device,
1026
+ path,
1027
+ task: () => device.open(),
1028
+ });
726
1029
  } catch (error) {
727
1030
  return await this.throwWebUsbIoError(path, 'open', error);
728
1031
  } finally {
729
1032
  openingHandles.delete(device);
730
1033
  }
731
1034
  }
1035
+
1036
+ if (this.shouldUseLegacyV1Connection(path, options)) {
1037
+ // Match the last V1-only WebUSB lifecycle from SDK 1.1.31. Legacy devices
1038
+ // configure before reset and keep the same USBDevice object afterwards.
1039
+ if (first) {
1040
+ await this.runLoggedHandleOperation({
1041
+ operation: 'selectConfiguration',
1042
+ device,
1043
+ path,
1044
+ task: () => device.selectConfiguration(this.configurationId),
1045
+ });
1046
+ try {
1047
+ await this.runLoggedHandleOperation({
1048
+ operation: 'reset',
1049
+ device,
1050
+ path,
1051
+ task: () => device.reset(),
1052
+ });
1053
+ } catch {
1054
+ this.assertRunning();
1055
+ // Reset is best-effort in 1.1.31; runLoggedHandleOperation already recorded the error.
1056
+ }
1057
+ }
1058
+
1059
+ // Descriptor lookup is local-only and does not alter the 1.1.31 USB
1060
+ // operation order. Defaults remain available for legacy descriptors.
1061
+ const legacyEndpoints = this.discoverEndpoints(device);
1062
+ this.deviceEndpoints.set(path, legacyEndpoints);
1063
+ await this.claimDeviceInterface(path, device, legacyEndpoints);
1064
+ return;
1065
+ }
1066
+
732
1067
  // A V1 packet retry continues an in-flight exchange. Preserve its endpoint
733
1068
  // state as in the legacy transport; probing and V2 recovery still reset.
734
1069
  if (first || this.deviceProtocol.get(path) !== 'V1') {
735
1070
  try {
736
- await this.runHandleOperation(() => device.reset());
737
- } catch (error) {
1071
+ await this.runLoggedHandleOperation({
1072
+ operation: 'reset',
1073
+ device,
1074
+ path,
1075
+ task: () => device.reset(),
1076
+ });
1077
+ } catch {
738
1078
  this.assertRunning();
739
- this.Log?.debug('[WebUsbTransport] reset before claim failed, continuing:', error);
1079
+ // Reset remains best-effort; runLoggedHandleOperation already recorded the error.
740
1080
  }
741
1081
  }
742
1082
  await this.getConnectedDevices();
@@ -751,7 +1091,12 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
751
1091
  if (!device.opened) {
752
1092
  openingHandles.add(device);
753
1093
  try {
754
- await this.runHandleOperation(() => device.open());
1094
+ await this.runLoggedHandleOperation({
1095
+ operation: 'open',
1096
+ device,
1097
+ path,
1098
+ task: () => device.open(),
1099
+ });
755
1100
  } catch (error) {
756
1101
  return await this.throwWebUsbIoError(path, 'open', error);
757
1102
  } finally {
@@ -764,19 +1109,34 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
764
1109
  !device.configuration ||
765
1110
  device.configuration.configurationValue !== this.configurationId
766
1111
  ) {
767
- await this.runHandleOperation(() => device.selectConfiguration(this.configurationId));
1112
+ await this.runLoggedHandleOperation({
1113
+ operation: 'selectConfiguration',
1114
+ device,
1115
+ path,
1116
+ task: () => device.selectConfiguration(this.configurationId),
1117
+ });
768
1118
  }
769
1119
 
770
1120
  // Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
771
1121
  const endpoints = this.discoverEndpoints(device);
772
1122
  this.deviceEndpoints.set(path, endpoints);
1123
+ await this.claimDeviceInterface(path, device, endpoints);
1124
+ await this.clearEndpointHalt(path, device, 'in', endpoints.endpointIn);
1125
+ await this.clearEndpointHalt(path, device, 'out', endpoints.endpointOut);
1126
+ }
1127
+
1128
+ private async claimDeviceInterface(path: string, device: USBDevice, endpoints: DeviceEndpoints) {
773
1129
  try {
774
- await this.runHandleOperation(() => device.claimInterface(endpoints.interfaceNumber));
1130
+ await this.runLoggedHandleOperation({
1131
+ operation: 'claimInterface',
1132
+ device,
1133
+ path,
1134
+ interfaceNumber: endpoints.interfaceNumber,
1135
+ task: () => device.claimInterface(endpoints.interfaceNumber),
1136
+ });
775
1137
  } catch (error) {
776
1138
  return this.throwWebUsbIoError(path, 'claimInterface', error);
777
1139
  }
778
- await this.clearEndpointHalt(device, 'in', endpoints.endpointIn);
779
- await this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
780
1140
  }
781
1141
 
782
1142
  private assertHandleAvailable(device: USBDevice) {
@@ -800,7 +1160,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
800
1160
  private releaseNormalHandles(path: string) {
801
1161
  if (this.stopped) return;
802
1162
  for (const [device, ownedPath] of this.normalDevices) {
803
- if (ownedPath === path) {
1163
+ if (ownedPath === path && !device.opened) {
1164
+ // Do not expose a native handle to another transport while close failed.
1165
+ // A later release/stop can retry cleanup through normalDevices.
804
1166
  normalHandleOwners.delete(device);
805
1167
  this.normalDevices.delete(device);
806
1168
  }
@@ -844,36 +1206,83 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
844
1206
 
845
1207
  private async closeOpenDevice(path: string) {
846
1208
  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;
1209
+ const devices = this.identityProbe
1210
+ ? [this.identityProbeDevices.get(path)].filter(
1211
+ (device): device is USBDevice => device !== undefined
1212
+ )
1213
+ : [...this.normalDevices]
1214
+ .filter(([, ownedPath]) => ownedPath === path)
1215
+ .map(([device]) => device);
1216
+ let firstCloseError: unknown;
1217
+ for (const device of devices) {
1218
+ try {
1219
+ await this.closeDeviceHandle(path, device);
1220
+ } catch (error) {
1221
+ firstCloseError ??= error;
1222
+ }
1223
+ }
1224
+ if (firstCloseError) {
1225
+ throw firstCloseError instanceof Error
1226
+ ? firstCloseError
1227
+ : new Error(this.getErrorMessage(firstCloseError));
1228
+ }
1229
+ }
1230
+
1231
+ private async closeDeviceHandle(path: string, current: USBDevice) {
1232
+ if (!current.opened) return;
851
1233
  this.assertHandleAvailable(current);
852
1234
 
853
1235
  const endpoints = this.deviceEndpoints.get(path);
854
1236
  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);
1237
+ if (this.getInterfaceClaimed(current, ifaceNum) === false) {
1238
+ this.logSkippedHandleOperation(
1239
+ 'releaseInterface',
1240
+ current,
1241
+ 'interface-not-claimed',
1242
+ ifaceNum
1243
+ );
1244
+ } else {
1245
+ try {
1246
+ await this.runLoggedHandleOperation({
1247
+ operation: 'releaseInterface',
1248
+ device: current,
1249
+ path,
1250
+ interfaceNumber: ifaceNum,
1251
+ task: () => current.releaseInterface(ifaceNum),
1252
+ });
1253
+ } catch (error) {
1254
+ this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
1255
+ }
859
1256
  }
860
1257
  this.assertHandleAvailable(current);
861
1258
  try {
862
- await this.runHandleOperation(() => current.close());
1259
+ await this.runLoggedHandleOperation({
1260
+ operation: 'close',
1261
+ device: current,
1262
+ path,
1263
+ task: () => current.close(),
1264
+ });
863
1265
  } catch (error) {
864
1266
  this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
865
- if (this.identityProbe) throw error;
1267
+ throw error;
866
1268
  }
867
1269
  }
868
1270
 
869
1271
  private async clearEndpointHalt(
1272
+ path: string,
870
1273
  device: USBDevice,
871
1274
  direction: USBDirection,
872
1275
  endpointNumber: number
873
1276
  ) {
874
1277
  this.assertHandleAvailable(device);
875
1278
  try {
876
- await this.runHandleOperation(() => device.clearHalt(direction, endpointNumber));
1279
+ await this.runLoggedHandleOperation({
1280
+ operation: direction === 'in' ? 'clearHaltIn' : 'clearHaltOut',
1281
+ device,
1282
+ path,
1283
+ endpointNumber,
1284
+ task: () => device.clearHalt(direction, endpointNumber),
1285
+ });
877
1286
  } catch (error) {
878
1287
  this.assertRunning();
879
1288
  this.Log?.debug(
@@ -1030,13 +1439,33 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
1030
1439
  if (currentDevice.opened) {
1031
1440
  const endpoints = this.deviceEndpoints.get(path);
1032
1441
  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);
1442
+ if (this.getInterfaceClaimed(currentDevice, ifaceNum) === false) {
1443
+ this.logSkippedHandleOperation(
1444
+ 'releaseInterface',
1445
+ currentDevice,
1446
+ 'interface-not-claimed',
1447
+ ifaceNum
1448
+ );
1449
+ } else {
1450
+ try {
1451
+ await this.runLoggedHandleOperation({
1452
+ operation: 'releaseInterface',
1453
+ device: currentDevice,
1454
+ path,
1455
+ interfaceNumber: ifaceNum,
1456
+ task: () => currentDevice.releaseInterface(ifaceNum),
1457
+ });
1458
+ } catch (releaseError) {
1459
+ this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
1460
+ }
1037
1461
  }
1038
1462
  this.assertHandleAvailable(currentDevice);
1039
- await this.runHandleOperation(() => currentDevice.close());
1463
+ await this.runLoggedHandleOperation({
1464
+ operation: 'close',
1465
+ device: currentDevice,
1466
+ path,
1467
+ task: () => currentDevice.close(),
1468
+ });
1040
1469
  }
1041
1470
  } catch (closeError) {
1042
1471
  this.assertRunning();
@@ -1401,10 +1830,23 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
1401
1830
  * Release device
1402
1831
  */
1403
1832
  async release(path: string) {
1833
+ const startedAt = Date.now();
1834
+ this.logLifecycle('release', { phase: 'start' });
1404
1835
  this.acquiredPaths.delete(path);
1405
1836
  try {
1406
1837
  await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1407
1838
  await this.closeOpenDevice(path);
1839
+ this.logLifecycle('release', {
1840
+ phase: 'success',
1841
+ durationMs: Date.now() - startedAt,
1842
+ });
1843
+ } catch (error) {
1844
+ this.logLifecycle('release', {
1845
+ phase: 'error',
1846
+ durationMs: Date.now() - startedAt,
1847
+ ...this.getDiagnosticError(error, path),
1848
+ });
1849
+ throw error;
1408
1850
  } finally {
1409
1851
  this.releaseIdentityProbeHandle(path);
1410
1852
  this.releaseNormalHandles(path);