@onekeyfe/hd-transport 1.2.8-alpha.111 → 1.2.8-alpha.113

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 +1 @@
1
- {"version":3,"file":"transportLog.d.ts","sourceRoot":"","sources":["../../src/utils/transportLog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAoF7C,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,OAAO,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAKxF;AAED,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;;;EAO9B;AAID,wBAAgB,+BAA+B,CAAC,IAAI,EAAE,MAAM,WAE3D"}
1
+ {"version":3,"file":"transportLog.d.ts","sourceRoot":"","sources":["../../src/utils/transportLog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AA+E7C,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,OAAO,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAKxF;AAED,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;;;EAO9B;AAID,wBAAgB,+BAA+B,CAAC,IAAI,EAAE,MAAM,WAE3D"}
@@ -11034,61 +11034,6 @@
11034
11034
  }
11035
11035
  }
11036
11036
  },
11037
- "ZcashSignPczt": {
11038
- "fields": {
11039
- "pczt_length": {
11040
- "rule": "required",
11041
- "type": "uint32",
11042
- "id": 1
11043
- },
11044
- "pczt_initial_chunk": {
11045
- "rule": "required",
11046
- "type": "bytes",
11047
- "id": 2
11048
- }
11049
- }
11050
- },
11051
- "ZcashPcztChunkRequest": {
11052
- "fields": {
11053
- "chunk_length": {
11054
- "rule": "required",
11055
- "type": "uint32",
11056
- "id": 1
11057
- }
11058
- }
11059
- },
11060
- "ZcashPcztChunkAck": {
11061
- "fields": {
11062
- "data_chunk": {
11063
- "rule": "required",
11064
- "type": "bytes",
11065
- "id": 1
11066
- }
11067
- }
11068
- },
11069
- "ZcashSignedPczt": {
11070
- "fields": {
11071
- "pczt_length": {
11072
- "rule": "required",
11073
- "type": "uint32",
11074
- "id": 1
11075
- },
11076
- "pczt_initial_chunk": {
11077
- "rule": "required",
11078
- "type": "bytes",
11079
- "id": 2
11080
- }
11081
- }
11082
- },
11083
- "ZcashSignedPcztChunkRequest": {
11084
- "fields": {
11085
- "chunk_length": {
11086
- "rule": "required",
11087
- "type": "uint32",
11088
- "id": 1
11089
- }
11090
- }
11091
- },
11092
11037
  "wire_in": {
11093
11038
  "type": "bool",
11094
11039
  "id": 50002,
@@ -11514,11 +11459,6 @@
11514
11459
  "MessageType_NeoAddress": 12302,
11515
11460
  "MessageType_NeoSignTx": 12303,
11516
11461
  "MessageType_NeoSignedTx": 12304,
11517
- "MessageType_ZcashSignPczt": 12402,
11518
- "MessageType_ZcashSignedPczt": 12403,
11519
- "MessageType_ZcashPcztChunkRequest": 12404,
11520
- "MessageType_ZcashPcztChunkAck": 12405,
11521
- "MessageType_ZcashSignedPcztChunkRequest": 12406,
11522
11462
  "MessageType_DeviceFactoryInfoSet": 60000,
11523
11463
  "MessageType_DeviceFactoryInfoGet": 60001,
11524
11464
  "MessageType_DeviceFactoryInfo": 60002,
@@ -11571,9 +11511,7 @@
11571
11511
  "MessageType_PortfolioUpdate": 61400,
11572
11512
  "MessageType_NftUpdate": 61500,
11573
11513
  "MessageType_OnboardingStatusGet": 61600,
11574
- "MessageType_OnboardingStatus": 61601,
11575
- "MessageType_ZcashGetAddress": 12400,
11576
- "MessageType_ZcashAddress": 12401
11514
+ "MessageType_OnboardingStatus": 61601
11577
11515
  },
11578
11516
  "reserved": [
11579
11517
  [90, 92],
@@ -13792,81 +13730,6 @@
13792
13730
  }
13793
13731
  }
13794
13732
  }
13795
- },
13796
- "ZcashAddressType": {
13797
- "values": {
13798
- "UNIFIED_ORCHARD_P2PKH": 0,
13799
- "TRANSPARENT_P2PKH": 1,
13800
- "UNIFIED_ORCHARD": 2
13801
- }
13802
- },
13803
- "ZcashAddressScope": {
13804
- "values": {
13805
- "EXTERNAL": 0,
13806
- "INTERNAL": 1
13807
- }
13808
- },
13809
- "ZcashGetAddress": {
13810
- "fields": {
13811
- "address_n": {
13812
- "rule": "repeated",
13813
- "type": "uint32",
13814
- "id": 1,
13815
- "options": {
13816
- "packed": false
13817
- }
13818
- },
13819
- "show_display": {
13820
- "type": "bool",
13821
- "id": 2
13822
- },
13823
- "include_seed_fingerprint": {
13824
- "type": "bool",
13825
- "id": 3
13826
- },
13827
- "include_ufvk": {
13828
- "type": "bool",
13829
- "id": 4
13830
- },
13831
- "diversifier_index": {
13832
- "type": "uint32",
13833
- "id": 5,
13834
- "options": {
13835
- "default": 0
13836
- }
13837
- },
13838
- "address_type": {
13839
- "type": "ZcashAddressType",
13840
- "id": 6,
13841
- "options": {
13842
- "default": "UNIFIED_ORCHARD_P2PKH"
13843
- }
13844
- },
13845
- "scope": {
13846
- "type": "ZcashAddressScope",
13847
- "id": 7,
13848
- "options": {
13849
- "default": "EXTERNAL"
13850
- }
13851
- }
13852
- }
13853
- },
13854
- "ZcashAddress": {
13855
- "fields": {
13856
- "address": {
13857
- "rule": "required",
13858
- "type": "string",
13859
- "id": 1
13860
- },
13861
- "ufvk": {
13862
- "type": "string",
13863
- "id": 2
13864
- },
13865
- "seed_fingerprint": {
13866
- "type": "bytes",
13867
- "id": 3
13868
- }
13869
- }
13870
13733
  }
13871
13734
  }
13872
13735
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport",
3
- "version": "1.2.8-alpha.111",
3
+ "version": "1.2.8-alpha.113",
4
4
  "description": "Transport layer abstractions and utilities for OneKey hardware SDK.",
5
5
  "author": "OneKey",
6
6
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
@@ -28,5 +28,5 @@
28
28
  "long": "^4.0.0",
29
29
  "protobufjs": "^6.11.2"
30
30
  },
31
- "gitHead": "d1e821b961d8060f9cb00d60a23e37c7f6d9206c"
31
+ "gitHead": "c56caf8fdd87e3a00bdc90aca044e75693704ae3"
32
32
  }
package/src/constants.ts CHANGED
@@ -41,6 +41,13 @@ export const PROTOCOL_V2_BLE_FILE_READ_CHUNK_SIZE = 900;
41
41
  /** Pro2 BLE/UART RX FIFO must hold a complete Proto Link frame. */
42
42
  export const PROTOCOL_V2_BLE_FRAME_MAX_BYTES = 2048;
43
43
 
44
+ /**
45
+ * FilesystemFileWrite frames kept in flight by a file write pipeline. The main MCU copies a
46
+ * complete frame out of its UART FIFO before handling it, but a frame waiting to be parsed and
47
+ * the next one being received can both be resident: two full frames must fit (4096-byte FIFO).
48
+ */
49
+ export const PROTOCOL_V2_FILE_WRITE_PIPELINE_WINDOW = 2;
50
+
44
51
  /** @deprecated Use the transport-specific WebUSB or BLE file chunk constant. */
45
52
  export const PROTOCOL_V2_FILE_CHUNK_SIZE = PROTOCOL_V2_WEBUSB_FILE_CHUNK_SIZE;
46
53
 
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export type {
37
37
  MessageFromOneKey,
38
38
  TransportCallOptions,
39
39
  TransportWriteMetrics,
40
+ ProtocolV2FileWritePipeline,
40
41
  LowlevelTransportSharedPlugin,
41
42
  LowLevelDevice,
42
43
  OneKeyDeviceInfoBase,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
3
+ PROTOCOL_V2_FILE_WRITE_PIPELINE_WINDOW,
3
4
  PROTOCOL_V2_PACKET_SRC_COMMAND,
4
5
  } from '../../constants';
5
6
  import { ProtocolV2FrameAssembler, concatUint8Arrays } from './frame-assembler';
@@ -15,7 +16,11 @@ import * as check from '../../utils/highlevel-checks';
15
16
  import { LogBlockCommand } from '../../utils/logBlockCommand';
16
17
 
17
18
  import type { Root } from 'protobufjs/light';
18
- import type { MessageFromOneKey, TransportWriteMetrics } from '../../types';
19
+ import type {
20
+ MessageFromOneKey,
21
+ ProtocolV2FileWritePipeline,
22
+ TransportWriteMetrics,
23
+ } from '../../types';
19
24
 
20
25
  export * from './errors';
21
26
 
@@ -62,6 +67,7 @@ export type ProtocolV2CallOptions = {
62
67
  returnAfterWrite?: boolean;
63
68
  onResponseAfterWrite?: (response: MessageFromOneKey) => void;
64
69
  writeWithResponse?: boolean;
70
+ fileWritePipeline?: ProtocolV2FileWritePipeline;
65
71
  };
66
72
 
67
73
  export { concatUint8Arrays, ProtocolV2FrameAssembler };
@@ -214,6 +220,27 @@ export async function withProtocolTimeout<T>(
214
220
  }
215
221
  }
216
222
 
223
+ function readFileWriteChunk(request: Record<string, unknown>) {
224
+ const file = request.file as
225
+ | { path?: unknown; offset?: unknown; total_size?: unknown; data?: { byteLength?: unknown } }
226
+ | undefined;
227
+ const offset = Number(file?.offset);
228
+ const totalSize = Number(file?.total_size);
229
+ const length = Number(file?.data?.byteLength);
230
+ if (
231
+ typeof file?.path !== 'string' ||
232
+ !Number.isSafeInteger(offset) ||
233
+ offset < 0 ||
234
+ !Number.isSafeInteger(totalSize) ||
235
+ !Number.isSafeInteger(length) ||
236
+ length <= 0 ||
237
+ offset + length > totalSize
238
+ ) {
239
+ throw new Error('Protocol V2 file write pipeline chunk is invalid');
240
+ }
241
+ return { path: file.path, offset, end: offset + length, totalSize };
242
+ }
243
+
217
244
  export class ProtocolV2Session {
218
245
  private readonly options: ProtocolV2SessionOptions;
219
246
 
@@ -298,6 +325,9 @@ export class ProtocolV2Session {
298
325
  data: Record<string, unknown>,
299
326
  callOptions: ProtocolV2CallOptions
300
327
  ): Promise<MessageFromOneKey> {
328
+ if (callOptions.fileWritePipeline) {
329
+ return this.executeFileWritePipeline(name, data, callOptions, callOptions.fileWritePipeline);
330
+ }
301
331
  const {
302
332
  schemas,
303
333
  router,
@@ -376,68 +406,8 @@ export class ProtocolV2Session {
376
406
  const rxFrame = await readFrame(baseCallContext);
377
407
  if (abortController.signal.aborted) break;
378
408
 
379
- let header: ReturnType<typeof ProtocolV2.inspectFrameHeader>;
380
- try {
381
- header = ProtocolV2.inspectFrameHeader(rxFrame);
382
- } catch (cause) {
383
- throw new ProtocolV2LinkError(
384
- 'frame',
385
- `Protocol V2 frame validation failed: ${getErrorMessage(cause)}`,
386
- cause
387
- );
388
- }
389
- if (header.router !== router) {
390
- throw new ProtocolV2LinkError(
391
- 'router',
392
- `Protocol V2 router mismatch: expected ${router}, got ${header.router}`
393
- );
394
- }
395
- if (header.packetSrc !== packetSrc) {
396
- throw new ProtocolV2LinkError(
397
- 'packet-source',
398
- `Protocol V2 packet source mismatch: expected ${packetSrc}, got ${header.packetSrc}`
399
- );
400
- }
401
-
402
- let isAck: boolean;
403
- try {
404
- isAck = ProtocolV2.isAckFrame(rxFrame);
405
- } catch (cause) {
406
- throw new ProtocolV2LinkError(
407
- 'frame',
408
- `Protocol V2 ACK validation failed: ${getErrorMessage(cause)}`,
409
- cause
410
- );
411
- }
412
- if (isAck) {
413
- if (header.seq !== protoSeq) {
414
- throw new ProtocolV2LinkError(
415
- 'ack-sequence',
416
- `Protocol V2 ACK sequence mismatch: expected ${protoSeq}, got ${header.seq}`
417
- );
418
- }
419
- } else {
420
- let decoded: ReturnType<typeof ProtocolV2.decodeFrame>;
421
- try {
422
- decoded = ProtocolV2.decodeFrame(schemas, rxFrame);
423
- } catch (cause) {
424
- throw new ProtocolV2LinkError(
425
- 'frame',
426
- `Protocol V2 frame decode failed: ${getErrorMessage(cause)}`,
427
- cause
428
- );
429
- }
430
- // Firmware owns one TX sequence across all channels and packet sources,
431
- // so frames routed elsewhere create valid gaps in this session.
432
- if (this.lastResponseSequence === decoded.seq) {
433
- throw new ProtocolV2LinkError(
434
- 'response-sequence',
435
- `Protocol V2 duplicate response sequence: ${decoded.seq}`
436
- );
437
- }
438
- this.lastResponseSequence = decoded.seq;
439
-
440
- const response = check.call(decoded);
409
+ const response = this.parseResponseFrame(rxFrame, [protoSeq]);
410
+ if (response) {
441
411
  const { pendingResponseAfterWrite } = this;
442
412
  let belongsToWriteOnlyCall = false;
443
413
  if (pendingResponseAfterWrite) {
@@ -486,6 +456,273 @@ export class ProtocolV2Session {
486
456
  }
487
457
  );
488
458
  }
459
+
460
+ /**
461
+ * Validates one received frame for this session. Returns undefined for an ACK of one of
462
+ * `ackSequences`, otherwise the decoded response.
463
+ */
464
+ private parseResponseFrame(
465
+ rxFrame: Uint8Array,
466
+ ackSequences: readonly number[]
467
+ ): MessageFromOneKey | undefined {
468
+ const { schemas, router, packetSrc = PROTOCOL_V2_PACKET_SRC_COMMAND } = this.options;
469
+ let header: ReturnType<typeof ProtocolV2.inspectFrameHeader>;
470
+ try {
471
+ header = ProtocolV2.inspectFrameHeader(rxFrame);
472
+ } catch (cause) {
473
+ throw new ProtocolV2LinkError(
474
+ 'frame',
475
+ `Protocol V2 frame validation failed: ${getErrorMessage(cause)}`,
476
+ cause
477
+ );
478
+ }
479
+ if (header.router !== router) {
480
+ throw new ProtocolV2LinkError(
481
+ 'router',
482
+ `Protocol V2 router mismatch: expected ${router}, got ${header.router}`
483
+ );
484
+ }
485
+ if (header.packetSrc !== packetSrc) {
486
+ throw new ProtocolV2LinkError(
487
+ 'packet-source',
488
+ `Protocol V2 packet source mismatch: expected ${packetSrc}, got ${header.packetSrc}`
489
+ );
490
+ }
491
+
492
+ let isAck: boolean;
493
+ try {
494
+ isAck = ProtocolV2.isAckFrame(rxFrame);
495
+ } catch (cause) {
496
+ throw new ProtocolV2LinkError(
497
+ 'frame',
498
+ `Protocol V2 ACK validation failed: ${getErrorMessage(cause)}`,
499
+ cause
500
+ );
501
+ }
502
+ if (isAck) {
503
+ if (!ackSequences.includes(header.seq)) {
504
+ throw new ProtocolV2LinkError(
505
+ 'ack-sequence',
506
+ `Protocol V2 ACK sequence mismatch: expected ${ackSequences.join('|')}, got ${header.seq}`
507
+ );
508
+ }
509
+ return undefined;
510
+ }
511
+
512
+ let decoded: ReturnType<typeof ProtocolV2.decodeFrame>;
513
+ try {
514
+ decoded = ProtocolV2.decodeFrame(schemas, rxFrame);
515
+ } catch (cause) {
516
+ throw new ProtocolV2LinkError(
517
+ 'frame',
518
+ `Protocol V2 frame decode failed: ${getErrorMessage(cause)}`,
519
+ cause
520
+ );
521
+ }
522
+ // Firmware owns one TX sequence across all channels and packet sources,
523
+ // so frames routed elsewhere create valid gaps in this session.
524
+ if (this.lastResponseSequence === decoded.seq) {
525
+ throw new ProtocolV2LinkError(
526
+ 'response-sequence',
527
+ `Protocol V2 duplicate response sequence: ${decoded.seq}`
528
+ );
529
+ }
530
+ this.lastResponseSequence = decoded.seq;
531
+ return check.call(decoded);
532
+ }
533
+
534
+ /**
535
+ * Writes one file as ordered FilesystemFileWrite chunks while keeping up to
536
+ * PROTOCOL_V2_FILE_WRITE_PIPELINE_WINDOW chunks in flight. Firmware still processes and
537
+ * answers every chunk in order, so responses are matched to chunks by position. Upstream
538
+ * bootloaders append at their own cursor without checking the request offset, so the echoed
539
+ * offset is what exposes a lost chunk. A device Failure, or `next` returning undefined, stops
540
+ * sending; the remaining in-flight responses are drained so the link stays usable. The call
541
+ * then returns the Failure like a serial call, or the last confirmation, which the caller
542
+ * checks for completeness.
543
+ */
544
+ private async executeFileWritePipeline(
545
+ name: string,
546
+ data: Record<string, unknown>,
547
+ callOptions: ProtocolV2CallOptions,
548
+ pipeline: ProtocolV2FileWritePipeline
549
+ ): Promise<MessageFromOneKey> {
550
+ if (name !== 'FilesystemFileWrite') {
551
+ throw new Error(`Protocol V2 file write pipeline does not support ${name}`);
552
+ }
553
+ if (this.pendingResponseAfterWrite) {
554
+ throw new Error('Protocol V2 file write pipeline cannot start while a response is pending');
555
+ }
556
+ const {
557
+ schemas,
558
+ router,
559
+ packetSrc = PROTOCOL_V2_PACKET_SRC_COMMAND,
560
+ maxFrameBytes,
561
+ prepareCall,
562
+ writeFrame,
563
+ readFrame,
564
+ logger,
565
+ logPrefix = 'ProtocolV2',
566
+ createTimeoutError,
567
+ generation = 0,
568
+ } = this.options;
569
+ const timeoutMs =
570
+ callOptions.timeoutMs && callOptions.timeoutMs > 0
571
+ ? callOptions.timeoutMs
572
+ : PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS;
573
+ const abortController = new AbortController();
574
+ const context: ProtocolV2CallContext = {
575
+ messageName: name,
576
+ timeoutMs,
577
+ highThroughput: true,
578
+ ...(callOptions.writeWithResponse === undefined
579
+ ? {}
580
+ : { writeWithResponse: callOptions.writeWithResponse }),
581
+ generation,
582
+ signal: abortController.signal,
583
+ };
584
+ // One file can take far longer than one chunk, so the watchdog bounds each step instead.
585
+ const withStepTimeout = <T>(promise: Promise<T>, stepTimeoutMs = timeoutMs) =>
586
+ withProtocolTimeout(
587
+ promise,
588
+ stepTimeoutMs,
589
+ () =>
590
+ createTimeoutError
591
+ ? createTimeoutError(name, timeoutMs)
592
+ : new Error(`Protocol V2 response timeout after ${timeoutMs}ms for ${name}`),
593
+ () => {
594
+ abortController.abort();
595
+ }
596
+ );
597
+ // ACKs and skipped frames must not extend the wait for an in-flight chunk's response.
598
+ let progressDeadline = 0;
599
+ const markProgress = () => {
600
+ progressDeadline = Date.now() + timeoutMs;
601
+ };
602
+
603
+ const inFlight: Array<{ seq: number; path: string; offset: number; end: number }> = [];
604
+ let firstRequest: Record<string, unknown> | undefined = data;
605
+ let path: string | undefined;
606
+ let totalSize: number | undefined;
607
+ let nextOffset: number | undefined;
608
+ let sourceDone = false;
609
+ let failure: MessageFromOneKey | undefined;
610
+ let lastConfirmed: MessageFromOneKey | undefined;
611
+
612
+ const sendNextChunk = async () => {
613
+ const request = firstRequest ?? (await pipeline.next());
614
+ firstRequest = undefined;
615
+ if (!request) {
616
+ sourceDone = true;
617
+ return;
618
+ }
619
+ const chunk = readFileWriteChunk(request);
620
+ if (
621
+ (path !== undefined && chunk.path !== path) ||
622
+ (totalSize !== undefined && chunk.totalSize !== totalSize) ||
623
+ (nextOffset !== undefined && chunk.offset !== nextOffset)
624
+ ) {
625
+ throw new Error(
626
+ `Protocol V2 file write pipeline chunk out of order: expected offset ${String(
627
+ nextOffset
628
+ )}, got ${chunk.offset}`
629
+ );
630
+ }
631
+ path = chunk.path;
632
+ totalSize = chunk.totalSize;
633
+ nextOffset = chunk.end;
634
+
635
+ const seq = this.sequenceCursor.next();
636
+ const frame = ProtocolV2.encodeFrame(schemas, name, request, {
637
+ packetSrc,
638
+ router,
639
+ seq,
640
+ });
641
+ if (maxFrameBytes !== undefined && frame.length > maxFrameBytes) {
642
+ throw new Error(
643
+ `Protocol V2 frame too large for transport: ${frame.length} > ${maxFrameBytes}`
644
+ );
645
+ }
646
+ const writeStartedAt = Date.now();
647
+ await withStepTimeout(this.serializeWrite(() => writeFrame(frame, context)));
648
+ inFlight.push({ seq, path: chunk.path, offset: chunk.offset, end: chunk.end });
649
+ markProgress();
650
+ try {
651
+ callOptions.onWriteCompleted?.({
652
+ elapsedMs: Math.max(Date.now() - writeStartedAt, 0),
653
+ frameBytes: frame.byteLength,
654
+ });
655
+ } catch (error) {
656
+ logger?.error?.(`${logPrefix} write metrics callback failed: ${String(error)}`);
657
+ }
658
+ };
659
+
660
+ await withStepTimeout(Promise.resolve().then(() => prepareCall?.(context)));
661
+ while (!sourceDone && inFlight.length < PROTOCOL_V2_FILE_WRITE_PIPELINE_WINDOW) {
662
+ await sendNextChunk();
663
+ }
664
+
665
+ while (inFlight.length > 0) {
666
+ const rxFrame = await withStepTimeout(
667
+ readFrame(context),
668
+ Math.max(progressDeadline - Date.now(), 1)
669
+ );
670
+ const response = this.parseResponseFrame(
671
+ rxFrame,
672
+ inFlight.map(item => item.seq)
673
+ );
674
+ if (!response) {
675
+ // ACK of an in-flight chunk; its FilesystemFile response follows.
676
+ } else if (response.type === 'FilesystemFile') {
677
+ const [expected] = inFlight;
678
+ const { message } = response;
679
+ if (
680
+ Number(message.processed_byte) !== expected.end ||
681
+ message.path !== expected.path ||
682
+ Number(message.offset) !== expected.offset ||
683
+ Number(message.total_size) !== totalSize
684
+ ) {
685
+ throw new ProtocolV2LinkError(
686
+ 'frame',
687
+ `Protocol V2 file write response does not match chunk ${expected.offset}-${
688
+ expected.end
689
+ }: got offset ${String(message.offset)} processed_byte ${String(
690
+ message.processed_byte
691
+ )}`
692
+ );
693
+ }
694
+ inFlight.shift();
695
+ markProgress();
696
+ if (!failure) {
697
+ lastConfirmed = response;
698
+ pipeline.onResponse(response);
699
+ if (!sourceDone) {
700
+ await sendNextChunk();
701
+ }
702
+ }
703
+ } else if (response.type === 'Failure') {
704
+ inFlight.shift();
705
+ markProgress();
706
+ failure = failure ?? response;
707
+ sourceDone = true;
708
+ } else if (COMMON_TERMINAL_RESPONSE_TYPES.has(response.type)) {
709
+ throw new ProtocolV2LinkError(
710
+ 'frame',
711
+ `Protocol V2 file write pipeline received unexpected ${response.type}`
712
+ );
713
+ } else {
714
+ logger?.debug?.(
715
+ `[${logPrefix}] skip unexpected response for ${name}: expected=FilesystemFile got=${response.type}`
716
+ );
717
+ }
718
+ }
719
+
720
+ const terminal = failure ?? lastConfirmed;
721
+ if (!terminal) {
722
+ throw new ProtocolV2LinkError('frame', 'Protocol V2 file write pipeline ended unconfirmed');
723
+ }
724
+ return terminal;
725
+ }
489
726
  }
490
727
 
491
728
  export async function probeProtocolV2({