@onekeyfe/hd-transport-web-device 1.2.8-alpha.52 → 1.2.8-alpha.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport-web-device",
3
- "version": "1.2.8-alpha.52",
3
+ "version": "1.2.8-alpha.54",
4
4
  "author": "OneKey",
5
5
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
6
6
  "license": "MIT",
@@ -21,13 +21,13 @@
21
21
  "lint:fix": "eslint . --fix"
22
22
  },
23
23
  "dependencies": {
24
- "@onekeyfe/hd-shared": "1.2.8-alpha.52",
25
- "@onekeyfe/hd-transport": "1.2.8-alpha.52"
24
+ "@onekeyfe/hd-shared": "1.2.8-alpha.54",
25
+ "@onekeyfe/hd-transport": "1.2.8-alpha.54"
26
26
  },
27
27
  "devDependencies": {
28
- "@onekeyfe/hd-transport-electron": "1.2.8-alpha.52",
28
+ "@onekeyfe/hd-transport-electron": "1.2.8-alpha.54",
29
29
  "@types/w3c-web-usb": "^1.0.6",
30
30
  "@types/web-bluetooth": "^0.0.17"
31
31
  },
32
- "gitHead": "389c588beb19de26451c729bb09bc8e7ce6b0717"
32
+ "gitHead": "167d8127232ef23d345ccc0c12fd0c21fcaf6552"
33
33
  }
@@ -526,17 +526,13 @@ export default class ElectronBleTransport {
526
526
  }
527
527
 
528
528
  private createProtocolMismatchError(expected: ProtocolType) {
529
- return ERRORS.TypedError(
530
- HardwareErrorCode.RuntimeError,
531
- `Device protocol mismatch: expected ${expected}, but device did not respond to expected protocol`
532
- );
529
+ return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
530
+ expectedProtocol: expected,
531
+ });
533
532
  }
534
533
 
535
534
  private createProtocolDetectionError() {
536
- return ERRORS.TypedError(
537
- HardwareErrorCode.BleTimeoutError,
538
- 'Unable to detect BLE protocol: device did not respond to Protocol V1 GetFeatures or Protocol V2 Ping'
539
- );
535
+ return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed);
540
536
  }
541
537
 
542
538
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
package/src/webusb.ts CHANGED
@@ -44,6 +44,7 @@ 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';
47
48
  // Legacy devices can take longer to answer Initialize after a WebUSB reset.
48
49
  // Keep this aligned with Node USB so a slow Protocol V1 response is not
49
50
  // misreported as a protocol mismatch during acquire.
@@ -82,6 +83,12 @@ type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
82
83
  */
83
84
  let activeWebUsbTransport: WebUsbTransport | undefined;
84
85
  let usbDisconnectListenerAttached = false;
86
+ // Only isolated discovery owns these handles. Normal calls must never close or
87
+ // open them, including while native open() is still pending.
88
+ const identityProbeHandles = new WeakSet<USBDevice>();
89
+ const openingHandles = new WeakSet<USBDevice>();
90
+ // A normal acquire keeps ownership even while recovery temporarily closes the handle.
91
+ const normalHandleOwners = new WeakMap<USBDevice, WebUsbTransport>();
85
92
 
86
93
  function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
87
94
  activeWebUsbTransport = instance;
@@ -124,6 +131,16 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
124
131
  /** Paths currently acquired (between a successful acquire() and its release()). */
125
132
  private acquiredPaths: Set<string> = new Set();
126
133
 
134
+ private identityProbeDevices = new Map<string, USBDevice>();
135
+
136
+ private normalDevices = new Map<USBDevice, string>();
137
+
138
+ private pendingHandleOperations = new Set<Promise<unknown>>();
139
+
140
+ private stopPromise?: Promise<void>;
141
+
142
+ private identityProbeCleanupPaths = new Set<string>();
143
+
127
144
  /**
128
145
  * The exact USBDevice object each cached protocol was probed against. The
129
146
  * browser returns the same object identity for a device as long as it stays
@@ -160,7 +177,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
160
177
 
161
178
  interfaceId = INTERFACE_ID;
162
179
 
163
- constructor() {
180
+ constructor(private readonly identityProbe = false) {
164
181
  super({
165
182
  router: PROTOCOL_V2_CHANNEL_USB,
166
183
  maxFrameBytes: PROTOCOL_V2_FRAME_MAX_BYTES,
@@ -183,7 +200,97 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
183
200
  );
184
201
  }
185
202
  this.usb = usb;
186
- registerActiveWebUsbTransport(this, usb);
203
+ if (!this.identityProbe) registerActiveWebUsbTransport(this, usb);
204
+ }
205
+
206
+ stop(): Promise<void> {
207
+ this.stopped = true;
208
+ this.acquiredPaths.clear();
209
+ if (activeWebUsbTransport === this) activeWebUsbTransport = undefined;
210
+ 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
+ });
216
+ }
217
+ return this.stopPromise;
218
+ }
219
+
220
+ private async stopResources() {
221
+ let cleanupError: unknown;
222
+ try {
223
+ await this.disposeProtocolV2UsbLinks('WebUSB transport stopped');
224
+ } catch (error) {
225
+ cleanupError = error;
226
+ }
227
+ // Wait for late open/reset/claim/close completions before releasing ownership.
228
+ // Reads are aborted by closing their handle and reject late results below.
229
+ await Promise.allSettled([...this.pendingHandleOperations]);
230
+ const handles = this.identityProbe
231
+ ? [...this.identityProbeDevices].map(([path, device]) => [device, path] as const)
232
+ : [...this.normalDevices];
233
+ for (const [device, path] of handles) {
234
+ if (!this.identityProbe && normalHandleOwners.get(device) !== this) {
235
+ this.normalDevices.delete(device);
236
+ } else {
237
+ try {
238
+ if (device.opened) {
239
+ 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);
244
+ }
245
+ await device.close();
246
+ if (device.opened) throw new Error('WebUSB handle remains open after stop');
247
+ }
248
+ if (this.identityProbe) {
249
+ identityProbeHandles.delete(device);
250
+ this.identityProbeDevices.delete(path);
251
+ this.identityProbeCleanupPaths.delete(path);
252
+ } else {
253
+ normalHandleOwners.delete(device);
254
+ this.normalDevices.delete(device);
255
+ }
256
+ } catch (error) {
257
+ cleanupError ??= error;
258
+ }
259
+ }
260
+ }
261
+ this.deviceList = [];
262
+ this.deviceProtocol.clear();
263
+ this.confirmedDeviceProtocols.clear();
264
+ this.deviceProtocolHints.clear();
265
+ this.staleProtocolPaths.clear();
266
+ this.probedDeviceObjects.clear();
267
+ if (!this.normalDevices.size && !this.identityProbeDevices.size) {
268
+ this.deviceEndpoints.clear();
269
+ }
270
+ if (cleanupError) {
271
+ throw cleanupError instanceof Error
272
+ ? cleanupError
273
+ : new Error(this.getErrorMessage(cleanupError));
274
+ }
275
+ }
276
+
277
+ private assertRunning() {
278
+ if (this.stopped) {
279
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'WebUSB transport is stopped');
280
+ }
281
+ }
282
+
283
+ private async runHandleOperation<T>(operation: () => Promise<T>): Promise<T> {
284
+ this.assertRunning();
285
+ const pending = Promise.resolve(operation());
286
+ this.pendingHandleOperations.add(pending);
287
+ try {
288
+ const result = await pending;
289
+ this.assertRunning();
290
+ return result;
291
+ } finally {
292
+ this.pendingHandleOperations.delete(pending);
293
+ }
187
294
  }
188
295
 
189
296
  /**
@@ -267,6 +374,14 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
267
374
  * It does NOT prompt the user to select a device
268
375
  */
269
376
  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.
383
+ }
384
+ }
270
385
  await this.getConnectedDevices();
271
386
  return this.deviceList;
272
387
  }
@@ -275,9 +390,21 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
275
390
  * Get list of connected devices
276
391
  */
277
392
  async getConnectedDevices() {
393
+ this.assertRunning();
278
394
  if (!this.usb) return [];
279
395
 
280
396
  const devices = await this.usb.getDevices();
397
+ this.assertRunning();
398
+ for (const owner of new Set(devices.map(device => normalHandleOwners.get(device)))) {
399
+ if (owner?.stopped) {
400
+ try {
401
+ await owner.stop();
402
+ } catch (error) {
403
+ this.Log?.debug('[WebUsbTransport] stopped owner cleanup failed:', error);
404
+ }
405
+ }
406
+ }
407
+ this.assertRunning();
281
408
  const onekeyDevices = devices.filter(dev => {
282
409
  const isOneKey = ONEKEY_WEBUSB_FILTER.some(
283
410
  (desc: { vendorId: number; productId: number }) =>
@@ -310,7 +437,14 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
310
437
  * Acquire device control
311
438
  */
312
439
  async acquire(input: AcquireInput) {
440
+ this.assertRunning();
313
441
  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
+ }
314
448
  try {
315
449
  await this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
316
450
  await this.closeOpenDevice(input.path);
@@ -383,18 +517,25 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
383
517
  input.expectedProtocol,
384
518
  protocolHint
385
519
  );
520
+ this.assertRunning();
386
521
  this.confirmedDeviceProtocols.set(input.path, detectedProtocol);
387
522
  const probedDevice = this.deviceList.find(d => d.path === input.path)?.device;
388
523
  if (probedDevice) {
389
524
  this.probedDeviceObjects.set(input.path, probedDevice);
390
525
  }
391
526
  }
527
+ this.assertRunning();
392
528
  this.acquiredPaths.add(input.path);
393
529
  return await Promise.resolve(input.path);
394
530
  } catch (e) {
395
531
  this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
396
532
  this.acquiredPaths.delete(input.path);
397
- await this.closeOpenDevice(input.path);
533
+ try {
534
+ await this.closeOpenDevice(input.path);
535
+ } finally {
536
+ this.releaseIdentityProbeHandle(input.path);
537
+ this.releaseNormalHandles(input.path);
538
+ }
398
539
  throw e;
399
540
  }
400
541
  }
@@ -405,21 +546,20 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
405
546
  * fall back to a Protocol V2 Ping probe.
406
547
  */
407
548
  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
- );
549
+ return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
550
+ expectedProtocol: expected,
551
+ });
412
552
  }
413
553
 
414
554
  private createProtocolProbeTimeoutError(expected: ProtocolType, attempts: number) {
415
- return ERRORS.TypedError(
416
- HardwareErrorCode.DeviceInitializeFailed,
417
- `Protocol ${expected} probe timeout after ${attempts} attempts`
418
- );
555
+ return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
556
+ expectedProtocol: expected,
557
+ attempts,
558
+ });
419
559
  }
420
560
 
421
561
  private createProtocolDetectionError() {
422
- return ERRORS.TypedError(HardwareErrorCode.DeviceNotFound);
562
+ return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed);
423
563
  }
424
564
 
425
565
  private async detectProtocol(
@@ -516,9 +656,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
516
656
  async connect(path: string, first: boolean) {
517
657
  const maxRetries = 5;
518
658
  for (let i = 0; i < maxRetries; i++) {
659
+ this.assertRunning();
519
660
  try {
520
661
  return await this.connectToDevice(path, first);
521
662
  } catch (e) {
663
+ this.assertRunning();
522
664
  if (e instanceof HardwareError && e.errorCode === HardwareErrorCode.BridgeNeedsPermission) {
523
665
  throw e;
524
666
  }
@@ -568,30 +710,52 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
568
710
  * Discovers interface/endpoint numbers from USB descriptors on first connection.
569
711
  */
570
712
  async connectToDevice(path: string, first: boolean) {
713
+ this.assertRunning();
571
714
  let device: USBDevice = await this.findDevice(path);
715
+ if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
716
+ await this.closeOpenDevice(path);
717
+ this.releaseIdentityProbeHandle(path);
718
+ this.claimIdentityProbeHandle(path, device);
719
+ }
720
+ if (!this.identityProbe) this.claimNormalHandle(path, device);
721
+ this.assertHandleAvailable(device);
572
722
  if (!device.opened) {
723
+ openingHandles.add(device);
573
724
  try {
574
- await device.open();
725
+ await this.runHandleOperation(() => device.open());
575
726
  } catch (error) {
576
- return this.throwWebUsbIoError(path, 'open', error);
727
+ return await this.throwWebUsbIoError(path, 'open', error);
728
+ } finally {
729
+ openingHandles.delete(device);
577
730
  }
578
731
  }
579
732
  // A V1 packet retry continues an in-flight exchange. Preserve its endpoint
580
733
  // state as in the legacy transport; probing and V2 recovery still reset.
581
734
  if (first || this.deviceProtocol.get(path) !== 'V1') {
582
735
  try {
583
- await device.reset();
736
+ await this.runHandleOperation(() => device.reset());
584
737
  } catch (error) {
738
+ this.assertRunning();
585
739
  this.Log?.debug('[WebUsbTransport] reset before claim failed, continuing:', error);
586
740
  }
587
741
  }
588
742
  await this.getConnectedDevices();
589
743
  device = await this.findDevice(path);
744
+ if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
745
+ await this.closeOpenDevice(path);
746
+ this.releaseIdentityProbeHandle(path);
747
+ this.claimIdentityProbeHandle(path, device);
748
+ }
749
+ if (!this.identityProbe) this.claimNormalHandle(path, device);
750
+ this.assertHandleAvailable(device);
590
751
  if (!device.opened) {
752
+ openingHandles.add(device);
591
753
  try {
592
- await device.open();
754
+ await this.runHandleOperation(() => device.open());
593
755
  } catch (error) {
594
- return this.throwWebUsbIoError(path, 'open', error);
756
+ return await this.throwWebUsbIoError(path, 'open', error);
757
+ } finally {
758
+ openingHandles.delete(device);
595
759
  }
596
760
  }
597
761
 
@@ -600,14 +764,14 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
600
764
  !device.configuration ||
601
765
  device.configuration.configurationValue !== this.configurationId
602
766
  ) {
603
- await device.selectConfiguration(this.configurationId);
767
+ await this.runHandleOperation(() => device.selectConfiguration(this.configurationId));
604
768
  }
605
769
 
606
770
  // Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
607
771
  const endpoints = this.discoverEndpoints(device);
608
772
  this.deviceEndpoints.set(path, endpoints);
609
773
  try {
610
- await device.claimInterface(endpoints.interfaceNumber);
774
+ await this.runHandleOperation(() => device.claimInterface(endpoints.interfaceNumber));
611
775
  } catch (error) {
612
776
  return this.throwWebUsbIoError(path, 'claimInterface', error);
613
777
  }
@@ -615,21 +779,90 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
615
779
  await this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
616
780
  }
617
781
 
782
+ private assertHandleAvailable(device: USBDevice) {
783
+ this.assertRunning();
784
+ const normalOwner = normalHandleOwners.get(device);
785
+ if (
786
+ (normalOwner && normalOwner !== this) ||
787
+ (identityProbeHandles.has(device) &&
788
+ ![...this.identityProbeDevices.values()].includes(device))
789
+ ) {
790
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
791
+ }
792
+ }
793
+
794
+ private claimNormalHandle(path: string, device: USBDevice) {
795
+ this.assertHandleAvailable(device);
796
+ normalHandleOwners.set(device, this);
797
+ this.normalDevices.set(device, path);
798
+ }
799
+
800
+ private releaseNormalHandles(path: string) {
801
+ if (this.stopped) return;
802
+ for (const [device, ownedPath] of this.normalDevices) {
803
+ if (ownedPath === path) {
804
+ normalHandleOwners.delete(device);
805
+ this.normalDevices.delete(device);
806
+ }
807
+ }
808
+ }
809
+
810
+ private isHandleOwnershipError(error: unknown): error is HardwareError {
811
+ return (
812
+ error instanceof HardwareError &&
813
+ error.errorCode === HardwareErrorCode.RuntimeError &&
814
+ this.getErrorMessage(error).includes(USB_DEVICE_IN_USE)
815
+ );
816
+ }
817
+
818
+ private claimIdentityProbeHandle(path: string, device: USBDevice) {
819
+ this.assertRunning();
820
+ if (
821
+ device.opened ||
822
+ openingHandles.has(device) ||
823
+ identityProbeHandles.has(device) ||
824
+ normalHandleOwners.has(device)
825
+ ) {
826
+ throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
827
+ }
828
+ identityProbeHandles.add(device);
829
+ this.identityProbeDevices.set(path, device);
830
+ }
831
+
832
+ private releaseIdentityProbeHandle(path: string) {
833
+ if (this.stopped) return;
834
+ const device = this.identityProbeDevices.get(path);
835
+ // A failed native close must not make an open probe handle available to other callers.
836
+ if (device?.opened) {
837
+ this.identityProbeCleanupPaths.add(path);
838
+ return;
839
+ }
840
+ if (device) identityProbeHandles.delete(device);
841
+ this.identityProbeDevices.delete(path);
842
+ this.identityProbeCleanupPaths.delete(path);
843
+ }
844
+
618
845
  private async closeOpenDevice(path: string) {
619
- const current = this.deviceList.find(device => device.path === path)?.device;
846
+ if (this.stopped) return;
847
+ const current = this.identityProbe
848
+ ? this.identityProbeDevices.get(path)
849
+ : this.deviceList.find(device => device.path === path)?.device;
620
850
  if (!current?.opened) return;
851
+ this.assertHandleAvailable(current);
621
852
 
622
853
  const endpoints = this.deviceEndpoints.get(path);
623
854
  const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
624
855
  try {
625
- await current.releaseInterface(ifaceNum);
856
+ await this.runHandleOperation(() => current.releaseInterface(ifaceNum));
626
857
  } catch (error) {
627
858
  this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
628
859
  }
860
+ this.assertHandleAvailable(current);
629
861
  try {
630
- await current.close();
862
+ await this.runHandleOperation(() => current.close());
631
863
  } catch (error) {
632
864
  this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
865
+ if (this.identityProbe) throw error;
633
866
  }
634
867
  }
635
868
 
@@ -638,9 +871,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
638
871
  direction: USBDirection,
639
872
  endpointNumber: number
640
873
  ) {
874
+ this.assertHandleAvailable(device);
641
875
  try {
642
- await device.clearHalt(direction, endpointNumber);
876
+ await this.runHandleOperation(() => device.clearHalt(direction, endpointNumber));
643
877
  } catch (error) {
878
+ this.assertRunning();
644
879
  this.Log?.debug(
645
880
  `[WebUsbTransport] clearHalt ${direction} endpoint ${endpointNumber} failed, continuing:`,
646
881
  error
@@ -656,6 +891,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
656
891
  * protocol entry produced the same fail-fast).
657
892
  */
658
893
  private assertAcquired(path: string) {
894
+ this.assertRunning();
659
895
  if (!this.acquiredPaths.has(path)) {
660
896
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, `Device is not acquired for ${path}`);
661
897
  }
@@ -713,7 +949,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
713
949
  operation: WebUsbOperation,
714
950
  error: unknown
715
951
  ): Promise<never> {
716
- if (this.isWebUsbIoHardwareError(error)) throw error;
952
+ this.assertRunning();
953
+ if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) throw error;
717
954
  if (error instanceof ProtocolV2LinkError && error.code !== 'io') throw error;
718
955
 
719
956
  const nativeError = error instanceof ProtocolV2LinkError ? error.cause ?? error : error;
@@ -753,7 +990,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
753
990
  }
754
991
 
755
992
  private isRetryablePacketIoError(error: unknown) {
756
- if (this.isWebUsbIoHardwareError(error)) return false;
993
+ if (this.stopped) return false;
994
+ if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) return false;
757
995
  const message = this.getErrorMessage(error).toLowerCase();
758
996
  if (this.isWebUsbPermissionDeniedMessage(message)) return false;
759
997
  return (
@@ -784,20 +1022,25 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
784
1022
  // USB disconnect event itself was missed.
785
1023
  this.staleProtocolPaths.add(path);
786
1024
  await wait(attempt * PACKET_IO_RETRY_DELAY);
1025
+ this.assertRunning();
787
1026
 
788
1027
  try {
789
1028
  const currentDevice = await this.findDevice(path);
1029
+ this.assertHandleAvailable(currentDevice);
790
1030
  if (currentDevice.opened) {
791
1031
  const endpoints = this.deviceEndpoints.get(path);
792
1032
  const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
793
1033
  try {
794
- await currentDevice.releaseInterface(ifaceNum);
1034
+ await this.runHandleOperation(() => currentDevice.releaseInterface(ifaceNum));
795
1035
  } catch (releaseError) {
796
1036
  this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
797
1037
  }
798
- await currentDevice.close();
1038
+ this.assertHandleAvailable(currentDevice);
1039
+ await this.runHandleOperation(() => currentDevice.close());
799
1040
  }
800
1041
  } catch (closeError) {
1042
+ this.assertRunning();
1043
+ if (this.isHandleOwnershipError(closeError)) throw closeError;
801
1044
  this.Log.debug('[WebUsbTransport] close device before retry error:', closeError);
802
1045
  }
803
1046
 
@@ -839,6 +1082,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
839
1082
  try {
840
1083
  await this.reconnectForPacketIoRetry(path, 'out', attempt, error);
841
1084
  } catch (reconnectError) {
1085
+ this.assertRunning();
1086
+ if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
842
1087
  lastError = reconnectError;
843
1088
  this.Log.debug(
844
1089
  `[WebUsbTransport] transferout reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
@@ -861,7 +1106,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
861
1106
  const transferBuffer = this.toArrayBuffer(
862
1107
  packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength)
863
1108
  );
1109
+ this.assertHandleAvailable(device);
864
1110
  const result = await device.transferOut(endpointOut, transferBuffer);
1111
+ this.assertHandleAvailable(device);
865
1112
  if (result.status !== 'ok') {
866
1113
  return this.throwWebUsbIoError(
867
1114
  path,
@@ -888,7 +1135,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
888
1135
  }
889
1136
  const endpoints = this.deviceEndpoints.get(path);
890
1137
  const endpointIn = endpoints?.endpointIn ?? this.endpointId;
1138
+ this.assertHandleAvailable(device);
891
1139
  const result = await device.transferIn(endpointIn, length);
1140
+ this.assertHandleAvailable(device);
892
1141
  return this.getTransferInData(result);
893
1142
  } catch (error) {
894
1143
  lastError = error;
@@ -902,6 +1151,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
902
1151
  try {
903
1152
  await this.reconnectForPacketIoRetry(path, 'in', attempt, error);
904
1153
  } catch (reconnectError) {
1154
+ this.assertRunning();
1155
+ if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
905
1156
  lastError = reconnectError;
906
1157
  this.Log.debug(
907
1158
  `[WebUsbTransport] transferin reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
@@ -921,7 +1172,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
921
1172
  }
922
1173
  const endpoints = this.deviceEndpoints.get(path);
923
1174
  const endpointIn = endpoints?.endpointIn ?? this.endpointId;
1175
+ this.assertHandleAvailable(device);
924
1176
  const result = await device.transferIn(endpointIn, length);
1177
+ this.assertHandleAvailable(device);
925
1178
  return this.getTransferInData(result);
926
1179
  }
927
1180
 
@@ -984,15 +1237,15 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
984
1237
  }
985
1238
 
986
1239
  try {
987
- await this.callProtocolV1(
1240
+ const response = await this.callProtocolV1(
988
1241
  path,
989
- 'Initialize',
1242
+ this.identityProbe ? 'GetFeatures' : 'Initialize',
990
1243
  {},
991
1244
  {
992
1245
  timeoutMs: PROTOCOL_V1_PROBE_TIMEOUT,
993
1246
  }
994
1247
  );
995
- return true;
1248
+ return !this.identityProbe || response.type === 'Features';
996
1249
  } catch (error) {
997
1250
  if (this.isWebUsbIoHardwareError(error)) throw error;
998
1251
  return false;
@@ -1149,8 +1402,13 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
1149
1402
  */
1150
1403
  async release(path: string) {
1151
1404
  this.acquiredPaths.delete(path);
1152
- await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1153
- await this.closeOpenDevice(path);
1405
+ try {
1406
+ await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1407
+ await this.closeOpenDevice(path);
1408
+ } finally {
1409
+ this.releaseIdentityProbeHandle(path);
1410
+ this.releaseNormalHandles(path);
1411
+ }
1154
1412
  // Keep deviceProtocol/deviceProtocolHints across release: the probe result is a
1155
1413
  // physical-device property, so the next acquire can skip the wire-level probe.
1156
1414
  // V2 entries are still dropped by onProtocolV2UsbLinkInvalidated via the link