@onekeyfe/hd-transport-web-device 1.2.8-alpha.100 → 1.2.8-alpha.102

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
@@ -44,6 +44,9 @@ const REPORT_ID = PROTOCOL_V1_REPORT_ID;
44
44
  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
+ 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;
47
50
  // Legacy devices can take longer to answer Initialize after a WebUSB reset.
48
51
  // Keep this aligned with Node USB so a slow Protocol V1 response is not
49
52
  // misreported as a protocol mismatch during acquire.
@@ -73,6 +76,23 @@ interface TransferCancelToken {
73
76
 
74
77
  type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
75
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
+
76
96
  /**
77
97
  * The navigator.usb disconnect listener is module-scoped and attached at most
78
98
  * once: listeners on navigator.usb are global and never garbage collected, so a
@@ -82,6 +102,13 @@ type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
82
102
  */
83
103
  let activeWebUsbTransport: WebUsbTransport | undefined;
84
104
  let usbDisconnectListenerAttached = false;
105
+ let nextWebUsbTransportInstanceId = 0;
106
+ // Only isolated discovery owns these handles. Normal calls must never close or
107
+ // open them, including while native open() is still pending.
108
+ const identityProbeHandles = new WeakSet<USBDevice>();
109
+ const openingHandles = new WeakSet<USBDevice>();
110
+ // A normal acquire keeps ownership even while recovery temporarily closes the handle.
111
+ const normalHandleOwners = new WeakMap<USBDevice, WebUsbTransport>();
85
112
 
86
113
  function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
87
114
  activeWebUsbTransport = instance;
@@ -100,6 +127,14 @@ function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
100
127
  }
101
128
 
102
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
+
103
138
  messages: ReturnType<typeof transport.parseConfigure> | undefined;
104
139
 
105
140
  /** Protobuf schema for Protocol V2 transports. */
@@ -124,6 +159,16 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
124
159
  /** Paths currently acquired (between a successful acquire() and its release()). */
125
160
  private acquiredPaths: Set<string> = new Set();
126
161
 
162
+ private identityProbeDevices = new Map<string, USBDevice>();
163
+
164
+ private normalDevices = new Map<USBDevice, string>();
165
+
166
+ private pendingHandleOperations = new Set<Promise<unknown>>();
167
+
168
+ private stopPromise?: Promise<void>;
169
+
170
+ private identityProbeCleanupPaths = new Set<string>();
171
+
127
172
  /**
128
173
  * The exact USBDevice object each cached protocol was probed against. The
129
174
  * browser returns the same object identity for a device as long as it stays
@@ -160,7 +205,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
160
205
 
161
206
  interfaceId = INTERFACE_ID;
162
207
 
163
- constructor() {
208
+ constructor(private readonly identityProbe = false) {
164
209
  super({
165
210
  router: PROTOCOL_V2_CHANNEL_USB,
166
211
  maxFrameBytes: PROTOCOL_V2_FRAME_MAX_BYTES,
@@ -183,7 +228,250 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
183
228
  );
184
229
  }
185
230
  this.usb = usb;
186
- registerActiveWebUsbTransport(this, usb);
231
+ if (!this.identityProbe) registerActiveWebUsbTransport(this, usb);
232
+ }
233
+
234
+ stop(): Promise<void> {
235
+ const startedAt = Date.now();
236
+ this.stopped = true;
237
+ this.acquiredPaths.clear();
238
+ if (activeWebUsbTransport === this) activeWebUsbTransport = undefined;
239
+ if (!this.stopPromise) {
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
+ );
259
+ }
260
+ return this.stopPromise;
261
+ }
262
+
263
+ private async stopResources() {
264
+ let cleanupError: unknown;
265
+ try {
266
+ await this.disposeProtocolV2UsbLinks('WebUSB transport stopped');
267
+ } catch (error) {
268
+ cleanupError = error;
269
+ }
270
+ // Wait for late open/reset/claim/close completions before releasing ownership.
271
+ // Reads are aborted by closing their handle and reject late results below.
272
+ await Promise.allSettled([...this.pendingHandleOperations]);
273
+ const handles = this.identityProbe
274
+ ? [...this.identityProbeDevices].map(([path, device]) => [device, path] as const)
275
+ : [...this.normalDevices];
276
+ for (const [device, path] of handles) {
277
+ if (!this.identityProbe && normalHandleOwners.get(device) !== this) {
278
+ this.normalDevices.delete(device);
279
+ } else {
280
+ try {
281
+ if (device.opened) {
282
+ const iface = this.deviceEndpoints.get(path)?.interfaceNumber ?? this.interfaceId;
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
+ }
303
+ }
304
+ await this.runLoggedHandleOperation({
305
+ operation: 'close',
306
+ device,
307
+ path,
308
+ allowStopped: true,
309
+ task: () => device.close(),
310
+ });
311
+ if (device.opened) throw new Error('WebUSB handle remains open after stop');
312
+ }
313
+ if (this.identityProbe) {
314
+ identityProbeHandles.delete(device);
315
+ this.identityProbeDevices.delete(path);
316
+ this.identityProbeCleanupPaths.delete(path);
317
+ } else {
318
+ normalHandleOwners.delete(device);
319
+ this.normalDevices.delete(device);
320
+ }
321
+ } catch (error) {
322
+ cleanupError ??= error;
323
+ }
324
+ }
325
+ }
326
+ this.deviceList = [];
327
+ this.deviceProtocol.clear();
328
+ this.confirmedDeviceProtocols.clear();
329
+ this.deviceProtocolHints.clear();
330
+ this.staleProtocolPaths.clear();
331
+ this.probedDeviceObjects.clear();
332
+ if (!this.normalDevices.size && !this.identityProbeDevices.size) {
333
+ this.deviceEndpoints.clear();
334
+ }
335
+ if (cleanupError) {
336
+ throw cleanupError instanceof Error
337
+ ? cleanupError
338
+ : new Error(this.getErrorMessage(cleanupError));
339
+ }
340
+ }
341
+
342
+ private assertRunning() {
343
+ if (this.stopped) {
344
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'WebUSB transport is stopped');
345
+ }
346
+ }
347
+
348
+ private async runHandleOperation<T>(operation: () => Promise<T>): Promise<T> {
349
+ this.assertRunning();
350
+ const pending = Promise.resolve(operation());
351
+ this.pendingHandleOperations.add(pending);
352
+ try {
353
+ const result = await pending;
354
+ this.assertRunning();
355
+ return result;
356
+ } finally {
357
+ this.pendingHandleOperations.delete(pending);
358
+ }
359
+ }
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
+ }
187
475
  }
188
476
 
189
477
  /**
@@ -249,10 +537,22 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
249
537
  */
250
538
  async promptDeviceAccess() {
251
539
  if (!this.usb) return null;
540
+ const startedAt = Date.now();
541
+ this.logLifecycle('prompt-device-access', { phase: 'start' });
252
542
  try {
253
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
+ });
254
549
  return device;
255
550
  } catch (e) {
551
+ this.logLifecycle('prompt-device-access', {
552
+ phase: 'error',
553
+ durationMs: Date.now() - startedAt,
554
+ ...this.getDiagnosticError(e),
555
+ });
256
556
  this.Log.debug(
257
557
  'requestDevice error: ',
258
558
  e instanceof Error ? `${e.name}: ${e.message}` : String(e)
@@ -267,17 +567,73 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
267
567
  * It does NOT prompt the user to select a device
268
568
  */
269
569
  async enumerate() {
270
- await this.getConnectedDevices();
271
- return this.deviceList;
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
+ }
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;
598
+ }
272
599
  }
273
600
 
274
601
  /**
275
602
  * Get list of connected devices
276
603
  */
277
604
  async getConnectedDevices() {
605
+ this.assertRunning();
278
606
  if (!this.usb) return [];
279
607
 
280
- 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
+ }
626
+ this.assertRunning();
627
+ for (const owner of new Set(devices.map(device => normalHandleOwners.get(device)))) {
628
+ if (owner?.stopped) {
629
+ try {
630
+ await owner.stop();
631
+ } catch (error) {
632
+ this.Log?.debug('[WebUsbTransport] stopped owner cleanup failed:', error);
633
+ }
634
+ }
635
+ }
636
+ this.assertRunning();
281
637
  const onekeyDevices = devices.filter(dev => {
282
638
  const isOneKey = ONEKEY_WEBUSB_FILTER.some(
283
639
  (desc: { vendorId: number; productId: number }) =>
@@ -310,11 +666,30 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
310
666
  * Acquire device control
311
667
  */
312
668
  async acquire(input: AcquireInput) {
669
+ this.assertRunning();
313
670
  if (!input.path) return;
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
+ });
314
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
+ }
315
687
  await this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
316
688
  await this.closeOpenDevice(input.path);
317
- await this.connect(input.path ?? '', true);
689
+ await this.connect(input.path ?? '', true, {
690
+ expectedProtocol: input.expectedProtocol,
691
+ forceProtocolDetection: input.forceProtocolDetection,
692
+ });
318
693
  if (input.skipProtocolProbe) {
319
694
  if (!input.expectedProtocol) {
320
695
  throw ERRORS.TypedError(
@@ -383,18 +758,35 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
383
758
  input.expectedProtocol,
384
759
  protocolHint
385
760
  );
761
+ this.assertRunning();
386
762
  this.confirmedDeviceProtocols.set(input.path, detectedProtocol);
387
763
  const probedDevice = this.deviceList.find(d => d.path === input.path)?.device;
388
764
  if (probedDevice) {
389
765
  this.probedDeviceObjects.set(input.path, probedDevice);
390
766
  }
391
767
  }
768
+ this.assertRunning();
392
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
+ });
393
775
  return await Promise.resolve(input.path);
394
776
  } catch (e) {
777
+ this.logLifecycle('acquire', {
778
+ phase: 'error',
779
+ durationMs: Date.now() - startedAt,
780
+ ...this.getDiagnosticError(e, input.path),
781
+ });
395
782
  this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
396
783
  this.acquiredPaths.delete(input.path);
397
- await this.closeOpenDevice(input.path);
784
+ try {
785
+ await this.closeOpenDevice(input.path);
786
+ } finally {
787
+ this.releaseIdentityProbeHandle(input.path);
788
+ this.releaseNormalHandles(input.path);
789
+ }
398
790
  throw e;
399
791
  }
400
792
  }
@@ -405,21 +797,20 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
405
797
  * fall back to a Protocol V2 Ping probe.
406
798
  */
407
799
  private createProtocolMismatchError(expected: ProtocolType) {
408
- return ERRORS.TypedError(
409
- HardwareErrorCode.RuntimeError,
410
- `Device protocol mismatch: expected ${expected}, but device did not respond to expected protocol`
411
- );
800
+ return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
801
+ expectedProtocol: expected,
802
+ });
412
803
  }
413
804
 
414
805
  private createProtocolProbeTimeoutError(expected: ProtocolType, attempts: number) {
415
- return ERRORS.TypedError(
416
- HardwareErrorCode.DeviceInitializeFailed,
417
- `Protocol ${expected} probe timeout after ${attempts} attempts`
418
- );
806
+ return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
807
+ expectedProtocol: expected,
808
+ attempts,
809
+ });
419
810
  }
420
811
 
421
812
  private createProtocolDetectionError() {
422
- return ERRORS.TypedError(HardwareErrorCode.DeviceNotFound);
813
+ return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed);
423
814
  }
424
815
 
425
816
  private async detectProtocol(
@@ -513,16 +904,55 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
513
904
  /**
514
905
  * Connect to device with retry mechanism
515
906
  */
516
- async connect(path: string, first: boolean) {
907
+ async connect(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
517
908
  const maxRetries = 5;
518
909
  for (let i = 0; i < maxRetries; i++) {
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
+ });
519
923
  try {
520
- 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;
521
934
  } catch (e) {
522
- if (e instanceof HardwareError && e.errorCode === HardwareErrorCode.BridgeNeedsPermission) {
935
+ this.assertRunning();
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) {
523
953
  throw e;
524
954
  }
525
- if (i === maxRetries - 1) {
955
+ if (!willRetry) {
526
956
  throw e;
527
957
  }
528
958
  await wait(i * 200);
@@ -530,6 +960,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
530
960
  }
531
961
  }
532
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
+
533
968
  /**
534
969
  * Discover vendor-class (0xFF) interface and its IN/OUT endpoint numbers from USB descriptors.
535
970
  * Falls back to legacy hardcoded values if no vendor interface is found.
@@ -567,31 +1002,102 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
567
1002
  * Connect to specific device.
568
1003
  * Discovers interface/endpoint numbers from USB descriptors on first connection.
569
1004
  */
570
- async connectToDevice(path: string, first: boolean) {
1005
+ async connectToDevice(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
1006
+ this.assertRunning();
571
1007
  let device: USBDevice = await this.findDevice(path);
1008
+ if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
1009
+ await this.closeOpenDevice(path);
1010
+ this.releaseIdentityProbeHandle(path);
1011
+ this.claimIdentityProbeHandle(path, device);
1012
+ }
1013
+ if (!this.identityProbe) this.claimNormalHandle(path, device);
1014
+ this.assertHandleAvailable(device);
572
1015
  if (!device.opened) {
1016
+ openingHandles.add(device);
573
1017
  try {
574
- await device.open();
1018
+ await this.runLoggedHandleOperation({
1019
+ operation: 'open',
1020
+ device,
1021
+ path,
1022
+ task: () => device.open(),
1023
+ });
575
1024
  } catch (error) {
576
- return this.throwWebUsbIoError(path, 'open', error);
1025
+ return await this.throwWebUsbIoError(path, 'open', error);
1026
+ } finally {
1027
+ openingHandles.delete(device);
577
1028
  }
578
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
+ const legacyEndpoints = {
1055
+ interfaceNumber: this.interfaceId,
1056
+ endpointIn: this.endpointId,
1057
+ endpointOut: this.endpointId,
1058
+ };
1059
+ this.deviceEndpoints.set(path, legacyEndpoints);
1060
+ await this.claimDeviceInterface(path, device, legacyEndpoints);
1061
+ return;
1062
+ }
1063
+
579
1064
  // A V1 packet retry continues an in-flight exchange. Preserve its endpoint
580
1065
  // state as in the legacy transport; probing and V2 recovery still reset.
581
1066
  if (first || this.deviceProtocol.get(path) !== 'V1') {
582
1067
  try {
583
- await device.reset();
584
- } catch (error) {
585
- this.Log?.debug('[WebUsbTransport] reset before claim failed, continuing:', error);
1068
+ await this.runLoggedHandleOperation({
1069
+ operation: 'reset',
1070
+ device,
1071
+ path,
1072
+ task: () => device.reset(),
1073
+ });
1074
+ } catch {
1075
+ this.assertRunning();
1076
+ // Reset remains best-effort; runLoggedHandleOperation already recorded the error.
586
1077
  }
587
1078
  }
588
1079
  await this.getConnectedDevices();
589
1080
  device = await this.findDevice(path);
1081
+ if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
1082
+ await this.closeOpenDevice(path);
1083
+ this.releaseIdentityProbeHandle(path);
1084
+ this.claimIdentityProbeHandle(path, device);
1085
+ }
1086
+ if (!this.identityProbe) this.claimNormalHandle(path, device);
1087
+ this.assertHandleAvailable(device);
590
1088
  if (!device.opened) {
1089
+ openingHandles.add(device);
591
1090
  try {
592
- await device.open();
1091
+ await this.runLoggedHandleOperation({
1092
+ operation: 'open',
1093
+ device,
1094
+ path,
1095
+ task: () => device.open(),
1096
+ });
593
1097
  } catch (error) {
594
- return this.throwWebUsbIoError(path, 'open', error);
1098
+ return await this.throwWebUsbIoError(path, 'open', error);
1099
+ } finally {
1100
+ openingHandles.delete(device);
595
1101
  }
596
1102
  }
597
1103
 
@@ -600,47 +1106,182 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
600
1106
  !device.configuration ||
601
1107
  device.configuration.configurationValue !== this.configurationId
602
1108
  ) {
603
- await device.selectConfiguration(this.configurationId);
1109
+ await this.runLoggedHandleOperation({
1110
+ operation: 'selectConfiguration',
1111
+ device,
1112
+ path,
1113
+ task: () => device.selectConfiguration(this.configurationId),
1114
+ });
604
1115
  }
605
1116
 
606
1117
  // Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
607
1118
  const endpoints = this.discoverEndpoints(device);
608
1119
  this.deviceEndpoints.set(path, endpoints);
1120
+ await this.claimDeviceInterface(path, device, endpoints);
1121
+ await this.clearEndpointHalt(path, device, 'in', endpoints.endpointIn);
1122
+ await this.clearEndpointHalt(path, device, 'out', endpoints.endpointOut);
1123
+ }
1124
+
1125
+ private async claimDeviceInterface(path: string, device: USBDevice, endpoints: DeviceEndpoints) {
609
1126
  try {
610
- await device.claimInterface(endpoints.interfaceNumber);
1127
+ await this.runLoggedHandleOperation({
1128
+ operation: 'claimInterface',
1129
+ device,
1130
+ path,
1131
+ interfaceNumber: endpoints.interfaceNumber,
1132
+ task: () => device.claimInterface(endpoints.interfaceNumber),
1133
+ });
611
1134
  } catch (error) {
612
1135
  return this.throwWebUsbIoError(path, 'claimInterface', error);
613
1136
  }
614
- await this.clearEndpointHalt(device, 'in', endpoints.endpointIn);
615
- await this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
1137
+ }
1138
+
1139
+ private assertHandleAvailable(device: USBDevice) {
1140
+ this.assertRunning();
1141
+ const normalOwner = normalHandleOwners.get(device);
1142
+ if (
1143
+ (normalOwner && normalOwner !== this) ||
1144
+ (identityProbeHandles.has(device) &&
1145
+ ![...this.identityProbeDevices.values()].includes(device))
1146
+ ) {
1147
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
1148
+ }
1149
+ }
1150
+
1151
+ private claimNormalHandle(path: string, device: USBDevice) {
1152
+ this.assertHandleAvailable(device);
1153
+ normalHandleOwners.set(device, this);
1154
+ this.normalDevices.set(device, path);
1155
+ }
1156
+
1157
+ private releaseNormalHandles(path: string) {
1158
+ if (this.stopped) return;
1159
+ for (const [device, ownedPath] of this.normalDevices) {
1160
+ if (ownedPath === path && !device.opened) {
1161
+ // Do not expose a native handle to another transport while close failed.
1162
+ // A later release/stop can retry cleanup through normalDevices.
1163
+ normalHandleOwners.delete(device);
1164
+ this.normalDevices.delete(device);
1165
+ }
1166
+ }
1167
+ }
1168
+
1169
+ private isHandleOwnershipError(error: unknown): error is HardwareError {
1170
+ return (
1171
+ error instanceof HardwareError &&
1172
+ error.errorCode === HardwareErrorCode.RuntimeError &&
1173
+ this.getErrorMessage(error).includes(USB_DEVICE_IN_USE)
1174
+ );
1175
+ }
1176
+
1177
+ private claimIdentityProbeHandle(path: string, device: USBDevice) {
1178
+ this.assertRunning();
1179
+ if (
1180
+ device.opened ||
1181
+ openingHandles.has(device) ||
1182
+ identityProbeHandles.has(device) ||
1183
+ normalHandleOwners.has(device)
1184
+ ) {
1185
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
1186
+ }
1187
+ identityProbeHandles.add(device);
1188
+ this.identityProbeDevices.set(path, device);
1189
+ }
1190
+
1191
+ private releaseIdentityProbeHandle(path: string) {
1192
+ if (this.stopped) return;
1193
+ const device = this.identityProbeDevices.get(path);
1194
+ // A failed native close must not make an open probe handle available to other callers.
1195
+ if (device?.opened) {
1196
+ this.identityProbeCleanupPaths.add(path);
1197
+ return;
1198
+ }
1199
+ if (device) identityProbeHandles.delete(device);
1200
+ this.identityProbeDevices.delete(path);
1201
+ this.identityProbeCleanupPaths.delete(path);
616
1202
  }
617
1203
 
618
1204
  private async closeOpenDevice(path: string) {
619
- const current = this.deviceList.find(device => device.path === path)?.device;
620
- if (!current?.opened) return;
1205
+ if (this.stopped) return;
1206
+ const devices = this.identityProbe
1207
+ ? [this.identityProbeDevices.get(path)].filter(
1208
+ (device): device is USBDevice => device !== undefined
1209
+ )
1210
+ : [...this.normalDevices]
1211
+ .filter(([, ownedPath]) => ownedPath === path)
1212
+ .map(([device]) => device);
1213
+ let firstCloseError: unknown;
1214
+ for (const device of devices) {
1215
+ try {
1216
+ await this.closeDeviceHandle(path, device);
1217
+ } catch (error) {
1218
+ firstCloseError ??= error;
1219
+ }
1220
+ }
1221
+ if (firstCloseError) {
1222
+ throw firstCloseError instanceof Error
1223
+ ? firstCloseError
1224
+ : new Error(this.getErrorMessage(firstCloseError));
1225
+ }
1226
+ }
1227
+
1228
+ private async closeDeviceHandle(path: string, current: USBDevice) {
1229
+ if (!current.opened) return;
1230
+ this.assertHandleAvailable(current);
621
1231
 
622
1232
  const endpoints = this.deviceEndpoints.get(path);
623
1233
  const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
624
- try {
625
- await current.releaseInterface(ifaceNum);
626
- } catch (error) {
627
- this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
1234
+ if (this.getInterfaceClaimed(current, ifaceNum) === false) {
1235
+ this.logSkippedHandleOperation(
1236
+ 'releaseInterface',
1237
+ current,
1238
+ 'interface-not-claimed',
1239
+ ifaceNum
1240
+ );
1241
+ } else {
1242
+ try {
1243
+ await this.runLoggedHandleOperation({
1244
+ operation: 'releaseInterface',
1245
+ device: current,
1246
+ path,
1247
+ interfaceNumber: ifaceNum,
1248
+ task: () => current.releaseInterface(ifaceNum),
1249
+ });
1250
+ } catch (error) {
1251
+ this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
1252
+ }
628
1253
  }
1254
+ this.assertHandleAvailable(current);
629
1255
  try {
630
- await current.close();
1256
+ await this.runLoggedHandleOperation({
1257
+ operation: 'close',
1258
+ device: current,
1259
+ path,
1260
+ task: () => current.close(),
1261
+ });
631
1262
  } catch (error) {
632
1263
  this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
1264
+ throw error;
633
1265
  }
634
1266
  }
635
1267
 
636
1268
  private async clearEndpointHalt(
1269
+ path: string,
637
1270
  device: USBDevice,
638
1271
  direction: USBDirection,
639
1272
  endpointNumber: number
640
1273
  ) {
1274
+ this.assertHandleAvailable(device);
641
1275
  try {
642
- await device.clearHalt(direction, endpointNumber);
1276
+ await this.runLoggedHandleOperation({
1277
+ operation: direction === 'in' ? 'clearHaltIn' : 'clearHaltOut',
1278
+ device,
1279
+ path,
1280
+ endpointNumber,
1281
+ task: () => device.clearHalt(direction, endpointNumber),
1282
+ });
643
1283
  } catch (error) {
1284
+ this.assertRunning();
644
1285
  this.Log?.debug(
645
1286
  `[WebUsbTransport] clearHalt ${direction} endpoint ${endpointNumber} failed, continuing:`,
646
1287
  error
@@ -656,6 +1297,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
656
1297
  * protocol entry produced the same fail-fast).
657
1298
  */
658
1299
  private assertAcquired(path: string) {
1300
+ this.assertRunning();
659
1301
  if (!this.acquiredPaths.has(path)) {
660
1302
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, `Device is not acquired for ${path}`);
661
1303
  }
@@ -713,7 +1355,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
713
1355
  operation: WebUsbOperation,
714
1356
  error: unknown
715
1357
  ): Promise<never> {
716
- if (this.isWebUsbIoHardwareError(error)) throw error;
1358
+ this.assertRunning();
1359
+ if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) throw error;
717
1360
  if (error instanceof ProtocolV2LinkError && error.code !== 'io') throw error;
718
1361
 
719
1362
  const nativeError = error instanceof ProtocolV2LinkError ? error.cause ?? error : error;
@@ -753,7 +1396,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
753
1396
  }
754
1397
 
755
1398
  private isRetryablePacketIoError(error: unknown) {
756
- if (this.isWebUsbIoHardwareError(error)) return false;
1399
+ if (this.stopped) return false;
1400
+ if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) return false;
757
1401
  const message = this.getErrorMessage(error).toLowerCase();
758
1402
  if (this.isWebUsbPermissionDeniedMessage(message)) return false;
759
1403
  return (
@@ -784,20 +1428,45 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
784
1428
  // USB disconnect event itself was missed.
785
1429
  this.staleProtocolPaths.add(path);
786
1430
  await wait(attempt * PACKET_IO_RETRY_DELAY);
1431
+ this.assertRunning();
787
1432
 
788
1433
  try {
789
1434
  const currentDevice = await this.findDevice(path);
1435
+ this.assertHandleAvailable(currentDevice);
790
1436
  if (currentDevice.opened) {
791
1437
  const endpoints = this.deviceEndpoints.get(path);
792
1438
  const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
793
- try {
794
- await currentDevice.releaseInterface(ifaceNum);
795
- } catch (releaseError) {
796
- this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
1439
+ if (this.getInterfaceClaimed(currentDevice, ifaceNum) === false) {
1440
+ this.logSkippedHandleOperation(
1441
+ 'releaseInterface',
1442
+ currentDevice,
1443
+ 'interface-not-claimed',
1444
+ ifaceNum
1445
+ );
1446
+ } else {
1447
+ try {
1448
+ await this.runLoggedHandleOperation({
1449
+ operation: 'releaseInterface',
1450
+ device: currentDevice,
1451
+ path,
1452
+ interfaceNumber: ifaceNum,
1453
+ task: () => currentDevice.releaseInterface(ifaceNum),
1454
+ });
1455
+ } catch (releaseError) {
1456
+ this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
1457
+ }
797
1458
  }
798
- await currentDevice.close();
1459
+ this.assertHandleAvailable(currentDevice);
1460
+ await this.runLoggedHandleOperation({
1461
+ operation: 'close',
1462
+ device: currentDevice,
1463
+ path,
1464
+ task: () => currentDevice.close(),
1465
+ });
799
1466
  }
800
1467
  } catch (closeError) {
1468
+ this.assertRunning();
1469
+ if (this.isHandleOwnershipError(closeError)) throw closeError;
801
1470
  this.Log.debug('[WebUsbTransport] close device before retry error:', closeError);
802
1471
  }
803
1472
 
@@ -839,6 +1508,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
839
1508
  try {
840
1509
  await this.reconnectForPacketIoRetry(path, 'out', attempt, error);
841
1510
  } catch (reconnectError) {
1511
+ this.assertRunning();
1512
+ if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
842
1513
  lastError = reconnectError;
843
1514
  this.Log.debug(
844
1515
  `[WebUsbTransport] transferout reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
@@ -861,7 +1532,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
861
1532
  const transferBuffer = this.toArrayBuffer(
862
1533
  packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength)
863
1534
  );
1535
+ this.assertHandleAvailable(device);
864
1536
  const result = await device.transferOut(endpointOut, transferBuffer);
1537
+ this.assertHandleAvailable(device);
865
1538
  if (result.status !== 'ok') {
866
1539
  return this.throwWebUsbIoError(
867
1540
  path,
@@ -888,7 +1561,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
888
1561
  }
889
1562
  const endpoints = this.deviceEndpoints.get(path);
890
1563
  const endpointIn = endpoints?.endpointIn ?? this.endpointId;
1564
+ this.assertHandleAvailable(device);
891
1565
  const result = await device.transferIn(endpointIn, length);
1566
+ this.assertHandleAvailable(device);
892
1567
  return this.getTransferInData(result);
893
1568
  } catch (error) {
894
1569
  lastError = error;
@@ -902,6 +1577,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
902
1577
  try {
903
1578
  await this.reconnectForPacketIoRetry(path, 'in', attempt, error);
904
1579
  } catch (reconnectError) {
1580
+ this.assertRunning();
1581
+ if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
905
1582
  lastError = reconnectError;
906
1583
  this.Log.debug(
907
1584
  `[WebUsbTransport] transferin reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
@@ -921,7 +1598,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
921
1598
  }
922
1599
  const endpoints = this.deviceEndpoints.get(path);
923
1600
  const endpointIn = endpoints?.endpointIn ?? this.endpointId;
1601
+ this.assertHandleAvailable(device);
924
1602
  const result = await device.transferIn(endpointIn, length);
1603
+ this.assertHandleAvailable(device);
925
1604
  return this.getTransferInData(result);
926
1605
  }
927
1606
 
@@ -984,15 +1663,15 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
984
1663
  }
985
1664
 
986
1665
  try {
987
- await this.callProtocolV1(
1666
+ const response = await this.callProtocolV1(
988
1667
  path,
989
- 'Initialize',
1668
+ this.identityProbe ? 'GetFeatures' : 'Initialize',
990
1669
  {},
991
1670
  {
992
1671
  timeoutMs: PROTOCOL_V1_PROBE_TIMEOUT,
993
1672
  }
994
1673
  );
995
- return true;
1674
+ return !this.identityProbe || response.type === 'Features';
996
1675
  } catch (error) {
997
1676
  if (this.isWebUsbIoHardwareError(error)) throw error;
998
1677
  return false;
@@ -1148,9 +1827,27 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
1148
1827
  * Release device
1149
1828
  */
1150
1829
  async release(path: string) {
1830
+ const startedAt = Date.now();
1831
+ this.logLifecycle('release', { phase: 'start' });
1151
1832
  this.acquiredPaths.delete(path);
1152
- await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1153
- await this.closeOpenDevice(path);
1833
+ try {
1834
+ await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1835
+ await this.closeOpenDevice(path);
1836
+ this.logLifecycle('release', {
1837
+ phase: 'success',
1838
+ durationMs: Date.now() - startedAt,
1839
+ });
1840
+ } catch (error) {
1841
+ this.logLifecycle('release', {
1842
+ phase: 'error',
1843
+ durationMs: Date.now() - startedAt,
1844
+ ...this.getDiagnosticError(error, path),
1845
+ });
1846
+ throw error;
1847
+ } finally {
1848
+ this.releaseIdentityProbeHandle(path);
1849
+ this.releaseNormalHandles(path);
1850
+ }
1154
1851
  // Keep deviceProtocol/deviceProtocolHints across release: the probe result is a
1155
1852
  // physical-device property, so the next acquire can skip the wire-level probe.
1156
1853
  // V2 entries are still dropped by onProtocolV2UsbLinkInvalidated via the link