@onekeyfe/hd-transport-web-device 1.2.8-alpha.51 → 1.2.8-alpha.53

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.
@@ -1,7 +1,13 @@
1
+ import { ERRORS, HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
2
+
1
3
  import WebUsbTransport from '../src/webusb';
2
4
  import { probeUsbDeviceIdentities } from '@onekeyfe/hd-common-connect-sdk/src';
5
+ import Core, { switchTransport } from '@onekeyfe/hd-core/src/core';
6
+ import DataManager from '@onekeyfe/hd-core/src/data-manager/DataManager';
7
+ import TransportManager from '@onekeyfe/hd-core/src/data-manager/TransportManager';
8
+ import { Device } from '@onekeyfe/hd-core/src/device/Device';
3
9
 
4
- import type { MessageFromOneKey, TransportCallOptions } from '@onekeyfe/hd-transport';
10
+ import type { MessageFromOneKey, Transport, TransportCallOptions } from '@onekeyfe/hd-transport';
5
11
 
6
12
  jest.mock('@onekeyfe/hd-core', () => jest.requireActual('../../core/src'));
7
13
  jest.mock('../../core/src/data/config', () => ({
@@ -15,6 +21,21 @@ jest.mock('@onekeyfe/hd-transport-http', () => ({ __esModule: true, default: jes
15
21
  jest.mock('@onekeyfe/hd-transport-lowlevel', () => ({}));
16
22
  jest.mock('@onekeyfe/hd-transport-emulator', () => ({}));
17
23
 
24
+ type PacketIoTransport = {
25
+ rotateProtocolV2UsbGeneration: () => Promise<void>;
26
+ detectProtocol: () => Promise<'V1' | 'V2'>;
27
+ transferInWithRetry: (path: string, length: number) => Promise<DataView>;
28
+ transferInOnce: (path: string, length: number) => Promise<DataView>;
29
+ transferOutWithRetry: (path: string, packet: Uint8Array) => Promise<void>;
30
+ transferOutOnce: (path: string, packet: Uint8Array) => Promise<void>;
31
+ reconnectForPacketIoRetry: (
32
+ path: string,
33
+ direction: 'in' | 'out',
34
+ attempt: number,
35
+ error: unknown
36
+ ) => Promise<void>;
37
+ };
38
+
18
39
  describe('WebUSB identity probe ownership', () => {
19
40
  let originalNavigator: PropertyDescriptor | undefined;
20
41
  const device = {
@@ -47,6 +68,370 @@ describe('WebUSB identity probe ownership', () => {
47
68
  else delete (globalThis as { navigator?: Navigator }).navigator;
48
69
  });
49
70
 
71
+ function createAcquiredTransport(protocol: 'V1' | 'V2' = 'V1') {
72
+ const active = new WebUsbTransport();
73
+ active.init({ debug: jest.fn() });
74
+ jest
75
+ .spyOn(active as unknown as PacketIoTransport, 'detectProtocol')
76
+ .mockResolvedValue(protocol);
77
+ jest.spyOn(active, 'connect').mockImplementation(() => {
78
+ device.opened = true;
79
+ return Promise.resolve();
80
+ });
81
+ return active;
82
+ }
83
+
84
+ test.each(['V1', 'V2'] as const)(
85
+ 'reacquires %s USB after keepSession and switching away and back',
86
+ async protocol => {
87
+ const previous = TransportManager.transport;
88
+ const { settings } = DataManager;
89
+ const active = createAcquiredTransport(protocol);
90
+ const next = createAcquiredTransport(protocol);
91
+ const selected = new Device({
92
+ path: 'USB_PATH',
93
+ id: 'USB_PATH',
94
+ name: 'Mock device',
95
+ commType: 'webusb',
96
+ debug: false,
97
+ });
98
+ selected.keepSession = true;
99
+ selected.mainId = 'USB_PATH';
100
+ jest.spyOn(selected, 'isUsedHere').mockReturnValue(true);
101
+ const release = jest.spyOn(active, 'release');
102
+ const Emulator = jest.fn(() => ({ stop: jest.fn().mockResolvedValue(undefined) }));
103
+ const RestoreUsb = jest.fn(() => next);
104
+ TransportManager.transport = active as unknown as Transport;
105
+ try {
106
+ await active.acquire({ path: 'USB_PATH' });
107
+ await selected.release();
108
+ expect(release).not.toHaveBeenCalled();
109
+ await switchTransport({ env: 'emulator', Transport: Emulator });
110
+ expect(device.opened).toBe(false);
111
+ await switchTransport({ env: 'webusb', Transport: RestoreUsb });
112
+ await expect(next.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
113
+ } finally {
114
+ await active.stop();
115
+ await next.stop();
116
+ TransportManager.transport = previous;
117
+ DataManager.settings = settings;
118
+ jest.restoreAllMocks();
119
+ }
120
+ }
121
+ );
122
+
123
+ test('does not replace the transport until USB cleanup completes, or after it fails', async () => {
124
+ const previous = TransportManager.transport;
125
+ const { settings } = DataManager;
126
+ const active = createAcquiredTransport();
127
+ const closing = createDeferred<void>();
128
+ const entered = createDeferred<void>();
129
+ device.close.mockImplementation(() => {
130
+ entered.resolve();
131
+ return closing.promise;
132
+ });
133
+ const Emulator = jest.fn();
134
+ TransportManager.transport = active as unknown as Transport;
135
+ try {
136
+ await active.acquire({ path: 'USB_PATH' });
137
+ const switching = Promise.resolve(switchTransport({ env: 'emulator', Transport: Emulator }));
138
+ const result = switching.catch(error => error as Error);
139
+ await Promise.resolve();
140
+ expect(Emulator).not.toHaveBeenCalled();
141
+ await entered.promise;
142
+ expect(Emulator).not.toHaveBeenCalled();
143
+ expect(TransportManager.transport).toBe(active);
144
+ closing.reject(new Error('close failed'));
145
+ expect(await result).toEqual(new Error('close failed'));
146
+ expect(TransportManager.transport).toBe(active);
147
+ expect(DataManager.settings).toBe(settings);
148
+ } finally {
149
+ closing.resolve();
150
+ device.close.mockImplementation(() => {
151
+ device.opened = false;
152
+ return Promise.resolve();
153
+ });
154
+ await active.stop();
155
+ TransportManager.transport = previous;
156
+ DataManager.settings = settings;
157
+ jest.restoreAllMocks();
158
+ }
159
+ });
160
+
161
+ test('serializes overlapping transport switches and stops each replaced instance', async () => {
162
+ const previous = TransportManager.transport;
163
+ const { settings } = DataManager;
164
+ const active = createAcquiredTransport();
165
+ const next = createAcquiredTransport();
166
+ const closing = createDeferred<void>();
167
+ const entered = createDeferred<void>();
168
+ const emulatorCleanup = createDeferred<void>();
169
+ device.close.mockImplementation(() => {
170
+ entered.resolve();
171
+ return closing.promise.then(() => {
172
+ device.opened = false;
173
+ });
174
+ });
175
+ const emulator = { stop: jest.fn(() => emulatorCleanup.promise) };
176
+ const Emulator = jest.fn(() => emulator);
177
+ const RestoreUsb = jest.fn(() => next);
178
+ TransportManager.transport = active as unknown as Transport;
179
+ try {
180
+ await active.acquire({ path: 'USB_PATH' });
181
+ const first = switchTransport({ env: 'emulator', Transport: Emulator });
182
+ await entered.promise;
183
+ const second = switchTransport({ env: 'webusb', Transport: RestoreUsb });
184
+ closing.resolve();
185
+ await first;
186
+ await Promise.resolve();
187
+ expect(emulator.stop).toHaveBeenCalledTimes(1);
188
+ expect(RestoreUsb).not.toHaveBeenCalled();
189
+ emulatorCleanup.resolve();
190
+ await second;
191
+ expect(TransportManager.transport).toBe(next);
192
+ } finally {
193
+ closing.resolve();
194
+ emulatorCleanup.resolve();
195
+ await active.stop();
196
+ await next.stop();
197
+ TransportManager.transport = previous;
198
+ DataManager.settings = settings;
199
+ jest.restoreAllMocks();
200
+ }
201
+ });
202
+
203
+ test.each(['V1', 'V2'] as const)(
204
+ 'Core disposal releases a retained %s USB session for a new transport',
205
+ async protocol => {
206
+ const active = createAcquiredTransport(protocol);
207
+ const next = createAcquiredTransport(protocol);
208
+ const selected = new Device({
209
+ path: 'USB_PATH',
210
+ id: 'USB_PATH',
211
+ name: 'Mock device',
212
+ commType: 'webusb',
213
+ debug: false,
214
+ });
215
+ selected.keepSession = true;
216
+ selected.mainId = 'USB_PATH';
217
+ jest.spyOn(selected, 'isUsedHere').mockReturnValue(true);
218
+ const release = jest.spyOn(active, 'release');
219
+ jest.spyOn(TransportManager, 'getTransport').mockReturnValue(active as unknown as Transport);
220
+ const core = new Core();
221
+ try {
222
+ await active.acquire({ path: 'USB_PATH' });
223
+ await selected.release();
224
+ expect(release).not.toHaveBeenCalled();
225
+ expect(device.opened).toBe(true);
226
+ await core.dispose();
227
+ await core.dispose();
228
+ expect(device.opened).toBe(false);
229
+ expect(device.close).toHaveBeenCalledTimes(1);
230
+ await expect(next.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
231
+ } finally {
232
+ await active.release('USB_PATH');
233
+ await next.release('USB_PATH');
234
+ jest.restoreAllMocks();
235
+ }
236
+ }
237
+ );
238
+
239
+ test('stop waits for a pending native open and closes its captured handle', async () => {
240
+ const entered = createDeferred<void>();
241
+ const opening = createDeferred<void>();
242
+ const native = Object.assign(device, {
243
+ open: jest.fn(async () => {
244
+ entered.resolve();
245
+ await opening.promise;
246
+ device.opened = true;
247
+ }),
248
+ reset: jest.fn(),
249
+ configurations: [],
250
+ configuration: { configurationValue: 1 },
251
+ selectConfiguration: jest.fn(),
252
+ claimInterface: jest.fn(),
253
+ clearHalt: jest.fn(),
254
+ });
255
+ const active = new WebUsbTransport();
256
+ active.init({ debug: jest.fn() });
257
+ const connecting = active.connectToDevice('USB_PATH', true);
258
+ const result = expect(connecting).rejects.toThrow('WebUSB transport is stopped');
259
+ try {
260
+ await entered.promise;
261
+ let stopped = false;
262
+ const stopping = active.stop().then(() => {
263
+ stopped = true;
264
+ });
265
+ await Promise.resolve();
266
+ expect(stopped).toBe(false);
267
+ opening.resolve();
268
+ await result;
269
+ await stopping;
270
+ expect(native.reset).not.toHaveBeenCalled();
271
+ expect(native.claimInterface).not.toHaveBeenCalled();
272
+ expect(device.opened).toBe(false);
273
+ const next = createAcquiredTransport();
274
+ await expect(next.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
275
+ await next.release('USB_PATH');
276
+ } finally {
277
+ opening.resolve();
278
+ await connecting.catch(() => undefined);
279
+ await active.release('USB_PATH');
280
+ jest.restoreAllMocks();
281
+ }
282
+ });
283
+
284
+ test.each(['read', 'read retry', 'write', 'write retry'])(
285
+ 'stop rejects a late native %s result without touching the new owner',
286
+ async operation => {
287
+ const entered = createDeferred<void>();
288
+ const packet = createDeferred<{ status: 'ok'; data: DataView; bytesWritten: number }>();
289
+ Object.assign(device, {
290
+ transferIn: jest.fn(() => {
291
+ entered.resolve();
292
+ return packet.promise;
293
+ }),
294
+ transferOut: jest.fn(() => {
295
+ entered.resolve();
296
+ return packet.promise;
297
+ }),
298
+ });
299
+ const active = createAcquiredTransport();
300
+ const next = createAcquiredTransport();
301
+ await active.acquire({ path: 'USB_PATH' });
302
+ const state = active as unknown as PacketIoTransport;
303
+ let call: Promise<unknown>;
304
+ if (operation === 'read') call = state.transferInOnce('USB_PATH', 64);
305
+ else if (operation === 'read retry') call = state.transferInWithRetry('USB_PATH', 64);
306
+ else if (operation === 'write') call = state.transferOutOnce('USB_PATH', new Uint8Array([1]));
307
+ else call = state.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
308
+ const result = expect(call).rejects.toThrow('WebUSB transport is stopped');
309
+ try {
310
+ await entered.promise;
311
+ await active.stop();
312
+ await next.acquire({ path: 'USB_PATH' });
313
+ packet.resolve({ status: 'ok', data: new DataView(new ArrayBuffer(64)), bytesWritten: 1 });
314
+ await result;
315
+ expect(device.close).toHaveBeenCalledTimes(1);
316
+ expect(device.opened).toBe(true);
317
+ } finally {
318
+ packet.resolve({ status: 'ok', data: new DataView(new ArrayBuffer(64)), bytesWritten: 1 });
319
+ await call.catch(() => undefined);
320
+ await active.release('USB_PATH');
321
+ await next.release('USB_PATH');
322
+ jest.restoreAllMocks();
323
+ }
324
+ }
325
+ );
326
+
327
+ test.each(['in', 'out'] as const)(
328
+ 'stop prevents a delayed transfer%s retry from closing or reopening the new owner',
329
+ async direction => {
330
+ const entered = createDeferred<void>();
331
+ const native = Object.assign(device, {
332
+ transferIn: jest.fn().mockRejectedValue(new Error('transferIn NetworkError')),
333
+ transferOut: jest.fn().mockRejectedValue(new Error('transferOut NetworkError')),
334
+ });
335
+ const active = createAcquiredTransport();
336
+ const next = createAcquiredTransport();
337
+ await active.acquire({ path: 'USB_PATH' });
338
+ const state = active as unknown as PacketIoTransport;
339
+ const retry = state.reconnectForPacketIoRetry.bind(active);
340
+ jest.spyOn(state, 'reconnectForPacketIoRetry').mockImplementation((...args) => {
341
+ entered.resolve();
342
+ return retry(...args);
343
+ });
344
+ const call =
345
+ direction === 'in'
346
+ ? state.transferInWithRetry('USB_PATH', 64)
347
+ : state.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
348
+ const result = expect(call).rejects.toThrow('WebUSB transport is stopped');
349
+ try {
350
+ await entered.promise;
351
+ await active.stop();
352
+ await next.acquire({ path: 'USB_PATH' });
353
+ await result;
354
+ expect(native[direction === 'in' ? 'transferIn' : 'transferOut']).toHaveBeenCalledTimes(1);
355
+ expect(device.close).toHaveBeenCalledTimes(1);
356
+ expect(device.opened).toBe(true);
357
+ } finally {
358
+ await call.catch(() => undefined);
359
+ await active.release('USB_PATH');
360
+ await next.release('USB_PATH');
361
+ jest.restoreAllMocks();
362
+ }
363
+ }
364
+ );
365
+
366
+ test('failed stop keeps ownership until a later discovery confirms native closure', async () => {
367
+ const active = createAcquiredTransport();
368
+ const next = createAcquiredTransport();
369
+ const failure = new Error('native close failed');
370
+ await active.acquire({ path: 'USB_PATH' });
371
+ device.close.mockRejectedValueOnce(failure).mockRejectedValueOnce(failure);
372
+ try {
373
+ await expect(active.stop()).rejects.toBe(failure);
374
+ await next.getConnectedDevices();
375
+ await expect(next.acquire({ path: 'USB_PATH' })).rejects.toThrow(
376
+ 'USB device is already in use'
377
+ );
378
+ expect(device.opened).toBe(true);
379
+ await next.getConnectedDevices();
380
+ expect(device.opened).toBe(false);
381
+ await expect(next.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
382
+ expect(device.close).toHaveBeenCalledTimes(3);
383
+ } finally {
384
+ await active.release('USB_PATH');
385
+ await next.release('USB_PATH');
386
+ jest.restoreAllMocks();
387
+ }
388
+ });
389
+
390
+ test('stop closes captured handles across paths without closing re-enumerated or foreign handles', async () => {
391
+ const other = {
392
+ ...device,
393
+ serialNumber: 'OTHER_PATH',
394
+ close: jest.fn(() => {
395
+ other.opened = false;
396
+ return Promise.resolve();
397
+ }),
398
+ };
399
+ const foreign = { ...device, serialNumber: 'FOREIGN_PATH', close: jest.fn() };
400
+ usb.getDevices.mockResolvedValue([device, other, foreign]);
401
+ const active = createAcquiredTransport();
402
+ jest.spyOn(active, 'connect').mockImplementation(path => {
403
+ (path === 'USB_PATH' ? device : other).opened = true;
404
+ return Promise.resolve();
405
+ });
406
+ const current = createAcquiredTransport();
407
+ jest.spyOn(current, 'connect').mockImplementation(() => {
408
+ foreign.opened = true;
409
+ return Promise.resolve();
410
+ });
411
+ const currentDisconnect = jest.spyOn(current, 'markProtocolStale');
412
+ try {
413
+ await active.acquire({ path: 'USB_PATH' });
414
+ await active.acquire({ path: 'OTHER_PATH' });
415
+ await current.acquire({ path: 'FOREIGN_PATH' });
416
+ const replacement = { ...device, opened: false, close: jest.fn() };
417
+ usb.getDevices.mockResolvedValue([replacement, foreign]);
418
+ await active.getConnectedDevices();
419
+ await active.stop();
420
+ expect(device.opened).toBe(false);
421
+ expect(other.opened).toBe(false);
422
+ expect(replacement.close).not.toHaveBeenCalled();
423
+ expect(foreign.close).not.toHaveBeenCalled();
424
+ expect(foreign.opened).toBe(true);
425
+ disconnectListener({ device: replacement });
426
+ expect(currentDisconnect).toHaveBeenCalledWith('USB_PATH');
427
+ } finally {
428
+ await active.release('USB_PATH');
429
+ await active.release('OTHER_PATH');
430
+ await current.release('FOREIGN_PATH');
431
+ jest.restoreAllMocks();
432
+ }
433
+ });
434
+
50
435
  test('does not close a handle already opened by another operation', async () => {
51
436
  const probe = new WebUsbTransport(true);
52
437
  probe.init({ debug: jest.fn() });
@@ -93,6 +478,7 @@ describe('WebUSB identity probe ownership', () => {
93
478
  expect(device.close).not.toHaveBeenCalled();
94
479
  finishOpen();
95
480
  await connecting;
481
+ await active.release('USB_PATH');
96
482
  });
97
483
 
98
484
  test('a normal connection cannot close an isolated probe handle and release closes the captured handle after re-enumeration', async () => {
@@ -243,6 +629,239 @@ describe('WebUSB identity probe ownership', () => {
243
629
  }
244
630
  );
245
631
 
632
+ test.each(['in', 'out'] as const)(
633
+ 'the public probe cannot take over a first acquire while transfer%s recovery has closed the handle',
634
+ async direction => {
635
+ const closed = createDeferred<void>();
636
+ const resume = createDeferred<void>();
637
+ Object.assign(device, {
638
+ open: jest.fn(() => {
639
+ device.opened = true;
640
+ return Promise.resolve();
641
+ }),
642
+ releaseInterface: jest.fn(),
643
+ reset: jest.fn(),
644
+ configurations: [],
645
+ configuration: { configurationValue: 1 },
646
+ selectConfiguration: jest.fn(),
647
+ claimInterface: jest.fn(),
648
+ clearHalt: jest.fn(),
649
+ transferIn: jest
650
+ .fn()
651
+ .mockResolvedValue({ status: 'ok', data: new DataView(new ArrayBuffer(64)) }),
652
+ transferOut: jest.fn().mockResolvedValue({ status: 'ok' }),
653
+ });
654
+ const native = device as typeof device & { transferIn: jest.Mock; transferOut: jest.Mock };
655
+ native[direction === 'in' ? 'transferIn' : 'transferOut'].mockRejectedValueOnce(
656
+ new Error(`transfer${direction} NetworkError`)
657
+ );
658
+ device.close.mockImplementationOnce(async () => {
659
+ device.opened = false;
660
+ closed.resolve();
661
+ await resume.promise;
662
+ });
663
+ const active = new WebUsbTransport();
664
+ active.init({ debug: jest.fn() });
665
+ const internals = active as unknown as PacketIoTransport;
666
+ jest
667
+ .spyOn(
668
+ WebUsbTransport.prototype as unknown as PacketIoTransport,
669
+ 'rotateProtocolV2UsbGeneration'
670
+ )
671
+ .mockResolvedValue(undefined);
672
+ jest
673
+ .spyOn(WebUsbTransport.prototype as unknown as PacketIoTransport, 'detectProtocol')
674
+ .mockImplementation(async function (this: WebUsbTransport) {
675
+ if (this !== active) return 'V1';
676
+ if (direction === 'in') await internals.transferInWithRetry('USB_PATH', 64);
677
+ else await internals.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
678
+ return 'V1';
679
+ });
680
+ jest.spyOn(WebUsbTransport.prototype, 'getProtocolType').mockReturnValue('V1');
681
+ jest.spyOn(WebUsbTransport.prototype, 'configure').mockImplementation(() => undefined);
682
+ jest
683
+ .spyOn(WebUsbTransport.prototype, 'configureProtocolV2')
684
+ .mockImplementation(() => undefined);
685
+ jest
686
+ .spyOn(WebUsbTransport.prototype, 'connect')
687
+ .mockImplementation(async function (this: WebUsbTransport, path, first) {
688
+ if (this === active && !first) return this.connectToDevice(path, first);
689
+ device.opened = true;
690
+ });
691
+ jest.spyOn(WebUsbTransport.prototype, 'call').mockResolvedValue({
692
+ type: 'Features',
693
+ message: { onekey_serial_no: 'PHYSICAL_SERIAL', onekey_device_type: 'CLASSIC1S' },
694
+ });
695
+ try {
696
+ const acquiring = active.acquire({ path: 'USB_PATH' });
697
+ try {
698
+ await closed.promise;
699
+ await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toEqual([]);
700
+ resume.resolve();
701
+ await expect(acquiring).resolves.toBe('USB_PATH');
702
+ } finally {
703
+ resume.resolve();
704
+ await acquiring.catch(() => undefined);
705
+ await active.release('USB_PATH');
706
+ }
707
+ await expect(probeUsbDeviceIdentities({ commType: 'webusb' })).resolves.toMatchObject([
708
+ { serialNo: 'PHYSICAL_SERIAL' },
709
+ ]);
710
+ } finally {
711
+ jest.restoreAllMocks();
712
+ }
713
+ }
714
+ );
715
+
716
+ test('releases ownership after a failed normal acquire', async () => {
717
+ const active = new WebUsbTransport();
718
+ const probe = new WebUsbTransport(true);
719
+ active.init({ debug: jest.fn() });
720
+ probe.init({ debug: jest.fn() });
721
+ const state = active as unknown as PacketIoTransport;
722
+ jest.spyOn(state, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
723
+ jest.spyOn(active, 'connect').mockImplementation(() => {
724
+ device.opened = true;
725
+ return Promise.resolve();
726
+ });
727
+ jest.spyOn(state, 'detectProtocol').mockRejectedValue(new Error('protocol failed'));
728
+ await expect(active.acquire({ path: 'USB_PATH' })).rejects.toThrow('protocol failed');
729
+ expect(device.opened).toBe(false);
730
+ const probeState = probe as unknown as PacketIoTransport;
731
+ jest.spyOn(probeState, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
732
+ jest.spyOn(probeState, 'detectProtocol').mockResolvedValue('V1');
733
+ jest.spyOn(probe, 'connect').mockImplementation(() => {
734
+ device.opened = true;
735
+ return Promise.resolve();
736
+ });
737
+ try {
738
+ await expect(probe.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
739
+ } finally {
740
+ await probe.release('USB_PATH');
741
+ }
742
+ });
743
+
744
+ test('releasing one path preserves another closed handle owned by the same transport', async () => {
745
+ const other = {
746
+ ...device,
747
+ serialNumber: 'OTHER_PATH',
748
+ opened: false,
749
+ close: jest.fn(() => {
750
+ other.opened = false;
751
+ return Promise.resolve();
752
+ }),
753
+ };
754
+ usb.getDevices.mockResolvedValue([device, other]);
755
+ const active = new WebUsbTransport();
756
+ active.init({ debug: jest.fn() });
757
+ const state = active as unknown as PacketIoTransport;
758
+ jest.spyOn(state, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
759
+ jest.spyOn(state, 'detectProtocol').mockResolvedValue('V1');
760
+ jest.spyOn(active, 'connect').mockImplementation(path => {
761
+ const target = path === 'USB_PATH' ? device : other;
762
+ target.opened = true;
763
+ return Promise.resolve();
764
+ });
765
+ await active.acquire({ path: 'USB_PATH' });
766
+ await active.acquire({ path: 'OTHER_PATH' });
767
+ await active.release('USB_PATH');
768
+ other.opened = false;
769
+ const probe = new WebUsbTransport(true);
770
+ probe.init({ debug: jest.fn() });
771
+ const probeState = probe as unknown as PacketIoTransport;
772
+ jest.spyOn(probeState, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
773
+ jest.spyOn(probeState, 'detectProtocol').mockResolvedValue('V1');
774
+ jest.spyOn(probe, 'connect').mockImplementation(path => {
775
+ const target = path === 'USB_PATH' ? device : other;
776
+ target.opened = true;
777
+ return Promise.resolve();
778
+ });
779
+ try {
780
+ await expect(probe.acquire({ path: 'USB_PATH' })).resolves.toBe('USB_PATH');
781
+ await expect(probe.acquire({ path: 'OTHER_PATH' })).rejects.toThrow(
782
+ 'USB device is already in use'
783
+ );
784
+ } finally {
785
+ await active.release('OTHER_PATH');
786
+ await probe.release('USB_PATH');
787
+ }
788
+ });
789
+
790
+ test.each(['read', 'read retry', 'write', 'write retry', 'retry cleanup'])(
791
+ 'blocks native %s while an isolated probe owns the handle',
792
+ async operation => {
793
+ const probe = new WebUsbTransport(true);
794
+ probe.init({ debug: jest.fn() });
795
+ const probeState = probe as unknown as PacketIoTransport;
796
+ jest.spyOn(probeState, 'rotateProtocolV2UsbGeneration').mockResolvedValue(undefined);
797
+ jest.spyOn(probeState, 'detectProtocol').mockResolvedValue('V1');
798
+ jest.spyOn(probe, 'connect').mockImplementation(() => {
799
+ device.opened = true;
800
+ return Promise.resolve();
801
+ });
802
+ const native = Object.assign(device, {
803
+ releaseInterface: jest.fn(),
804
+ transferIn: jest
805
+ .fn()
806
+ .mockResolvedValue({ status: 'ok', data: new DataView(new ArrayBuffer(64)) }),
807
+ transferOut: jest.fn().mockResolvedValue({ status: 'ok' }),
808
+ });
809
+ await probe.acquire({ path: 'USB_PATH' });
810
+ const active = new WebUsbTransport();
811
+ active.init({ debug: jest.fn() });
812
+ const state = active as unknown as PacketIoTransport;
813
+ try {
814
+ let call: Promise<unknown>;
815
+ if (operation === 'read') call = state.transferInOnce('USB_PATH', 64);
816
+ else if (operation === 'read retry') call = state.transferInWithRetry('USB_PATH', 64);
817
+ else if (operation === 'write')
818
+ call = state.transferOutOnce('USB_PATH', new Uint8Array([1]));
819
+ else if (operation === 'write retry')
820
+ call = state.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
821
+ else
822
+ call = state.reconnectForPacketIoRetry(
823
+ 'USB_PATH',
824
+ 'in',
825
+ 0,
826
+ new Error('transferIn failed')
827
+ );
828
+ await expect(call).rejects.toThrow('USB device is already in use');
829
+ expect(native.transferIn).not.toHaveBeenCalled();
830
+ expect(native.transferOut).not.toHaveBeenCalled();
831
+ expect(native.releaseInterface).not.toHaveBeenCalled();
832
+ expect(device.close).not.toHaveBeenCalled();
833
+ } finally {
834
+ await probe.release('USB_PATH');
835
+ }
836
+ }
837
+ );
838
+
839
+ test.each(['in', 'out'] as const)(
840
+ 'ends transfer%s immediately if reconnect reports an ownership conflict',
841
+ async direction => {
842
+ const active = new WebUsbTransport();
843
+ active.init({ debug: jest.fn() });
844
+ device.opened = true;
845
+ const native = Object.assign(device, {
846
+ transferIn: jest.fn().mockRejectedValue(new Error('transferIn NetworkError')),
847
+ transferOut: jest.fn().mockRejectedValue(new Error('transferOut NetworkError')),
848
+ });
849
+ const state = active as unknown as PacketIoTransport;
850
+ const conflict = ERRORS.TypedError(
851
+ HardwareErrorCode.RuntimeError,
852
+ 'USB device is already in use'
853
+ );
854
+ const reconnect = jest.spyOn(state, 'reconnectForPacketIoRetry').mockRejectedValue(conflict);
855
+ const call =
856
+ direction === 'in'
857
+ ? state.transferInWithRetry('USB_PATH', 64)
858
+ : state.transferOutWithRetry('USB_PATH', new Uint8Array([1]));
859
+ await expect(call).rejects.toBe(conflict);
860
+ expect(reconnect).toHaveBeenCalledTimes(1);
861
+ expect(native[direction === 'in' ? 'transferIn' : 'transferOut']).toHaveBeenCalledTimes(1);
862
+ }
863
+ );
864
+
246
865
  test.each([false, true])(
247
866
  'uses a read-only V1 probe only for isolated discovery (%s)',
248
867
  async identityProbe => {