@onekeyfe/hd-transport 1.2.0-alpha.13 → 1.2.0-alpha.130

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 (59) hide show
  1. package/__tests__/messages.test.js +144 -4
  2. package/__tests__/protocol-v2-ble-frame-writer.test.js +119 -0
  3. package/__tests__/protocol-v2-link-manager.test.js +176 -9
  4. package/__tests__/protocol-v2-usb-transport-base.test.js +36 -2
  5. package/__tests__/protocol-v2.test.js +540 -108
  6. package/__tests__/transport-log.test.js +79 -0
  7. package/dist/constants.d.ts +3 -1
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/index.d.ts +645 -217
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +679 -153
  12. package/dist/protocols/index.d.ts +17 -1
  13. package/dist/protocols/index.d.ts.map +1 -1
  14. package/dist/protocols/v2/ble-frame-writer.d.ts +15 -0
  15. package/dist/protocols/v2/ble-frame-writer.d.ts.map +1 -0
  16. package/dist/protocols/v2/decode.d.ts +11 -0
  17. package/dist/protocols/v2/decode.d.ts.map +1 -1
  18. package/dist/protocols/v2/errors.d.ts +16 -0
  19. package/dist/protocols/v2/errors.d.ts.map +1 -0
  20. package/dist/protocols/v2/frame-assembler.d.ts.map +1 -1
  21. package/dist/protocols/v2/index.d.ts +2 -0
  22. package/dist/protocols/v2/index.d.ts.map +1 -1
  23. package/dist/protocols/v2/link-manager.d.ts +5 -0
  24. package/dist/protocols/v2/link-manager.d.ts.map +1 -1
  25. package/dist/protocols/v2/session.d.ts +14 -4
  26. package/dist/protocols/v2/session.d.ts.map +1 -1
  27. package/dist/protocols/v2/usb-transport-base.d.ts +2 -0
  28. package/dist/protocols/v2/usb-transport-base.d.ts.map +1 -1
  29. package/dist/serialization/protobuf/decode.d.ts.map +1 -1
  30. package/dist/serialization/protobuf/encode.d.ts.map +1 -1
  31. package/dist/types/messages.d.ts +301 -156
  32. package/dist/types/messages.d.ts.map +1 -1
  33. package/dist/types/transport.d.ts +21 -3
  34. package/dist/types/transport.d.ts.map +1 -1
  35. package/dist/utils/transportLog.d.ts +9 -0
  36. package/dist/utils/transportLog.d.ts.map +1 -0
  37. package/messages-protocol-v2.json +523 -449
  38. package/package.json +2 -2
  39. package/scripts/protobuf-build.sh +112 -32
  40. package/scripts/protobuf-patches/index.js +1 -0
  41. package/scripts/protobuf-types.js +10 -1
  42. package/src/constants.ts +30 -16
  43. package/src/index.ts +5 -1
  44. package/src/protocols/index.ts +114 -3
  45. package/src/protocols/v2/ble-frame-writer.ts +77 -0
  46. package/src/protocols/v2/crc8.ts +1 -1
  47. package/src/protocols/v2/decode.ts +49 -12
  48. package/src/protocols/v2/encode.ts +1 -1
  49. package/src/protocols/v2/errors.ts +54 -0
  50. package/src/protocols/v2/frame-assembler.ts +6 -4
  51. package/src/protocols/v2/index.ts +2 -0
  52. package/src/protocols/v2/link-manager.ts +65 -5
  53. package/src/protocols/v2/session.ts +224 -80
  54. package/src/protocols/v2/usb-transport-base.ts +42 -10
  55. package/src/serialization/protobuf/decode.ts +4 -1
  56. package/src/serialization/protobuf/encode.ts +5 -1
  57. package/src/types/messages.ts +375 -198
  58. package/src/types/transport.ts +30 -3
  59. package/src/utils/transportLog.ts +102 -0
@@ -3,6 +3,7 @@ const {
3
3
  createMessageFromType,
4
4
  parseConfigure,
5
5
  } = require('../src/serialization/protobuf/messages');
6
+ const { encode } = require('../src/serialization/protobuf/encode');
6
7
  const v1Messages = require('../../core/src/data/messages/messages.json');
7
8
  const v2Messages = require('../messages-protocol-v2.json');
8
9
  const coreV2Messages = require('../../core/src/data/messages/messages-protocol-v2.json');
@@ -101,26 +102,165 @@ describe('messages', () => {
101
102
  expect(v2Messages.nested.MessageType.values).toMatchObject({
102
103
  MessageType_DeviceStatusGet: 60602,
103
104
  MessageType_DeviceStatus: 60603,
104
- MessageType_DeviceSessionGet: 60606,
105
- MessageType_DeviceSession: 60607,
106
- MessageType_DeviceSessionAskPin: 60608,
105
+ MessageType_DeviceSessionGet: 61200,
106
+ MessageType_DeviceSession: 61201,
107
+ MessageType_DeviceSessionAskPin: 61202,
108
+ MessageType_DeviceSessionAskPassphrase: 61203,
107
109
  });
108
110
  expect(v2Messages.nested.DeviceStatusGet).toEqual({ fields: {} });
111
+ expect(v2Messages.nested.ProtocolInfoRequest.fields.eventless_wallet_session).toMatchObject({
112
+ id: 1,
113
+ type: 'bool',
114
+ options: { default: false },
115
+ });
116
+ expect(v2Messages.nested.ProtocolInfo.fields).toMatchObject({
117
+ version: { id: 1, type: 'uint32', rule: 'required' },
118
+ build_fingerprint: { id: 2, type: 'string', rule: 'required' },
119
+ supported_messages: { id: 3, type: 'uint32', rule: 'repeated' },
120
+ });
109
121
  expect(v2Messages.nested.DeviceSessionGet.fields.session_id).toMatchObject({
110
122
  id: 1,
111
123
  type: 'bytes',
112
124
  });
125
+ expect(v2Messages.nested.DeviceSessionGet.fields).toEqual({
126
+ session_id: { id: 1, type: 'bytes' },
127
+ btc_test_address: { id: 2, type: 'string' },
128
+ seed_domains: {
129
+ id: 3,
130
+ rule: 'repeated',
131
+ type: 'DeviceSessionSeedDomain',
132
+ },
133
+ });
134
+ expect(v2Messages.nested.DeviceSessionSeedDomain.values).toEqual({
135
+ SeedDomain_Standard: 1,
136
+ SeedDomain_Cardano: 2,
137
+ });
138
+ expect(v2Messages.nested.DeviceSessionPinType.values).toEqual({
139
+ Any: 1,
140
+ Main: 2,
141
+ AttachToPin: 3,
142
+ });
143
+ expect(v2Messages.nested.DeviceSessionErrorCode.values).toEqual({
144
+ DeviceSessionError_None: 0,
145
+ DeviceSessionError_UserCancelled: 1,
146
+ DeviceSessionError_InvalidSession: 2,
147
+ DeviceSessionError_AttachPinUnavailable: 3,
148
+ DeviceSessionError_PassphraseDisabled: 4,
149
+ DeviceSessionError_Busy: 5,
150
+ });
151
+ expect(v2Messages.nested).not.toHaveProperty('DeviceWalletSelect');
152
+ expect(v2Messages.nested).not.toHaveProperty('DeviceWalletType');
153
+ expect(v2Messages.nested).not.toHaveProperty('DeviceHiddenWalletSelect');
113
154
  expect(v2Messages.nested.DeviceSession.fields).toMatchObject({
114
155
  session_id: { id: 1, type: 'bytes' },
115
156
  btc_test_address: { id: 2, type: 'string' },
116
157
  });
117
- expect(v2Messages.nested.DeviceSessionAskPin).toEqual({ fields: {} });
158
+ expect(v2Messages.nested.DeviceSessionAskPin.fields.type).toMatchObject({
159
+ id: 1,
160
+ type: 'DeviceSessionPinType',
161
+ });
162
+ expect(v2Messages.nested.DeviceSessionAskPassphrase).toEqual({
163
+ fields: {
164
+ passphrase: {
165
+ type: 'string',
166
+ id: 1,
167
+ },
168
+ on_device: {
169
+ rule: 'required',
170
+ type: 'bool',
171
+ id: 2,
172
+ },
173
+ },
174
+ });
118
175
  expect(v2Messages.nested.DeviceSessionAskPin_FailureSubCodes.values).toEqual({
119
176
  UserCancel: 1,
120
177
  });
121
178
  expect(v2Messages.nested.MessageType.values).not.toHaveProperty(
122
179
  'MessageType_DeviceSessionPinResult'
123
180
  );
181
+ expect(v2Messages.nested.MessageType.values).not.toHaveProperty(
182
+ 'MessageType_DeviceSessionOpen'
183
+ );
184
+ });
185
+
186
+ test('Protocol V2 passphrase selection explicitly selects host or device input on wire', () => {
187
+ const messages = parseConfigure(v2Messages);
188
+ const { Message } = createMessageFromName(messages, 'DeviceSessionAskPassphrase');
189
+
190
+ const standardWallet = encode(Message, { passphrase: '', on_device: false });
191
+ const onHost = Message.encode(
192
+ Message.create({ passphrase: 'host hidden wallet', on_device: false })
193
+ ).finish();
194
+ const onDevice = Message.encode(Message.create({ on_device: true })).finish();
195
+
196
+ expect(standardWallet.toString('hex')).toBe('0a001000');
197
+ expect(Buffer.from(onHost).toString('hex')).toBe(
198
+ '0a12686f73742068696464656e2077616c6c65741000'
199
+ );
200
+ expect(Buffer.from(onDevice).toString('hex')).toBe('1001');
201
+ expect(Message.decode(onHost)).toMatchObject({
202
+ passphrase: 'host hidden wallet',
203
+ on_device: false,
204
+ });
205
+ expect(Message.decode(onDevice)).toMatchObject({ on_device: true });
206
+ });
207
+
208
+ test('Protocol V2 wallet recovery carries the expected wallet and seed domains on wire', () => {
209
+ const messages = parseConfigure(v2Messages);
210
+ const { Message } = createMessageFromName(messages, 'DeviceSessionGet');
211
+ const payload = encode(Message, {
212
+ btc_test_address: 'tb1qwallet',
213
+ seed_domains: [
214
+ generatedTypes.DeviceSessionSeedDomain.SeedDomain_Standard,
215
+ generatedTypes.DeviceSessionSeedDomain.SeedDomain_Cardano,
216
+ ],
217
+ });
218
+
219
+ expect(payload.toString('hex')).toBe('120a7462317177616c6c65741a020102');
220
+ expect(Message.decode(payload.toBuffer())).toMatchObject({
221
+ btc_test_address: 'tb1qwallet',
222
+ seed_domains: [
223
+ generatedTypes.DeviceSessionSeedDomain.SeedDomain_Standard,
224
+ generatedTypes.DeviceSessionSeedDomain.SeedDomain_Cardano,
225
+ ],
226
+ });
227
+ });
228
+
229
+ test('Protocol V2 onboarding status matches the current firmware-pro2 schema', () => {
230
+ expect(v2Messages.nested.MessageType.values).toMatchObject({
231
+ MessageType_OnboardingStatusGet: 61600,
232
+ MessageType_OnboardingStatus: 61601,
233
+ });
234
+ expect(v2Messages.nested.OnboardingStep.values).toMatchObject({
235
+ ONBOARDING_STEP_UNKNOWN: 0,
236
+ ONBOARDING_STEP_CHECKING: 1,
237
+ ONBOARDING_STEP_PERSONALIZATION: 2,
238
+ ONBOARDING_STEP_PIN: 3,
239
+ ONBOARDING_STEP_SETUP: 4,
240
+ ONBOARDING_STEP_DONE: 5,
241
+ });
242
+ expect(v2Messages.nested.OnboardingPhase.values).toBeDefined();
243
+ expect(v2Messages.nested.OnboardingSetupKind.values).toBeDefined();
244
+ expect(v2Messages.nested.OnboardingSetupMethod.values).toBeDefined();
245
+ expect(v2Messages.nested.OnboardingSetupStatus.fields).toMatchObject({
246
+ kind: { id: 1, type: 'OnboardingSetupKind' },
247
+ method: { id: 2, type: 'OnboardingSetupMethod' },
248
+ });
249
+ expect(v2Messages.nested.OnboardingStatus.fields).toMatchObject({
250
+ step: { id: 1, type: 'OnboardingStep' },
251
+ phase: { id: 2, type: 'OnboardingPhase' },
252
+ setup: { id: 3, type: 'OnboardingSetupStatus' },
253
+ pin_set: { id: 4, type: 'bool' },
254
+ wallet_initialized: { id: 5, type: 'bool' },
255
+ });
256
+ expect(v2Messages.nested).not.toHaveProperty('DevOnboardingStatus');
257
+ });
258
+
259
+ test('Protocol V2 NFT update matches the current firmware-pro2 schema', () => {
260
+ expect(v2Messages.nested.MessageType.values.MessageType_NftUpdate).toBe(61500);
261
+ expect(v2Messages.nested.NftUpdate.fields).toEqual({
262
+ file_name_no_ext: { id: 1, type: 'string', rule: 'required' },
263
+ });
124
264
  });
125
265
 
126
266
  test('Protocol V2 does not restore retired unlock or passphrase ids', () => {
@@ -0,0 +1,119 @@
1
+ /* eslint-disable @typescript-eslint/no-var-requires */
2
+ const { writeProtocolV2BleFrame } = require('../src');
3
+
4
+ describe('Protocol V2 BLE frame writer', () => {
5
+ test('splits one complete Protocol V2 frame without changing packet order', async () => {
6
+ const packets = [];
7
+
8
+ await writeProtocolV2BleFrame({
9
+ frame: new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
10
+ packetCapacity: 4,
11
+ writePacket: packet => {
12
+ packets.push(Array.from(packet));
13
+ return Promise.resolve();
14
+ },
15
+ });
16
+
17
+ expect(packets).toEqual([
18
+ [0, 1, 2, 3],
19
+ [4, 5, 6, 7],
20
+ [8, 9],
21
+ ]);
22
+ });
23
+
24
+ test('stops before the next packet when the connection generation changes', async () => {
25
+ const packets = [];
26
+ let active = true;
27
+
28
+ await expect(
29
+ writeProtocolV2BleFrame({
30
+ frame: new Uint8Array(9),
31
+ packetCapacity: 4,
32
+ assertActive: () => {
33
+ if (!active) throw new Error('stale BLE generation');
34
+ },
35
+ writePacket: packet => {
36
+ packets.push(packet);
37
+ active = false;
38
+ return Promise.resolve();
39
+ },
40
+ })
41
+ ).rejects.toThrow('stale BLE generation');
42
+
43
+ expect(packets).toHaveLength(1);
44
+ });
45
+
46
+ test('does not write when the call is already aborted', async () => {
47
+ const controller = new AbortController();
48
+ const writePacket = jest.fn();
49
+ controller.abort();
50
+
51
+ await expect(
52
+ writeProtocolV2BleFrame({
53
+ frame: new Uint8Array(4),
54
+ packetCapacity: 4,
55
+ signal: controller.signal,
56
+ abortMessage: 'Protocol V2 BLE write aborted for Ping',
57
+ writePacket,
58
+ })
59
+ ).rejects.toThrow('Protocol V2 BLE write aborted for Ping');
60
+
61
+ expect(writePacket).not.toHaveBeenCalled();
62
+ });
63
+
64
+ test('surfaces an abort that occurs while the final packet write is completing', async () => {
65
+ const controller = new AbortController();
66
+ const writePacket = jest.fn().mockImplementation(() => {
67
+ controller.abort();
68
+ return Promise.resolve();
69
+ });
70
+
71
+ await expect(
72
+ writeProtocolV2BleFrame({
73
+ frame: new Uint8Array(4),
74
+ packetCapacity: 4,
75
+ signal: controller.signal,
76
+ abortMessage: 'Protocol V2 BLE write aborted for Ping',
77
+ writePacket,
78
+ })
79
+ ).rejects.toThrow('Protocol V2 BLE write aborted for Ping');
80
+
81
+ expect(writePacket).toHaveBeenCalledTimes(1);
82
+ });
83
+
84
+ test('applies bounded burst pauses and one final flush delay', async () => {
85
+ const waits = [];
86
+ const writePacket = jest.fn().mockResolvedValue(undefined);
87
+
88
+ await writeProtocolV2BleFrame({
89
+ frame: new Uint8Array(10),
90
+ packetCapacity: 3,
91
+ initialDelayMs: 3,
92
+ burstSize: 2,
93
+ burstPauseMs: 7,
94
+ flushDelayMs: 11,
95
+ wait: ms => {
96
+ waits.push(ms);
97
+ return Promise.resolve();
98
+ },
99
+ writePacket,
100
+ });
101
+
102
+ expect(writePacket).toHaveBeenCalledTimes(4);
103
+ expect(waits).toEqual([3, 7, 11]);
104
+ });
105
+
106
+ test('rejects an invalid packet capacity before writing', async () => {
107
+ const writePacket = jest.fn();
108
+
109
+ await expect(
110
+ writeProtocolV2BleFrame({
111
+ frame: new Uint8Array(4),
112
+ packetCapacity: 0,
113
+ writePacket,
114
+ })
115
+ ).rejects.toThrow('Protocol V2 BLE packet capacity must be a positive integer');
116
+
117
+ expect(writePacket).not.toHaveBeenCalled();
118
+ });
119
+ });
@@ -26,6 +26,9 @@ const protocolV2Messages = parseConfigure({
26
26
  message: { type: 'string', id: 1 },
27
27
  },
28
28
  },
29
+ Cancel: {
30
+ fields: {},
31
+ },
29
32
  Success: {
30
33
  fields: {
31
34
  message: { type: 'string', id: 1 },
@@ -35,6 +38,7 @@ const protocolV2Messages = parseConfigure({
35
38
  values: {
36
39
  MessageType_Ping: 60206,
37
40
  MessageType_Success: 60207,
41
+ MessageType_Cancel: 60004,
38
42
  },
39
43
  },
40
44
  },
@@ -55,7 +59,12 @@ const rewriteSeq = (frame, seq) => {
55
59
  const createAdapterFactory = sentSeqs => {
56
60
  const adapters = [];
57
61
  let generation = 0;
58
- const success = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
62
+ const success = ProtocolV2.encodeFrame(
63
+ schemas,
64
+ 'Success',
65
+ { message: 'ok' },
66
+ { router: 1, packetSrc: 0, seq: 1 }
67
+ );
59
68
 
60
69
  return {
61
70
  adapters,
@@ -108,12 +117,15 @@ describe('ProtocolV2LinkManager', () => {
108
117
 
109
118
  await manager.call('device-a', createAdapter, 'Ping', { message: '1' }, { timeoutMs: 123 });
110
119
 
111
- expect(adapters[0].prepareCall).toHaveBeenCalledWith({
112
- messageName: 'Ping',
113
- timeoutMs: 123,
114
- highVolume: false,
115
- generation: 1,
116
- });
120
+ expect(adapters[0].prepareCall).toHaveBeenCalledWith(
121
+ expect.objectContaining({
122
+ messageName: 'Ping',
123
+ timeoutMs: 123,
124
+ highThroughput: false,
125
+ generation: 1,
126
+ signal: expect.any(AbortSignal),
127
+ })
128
+ );
117
129
  });
118
130
 
119
131
  test('invalidates a link-fatal call before the next call creates a new link', async () => {
@@ -178,6 +190,54 @@ describe('ProtocolV2LinkManager', () => {
178
190
  expect(adapters[1].reset).toHaveBeenCalledWith('schema changed');
179
191
  });
180
192
 
193
+ test('waits for an in-flight native cleanup before a concurrent invalidation resolves', async () => {
194
+ const sentSeqs = [];
195
+ const { adapters, createAdapter } = createAdapterFactory(sentSeqs);
196
+ let markCleanupStarted;
197
+ let finishCleanup;
198
+ const cleanupStarted = new Promise(resolve => {
199
+ markCleanupStarted = resolve;
200
+ });
201
+ const cleanupBlocked = new Promise(resolve => {
202
+ finishCleanup = resolve;
203
+ });
204
+ const onLinkInvalidated = jest.fn(async () => {
205
+ markCleanupStarted();
206
+ await cleanupBlocked;
207
+ });
208
+ const manager = new ProtocolV2LinkManager({
209
+ getSchemas: () => schemas,
210
+ classifyError: () => 'recoverable',
211
+ onLinkInvalidated,
212
+ });
213
+
214
+ await manager.call('device-a', createAdapter, 'Ping', { message: '1' });
215
+
216
+ const release = manager.invalidateLink('device-a', 'release');
217
+ await cleanupStarted;
218
+
219
+ let acquireSettled = false;
220
+ const acquire = manager.invalidateLink('device-a', 'acquire').then(() => {
221
+ acquireSettled = true;
222
+ });
223
+ await new Promise(resolve => {
224
+ setImmediate(resolve);
225
+ });
226
+
227
+ expect(acquireSettled).toBe(false);
228
+ expect(adapters[0].reset).toHaveBeenCalledTimes(1);
229
+ expect(onLinkInvalidated).toHaveBeenCalledTimes(1);
230
+
231
+ finishCleanup();
232
+ await Promise.all([release, acquire]);
233
+
234
+ await manager.call('device-a', createAdapter, 'Ping', { message: '2' });
235
+
236
+ expect(acquireSettled).toBe(true);
237
+ expect(sentSeqs).toEqual([1, 2]);
238
+ expect(adapters).toHaveLength(2);
239
+ });
240
+
181
241
  test('dispose clears active links and sequence cursors', async () => {
182
242
  const sentSeqs = [];
183
243
  const { adapters, createAdapter } = createAdapterFactory(sentSeqs);
@@ -200,7 +260,12 @@ describe('ProtocolV2LinkManager', () => {
200
260
  const deviceABlocked = new Promise(resolve => {
201
261
  releaseDeviceA = resolve;
202
262
  });
203
- const success = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
263
+ const success = ProtocolV2.encodeFrame(
264
+ schemas,
265
+ 'Success',
266
+ { message: 'ok' },
267
+ { router: 1, packetSrc: 0, seq: 1 }
268
+ );
204
269
  const createAdapter = key => {
205
270
  let requestSeq = 0;
206
271
  return {
@@ -252,6 +317,58 @@ describe('ProtocolV2LinkManager', () => {
252
317
  ]);
253
318
  });
254
319
 
320
+ test('writes flow control while the active call is waiting for its response', async () => {
321
+ const sentSeqs = [];
322
+ let releaseRead;
323
+ let markReadStarted;
324
+ const readStarted = new Promise(resolve => {
325
+ markReadStarted = resolve;
326
+ });
327
+ const readBlocked = new Promise(resolve => {
328
+ releaseRead = resolve;
329
+ });
330
+ const success = ProtocolV2.encodeFrame(
331
+ schemas,
332
+ 'Success',
333
+ { message: 'ok' },
334
+ { router: 1, packetSrc: 0, seq: 1 }
335
+ );
336
+ let requestSeq = 0;
337
+ const adapter = {
338
+ router: 1,
339
+ generation: 1,
340
+ prepareCall: jest.fn(),
341
+ writeFrame: jest.fn(frame => {
342
+ [, , , , , , requestSeq] = frame;
343
+ sentSeqs.push(requestSeq);
344
+ return Promise.resolve();
345
+ }),
346
+ readFrame: jest.fn(async () => {
347
+ markReadStarted();
348
+ await readBlocked;
349
+ return rewriteSeq(success, 1);
350
+ }),
351
+ reset: jest.fn(),
352
+ };
353
+ const manager = new ProtocolV2LinkManager({
354
+ getSchemas: () => schemas,
355
+ classifyError: () => 'recoverable',
356
+ });
357
+ const createAdapter = jest.fn(() => adapter);
358
+
359
+ const activeCall = manager.call('device-a', createAdapter, 'Ping', { message: 'pending' });
360
+ await readStarted;
361
+ await expect(manager.sendFlowControl('device-a', createAdapter, 'Cancel', {})).resolves.toEqual(
362
+ { type: 'WriteCompleted', message: {} }
363
+ );
364
+
365
+ expect(sentSeqs).toEqual([1, 2]);
366
+ expect(adapter.prepareCall).toHaveBeenCalledTimes(1);
367
+ expect(adapter.readFrame).toHaveBeenCalledTimes(1);
368
+ releaseRead();
369
+ await activeCall;
370
+ });
371
+
255
372
  test('keeps a recoverable link after a call error', async () => {
256
373
  const sentSeqs = [];
257
374
  const { adapters, createAdapter } = createAdapterFactory(sentSeqs);
@@ -298,7 +415,12 @@ describe('ProtocolV2LinkManager', () => {
298
415
  const writeBlocked = new Promise(resolve => {
299
416
  releaseWrite = resolve;
300
417
  });
301
- const success = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
418
+ const success = ProtocolV2.encodeFrame(
419
+ schemas,
420
+ 'Success',
421
+ { message: 'ok' },
422
+ { router: 1, packetSrc: 0, seq: 1 }
423
+ );
302
424
  const adapter = {
303
425
  router: 1,
304
426
  generation: 1,
@@ -323,4 +445,49 @@ describe('ProtocolV2LinkManager', () => {
323
445
  await expect(call).rejects.toThrow('device disconnected');
324
446
  expect(adapter.readFrame).not.toHaveBeenCalled();
325
447
  });
448
+
449
+ test('rejects calls queued before their link generation is invalidated', async () => {
450
+ let releaseWrite;
451
+ let markWriteStarted;
452
+ const writeStarted = new Promise(resolve => {
453
+ markWriteStarted = resolve;
454
+ });
455
+ const writeBlocked = new Promise(resolve => {
456
+ releaseWrite = resolve;
457
+ });
458
+ const success = ProtocolV2.encodeFrame(
459
+ schemas,
460
+ 'Success',
461
+ { message: 'ok' },
462
+ { router: 1, packetSrc: 0, seq: 1 }
463
+ );
464
+ const adapter = {
465
+ router: 1,
466
+ generation: 1,
467
+ prepareCall: jest.fn(),
468
+ writeFrame: jest.fn(async () => {
469
+ markWriteStarted();
470
+ await writeBlocked;
471
+ }),
472
+ readFrame: jest.fn(() => Promise.resolve(success)),
473
+ reset: jest.fn(),
474
+ };
475
+ const createAdapter = jest.fn(() => adapter);
476
+ const manager = new ProtocolV2LinkManager({
477
+ getSchemas: () => schemas,
478
+ classifyError: () => 'recoverable',
479
+ });
480
+
481
+ const activeCall = manager.call('device-a', createAdapter, 'Ping', { message: 'active' });
482
+ await writeStarted;
483
+ const queuedCall = manager.call('device-a', createAdapter, 'Ping', { message: 'queued' });
484
+ await manager.invalidateLink('device-a', 'device released');
485
+ releaseWrite();
486
+
487
+ await expect(activeCall).rejects.toThrow('device released');
488
+ await expect(queuedCall).rejects.toThrow('device released');
489
+ expect(createAdapter).toHaveBeenCalledTimes(1);
490
+ expect(adapter.writeFrame).toHaveBeenCalledTimes(1);
491
+ expect(adapter.readFrame).not.toHaveBeenCalled();
492
+ });
326
493
  });
@@ -65,6 +65,7 @@ class FakeUsbTransport extends ProtocolV2UsbTransportBase {
65
65
  this.nextReadError = undefined;
66
66
  this.writeBlock = undefined;
67
67
  this.readBlock = undefined;
68
+ this.coalesceNextResponses = false;
68
69
  }
69
70
 
70
71
  callDevice(key, message, timeoutMs) {
@@ -118,6 +119,10 @@ class FakeUsbTransport extends ProtocolV2UsbTransportBase {
118
119
  return { started, release };
119
120
  }
120
121
 
122
+ coalesceNextTwoResponses() {
123
+ this.coalesceNextResponses = true;
124
+ }
125
+
121
126
  getProtocolV2UsbSchemas() {
122
127
  return schemas;
123
128
  }
@@ -135,8 +140,22 @@ class FakeUsbTransport extends ProtocolV2UsbTransportBase {
135
140
  { message: 'ok' },
136
141
  { router: PROTOCOL_V2_CHANNEL_USB, seq }
137
142
  );
138
- const splitAt = Math.min(4, response.length);
139
- this.packetQueues.set(key, [response.slice(0, splitAt), response.slice(splitAt)]);
143
+ if (this.coalesceNextResponses) {
144
+ this.coalesceNextResponses = false;
145
+ const nextResponse = ProtocolV2.encodeFrame(
146
+ schemas,
147
+ 'Success',
148
+ { message: 'ok' },
149
+ { router: PROTOCOL_V2_CHANNEL_USB, seq: Number(seq) + 1 }
150
+ );
151
+ const packet = new Uint8Array(response.length + nextResponse.length);
152
+ packet.set(response);
153
+ packet.set(nextResponse, response.length);
154
+ this.packetQueues.set(key, [packet]);
155
+ } else {
156
+ const splitAt = Math.min(4, response.length);
157
+ this.packetQueues.set(key, [response.slice(0, splitAt), response.slice(splitAt)]);
158
+ }
140
159
 
141
160
  const block = this.writeBlock;
142
161
  if (block) {
@@ -215,6 +234,21 @@ describe('ProtocolV2UsbTransportBase', () => {
215
234
  ]);
216
235
  });
217
236
 
237
+ test('keeps a coalesced response buffered for the next call', async () => {
238
+ const transport = new FakeUsbTransport();
239
+ await transport.rotate('device-a');
240
+ transport.coalesceNextTwoResponses();
241
+
242
+ await transport.callDevice('device-a', 'first');
243
+ await transport.callDevice('device-a', 'second');
244
+
245
+ expect(transport.readCounts.get('device-a')).toBe(1);
246
+ expect(transport.sentSeqs).toEqual([
247
+ ['device-a', 1],
248
+ ['device-a', 2],
249
+ ]);
250
+ });
251
+
218
252
  test('does not block a different USB path', async () => {
219
253
  const transport = new FakeUsbTransport();
220
254
  await transport.rotate('device-a');