@onekeyfe/hd-transport-web-device 1.2.8-alpha.13 → 1.2.8-alpha.131

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,40 @@ 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
+ } catch (cleanupError) {
787
+ this.logLifecycle('acquire-cleanup', {
788
+ phase: 'error',
789
+ ...this.getDiagnosticError(cleanupError, input.path),
790
+ });
791
+ } finally {
792
+ this.releaseIdentityProbeHandle(input.path);
793
+ this.releaseNormalHandles(input.path);
794
+ }
398
795
  throw e;
399
796
  }
400
797
  }
@@ -405,21 +802,20 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
405
802
  * fall back to a Protocol V2 Ping probe.
406
803
  */
407
804
  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
- );
805
+ return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
806
+ expectedProtocol: expected,
807
+ });
412
808
  }
413
809
 
414
810
  private createProtocolProbeTimeoutError(expected: ProtocolType, attempts: number) {
415
- return ERRORS.TypedError(
416
- HardwareErrorCode.DeviceInitializeFailed,
417
- `Protocol ${expected} probe timeout after ${attempts} attempts`
418
- );
811
+ return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
812
+ expectedProtocol: expected,
813
+ attempts,
814
+ });
419
815
  }
420
816
 
421
817
  private createProtocolDetectionError() {
422
- return ERRORS.TypedError(HardwareErrorCode.DeviceNotFound);
818
+ return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed);
423
819
  }
424
820
 
425
821
  private async detectProtocol(
@@ -513,16 +909,55 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
513
909
  /**
514
910
  * Connect to device with retry mechanism
515
911
  */
516
- async connect(path: string, first: boolean) {
912
+ async connect(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
517
913
  const maxRetries = 5;
518
914
  for (let i = 0; i < maxRetries; i++) {
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
+ });
519
928
  try {
520
- 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;
521
939
  } catch (e) {
522
- if (e instanceof HardwareError && e.errorCode === HardwareErrorCode.BridgeNeedsPermission) {
940
+ this.assertRunning();
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) {
523
958
  throw e;
524
959
  }
525
- if (i === maxRetries - 1) {
960
+ if (!willRetry) {
526
961
  throw e;
527
962
  }
528
963
  await wait(i * 200);
@@ -530,6 +965,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
530
965
  }
531
966
  }
532
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
+
533
973
  /**
534
974
  * Discover vendor-class (0xFF) interface and its IN/OUT endpoint numbers from USB descriptors.
535
975
  * Falls back to legacy hardcoded values if no vendor interface is found.
@@ -567,31 +1007,106 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
567
1007
  * Connect to specific device.
568
1008
  * Discovers interface/endpoint numbers from USB descriptors on first connection.
569
1009
  */
570
- async connectToDevice(path: string, first: boolean) {
1010
+ async connectToDevice(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
1011
+ this.assertRunning();
571
1012
  let device: USBDevice = await this.findDevice(path);
1013
+ if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
1014
+ await this.closeOpenDevice(path);
1015
+ this.releaseIdentityProbeHandle(path);
1016
+ this.claimIdentityProbeHandle(path, device);
1017
+ }
1018
+ if (!this.identityProbe) this.claimNormalHandle(path, device);
1019
+ this.assertHandleAvailable(device);
572
1020
  if (!device.opened) {
1021
+ openingHandles.add(device);
573
1022
  try {
574
- await device.open();
1023
+ await this.runLoggedHandleOperation({
1024
+ operation: 'open',
1025
+ device,
1026
+ path,
1027
+ task: () => device.open(),
1028
+ });
575
1029
  } catch (error) {
576
- return this.throwWebUsbIoError(path, 'open', error);
1030
+ return await this.throwWebUsbIoError(path, 'open', error);
1031
+ } finally {
1032
+ openingHandles.delete(device);
1033
+ }
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 (
1040
+ first ||
1041
+ !device.configuration ||
1042
+ device.configuration.configurationValue !== this.configurationId
1043
+ ) {
1044
+ await this.runLoggedHandleOperation({
1045
+ operation: 'selectConfiguration',
1046
+ device,
1047
+ path,
1048
+ task: () => device.selectConfiguration(this.configurationId),
1049
+ });
1050
+ }
1051
+ if (first) {
1052
+ try {
1053
+ await this.runLoggedHandleOperation({
1054
+ operation: 'reset',
1055
+ device,
1056
+ path,
1057
+ task: () => device.reset(),
1058
+ });
1059
+ } catch {
1060
+ this.assertRunning();
1061
+ // Reset is best-effort in 1.1.31; runLoggedHandleOperation already recorded the error.
1062
+ }
577
1063
  }
1064
+
1065
+ // Descriptor lookup is local-only and does not alter the 1.1.31 USB
1066
+ // operation order. Defaults remain available for legacy descriptors.
1067
+ const legacyEndpoints = this.discoverEndpoints(device);
1068
+ this.deviceEndpoints.set(path, legacyEndpoints);
1069
+ await this.claimDeviceInterface(path, device, legacyEndpoints);
1070
+ return;
578
1071
  }
1072
+
579
1073
  // A V1 packet retry continues an in-flight exchange. Preserve its endpoint
580
1074
  // state as in the legacy transport; probing and V2 recovery still reset.
581
1075
  if (first || this.deviceProtocol.get(path) !== 'V1') {
582
1076
  try {
583
- await device.reset();
584
- } catch (error) {
585
- this.Log?.debug('[WebUsbTransport] reset before claim failed, continuing:', error);
1077
+ await this.runLoggedHandleOperation({
1078
+ operation: 'reset',
1079
+ device,
1080
+ path,
1081
+ task: () => device.reset(),
1082
+ });
1083
+ } catch {
1084
+ this.assertRunning();
1085
+ // Reset remains best-effort; runLoggedHandleOperation already recorded the error.
586
1086
  }
587
1087
  }
588
1088
  await this.getConnectedDevices();
589
1089
  device = await this.findDevice(path);
1090
+ if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
1091
+ await this.closeOpenDevice(path);
1092
+ this.releaseIdentityProbeHandle(path);
1093
+ this.claimIdentityProbeHandle(path, device);
1094
+ }
1095
+ if (!this.identityProbe) this.claimNormalHandle(path, device);
1096
+ this.assertHandleAvailable(device);
590
1097
  if (!device.opened) {
1098
+ openingHandles.add(device);
591
1099
  try {
592
- await device.open();
1100
+ await this.runLoggedHandleOperation({
1101
+ operation: 'open',
1102
+ device,
1103
+ path,
1104
+ task: () => device.open(),
1105
+ });
593
1106
  } catch (error) {
594
- return this.throwWebUsbIoError(path, 'open', error);
1107
+ return await this.throwWebUsbIoError(path, 'open', error);
1108
+ } finally {
1109
+ openingHandles.delete(device);
595
1110
  }
596
1111
  }
597
1112
 
@@ -600,47 +1115,182 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
600
1115
  !device.configuration ||
601
1116
  device.configuration.configurationValue !== this.configurationId
602
1117
  ) {
603
- await device.selectConfiguration(this.configurationId);
1118
+ await this.runLoggedHandleOperation({
1119
+ operation: 'selectConfiguration',
1120
+ device,
1121
+ path,
1122
+ task: () => device.selectConfiguration(this.configurationId),
1123
+ });
604
1124
  }
605
1125
 
606
1126
  // Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
607
1127
  const endpoints = this.discoverEndpoints(device);
608
1128
  this.deviceEndpoints.set(path, endpoints);
1129
+ await this.claimDeviceInterface(path, device, endpoints);
1130
+ await this.clearEndpointHalt(path, device, 'in', endpoints.endpointIn);
1131
+ await this.clearEndpointHalt(path, device, 'out', endpoints.endpointOut);
1132
+ }
1133
+
1134
+ private async claimDeviceInterface(path: string, device: USBDevice, endpoints: DeviceEndpoints) {
609
1135
  try {
610
- await device.claimInterface(endpoints.interfaceNumber);
1136
+ await this.runLoggedHandleOperation({
1137
+ operation: 'claimInterface',
1138
+ device,
1139
+ path,
1140
+ interfaceNumber: endpoints.interfaceNumber,
1141
+ task: () => device.claimInterface(endpoints.interfaceNumber),
1142
+ });
611
1143
  } catch (error) {
612
1144
  return this.throwWebUsbIoError(path, 'claimInterface', error);
613
1145
  }
614
- await this.clearEndpointHalt(device, 'in', endpoints.endpointIn);
615
- await this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
1146
+ }
1147
+
1148
+ private assertHandleAvailable(device: USBDevice) {
1149
+ this.assertRunning();
1150
+ const normalOwner = normalHandleOwners.get(device);
1151
+ if (
1152
+ (normalOwner && normalOwner !== this) ||
1153
+ (identityProbeHandles.has(device) &&
1154
+ ![...this.identityProbeDevices.values()].includes(device))
1155
+ ) {
1156
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
1157
+ }
1158
+ }
1159
+
1160
+ private claimNormalHandle(path: string, device: USBDevice) {
1161
+ this.assertHandleAvailable(device);
1162
+ normalHandleOwners.set(device, this);
1163
+ this.normalDevices.set(device, path);
1164
+ }
1165
+
1166
+ private releaseNormalHandles(path: string) {
1167
+ if (this.stopped) return;
1168
+ for (const [device, ownedPath] of this.normalDevices) {
1169
+ if (ownedPath === path && !device.opened) {
1170
+ // Do not expose a native handle to another transport while close failed.
1171
+ // A later release/stop can retry cleanup through normalDevices.
1172
+ normalHandleOwners.delete(device);
1173
+ this.normalDevices.delete(device);
1174
+ }
1175
+ }
1176
+ }
1177
+
1178
+ private isHandleOwnershipError(error: unknown): error is HardwareError {
1179
+ return (
1180
+ error instanceof HardwareError &&
1181
+ error.errorCode === HardwareErrorCode.RuntimeError &&
1182
+ this.getErrorMessage(error).includes(USB_DEVICE_IN_USE)
1183
+ );
1184
+ }
1185
+
1186
+ private claimIdentityProbeHandle(path: string, device: USBDevice) {
1187
+ this.assertRunning();
1188
+ if (
1189
+ device.opened ||
1190
+ openingHandles.has(device) ||
1191
+ identityProbeHandles.has(device) ||
1192
+ normalHandleOwners.has(device)
1193
+ ) {
1194
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
1195
+ }
1196
+ identityProbeHandles.add(device);
1197
+ this.identityProbeDevices.set(path, device);
1198
+ }
1199
+
1200
+ private releaseIdentityProbeHandle(path: string) {
1201
+ if (this.stopped) return;
1202
+ const device = this.identityProbeDevices.get(path);
1203
+ // A failed native close must not make an open probe handle available to other callers.
1204
+ if (device?.opened) {
1205
+ this.identityProbeCleanupPaths.add(path);
1206
+ return;
1207
+ }
1208
+ if (device) identityProbeHandles.delete(device);
1209
+ this.identityProbeDevices.delete(path);
1210
+ this.identityProbeCleanupPaths.delete(path);
616
1211
  }
617
1212
 
618
1213
  private async closeOpenDevice(path: string) {
619
- const current = this.deviceList.find(device => device.path === path)?.device;
620
- if (!current?.opened) return;
1214
+ if (this.stopped) return;
1215
+ const devices = this.identityProbe
1216
+ ? [this.identityProbeDevices.get(path)].filter(
1217
+ (device): device is USBDevice => device !== undefined
1218
+ )
1219
+ : [...this.normalDevices]
1220
+ .filter(([, ownedPath]) => ownedPath === path)
1221
+ .map(([device]) => device);
1222
+ let firstCloseError: unknown;
1223
+ for (const device of devices) {
1224
+ try {
1225
+ await this.closeDeviceHandle(path, device);
1226
+ } catch (error) {
1227
+ firstCloseError ??= error;
1228
+ }
1229
+ }
1230
+ if (firstCloseError) {
1231
+ throw firstCloseError instanceof Error
1232
+ ? firstCloseError
1233
+ : new Error(this.getErrorMessage(firstCloseError));
1234
+ }
1235
+ }
1236
+
1237
+ private async closeDeviceHandle(path: string, current: USBDevice) {
1238
+ if (!current.opened) return;
1239
+ this.assertHandleAvailable(current);
621
1240
 
622
1241
  const endpoints = this.deviceEndpoints.get(path);
623
1242
  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);
1243
+ if (this.getInterfaceClaimed(current, ifaceNum) === false) {
1244
+ this.logSkippedHandleOperation(
1245
+ 'releaseInterface',
1246
+ current,
1247
+ 'interface-not-claimed',
1248
+ ifaceNum
1249
+ );
1250
+ } else {
1251
+ try {
1252
+ await this.runLoggedHandleOperation({
1253
+ operation: 'releaseInterface',
1254
+ device: current,
1255
+ path,
1256
+ interfaceNumber: ifaceNum,
1257
+ task: () => current.releaseInterface(ifaceNum),
1258
+ });
1259
+ } catch (error) {
1260
+ this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
1261
+ }
628
1262
  }
1263
+ this.assertHandleAvailable(current);
629
1264
  try {
630
- await current.close();
1265
+ await this.runLoggedHandleOperation({
1266
+ operation: 'close',
1267
+ device: current,
1268
+ path,
1269
+ task: () => current.close(),
1270
+ });
631
1271
  } catch (error) {
632
1272
  this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
1273
+ throw error;
633
1274
  }
634
1275
  }
635
1276
 
636
1277
  private async clearEndpointHalt(
1278
+ path: string,
637
1279
  device: USBDevice,
638
1280
  direction: USBDirection,
639
1281
  endpointNumber: number
640
1282
  ) {
1283
+ this.assertHandleAvailable(device);
641
1284
  try {
642
- await device.clearHalt(direction, endpointNumber);
1285
+ await this.runLoggedHandleOperation({
1286
+ operation: direction === 'in' ? 'clearHaltIn' : 'clearHaltOut',
1287
+ device,
1288
+ path,
1289
+ endpointNumber,
1290
+ task: () => device.clearHalt(direction, endpointNumber),
1291
+ });
643
1292
  } catch (error) {
1293
+ this.assertRunning();
644
1294
  this.Log?.debug(
645
1295
  `[WebUsbTransport] clearHalt ${direction} endpoint ${endpointNumber} failed, continuing:`,
646
1296
  error
@@ -656,6 +1306,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
656
1306
  * protocol entry produced the same fail-fast).
657
1307
  */
658
1308
  private assertAcquired(path: string) {
1309
+ this.assertRunning();
659
1310
  if (!this.acquiredPaths.has(path)) {
660
1311
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, `Device is not acquired for ${path}`);
661
1312
  }
@@ -713,7 +1364,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
713
1364
  operation: WebUsbOperation,
714
1365
  error: unknown
715
1366
  ): Promise<never> {
716
- if (this.isWebUsbIoHardwareError(error)) throw error;
1367
+ this.assertRunning();
1368
+ if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) throw error;
717
1369
  if (error instanceof ProtocolV2LinkError && error.code !== 'io') throw error;
718
1370
 
719
1371
  const nativeError = error instanceof ProtocolV2LinkError ? error.cause ?? error : error;
@@ -753,7 +1405,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
753
1405
  }
754
1406
 
755
1407
  private isRetryablePacketIoError(error: unknown) {
756
- if (this.isWebUsbIoHardwareError(error)) return false;
1408
+ if (this.stopped) return false;
1409
+ if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) return false;
757
1410
  const message = this.getErrorMessage(error).toLowerCase();
758
1411
  if (this.isWebUsbPermissionDeniedMessage(message)) return false;
759
1412
  return (
@@ -784,20 +1437,45 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
784
1437
  // USB disconnect event itself was missed.
785
1438
  this.staleProtocolPaths.add(path);
786
1439
  await wait(attempt * PACKET_IO_RETRY_DELAY);
1440
+ this.assertRunning();
787
1441
 
788
1442
  try {
789
1443
  const currentDevice = await this.findDevice(path);
1444
+ this.assertHandleAvailable(currentDevice);
790
1445
  if (currentDevice.opened) {
791
1446
  const endpoints = this.deviceEndpoints.get(path);
792
1447
  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);
1448
+ if (this.getInterfaceClaimed(currentDevice, ifaceNum) === false) {
1449
+ this.logSkippedHandleOperation(
1450
+ 'releaseInterface',
1451
+ currentDevice,
1452
+ 'interface-not-claimed',
1453
+ ifaceNum
1454
+ );
1455
+ } else {
1456
+ try {
1457
+ await this.runLoggedHandleOperation({
1458
+ operation: 'releaseInterface',
1459
+ device: currentDevice,
1460
+ path,
1461
+ interfaceNumber: ifaceNum,
1462
+ task: () => currentDevice.releaseInterface(ifaceNum),
1463
+ });
1464
+ } catch (releaseError) {
1465
+ this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
1466
+ }
797
1467
  }
798
- await currentDevice.close();
1468
+ this.assertHandleAvailable(currentDevice);
1469
+ await this.runLoggedHandleOperation({
1470
+ operation: 'close',
1471
+ device: currentDevice,
1472
+ path,
1473
+ task: () => currentDevice.close(),
1474
+ });
799
1475
  }
800
1476
  } catch (closeError) {
1477
+ this.assertRunning();
1478
+ if (this.isHandleOwnershipError(closeError)) throw closeError;
801
1479
  this.Log.debug('[WebUsbTransport] close device before retry error:', closeError);
802
1480
  }
803
1481
 
@@ -839,6 +1517,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
839
1517
  try {
840
1518
  await this.reconnectForPacketIoRetry(path, 'out', attempt, error);
841
1519
  } catch (reconnectError) {
1520
+ this.assertRunning();
1521
+ if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
842
1522
  lastError = reconnectError;
843
1523
  this.Log.debug(
844
1524
  `[WebUsbTransport] transferout reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
@@ -861,7 +1541,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
861
1541
  const transferBuffer = this.toArrayBuffer(
862
1542
  packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength)
863
1543
  );
1544
+ this.assertHandleAvailable(device);
864
1545
  const result = await device.transferOut(endpointOut, transferBuffer);
1546
+ this.assertHandleAvailable(device);
865
1547
  if (result.status !== 'ok') {
866
1548
  return this.throwWebUsbIoError(
867
1549
  path,
@@ -888,7 +1570,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
888
1570
  }
889
1571
  const endpoints = this.deviceEndpoints.get(path);
890
1572
  const endpointIn = endpoints?.endpointIn ?? this.endpointId;
1573
+ this.assertHandleAvailable(device);
891
1574
  const result = await device.transferIn(endpointIn, length);
1575
+ this.assertHandleAvailable(device);
892
1576
  return this.getTransferInData(result);
893
1577
  } catch (error) {
894
1578
  lastError = error;
@@ -902,6 +1586,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
902
1586
  try {
903
1587
  await this.reconnectForPacketIoRetry(path, 'in', attempt, error);
904
1588
  } catch (reconnectError) {
1589
+ this.assertRunning();
1590
+ if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
905
1591
  lastError = reconnectError;
906
1592
  this.Log.debug(
907
1593
  `[WebUsbTransport] transferin reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
@@ -921,7 +1607,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
921
1607
  }
922
1608
  const endpoints = this.deviceEndpoints.get(path);
923
1609
  const endpointIn = endpoints?.endpointIn ?? this.endpointId;
1610
+ this.assertHandleAvailable(device);
924
1611
  const result = await device.transferIn(endpointIn, length);
1612
+ this.assertHandleAvailable(device);
925
1613
  return this.getTransferInData(result);
926
1614
  }
927
1615
 
@@ -984,15 +1672,15 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
984
1672
  }
985
1673
 
986
1674
  try {
987
- await this.callProtocolV1(
1675
+ const response = await this.callProtocolV1(
988
1676
  path,
989
- 'Initialize',
1677
+ this.identityProbe ? 'GetFeatures' : 'Initialize',
990
1678
  {},
991
1679
  {
992
1680
  timeoutMs: PROTOCOL_V1_PROBE_TIMEOUT,
993
1681
  }
994
1682
  );
995
- return true;
1683
+ return !this.identityProbe || response.type === 'Features';
996
1684
  } catch (error) {
997
1685
  if (this.isWebUsbIoHardwareError(error)) throw error;
998
1686
  return false;
@@ -1148,9 +1836,27 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
1148
1836
  * Release device
1149
1837
  */
1150
1838
  async release(path: string) {
1839
+ const startedAt = Date.now();
1840
+ this.logLifecycle('release', { phase: 'start' });
1151
1841
  this.acquiredPaths.delete(path);
1152
- await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1153
- await this.closeOpenDevice(path);
1842
+ try {
1843
+ await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1844
+ await this.closeOpenDevice(path);
1845
+ this.logLifecycle('release', {
1846
+ phase: 'success',
1847
+ durationMs: Date.now() - startedAt,
1848
+ });
1849
+ } catch (error) {
1850
+ this.logLifecycle('release', {
1851
+ phase: 'error',
1852
+ durationMs: Date.now() - startedAt,
1853
+ ...this.getDiagnosticError(error, path),
1854
+ });
1855
+ throw error;
1856
+ } finally {
1857
+ this.releaseIdentityProbeHandle(path);
1858
+ this.releaseNormalHandles(path);
1859
+ }
1154
1860
  // Keep deviceProtocol/deviceProtocolHints across release: the probe result is a
1155
1861
  // physical-device property, so the next acquire can skip the wire-level probe.
1156
1862
  // V2 entries are still dropped by onProtocolV2UsbLinkInvalidated via the link