@onekeyfe/hd-transport 1.2.0-alpha.17 → 1.2.0-alpha.170

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 (58) hide show
  1. package/__tests__/messages.test.js +137 -32
  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 +535 -70
  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 +654 -286
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +681 -160
  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 +304 -203
  32. package/dist/types/messages.d.ts.map +1 -1
  33. package/dist/types/transport.d.ts +20 -2
  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 +515 -540
  38. package/package.json +2 -2
  39. package/scripts/protobuf-patches/index.js +1 -0
  40. package/scripts/protobuf-types.js +10 -1
  41. package/src/constants.ts +30 -16
  42. package/src/index.ts +5 -1
  43. package/src/protocols/index.ts +114 -3
  44. package/src/protocols/v2/ble-frame-writer.ts +77 -0
  45. package/src/protocols/v2/crc8.ts +1 -1
  46. package/src/protocols/v2/decode.ts +49 -12
  47. package/src/protocols/v2/encode.ts +1 -1
  48. package/src/protocols/v2/errors.ts +54 -0
  49. package/src/protocols/v2/frame-assembler.ts +6 -4
  50. package/src/protocols/v2/index.ts +2 -0
  51. package/src/protocols/v2/link-manager.ts +65 -5
  52. package/src/protocols/v2/session.ts +224 -86
  53. package/src/protocols/v2/usb-transport-base.ts +42 -10
  54. package/src/serialization/protobuf/decode.ts +4 -1
  55. package/src/serialization/protobuf/encode.ts +5 -1
  56. package/src/types/messages.ts +377 -259
  57. package/src/types/transport.ts +37 -7
  58. package/src/utils/transportLog.ts +102 -0
@@ -2,12 +2,27 @@ const { ProtocolV2 } = require('../src/protocols');
2
2
  const { parseConfigure } = require('../src/serialization/protobuf/messages');
3
3
  const {
4
4
  ProtocolV2FrameAssembler,
5
+ ProtocolV2LinkError,
5
6
  ProtocolV2SequenceCursor,
6
7
  ProtocolV2Session,
7
8
  hexToBytes,
9
+ isProtocolV2HighThroughputCall,
8
10
  probeProtocolV2,
11
+ isProtocolV2LinkDisabledFailure,
9
12
  } = require('../src/protocols/v2/session');
10
13
  const protocolV2 = require('../src/protocols/v2');
14
+ const {
15
+ PROTOCOL_V2_BLE_FIRMWARE_FILE_CHUNK_SIZE,
16
+ PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
17
+ PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
18
+ PROTOCOL_V2_FRAME_MAX_BYTES,
19
+ } = require('../src/constants');
20
+
21
+ test('keeps sensitive acknowledgements separate from high-throughput calls', () => {
22
+ expect(isProtocolV2HighThroughputCall('PassphraseAck')).toBe(false);
23
+ expect(isProtocolV2HighThroughputCall('PinMatrixAck')).toBe(false);
24
+ expect(isProtocolV2HighThroughputCall('FilesystemFileWrite')).toBe(true);
25
+ });
11
26
 
12
27
  const protocolV1Messages = parseConfigure({
13
28
  nested: {
@@ -68,14 +83,14 @@ const protocolV2Messages = parseConfigure({
68
83
  type: 'uint32',
69
84
  id: 1,
70
85
  },
71
- supported_messages: {
72
- type: 'uint32',
86
+ build_fingerprint: {
87
+ type: 'string',
73
88
  id: 2,
74
- rule: 'repeated',
75
89
  },
76
- protobuf_definition: {
77
- type: 'string',
90
+ supported_messages: {
91
+ type: 'uint32',
78
92
  id: 3,
93
+ rule: 'repeated',
79
94
  },
80
95
  },
81
96
  },
@@ -123,7 +138,7 @@ const protocolV2Messages = parseConfigure({
123
138
  },
124
139
  },
125
140
  },
126
- DeviceFirmwareUpdateRequest: {
141
+ DeviceFirmwareUpdateStage: {
127
142
  fields: {
128
143
  targets: {
129
144
  type: 'DeviceFirmwareTarget',
@@ -132,6 +147,9 @@ const protocolV2Messages = parseConfigure({
132
147
  },
133
148
  },
134
149
  },
150
+ DeviceFirmwareUpdateRequest: {
151
+ fields: {},
152
+ },
135
153
  DeviceFirmwareUpdateRecord: {
136
154
  fields: {
137
155
  target_id: {
@@ -161,6 +179,18 @@ const protocolV2Messages = parseConfigure({
161
179
  },
162
180
  },
163
181
  },
182
+ DeviceSession: {
183
+ fields: {
184
+ session_id: {
185
+ type: 'bytes',
186
+ id: 1,
187
+ },
188
+ btc_test_address: {
189
+ type: 'string',
190
+ id: 2,
191
+ },
192
+ },
193
+ },
164
194
  FileWrite: {
165
195
  fields: {},
166
196
  },
@@ -192,8 +222,10 @@ const protocolV2Messages = parseConfigure({
192
222
  MessageType_Success: 60207,
193
223
  MessageType_Failure: 60208,
194
224
  MessageType_FileWrite: 60805,
195
- MessageType_DeviceFirmwareUpdateRequest: 61000,
225
+ MessageType_DeviceFirmwareUpdateStage: 61000,
226
+ MessageType_DeviceFirmwareUpdateRequest: 61001,
196
227
  MessageType_DeviceFirmwareUpdateStatus: 61002,
228
+ MessageType_DeviceSession: 61201,
197
229
  MessageType_PartialNested: 62000,
198
230
  },
199
231
  },
@@ -204,9 +236,37 @@ const schemas = {
204
236
  protocolV1: protocolV1Messages,
205
237
  protocolV2: protocolV2Messages,
206
238
  };
239
+ const productionProtocolV2Messages = parseConfigure(require('../messages-protocol-v2.json'));
240
+
241
+ const legacyProtocolInfoMessage = parseConfigure({
242
+ nested: {
243
+ ProtocolInfo: {
244
+ fields: {
245
+ version: {
246
+ type: 'uint32',
247
+ id: 1,
248
+ rule: 'required',
249
+ },
250
+ supported_messages: {
251
+ type: 'uint32',
252
+ id: 2,
253
+ rule: 'repeated',
254
+ options: {
255
+ packed: false,
256
+ },
257
+ },
258
+ protobuf_definition: {
259
+ type: 'string',
260
+ id: 3,
261
+ },
262
+ },
263
+ },
264
+ },
265
+ }).lookupType('ProtocolInfo');
207
266
 
208
267
  const rewriteSeq = (frame, seq) => {
209
268
  const copy = new Uint8Array(frame);
269
+ copy[4] = 1;
210
270
  copy[6] = seq;
211
271
  copy[copy.length - 1] = protocolV2.crc8(copy, copy.length - 1);
212
272
  return copy;
@@ -216,8 +276,8 @@ describe('Protocol V2 framing and session', () => {
216
276
  test('encodes and decodes Protocol V2 protobuf frames', () => {
217
277
  const frame = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
218
278
  version: 1,
279
+ build_fingerprint: 'application__1.0.0__abc__DEV__DEBUG',
219
280
  supported_messages: [60200, 60201],
220
- protobuf_definition: 'proto',
221
281
  });
222
282
 
223
283
  const parsed = protocolV2.decodeFrame(frame);
@@ -232,12 +292,70 @@ describe('Protocol V2 framing and session', () => {
232
292
  seq: parsed.seq,
233
293
  message: {
234
294
  version: 1,
295
+ build_fingerprint: 'application__1.0.0__abc__DEV__DEBUG',
235
296
  supported_messages: [60200, 60201],
236
- protobuf_definition: 'proto',
237
297
  },
238
298
  });
239
299
  });
240
300
 
301
+ test('decodes a two-byte legacy ProtocolInfo at the generic frame boundary', () => {
302
+ const frame = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01]));
303
+ const productionSchemas = {
304
+ protocolV1: protocolV1Messages,
305
+ protocolV2: productionProtocolV2Messages,
306
+ };
307
+
308
+ expect(ProtocolV2.decodeFrame(productionSchemas, frame)).toMatchObject({
309
+ type: 'ProtocolInfo',
310
+ message: {
311
+ version: 1,
312
+ build_fingerprint: '',
313
+ supported_messages: [],
314
+ protobuf_definition: null,
315
+ },
316
+ });
317
+ });
318
+
319
+ test('does not reinterpret a malformed current ProtocolInfo as the legacy layout', () => {
320
+ const frame = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01, 0x18, 0x01]));
321
+
322
+ expect(() =>
323
+ ProtocolV2.decodeFrame(
324
+ {
325
+ protocolV1: protocolV1Messages,
326
+ protocolV2: productionProtocolV2Messages,
327
+ },
328
+ frame
329
+ )
330
+ ).toThrow('Protocol V2 protobuf decode failed for "ProtocolInfo"');
331
+ });
332
+
333
+ test('preserves legacy ProtocolInfo capabilities when using the old field numbers', () => {
334
+ const payload = legacyProtocolInfoMessage
335
+ .encode({
336
+ version: 1,
337
+ supported_messages: [60602],
338
+ protobuf_definition: 'legacy',
339
+ })
340
+ .finish();
341
+ const frame = protocolV2.encodeProtobufFrame(60201, payload);
342
+
343
+ const decoded = ProtocolV2.decodeFrame(
344
+ {
345
+ protocolV1: protocolV1Messages,
346
+ protocolV2: productionProtocolV2Messages,
347
+ },
348
+ frame
349
+ );
350
+
351
+ expect(decoded.message).toEqual({
352
+ version: 1,
353
+ build_fingerprint: '',
354
+ supported_messages: [60602],
355
+ protobuf_definition: null,
356
+ });
357
+ });
358
+
241
359
  test('does not encode V1-only messages into Protocol V2 frames', () => {
242
360
  expect(() => ProtocolV2.encodeFrame(schemas, 'Ping', { message: 'ok' })).not.toThrow();
243
361
  expect(() => ProtocolV2.encodeFrame(schemas, 'OnekeyGetFeatures', {})).toThrow(
@@ -258,6 +376,34 @@ describe('Protocol V2 framing and session', () => {
258
376
  expect(decoded.message).toEqual({ message: 'ok' });
259
377
  });
260
378
 
379
+ test('round-trips a DeviceSession response with a 32-byte session id', () => {
380
+ const sessionId = '01'.repeat(32);
381
+ const frame = ProtocolV2.encodeFrame(schemas, 'DeviceSession', {
382
+ session_id: sessionId,
383
+ btc_test_address: 'tb1qwallet',
384
+ });
385
+
386
+ const decoded = ProtocolV2.decodeFrame(schemas, frame);
387
+
388
+ expect(decoded.type).toBe('DeviceSession');
389
+ expect(decoded.message).toEqual({
390
+ session_id: sessionId,
391
+ btc_test_address: 'tb1qwallet',
392
+ });
393
+ });
394
+
395
+ test('reports malformed DeviceSession protobuf as a schema-compatible transport error', () => {
396
+ const malformedPayload = new Uint8Array(32);
397
+ malformedPayload[0] = 0x0a;
398
+ malformedPayload[1] = 101;
399
+ const frame = protocolV2.encodeProtobufFrame(61201, malformedPayload);
400
+
401
+ expect(() => ProtocolV2.decodeFrame(schemas, frame)).toThrow(
402
+ 'Protocol V2 protobuf decode failed for "DeviceSession" (61201, 32-byte payload); ' +
403
+ 'the payload is malformed or incompatible with the active SDK schema.'
404
+ );
405
+ });
406
+
261
407
  test('decodes missing optional nested messages as null', () => {
262
408
  const frame = ProtocolV2.encodeFrame(schemas, 'PartialNested', {
263
409
  label: 'only label',
@@ -284,6 +430,60 @@ describe('Protocol V2 framing and session', () => {
284
430
  expect(() => assembler.push(oversized)).toThrow('Protocol V2 frame too large');
285
431
  });
286
432
 
433
+ test('enforces a transport-specific receive frame boundary', () => {
434
+ const assembler = new ProtocolV2FrameAssembler(2048);
435
+ const oversizedHeader = new Uint8Array([0x5a, 0x01, 0x08]);
436
+
437
+ expect(() => assembler.push(oversizedHeader)).toThrow('Protocol V2 frame too large: 2049');
438
+ });
439
+
440
+ test('enforces the firmware 4200-byte Protocol V2 frame boundary', () => {
441
+ const boundaryFrame = ProtocolV2.encodeFrame(schemas, 'Ping', {
442
+ message: 'x'.repeat(4187),
443
+ });
444
+
445
+ expect(PROTOCOL_V2_FRAME_MAX_BYTES).toBe(4200);
446
+ expect(boundaryFrame).toHaveLength(PROTOCOL_V2_FRAME_MAX_BYTES);
447
+ expect(() =>
448
+ ProtocolV2.encodeFrame(schemas, 'Ping', {
449
+ message: 'x'.repeat(4188),
450
+ })
451
+ ).toThrow('Protocol V2 frame too large: 4201 > 4200');
452
+ });
453
+
454
+ test('keeps optimized BLE firmware chunks inside the transport frame boundary', () => {
455
+ const productionSchemas = {
456
+ protocolV1: protocolV1Messages,
457
+ protocolV2: productionProtocolV2Messages,
458
+ };
459
+ const stagingPaths = [
460
+ 'vol0:/bootloader.bin',
461
+ 'vol0:/application_p1.bin',
462
+ 'vol0:/application_p2.bin',
463
+ 'vol0:/coprocessor.bin',
464
+ 'vol0:/se01.bin',
465
+ 'vol0:/se02.bin',
466
+ 'vol0:/se03.bin',
467
+ 'vol0:/se04.bin',
468
+ ];
469
+
470
+ for (const path of stagingPaths) {
471
+ const frame = ProtocolV2.encodeFrame(productionSchemas, 'FilesystemFileWrite', {
472
+ file: {
473
+ path,
474
+ offset: 0xffffffff,
475
+ total_size: 0xffffffff,
476
+ data: new Uint8Array(PROTOCOL_V2_BLE_FIRMWARE_FILE_CHUNK_SIZE),
477
+ },
478
+ overwrite: true,
479
+ append: false,
480
+ ui_percentage: 100,
481
+ });
482
+
483
+ expect(frame.length).toBeLessThanOrEqual(PROTOCOL_V2_BLE_FRAME_MAX_BYTES);
484
+ }
485
+ });
486
+
287
487
  test('keeps bytes after the first complete frame for the next read', () => {
288
488
  const first = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
289
489
  version: 1,
@@ -329,7 +529,39 @@ describe('Protocol V2 framing and session', () => {
329
529
  type: 'ProtocolInfo',
330
530
  message: {
331
531
  version: 2,
532
+ build_fingerprint: null,
332
533
  supported_messages: [60206],
534
+ },
535
+ });
536
+ });
537
+
538
+ test('session decodes legacy ProtocolInfo through the generic frame boundary', async () => {
539
+ const productionSchemas = {
540
+ protocolV1: protocolV1Messages,
541
+ protocolV2: productionProtocolV2Messages,
542
+ };
543
+ const response = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01]));
544
+ const session = new ProtocolV2Session({
545
+ schemas: productionSchemas,
546
+ router: 1,
547
+ writeFrame: () => Promise.resolve(),
548
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
549
+ });
550
+
551
+ await expect(
552
+ session.call(
553
+ 'ProtocolInfoRequest',
554
+ { eventless_wallet_session: true },
555
+ {
556
+ expectedTypes: ['ProtocolInfo'],
557
+ }
558
+ )
559
+ ).resolves.toEqual({
560
+ type: 'ProtocolInfo',
561
+ message: {
562
+ version: 1,
563
+ build_fingerprint: '',
564
+ supported_messages: [],
333
565
  protobuf_definition: null,
334
566
  },
335
567
  });
@@ -340,8 +572,8 @@ describe('Protocol V2 framing and session', () => {
340
572
  const logger = { debug: jest.fn() };
341
573
  const response = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
342
574
  version: 2,
575
+ build_fingerprint: 'application__1.0.0__sensitive__DEV__DEBUG',
343
576
  supported_messages: [60206],
344
- protobuf_definition: 'sensitive-schema',
345
577
  });
346
578
  const session = new ProtocolV2Session({
347
579
  schemas,
@@ -373,7 +605,7 @@ describe('Protocol V2 framing and session', () => {
373
605
  logger,
374
606
  logPrefix: 'ProtocolV2 Test',
375
607
  writeFrame: () => Promise.resolve(),
376
- readFrame: () => Promise.resolve(response),
608
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
377
609
  });
378
610
 
379
611
  await expect(session.call('Ping', { message: 'hello' })).rejects.toThrow();
@@ -395,7 +627,10 @@ describe('Protocol V2 framing and session', () => {
395
627
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
396
628
  message: 'ok',
397
629
  });
398
- const readFrame = jest.fn().mockResolvedValueOnce(ack).mockResolvedValueOnce(response);
630
+ const readFrame = jest
631
+ .fn()
632
+ .mockResolvedValueOnce(ack)
633
+ .mockResolvedValueOnce(rewriteSeq(response, 1));
399
634
  const session = new ProtocolV2Session({
400
635
  schemas,
401
636
  router: 1,
@@ -412,6 +647,96 @@ describe('Protocol V2 framing and session', () => {
412
647
  expect(readFrame).toHaveBeenCalledTimes(2);
413
648
  });
414
649
 
650
+ test('session accepts a terminal response before an ACK', async () => {
651
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
652
+ message: 'ok',
653
+ });
654
+ const session = new ProtocolV2Session({
655
+ schemas,
656
+ router: 1,
657
+ writeFrame: () => Promise.resolve(),
658
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
659
+ });
660
+
661
+ await expect(
662
+ session.call('Ping', { message: 'hello' }, { expectedTypes: ['Success'] })
663
+ ).resolves.toEqual({
664
+ type: 'Success',
665
+ message: {
666
+ message: 'ok',
667
+ },
668
+ });
669
+ });
670
+
671
+ test('session uses the unified response timeout before the first ACK', async () => {
672
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
673
+ message: 'ok',
674
+ });
675
+ const readFrame = jest.fn(() => Promise.resolve(rewriteSeq(response, 1)));
676
+ const session = new ProtocolV2Session({
677
+ schemas,
678
+ router: 1,
679
+ writeFrame: () => Promise.resolve(),
680
+ readFrame,
681
+ });
682
+
683
+ await expect(
684
+ session.call(
685
+ 'Ping',
686
+ { message: 'hello' },
687
+ {
688
+ timeoutMs: 12_345,
689
+ expectedTypes: ['Success'],
690
+ }
691
+ )
692
+ ).resolves.toEqual({
693
+ type: 'Success',
694
+ message: {
695
+ message: 'ok',
696
+ },
697
+ });
698
+ expect(readFrame).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 12_345 }));
699
+ });
700
+
701
+ test('session rejects an ACK that belongs to another request sequence', async () => {
702
+ const ack = new Uint8Array(8);
703
+ ack[0] = 0x5a;
704
+ ack[1] = 8;
705
+ ack[4] = 1;
706
+ ack[5] = 1;
707
+ ack[6] = 2;
708
+ ack[3] = protocolV2.crc8(ack, 3);
709
+ ack[7] = protocolV2.crc8(ack, 7);
710
+ const session = new ProtocolV2Session({
711
+ schemas,
712
+ router: 1,
713
+ writeFrame: () => Promise.resolve(),
714
+ readFrame: () => Promise.resolve(ack),
715
+ });
716
+
717
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toEqual(
718
+ expect.objectContaining({
719
+ name: 'ProtocolV2LinkError',
720
+ code: 'ack-sequence',
721
+ message: 'Protocol V2 ACK sequence mismatch: expected 1, got 2',
722
+ })
723
+ );
724
+ });
725
+
726
+ test('session rejects a response from another routing channel', async () => {
727
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'wrong route' });
728
+ const session = new ProtocolV2Session({
729
+ schemas,
730
+ router: 1,
731
+ writeFrame: () => Promise.resolve(),
732
+ readFrame: () => Promise.resolve(response),
733
+ });
734
+
735
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toBeInstanceOf(
736
+ ProtocolV2LinkError
737
+ );
738
+ });
739
+
415
740
  test('session rejects BLE frames above its configured frame limit before writing', async () => {
416
741
  const writeFrame = jest.fn().mockResolvedValue(undefined);
417
742
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
@@ -422,7 +747,7 @@ describe('Protocol V2 framing and session', () => {
422
747
  router: 1,
423
748
  maxFrameBytes: 2048,
424
749
  writeFrame,
425
- readFrame: () => Promise.resolve(response),
750
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
426
751
  });
427
752
 
428
753
  await expect(session.call('Ping', { message: 'x'.repeat(2048) })).rejects.toThrow(
@@ -431,28 +756,40 @@ describe('Protocol V2 framing and session', () => {
431
756
  expect(writeFrame).not.toHaveBeenCalled();
432
757
  });
433
758
 
434
- test('session starts response timeout after the frame is written', async () => {
435
- const response = ProtocolV2.encodeFrame(schemas, 'Success', {
436
- message: 'ok',
437
- });
759
+ test('session watchdog covers a write that never settles', async () => {
760
+ const readFrame = jest.fn();
761
+ let writeSignal;
438
762
  const session = new ProtocolV2Session({
439
763
  schemas,
440
764
  router: 1,
441
- writeFrame: () =>
442
- new Promise(resolve => {
443
- setTimeout(resolve, 30);
444
- }),
445
- readFrame: () => Promise.resolve(response),
765
+ writeFrame: (_frame, context) => {
766
+ writeSignal = context.signal;
767
+ return new Promise(() => {});
768
+ },
769
+ readFrame,
446
770
  });
447
771
 
448
772
  await expect(
449
773
  session.call('Ping', { message: 'hello' }, { timeoutMs: 10, expectedTypes: ['Success'] })
450
- ).resolves.toEqual({
451
- type: 'Success',
452
- message: {
453
- message: 'ok',
454
- },
774
+ ).rejects.toThrow('Protocol V2 response timeout after 10ms for Ping');
775
+ expect(writeSignal.aborted).toBe(true);
776
+ expect(readFrame).not.toHaveBeenCalled();
777
+ });
778
+
779
+ test('session watchdog covers prepareCall before writing', async () => {
780
+ const writeFrame = jest.fn();
781
+ const session = new ProtocolV2Session({
782
+ schemas,
783
+ router: 1,
784
+ prepareCall: () => new Promise(() => {}),
785
+ writeFrame,
786
+ readFrame: jest.fn(),
455
787
  });
788
+
789
+ await expect(session.call('Ping', { message: 'hello' }, { timeoutMs: 10 })).rejects.toThrow(
790
+ 'Protocol V2 response timeout after 10ms for Ping'
791
+ );
792
+ expect(writeFrame).not.toHaveBeenCalled();
456
793
  });
457
794
 
458
795
  test('session logs a device-owned response seq without rejecting the frame', async () => {
@@ -475,13 +812,48 @@ describe('Protocol V2 framing and session', () => {
475
812
  type: 'ProtocolInfo',
476
813
  message: {
477
814
  version: 2,
815
+ build_fingerprint: null,
478
816
  supported_messages: [],
479
- protobuf_definition: null,
480
817
  },
481
818
  });
482
819
  expect(logger.debug).not.toHaveBeenCalled();
483
820
  });
484
821
 
822
+ test('session accepts gaps in the firmware-global response sequence', async () => {
823
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
824
+ const readFrame = jest
825
+ .fn()
826
+ .mockResolvedValueOnce(rewriteSeq(response, 1))
827
+ .mockResolvedValueOnce(rewriteSeq(response, 4));
828
+ const session = new ProtocolV2Session({
829
+ schemas,
830
+ router: 1,
831
+ writeFrame: () => Promise.resolve(),
832
+ readFrame,
833
+ });
834
+
835
+ await session.call('Ping', { message: 'first' });
836
+ await expect(session.call('Ping', { message: 'second' })).resolves.toMatchObject({
837
+ type: 'Success',
838
+ });
839
+ });
840
+
841
+ test('session rejects a duplicate device response sequence', async () => {
842
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
843
+ const readFrame = jest.fn(() => Promise.resolve(rewriteSeq(response, 7)));
844
+ const session = new ProtocolV2Session({
845
+ schemas,
846
+ router: 1,
847
+ writeFrame: () => Promise.resolve(),
848
+ readFrame,
849
+ });
850
+
851
+ await session.call('Ping', { message: 'first' });
852
+ await expect(session.call('Ping', { message: 'second' })).rejects.toThrow(
853
+ 'Protocol V2 duplicate response sequence: 7'
854
+ );
855
+ });
856
+
485
857
  test('session does not log transmit or receive payload details', async () => {
486
858
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
487
859
  message: 'accepted',
@@ -493,7 +865,7 @@ describe('Protocol V2 framing and session', () => {
493
865
  schemas,
494
866
  router: 1,
495
867
  writeFrame: () => Promise.resolve(),
496
- readFrame: () => Promise.resolve(response),
868
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
497
869
  logger,
498
870
  logPrefix: 'ProtocolV2 Test',
499
871
  });
@@ -519,7 +891,7 @@ describe('Protocol V2 framing and session', () => {
519
891
  schemas,
520
892
  router: 1,
521
893
  writeFrame: () => Promise.resolve(),
522
- readFrame: () => Promise.resolve(response),
894
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
523
895
  logger,
524
896
  });
525
897
 
@@ -533,6 +905,34 @@ describe('Protocol V2 framing and session', () => {
533
905
  expect(logger.debug).not.toHaveBeenCalled();
534
906
  });
535
907
 
908
+ test('session reports when the complete request frame has been written', async () => {
909
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
910
+ message: 'ok',
911
+ });
912
+ const events = [];
913
+ const onWriteCompleted = jest.fn(metrics => events.push(['written', metrics]));
914
+ const session = new ProtocolV2Session({
915
+ schemas,
916
+ router: 1,
917
+ writeFrame: frame => {
918
+ events.push(['write', frame.byteLength]);
919
+ return Promise.resolve();
920
+ },
921
+ readFrame: () => {
922
+ events.push(['read']);
923
+ return Promise.resolve(rewriteSeq(response, 1));
924
+ },
925
+ });
926
+
927
+ await session.call('Ping', { message: 'metrics' }, { onWriteCompleted });
928
+
929
+ expect(events.map(event => event[0])).toEqual(['write', 'written', 'read']);
930
+ expect(onWriteCompleted).toHaveBeenCalledWith({
931
+ elapsedMs: expect.any(Number),
932
+ frameBytes: expect.any(Number),
933
+ });
934
+ });
935
+
536
936
  test('session skips unrelated terminal frames when expected response types are provided', async () => {
537
937
  const stale = ProtocolV2.encodeFrame(schemas, 'Success', {
538
938
  message: 'stale response',
@@ -544,7 +944,10 @@ describe('Protocol V2 framing and session', () => {
544
944
  const logger = {
545
945
  debug: jest.fn(),
546
946
  };
547
- const readFrame = jest.fn().mockResolvedValueOnce(stale).mockResolvedValueOnce(response);
947
+ const readFrame = jest
948
+ .fn()
949
+ .mockResolvedValueOnce(rewriteSeq(stale, 1))
950
+ .mockResolvedValueOnce(rewriteSeq(response, 2));
548
951
  const session = new ProtocolV2Session({
549
952
  schemas,
550
953
  router: 1,
@@ -559,8 +962,8 @@ describe('Protocol V2 framing and session', () => {
559
962
  type: 'ProtocolInfo',
560
963
  message: {
561
964
  version: 2,
965
+ build_fingerprint: null,
562
966
  supported_messages: [],
563
- protobuf_definition: null,
564
967
  },
565
968
  });
566
969
 
@@ -568,22 +971,10 @@ describe('Protocol V2 framing and session', () => {
568
971
  expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('skip unexpected response'));
569
972
  });
570
973
 
571
- test('session consumes intermediate response frames before returning the final response', async () => {
974
+ test('session can return after a request frame is written', async () => {
572
975
  const written = [];
573
- const progress = ProtocolV2.encodeFrame(schemas, 'DeviceFirmwareUpdateStatus', {
574
- records: [{ target_id: 4, status: 1 }],
575
- });
576
- const success = ProtocolV2.encodeFrame(schemas, 'Success', {
577
- message: 'ok',
578
- });
579
- const onIntermediateResponse = jest.fn();
580
- const readFrame = jest.fn(() => {
581
- const [writtenFrame] = written;
582
- const { seq } = protocolV2.decodeFrame(writtenFrame);
583
- return Promise.resolve(
584
- readFrame.mock.calls.length === 1 ? rewriteSeq(progress, seq) : rewriteSeq(success, seq)
585
- );
586
- });
976
+ const readFrame = jest.fn();
977
+ const onWriteCompleted = jest.fn();
587
978
  const session = new ProtocolV2Session({
588
979
  schemas,
589
980
  router: 1,
@@ -596,26 +987,20 @@ describe('Protocol V2 framing and session', () => {
596
987
 
597
988
  const result = await session.call(
598
989
  'DeviceFirmwareUpdateRequest',
599
- { targets: [{ target_id: 4, path: 'vol1:firmware.bin' }] },
990
+ {},
600
991
  {
601
- intermediateTypes: ['DeviceFirmwareUpdateStatus'],
602
- onIntermediateResponse,
992
+ returnAfterWrite: true,
993
+ onWriteCompleted,
603
994
  }
604
995
  );
605
996
 
606
- expect(readFrame).toHaveBeenCalledTimes(2);
607
- expect(onIntermediateResponse).toHaveBeenCalledWith({
608
- type: 'DeviceFirmwareUpdateStatus',
609
- message: {
610
- records: [{ target_id: 4, status: 1, payload_version: null, path: null }],
611
- },
612
- });
613
- expect(result).toEqual({
614
- type: 'Success',
615
- message: {
616
- message: 'ok',
617
- },
618
- });
997
+ expect(written).toHaveLength(1);
998
+ expect(ProtocolV2.decodeFrame(schemas, written[0]).messageName).toBe(
999
+ 'DeviceFirmwareUpdateRequest'
1000
+ );
1001
+ expect(readFrame).not.toHaveBeenCalled();
1002
+ expect(onWriteCompleted).toHaveBeenCalledTimes(1);
1003
+ expect(result).toEqual({ type: 'WriteCompleted', message: {} });
619
1004
  });
620
1005
 
621
1006
  test('probeProtocolV2 accepts Success as a normal V2 probe response', async () => {
@@ -634,6 +1019,36 @@ describe('Protocol V2 framing and session', () => {
634
1019
  ).resolves.toBe(false);
635
1020
  });
636
1021
 
1022
+ test('recognizes USB-priority firmware failures independently of surrounding whitespace', () => {
1023
+ expect(isProtocolV2LinkDisabledFailure('Failure_ProcessError', ' link disabled ')).toBe(true);
1024
+ expect(isProtocolV2LinkDisabledFailure(5, 'Link Disabled')).toBe(true);
1025
+ expect(isProtocolV2LinkDisabledFailure('Failure_ProcessError', 'busy')).toBe(false);
1026
+ });
1027
+
1028
+ test.each(['Failure_ProcessError', 5])(
1029
+ 'probeProtocolV2 surfaces link disabled without resetting the link for code %s',
1030
+ async code => {
1031
+ const onProbeFailed = jest.fn();
1032
+
1033
+ await expect(
1034
+ probeProtocolV2({
1035
+ call: () =>
1036
+ Promise.resolve({
1037
+ type: 'Failure',
1038
+ message: { code, message: ' link disabled ' },
1039
+ }),
1040
+ timeoutMs: 1,
1041
+ onProbeFailed,
1042
+ })
1043
+ ).rejects.toMatchObject({
1044
+ name: 'ProtocolV2LinkDisabledError',
1045
+ failureCode: code,
1046
+ firmwareMessage: ' link disabled ',
1047
+ });
1048
+ expect(onProbeFailed).not.toHaveBeenCalled();
1049
+ }
1050
+ );
1051
+
637
1052
  test('decodeFrame rejects frames that are too short', () => {
638
1053
  expect(() => protocolV2.decodeFrame(new Uint8Array([0x5a, 0x08, 0x00]))).toThrow(
639
1054
  'Protocol V2 frame too short'
@@ -758,7 +1173,7 @@ describe('Protocol V2 framing and session', () => {
758
1173
  }
759
1174
  return Promise.resolve();
760
1175
  },
761
- readFrame: () => Promise.resolve(response),
1176
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
762
1177
  });
763
1178
 
764
1179
  await expect(session.call('Ping', { message: '1' })).rejects.toThrow('transport write failed');
@@ -827,6 +1242,7 @@ describe('Protocol V2 framing and session', () => {
827
1242
  const writeContexts = [];
828
1243
  const readContexts = [];
829
1244
  const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
1245
+ let responseSeq = 0;
830
1246
  const session = new ProtocolV2Session({
831
1247
  schemas,
832
1248
  router: 1,
@@ -837,18 +1253,67 @@ describe('Protocol V2 framing and session', () => {
837
1253
  },
838
1254
  readFrame: context => {
839
1255
  readContexts.push(context);
840
- return Promise.resolve(response);
1256
+ responseSeq = protocolV2.nextProtoSeq(responseSeq);
1257
+ return Promise.resolve(rewriteSeq(response, responseSeq));
841
1258
  },
842
1259
  });
843
1260
 
844
1261
  await session.call('Ping', { message: 'ping' }, { timeoutMs: 123 });
845
- await session.call('FileWrite', {}, { timeoutMs: 456 });
1262
+ await session.call('FileWrite', {}, { timeoutMs: 456, writeWithResponse: true });
1263
+ await session.call('Ping', { message: 'default-timeout' });
846
1264
 
847
- expect(writeContexts).toEqual([
848
- { messageName: 'Ping', timeoutMs: 123, highVolume: false, generation: 7 },
849
- { messageName: 'FileWrite', timeoutMs: 456, highVolume: true, generation: 7 },
1265
+ const normalizeContext = ({ signal, ...context }) => ({
1266
+ ...context,
1267
+ signalAborted: signal.aborted,
1268
+ });
1269
+ expect(writeContexts.map(normalizeContext)).toEqual([
1270
+ {
1271
+ messageName: 'Ping',
1272
+ timeoutMs: 123,
1273
+ highThroughput: false,
1274
+ generation: 7,
1275
+ signalAborted: false,
1276
+ },
1277
+ {
1278
+ messageName: 'FileWrite',
1279
+ timeoutMs: 456,
1280
+ highThroughput: true,
1281
+ writeWithResponse: true,
1282
+ generation: 7,
1283
+ signalAborted: false,
1284
+ },
1285
+ {
1286
+ messageName: 'Ping',
1287
+ timeoutMs: PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
1288
+ highThroughput: false,
1289
+ generation: 7,
1290
+ signalAborted: false,
1291
+ },
1292
+ ]);
1293
+ expect(readContexts.map(normalizeContext)).toEqual([
1294
+ {
1295
+ messageName: 'Ping',
1296
+ timeoutMs: 123,
1297
+ highThroughput: false,
1298
+ generation: 7,
1299
+ signalAborted: false,
1300
+ },
1301
+ {
1302
+ messageName: 'FileWrite',
1303
+ timeoutMs: 456,
1304
+ highThroughput: true,
1305
+ writeWithResponse: true,
1306
+ generation: 7,
1307
+ signalAborted: false,
1308
+ },
1309
+ {
1310
+ messageName: 'Ping',
1311
+ timeoutMs: PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
1312
+ highThroughput: false,
1313
+ generation: 7,
1314
+ signalAborted: false,
1315
+ },
850
1316
  ]);
851
- expect(readContexts).toEqual(writeContexts);
852
1317
  });
853
1318
 
854
1319
  test('assembler throws and resets on frames with an impossible length field', () => {