@onekeyfe/hd-transport 1.2.0-alpha.8 → 1.2.0-alpha.9

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.
Files changed (41) hide show
  1. package/__tests__/messages.test.js +62 -0
  2. package/__tests__/protocol-v2-link-manager.test.js +326 -0
  3. package/__tests__/protocol-v2-usb-transport-base.test.js +296 -0
  4. package/__tests__/protocol-v2.test.js +112 -4
  5. package/dist/constants.d.ts +2 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/index.d.ts +164 -6
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +352 -29
  10. package/dist/protocols/index.d.ts +2 -0
  11. package/dist/protocols/index.d.ts.map +1 -1
  12. package/dist/protocols/v2/constants.d.ts +1 -0
  13. package/dist/protocols/v2/constants.d.ts.map +1 -1
  14. package/dist/protocols/v2/decode.d.ts +1 -0
  15. package/dist/protocols/v2/decode.d.ts.map +1 -1
  16. package/dist/protocols/v2/link-manager.d.ts +35 -0
  17. package/dist/protocols/v2/link-manager.d.ts.map +1 -0
  18. package/dist/protocols/v2/sequence-cursor.d.ts +5 -0
  19. package/dist/protocols/v2/sequence-cursor.d.ts.map +1 -0
  20. package/dist/protocols/v2/session.d.ts +15 -3
  21. package/dist/protocols/v2/session.d.ts.map +1 -1
  22. package/dist/protocols/v2/usb-transport-base.d.ts +31 -0
  23. package/dist/protocols/v2/usb-transport-base.d.ts.map +1 -0
  24. package/dist/types/messages.d.ts +51 -1
  25. package/dist/types/messages.d.ts.map +1 -1
  26. package/dist/types/transport.d.ts +1 -1
  27. package/dist/types/transport.d.ts.map +1 -1
  28. package/messages-protocol-v2.json +134 -3
  29. package/package.json +2 -2
  30. package/scripts/protobuf-build.sh +46 -0
  31. package/src/constants.ts +6 -0
  32. package/src/index.ts +8 -0
  33. package/src/protocols/index.ts +3 -1
  34. package/src/protocols/v2/constants.ts +1 -0
  35. package/src/protocols/v2/decode.ts +36 -17
  36. package/src/protocols/v2/link-manager.ts +160 -0
  37. package/src/protocols/v2/sequence-cursor.ts +10 -0
  38. package/src/protocols/v2/session.ts +76 -40
  39. package/src/protocols/v2/usb-transport-base.ts +194 -0
  40. package/src/types/messages.ts +65 -1
  41. package/src/types/transport.ts +1 -1
@@ -3,6 +3,10 @@ const {
3
3
  createMessageFromType,
4
4
  parseConfigure,
5
5
  } = require('../src/serialization/protobuf/messages');
6
+ const v1Messages = require('../messages.json');
7
+ const v2Messages = require('../messages-protocol-v2.json');
8
+ const coreV2Messages = require('../../core/src/data/messages/messages-protocol-v2.json');
9
+
6
10
  // const json = require('./data/messages.json');
7
11
 
8
12
  const json = {
@@ -58,6 +62,64 @@ const json = {
58
62
  };
59
63
 
60
64
  describe('messages', () => {
65
+ test('V1 GetPassphraseState includes attach-to-pin request flags', () => {
66
+ const { fields } = v1Messages.nested.GetPassphraseState;
67
+
68
+ expect(fields._only_main_pin).toMatchObject({ id: 2, type: 'bool' });
69
+ expect(fields.allow_create_attach_pin).toMatchObject({ id: 3, type: 'bool' });
70
+ });
71
+
72
+ test('Protocol V2 firmware targets match the current firmware-pro2 enum', () => {
73
+ expect(v2Messages.nested.DeviceFirmwareTargetType.values).toEqual({
74
+ FW_MGMT_TARGET_INVALID: 0,
75
+ FW_MGMT_TARGET_CRATE: 1,
76
+ FW_MGMT_TARGET_ROMLOADER: 2,
77
+ FW_MGMT_TARGET_BOOTLOADER: 3,
78
+ FW_MGMT_TARGET_APPLICATION_P1: 4,
79
+ FW_MGMT_TARGET_APPLICATION_P2: 5,
80
+ FW_MGMT_TARGET_COPROCESSOR: 6,
81
+ FW_MGMT_TARGET_SE01: 7,
82
+ FW_MGMT_TARGET_SE02: 8,
83
+ FW_MGMT_TARGET_SE03: 9,
84
+ FW_MGMT_TARGET_SE04: 10,
85
+ });
86
+ });
87
+
88
+ test('Protocol V2 device status and session messages match firmware-pro2', () => {
89
+ expect(v2Messages.nested.MessageType.values).toMatchObject({
90
+ MessageType_DeviceStatusGet: 60602,
91
+ MessageType_DeviceStatus: 60603,
92
+ MessageType_DeviceSessionGet: 60606,
93
+ MessageType_DeviceSession: 60607,
94
+ });
95
+ expect(v2Messages.nested.DeviceStatusGet).toEqual({ fields: {} });
96
+ expect(v2Messages.nested.DeviceSessionGet.fields.session_id).toMatchObject({
97
+ id: 1,
98
+ type: 'bytes',
99
+ });
100
+ expect(v2Messages.nested.DeviceSession.fields).toMatchObject({
101
+ session_id: { id: 1, type: 'bytes' },
102
+ btc_test_address: { id: 2, type: 'string' },
103
+ });
104
+ });
105
+
106
+ test('Protocol V2 temporarily restores unlock ids without restoring old passphrase ids', () => {
107
+ expect(v2Messages.nested.MessageType.values).toMatchObject({
108
+ MessageType_UnLockDevice: 10030,
109
+ MessageType_UnLockDeviceResponse: 10031,
110
+ });
111
+ expect(v2Messages.nested.MessageType.values).not.toHaveProperty(
112
+ 'MessageType_GetPassphraseState'
113
+ );
114
+ expect(v2Messages.nested.MessageType.values).not.toHaveProperty('MessageType_PassphraseState');
115
+ expect(v2Messages.nested.UnLockDevice).toBeDefined();
116
+ expect(v2Messages.nested.UnLockDeviceResponse).toBeDefined();
117
+ });
118
+
119
+ test('Protocol V2 transport and core schemas stay identical', () => {
120
+ expect(coreV2Messages).toEqual(v2Messages);
121
+ });
122
+
61
123
  test('createMessageFromName (common case)', () => {
62
124
  const messages = parseConfigure(json);
63
125
  const name = 'Initialize';
@@ -0,0 +1,326 @@
1
+ /* eslint-disable @typescript-eslint/no-var-requires */
2
+ const { ProtocolV2, ProtocolV2LinkManager } = require('../src');
3
+ const { parseConfigure } = require('../src/serialization/protobuf/messages');
4
+ const protocolV2 = require('../src/protocols/v2');
5
+
6
+ const protocolV1Messages = parseConfigure({
7
+ nested: {
8
+ Failure: {
9
+ fields: {
10
+ code: { type: 'uint32', id: 1 },
11
+ message: { type: 'string', id: 2 },
12
+ },
13
+ },
14
+ MessageType: {
15
+ values: {
16
+ MessageType_Failure: 3,
17
+ },
18
+ },
19
+ },
20
+ });
21
+
22
+ const protocolV2Messages = parseConfigure({
23
+ nested: {
24
+ Ping: {
25
+ fields: {
26
+ message: { type: 'string', id: 1 },
27
+ },
28
+ },
29
+ Success: {
30
+ fields: {
31
+ message: { type: 'string', id: 1 },
32
+ },
33
+ },
34
+ MessageType: {
35
+ values: {
36
+ MessageType_Ping: 60206,
37
+ MessageType_Success: 60207,
38
+ },
39
+ },
40
+ },
41
+ });
42
+
43
+ const schemas = {
44
+ protocolV1: protocolV1Messages,
45
+ protocolV2: protocolV2Messages,
46
+ };
47
+
48
+ const rewriteSeq = (frame, seq) => {
49
+ const copy = new Uint8Array(frame);
50
+ copy[6] = seq;
51
+ copy[copy.length - 1] = protocolV2.crc8(copy, copy.length - 1);
52
+ return copy;
53
+ };
54
+
55
+ const createAdapterFactory = sentSeqs => {
56
+ const adapters = [];
57
+ let generation = 0;
58
+ const success = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
59
+
60
+ return {
61
+ adapters,
62
+ createAdapter: () => {
63
+ let requestSeq = 0;
64
+ const adapter = {
65
+ router: 1,
66
+ generation: ++generation,
67
+ prepareCall: jest.fn(),
68
+ writeFrame: jest.fn(frame => {
69
+ const [, , , , , , seq] = frame;
70
+ requestSeq = seq;
71
+ sentSeqs.push(requestSeq);
72
+ return Promise.resolve();
73
+ }),
74
+ readFrame: jest.fn(() => Promise.resolve(rewriteSeq(success, requestSeq))),
75
+ reset: jest.fn(() => Promise.resolve()),
76
+ };
77
+ adapters.push(adapter);
78
+ return adapter;
79
+ },
80
+ };
81
+ };
82
+
83
+ describe('ProtocolV2LinkManager', () => {
84
+ test('retains the device sequence cursor when an active link is rebuilt', async () => {
85
+ const sentSeqs = [];
86
+ const { adapters, createAdapter } = createAdapterFactory(sentSeqs);
87
+ const manager = new ProtocolV2LinkManager({
88
+ getSchemas: () => schemas,
89
+ classifyError: () => 'recoverable',
90
+ });
91
+
92
+ await manager.call('device-a', createAdapter, 'Ping', { message: '1' });
93
+ await manager.invalidateLink('device-a', 'reconnect');
94
+ await manager.call('device-a', createAdapter, 'Ping', { message: '2' });
95
+
96
+ expect(sentSeqs).toEqual([1, 2]);
97
+ expect(adapters).toHaveLength(2);
98
+ expect(adapters[0].reset).toHaveBeenCalledWith('reconnect');
99
+ });
100
+
101
+ test('prepares the active adapter with the same call context used for frame IO', async () => {
102
+ const sentSeqs = [];
103
+ const { adapters, createAdapter } = createAdapterFactory(sentSeqs);
104
+ const manager = new ProtocolV2LinkManager({
105
+ getSchemas: () => schemas,
106
+ classifyError: () => 'recoverable',
107
+ });
108
+
109
+ await manager.call('device-a', createAdapter, 'Ping', { message: '1' }, { timeoutMs: 123 });
110
+
111
+ expect(adapters[0].prepareCall).toHaveBeenCalledWith({
112
+ messageName: 'Ping',
113
+ timeoutMs: 123,
114
+ highVolume: false,
115
+ generation: 1,
116
+ });
117
+ });
118
+
119
+ test('invalidates a link-fatal call before the next call creates a new link', async () => {
120
+ const sentSeqs = [];
121
+ const onLinkInvalidated = jest.fn();
122
+ const { adapters, createAdapter: createWorkingAdapter } = createAdapterFactory(sentSeqs);
123
+ const createAdapter = jest
124
+ .fn()
125
+ .mockImplementationOnce(() => {
126
+ const adapter = createWorkingAdapter();
127
+ adapter.writeFrame.mockImplementationOnce(frame => {
128
+ sentSeqs.push(frame[6]);
129
+ return Promise.reject(new Error('transport write failed'));
130
+ });
131
+ return adapter;
132
+ })
133
+ .mockImplementation(() => createWorkingAdapter());
134
+ const manager = new ProtocolV2LinkManager({
135
+ getSchemas: () => schemas,
136
+ classifyError: () => 'link-fatal',
137
+ onLinkInvalidated,
138
+ });
139
+
140
+ await expect(manager.call('device-a', createAdapter, 'Ping', { message: '1' })).rejects.toThrow(
141
+ 'transport write failed'
142
+ );
143
+ await expect(
144
+ manager.call('device-a', createAdapter, 'Ping', { message: '2' })
145
+ ).resolves.toEqual({
146
+ type: 'Success',
147
+ message: { message: 'ok' },
148
+ });
149
+
150
+ expect(createAdapter).toHaveBeenCalledTimes(2);
151
+ expect(adapters[0].reset).toHaveBeenCalledWith(
152
+ expect.stringContaining('transport write failed')
153
+ );
154
+ expect(onLinkInvalidated).toHaveBeenCalledWith(
155
+ 'device-a',
156
+ expect.stringContaining('transport write failed')
157
+ );
158
+ expect(sentSeqs).toEqual([1, 2]);
159
+ });
160
+
161
+ test('invalidates every active link while retaining per-device cursors', async () => {
162
+ const sentSeqs = [];
163
+ const { adapters, createAdapter } = createAdapterFactory(sentSeqs);
164
+ const manager = new ProtocolV2LinkManager({
165
+ getSchemas: () => schemas,
166
+ classifyError: () => 'recoverable',
167
+ });
168
+
169
+ await manager.call('device-a', createAdapter, 'Ping', { message: 'a1' });
170
+ await manager.call('device-b', createAdapter, 'Ping', { message: 'b1' });
171
+ await manager.invalidateAllLinks('schema changed');
172
+ await manager.call('device-a', createAdapter, 'Ping', { message: 'a2' });
173
+ await manager.call('device-b', createAdapter, 'Ping', { message: 'b2' });
174
+
175
+ expect(sentSeqs).toEqual([1, 1, 2, 2]);
176
+ expect(adapters).toHaveLength(4);
177
+ expect(adapters[0].reset).toHaveBeenCalledWith('schema changed');
178
+ expect(adapters[1].reset).toHaveBeenCalledWith('schema changed');
179
+ });
180
+
181
+ test('dispose clears active links and sequence cursors', async () => {
182
+ const sentSeqs = [];
183
+ const { adapters, createAdapter } = createAdapterFactory(sentSeqs);
184
+ const manager = new ProtocolV2LinkManager({
185
+ getSchemas: () => schemas,
186
+ classifyError: () => 'recoverable',
187
+ });
188
+
189
+ await manager.call('device-a', createAdapter, 'Ping', { message: '1' });
190
+ await manager.dispose('transport disposed');
191
+ await manager.call('device-a', createAdapter, 'Ping', { message: '2' });
192
+
193
+ expect(sentSeqs).toEqual([1, 1]);
194
+ expect(adapters[0].reset).toHaveBeenCalledWith('transport disposed');
195
+ });
196
+
197
+ test('serializes calls per device without blocking another device', async () => {
198
+ const events = [];
199
+ let releaseDeviceA;
200
+ const deviceABlocked = new Promise(resolve => {
201
+ releaseDeviceA = resolve;
202
+ });
203
+ const success = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
204
+ const createAdapter = key => {
205
+ let requestSeq = 0;
206
+ return {
207
+ router: 1,
208
+ generation: 1,
209
+ prepareCall: jest.fn(),
210
+ writeFrame: jest.fn(frame => {
211
+ const [, , , , , , seq] = frame;
212
+ requestSeq = seq;
213
+ events.push(`write:${key}:${requestSeq}`);
214
+ return Promise.resolve();
215
+ }),
216
+ readFrame: jest.fn(async () => {
217
+ if (key === 'device-a' && requestSeq === 1) {
218
+ await deviceABlocked;
219
+ }
220
+ events.push(`read:${key}:${requestSeq}`);
221
+ return rewriteSeq(success, requestSeq);
222
+ }),
223
+ reset: jest.fn(),
224
+ };
225
+ };
226
+ const manager = new ProtocolV2LinkManager({
227
+ getSchemas: () => schemas,
228
+ classifyError: () => 'recoverable',
229
+ });
230
+
231
+ const firstA = manager.call('device-a', () => createAdapter('device-a'), 'Ping', {
232
+ message: 'a1',
233
+ });
234
+ const secondA = manager.call('device-a', () => createAdapter('device-a'), 'Ping', {
235
+ message: 'a2',
236
+ });
237
+ const firstB = manager.call('device-b', () => createAdapter('device-b'), 'Ping', {
238
+ message: 'b1',
239
+ });
240
+
241
+ await firstB;
242
+ expect(events).toEqual(['write:device-a:1', 'write:device-b:1', 'read:device-b:1']);
243
+ releaseDeviceA();
244
+ await Promise.all([firstA, secondA]);
245
+ expect(events).toEqual([
246
+ 'write:device-a:1',
247
+ 'write:device-b:1',
248
+ 'read:device-b:1',
249
+ 'read:device-a:1',
250
+ 'write:device-a:2',
251
+ 'read:device-a:2',
252
+ ]);
253
+ });
254
+
255
+ test('keeps a recoverable link after a call error', async () => {
256
+ const sentSeqs = [];
257
+ const { adapters, createAdapter } = createAdapterFactory(sentSeqs);
258
+ const manager = new ProtocolV2LinkManager({
259
+ getSchemas: () => schemas,
260
+ classifyError: () => 'recoverable',
261
+ });
262
+ const adapter = createAdapter();
263
+ adapter.writeFrame.mockImplementationOnce(frame => {
264
+ sentSeqs.push(frame[6]);
265
+ return Promise.reject(new Error('application retryable error'));
266
+ });
267
+ const factory = jest.fn(() => adapter);
268
+
269
+ await expect(manager.call('device-a', factory, 'Ping', { message: '1' })).rejects.toThrow(
270
+ 'application retryable error'
271
+ );
272
+ await manager.call('device-a', factory, 'Ping', { message: '2' });
273
+
274
+ expect(factory).toHaveBeenCalledTimes(1);
275
+ expect(adapter.reset).not.toHaveBeenCalled();
276
+ expect(sentSeqs).toEqual([1, 2]);
277
+ });
278
+
279
+ test('removes a settled per-device call queue', async () => {
280
+ const sentSeqs = [];
281
+ const { createAdapter } = createAdapterFactory(sentSeqs);
282
+ const manager = new ProtocolV2LinkManager({
283
+ getSchemas: () => schemas,
284
+ classifyError: () => 'recoverable',
285
+ });
286
+
287
+ await manager.call('device-a', createAdapter, 'Ping', { message: 'done' });
288
+
289
+ expect(manager.callQueues.size).toBe(0);
290
+ });
291
+
292
+ test('stops an in-flight call before reading when its link is invalidated during write', async () => {
293
+ let releaseWrite;
294
+ let markWriteStarted;
295
+ const writeStarted = new Promise(resolve => {
296
+ markWriteStarted = resolve;
297
+ });
298
+ const writeBlocked = new Promise(resolve => {
299
+ releaseWrite = resolve;
300
+ });
301
+ const success = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
302
+ const adapter = {
303
+ router: 1,
304
+ generation: 1,
305
+ prepareCall: jest.fn(),
306
+ writeFrame: jest.fn(async () => {
307
+ markWriteStarted();
308
+ await writeBlocked;
309
+ }),
310
+ readFrame: jest.fn(() => Promise.resolve(success)),
311
+ reset: jest.fn(),
312
+ };
313
+ const manager = new ProtocolV2LinkManager({
314
+ getSchemas: () => schemas,
315
+ classifyError: () => 'recoverable',
316
+ });
317
+
318
+ const call = manager.call('device-a', () => adapter, 'Ping', { message: 'pending' });
319
+ await writeStarted;
320
+ await manager.invalidateLink('device-a', 'device disconnected');
321
+ releaseWrite();
322
+
323
+ await expect(call).rejects.toThrow('device disconnected');
324
+ expect(adapter.readFrame).not.toHaveBeenCalled();
325
+ });
326
+ });
@@ -0,0 +1,296 @@
1
+ /* eslint-disable @typescript-eslint/no-var-requires */
2
+ const {
3
+ PROTOCOL_V2_CHANNEL_USB,
4
+ PROTOCOL_V2_FRAME_MAX_BYTES,
5
+ ProtocolV2,
6
+ ProtocolV2UsbTransportBase,
7
+ } = require('../src');
8
+ const { parseConfigure } = require('../src/serialization/protobuf/messages');
9
+
10
+ const protocolV1Messages = parseConfigure({
11
+ nested: {
12
+ Failure: {
13
+ fields: {
14
+ code: { type: 'uint32', id: 1 },
15
+ message: { type: 'string', id: 2 },
16
+ },
17
+ },
18
+ MessageType: {
19
+ values: {
20
+ MessageType_Failure: 3,
21
+ },
22
+ },
23
+ },
24
+ });
25
+
26
+ const protocolV2Messages = parseConfigure({
27
+ nested: {
28
+ Ping: {
29
+ fields: {
30
+ message: { type: 'string', id: 1 },
31
+ },
32
+ },
33
+ Success: {
34
+ fields: {
35
+ message: { type: 'string', id: 1 },
36
+ },
37
+ },
38
+ MessageType: {
39
+ values: {
40
+ MessageType_Ping: 60206,
41
+ MessageType_Success: 60207,
42
+ },
43
+ },
44
+ },
45
+ });
46
+
47
+ const schemas = {
48
+ protocolV1: protocolV1Messages,
49
+ protocolV2: protocolV2Messages,
50
+ };
51
+
52
+ class FakeUsbTransport extends ProtocolV2UsbTransportBase {
53
+ constructor() {
54
+ super({
55
+ router: PROTOCOL_V2_CHANNEL_USB,
56
+ maxFrameBytes: PROTOCOL_V2_FRAME_MAX_BYTES,
57
+ logPrefix: 'ProtocolV2 FakeUSB',
58
+ });
59
+ this.sentSeqs = [];
60
+ this.readContexts = [];
61
+ this.readCounts = new Map();
62
+ this.packetQueues = new Map();
63
+ this.nativeResets = [];
64
+ this.invalidations = [];
65
+ this.nextReadError = undefined;
66
+ this.writeBlock = undefined;
67
+ this.readBlock = undefined;
68
+ }
69
+
70
+ callDevice(key, message, timeoutMs) {
71
+ return this.callProtocolV2Usb(
72
+ key,
73
+ 'Ping',
74
+ { message },
75
+ timeoutMs === undefined ? undefined : { timeoutMs }
76
+ );
77
+ }
78
+
79
+ rotate(key, reason = 'USB connection rotated') {
80
+ return this.rotateProtocolV2UsbGeneration(key, reason);
81
+ }
82
+
83
+ invalidate(key, reason) {
84
+ return this.invalidateProtocolV2UsbLink(key, reason);
85
+ }
86
+
87
+ dispose(reason) {
88
+ return this.disposeProtocolV2UsbLinks(reason);
89
+ }
90
+
91
+ failNextRead(error) {
92
+ this.nextReadError = error;
93
+ }
94
+
95
+ blockNextWrite() {
96
+ let release;
97
+ let markStarted;
98
+ const started = new Promise(resolve => {
99
+ markStarted = resolve;
100
+ });
101
+ const blocked = new Promise(resolve => {
102
+ release = resolve;
103
+ });
104
+ this.writeBlock = { blocked, markStarted };
105
+ return { started, release };
106
+ }
107
+
108
+ blockNextRead() {
109
+ let release;
110
+ let markStarted;
111
+ const started = new Promise(resolve => {
112
+ markStarted = resolve;
113
+ });
114
+ const blocked = new Promise(resolve => {
115
+ release = resolve;
116
+ });
117
+ this.readBlock = { blocked, markStarted };
118
+ return { started, release };
119
+ }
120
+
121
+ getProtocolV2UsbSchemas() {
122
+ return schemas;
123
+ }
124
+
125
+ getProtocolV2UsbLogger() {
126
+ return undefined;
127
+ }
128
+
129
+ async writeProtocolV2UsbPacket(key, frame) {
130
+ const seq = frame[6];
131
+ this.sentSeqs.push([key, seq]);
132
+ const response = ProtocolV2.encodeFrame(
133
+ schemas,
134
+ 'Success',
135
+ { message: 'ok' },
136
+ { router: PROTOCOL_V2_CHANNEL_USB, seq }
137
+ );
138
+ const splitAt = Math.min(4, response.length);
139
+ this.packetQueues.set(key, [response.slice(0, splitAt), response.slice(splitAt)]);
140
+
141
+ const block = this.writeBlock;
142
+ if (block) {
143
+ this.writeBlock = undefined;
144
+ block.markStarted();
145
+ await block.blocked;
146
+ }
147
+ }
148
+
149
+ readProtocolV2UsbPacket(key, context) {
150
+ this.readContexts.push([key, context.timeoutMs]);
151
+ this.readCounts.set(key, Number(this.readCounts.get(key) ?? 0) + 1);
152
+ if (this.nextReadError) {
153
+ const error = this.nextReadError;
154
+ this.nextReadError = undefined;
155
+ return Promise.reject(error);
156
+ }
157
+ const readPacket = async () => {
158
+ const block = this.readBlock;
159
+ if (block) {
160
+ this.readBlock = undefined;
161
+ block.markStarted();
162
+ await block.blocked;
163
+ }
164
+ const packet = this.packetQueues.get(key)?.shift();
165
+ if (!packet) throw new Error(`No queued USB packet for ${key}`);
166
+ return packet;
167
+ };
168
+ return readPacket();
169
+ }
170
+
171
+ resetProtocolV2UsbNativeLink(key, reason) {
172
+ this.nativeResets.push([key, reason]);
173
+ return Promise.resolve();
174
+ }
175
+
176
+ onProtocolV2UsbLinkInvalidated(key, reason) {
177
+ this.invalidations.push([key, reason]);
178
+ }
179
+
180
+ createProtocolV2UsbTimeoutError(name, timeoutMs) {
181
+ return new Error(`USB timeout after ${timeoutMs}ms for ${name}`);
182
+ }
183
+ }
184
+
185
+ describe('ProtocolV2UsbTransportBase', () => {
186
+ test('keeps seq across USB generation rotation', async () => {
187
+ const transport = new FakeUsbTransport();
188
+
189
+ await transport.rotate('device-a');
190
+ await transport.callDevice('device-a', 'first');
191
+ await transport.rotate('device-a', 'USB reconnected');
192
+ await transport.callDevice('device-a', 'second');
193
+
194
+ expect(transport.sentSeqs).toEqual([
195
+ ['device-a', 1],
196
+ ['device-a', 2],
197
+ ]);
198
+ expect(transport.nativeResets).toEqual([['device-a', 'USB reconnected']]);
199
+ });
200
+
201
+ test('passes each queued call its own timeout context', async () => {
202
+ const transport = new FakeUsbTransport();
203
+ await transport.rotate('device-a');
204
+
205
+ await Promise.all([
206
+ transport.callDevice('device-a', 'first', 111),
207
+ transport.callDevice('device-a', 'second', 222),
208
+ ]);
209
+
210
+ expect(transport.readContexts).toEqual([
211
+ ['device-a', 111],
212
+ ['device-a', 111],
213
+ ['device-a', 222],
214
+ ['device-a', 222],
215
+ ]);
216
+ });
217
+
218
+ test('does not block a different USB path', async () => {
219
+ const transport = new FakeUsbTransport();
220
+ await transport.rotate('device-a');
221
+ await transport.rotate('device-b');
222
+ const blockedWrite = transport.blockNextWrite();
223
+
224
+ const callA = transport.callDevice('device-a', 'blocked');
225
+ await blockedWrite.started;
226
+ await expect(transport.callDevice('device-b', 'parallel')).resolves.toMatchObject({
227
+ type: 'Success',
228
+ });
229
+ blockedWrite.release();
230
+ await expect(callA).resolves.toMatchObject({ type: 'Success' });
231
+ });
232
+
233
+ test('invalidates an in-flight generation before it can read', async () => {
234
+ const transport = new FakeUsbTransport();
235
+ await transport.rotate('device-a');
236
+ const blockedWrite = transport.blockNextWrite();
237
+
238
+ const call = transport.callDevice('device-a', 'blocked');
239
+ await blockedWrite.started;
240
+ await transport.rotate('device-a', 'USB generation changed');
241
+ blockedWrite.release();
242
+
243
+ await expect(call).rejects.toThrow('USB generation changed');
244
+ expect(transport.readCounts.get('device-a') ?? 0).toBe(0);
245
+ });
246
+
247
+ test('rejects an in-flight packet read without waiting for native IO to settle', async () => {
248
+ const transport = new FakeUsbTransport();
249
+ await transport.rotate('device-a');
250
+ const blockedRead = transport.blockNextRead();
251
+
252
+ const call = transport.callDevice('device-a', 'blocked-read', 5000);
253
+ const outcome = call.then(
254
+ () => 'resolved',
255
+ error => error.message
256
+ );
257
+ await blockedRead.started;
258
+ await transport.invalidate('device-a', 'USB transport released');
259
+ const settled = await Promise.race([
260
+ outcome,
261
+ new Promise(resolve => {
262
+ setTimeout(() => resolve('still pending'), 50);
263
+ }),
264
+ ]);
265
+ blockedRead.release();
266
+
267
+ expect(settled).toContain('USB transport released');
268
+ });
269
+
270
+ test('invalidates the native link after a packet read failure', async () => {
271
+ const transport = new FakeUsbTransport();
272
+ await transport.rotate('device-a');
273
+ transport.failNextRead(new Error('USB transfer failed'));
274
+
275
+ await expect(transport.callDevice('device-a', 'failure')).rejects.toThrow(
276
+ 'USB transfer failed'
277
+ );
278
+ expect(transport.nativeResets).toEqual([
279
+ ['device-a', expect.stringContaining('USB transfer failed')],
280
+ ]);
281
+ expect(transport.invalidations).toEqual([
282
+ ['device-a', expect.stringContaining('USB transfer failed')],
283
+ ]);
284
+ });
285
+
286
+ test('dispose clears the cursor and restarts seq from one', async () => {
287
+ const transport = new FakeUsbTransport();
288
+ await transport.rotate('device-a');
289
+ await transport.callDevice('device-a', 'before-dispose');
290
+ await transport.dispose('transport disposed');
291
+ await transport.rotate('device-a');
292
+ await transport.callDevice('device-a', 'after-dispose');
293
+
294
+ expect(transport.sentSeqs.map(([, seq]) => seq)).toEqual([1, 1]);
295
+ });
296
+ });