teyyare 0.2.6 → 0.2.8
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.
- package/bin/teyyare.js +35 -2
- package/package.json +6 -2
- package/src/core/protocol.js +39 -6
- package/src/index.js +144 -58
- package/src/receiver/pipeline.js +290 -58
- package/src/sender/pipeline.js +37 -7
- package/test/e2e/e2e.js +276 -0
- package/test/integration/pull.test.js +51 -0
- package/test/integration/resume.test.js +62 -0
- package/test/integration/single-file.test.js +85 -0
- package/test/unit/protocol.test.js +20 -0
package/src/receiver/pipeline.js
CHANGED
|
@@ -9,7 +9,7 @@ import { existsSync, createReadStream } from 'node:fs';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
11
|
import { crc32 } from '../core/crc32.js';
|
|
12
|
-
import { Opcodes, unpackChunkMeta, unpackFileBegin, unpackTransferBegin, unpackFileEnd, unpackPullError } from '../core/protocol.js';
|
|
12
|
+
import { Opcodes, unpackChunkMeta, unpackFileBegin, unpackTransferBegin, unpackFileEnd, unpackTransferEnd, unpackPullError } from '../core/protocol.js';
|
|
13
13
|
import { unpackGeneration } from '../core/generation.js';
|
|
14
14
|
import { FileResumeState } from '../resume/state.js';
|
|
15
15
|
|
|
@@ -38,11 +38,15 @@ export class ReceiverPipeline {
|
|
|
38
38
|
this.activeFiles = new Map(); // fileId -> { destPath, partPath, statePath, fileHandle, resumeState, ... }
|
|
39
39
|
this.completedFiles = new Set();
|
|
40
40
|
this._pendingChunks = new Map(); // fileId -> Array<{ key, val }>
|
|
41
|
+
this._pendingFileEnds = new Map(); // fileId -> unpacked FileEnd meta
|
|
41
42
|
this._pendingTransferEnd = false;
|
|
42
43
|
this.transferFinished = false;
|
|
43
44
|
this.transferError = null;
|
|
44
45
|
this._finishResolvers = [];
|
|
45
46
|
this._errorRejecters = [];
|
|
47
|
+
this.currentTransferId = null;
|
|
48
|
+
this.expectedFileCount = null;
|
|
49
|
+
this._completedFileCount = 0;
|
|
46
50
|
|
|
47
51
|
// Processing queue to ensure strictly ordered, serialized generation processing
|
|
48
52
|
this._batchQueue = [];
|
|
@@ -126,7 +130,8 @@ export class ReceiverPipeline {
|
|
|
126
130
|
const entries = unpackGeneration(metaBuf, payloadBuf);
|
|
127
131
|
const count = entries.length;
|
|
128
132
|
|
|
129
|
-
|
|
133
|
+
let i = 0;
|
|
134
|
+
while (i < count) {
|
|
130
135
|
const { type, key, val } = entries[i];
|
|
131
136
|
|
|
132
137
|
if (this.metrics) {
|
|
@@ -135,34 +140,59 @@ export class ReceiverPipeline {
|
|
|
135
140
|
|
|
136
141
|
switch (type) {
|
|
137
142
|
case Opcodes.TRANSFER_BEGIN: {
|
|
138
|
-
this.transferFinished = false;
|
|
139
|
-
this.transferError = null;
|
|
140
|
-
this._pendingTransferEnd = false;
|
|
141
143
|
const tinfo = unpackTransferBegin(key);
|
|
144
|
+
if (this.currentTransferId !== tinfo.transferId) {
|
|
145
|
+
if (!this.currentTransferId) {
|
|
146
|
+
this.currentTransferId = tinfo.transferId;
|
|
147
|
+
} else {
|
|
148
|
+
this.currentTransferId = tinfo.transferId;
|
|
149
|
+
this.transferFinished = false;
|
|
150
|
+
this.transferError = null;
|
|
151
|
+
this._pendingTransferEnd = false;
|
|
152
|
+
this.completedFiles.clear();
|
|
153
|
+
this._completedFileCount = 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
this.expectedFileCount = tinfo.fileCount;
|
|
157
|
+
this.expectedTotalBytes = tinfo.totalBytes;
|
|
158
|
+
this._checkTransferCompletion();
|
|
159
|
+
i++;
|
|
142
160
|
break;
|
|
143
161
|
}
|
|
144
162
|
|
|
145
163
|
case Opcodes.FILE_BEGIN: {
|
|
146
164
|
await this._handleFileBegin(key);
|
|
165
|
+
i++;
|
|
147
166
|
break;
|
|
148
167
|
}
|
|
149
168
|
|
|
150
169
|
case Opcodes.CHUNK: {
|
|
151
|
-
|
|
170
|
+
const chunkBatch = [];
|
|
171
|
+
while (i < count && entries[i].type === Opcodes.CHUNK) {
|
|
172
|
+
chunkBatch.push(entries[i]);
|
|
173
|
+
if (chunkBatch.length > 1 && this.metrics) {
|
|
174
|
+
this.metrics.mutations++;
|
|
175
|
+
}
|
|
176
|
+
i++;
|
|
177
|
+
}
|
|
178
|
+
await this._handleChunks(chunkBatch);
|
|
152
179
|
break;
|
|
153
180
|
}
|
|
154
181
|
|
|
155
182
|
case Opcodes.FILE_END: {
|
|
156
183
|
await this._handleFileEnd(key);
|
|
184
|
+
i++;
|
|
157
185
|
break;
|
|
158
186
|
}
|
|
159
187
|
|
|
160
188
|
case Opcodes.TRANSFER_END: {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
this._pendingTransferEnd = true;
|
|
189
|
+
const tend = unpackTransferEnd(key);
|
|
190
|
+
if (!this.currentTransferId) {
|
|
191
|
+
this.currentTransferId = tend.transferId;
|
|
165
192
|
}
|
|
193
|
+
this._pendingTransferEnd = true;
|
|
194
|
+
this._checkTransferCompletion();
|
|
195
|
+
i++;
|
|
166
196
|
break;
|
|
167
197
|
}
|
|
168
198
|
|
|
@@ -177,10 +207,24 @@ export class ReceiverPipeline {
|
|
|
177
207
|
this._errorRejecters = [];
|
|
178
208
|
return;
|
|
179
209
|
}
|
|
210
|
+
|
|
211
|
+
default:
|
|
212
|
+
i++;
|
|
213
|
+
break;
|
|
180
214
|
}
|
|
181
215
|
}
|
|
182
216
|
}
|
|
183
217
|
|
|
218
|
+
_checkTransferCompletion() {
|
|
219
|
+
if (!this._pendingTransferEnd) return;
|
|
220
|
+
if (this.activeFiles.size > 0) return;
|
|
221
|
+
if (this._pendingFileEnds.size > 0) return;
|
|
222
|
+
if (this.expectedFileCount !== null && this._completedFileCount < this.expectedFileCount) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this._finishTransfer();
|
|
226
|
+
}
|
|
227
|
+
|
|
184
228
|
_finishTransfer() {
|
|
185
229
|
this.transferFinished = true;
|
|
186
230
|
for (const resolve of this._finishResolvers) {
|
|
@@ -204,12 +248,22 @@ export class ReceiverPipeline {
|
|
|
204
248
|
resumeState = new FileResumeState({
|
|
205
249
|
fileId: info.fileId,
|
|
206
250
|
fileSize: Number(info.fileSize),
|
|
251
|
+
chunkSize: info.chunkSize || 1048576,
|
|
207
252
|
sha256: info.sha256
|
|
208
253
|
});
|
|
209
254
|
await resumeState.save(statePath);
|
|
255
|
+
} else if (info.chunkSize && resumeState.chunkSize !== info.chunkSize) {
|
|
256
|
+
resumeState.chunkSize = info.chunkSize;
|
|
210
257
|
}
|
|
211
258
|
|
|
212
259
|
const fileHandle = await fs.open(partPath, 'a+');
|
|
260
|
+
const targetSize = Number(info.fileSize);
|
|
261
|
+
if (targetSize > 0) {
|
|
262
|
+
const currentStat = await fileHandle.stat();
|
|
263
|
+
if (currentStat.size < targetSize) {
|
|
264
|
+
await fileHandle.truncate(targetSize);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
213
267
|
|
|
214
268
|
const file = {
|
|
215
269
|
fileId: info.fileId,
|
|
@@ -222,6 +276,9 @@ export class ReceiverPipeline {
|
|
|
222
276
|
fileSize: Number(info.fileSize),
|
|
223
277
|
sha256: info.sha256,
|
|
224
278
|
chunksWrittenSinceSave: 0,
|
|
279
|
+
lastStateSaveTime: Date.now(),
|
|
280
|
+
_stateSaving: false,
|
|
281
|
+
_pendingStateSave: false,
|
|
225
282
|
pendingEnd: false,
|
|
226
283
|
endMeta: null
|
|
227
284
|
};
|
|
@@ -246,9 +303,19 @@ export class ReceiverPipeline {
|
|
|
246
303
|
const pending = this._pendingChunks.get(info.fileId);
|
|
247
304
|
if (pending && pending.length > 0) {
|
|
248
305
|
this._pendingChunks.delete(info.fileId);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
306
|
+
await this._writeChunkRun(file, pending);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Check if FILE_END arrived out-of-order before FILE_BEGIN
|
|
310
|
+
const pendingEnd = this._pendingFileEnds.get(info.fileId);
|
|
311
|
+
if (pendingEnd) {
|
|
312
|
+
this._pendingFileEnds.delete(info.fileId);
|
|
313
|
+
if (pendingEnd.sha256) file.sha256 = pendingEnd.sha256;
|
|
314
|
+
if (file.fileSize === 0 || file.resumeState.isComplete()) {
|
|
315
|
+
await this._finalizeFile(file);
|
|
316
|
+
} else {
|
|
317
|
+
file.pendingEnd = true;
|
|
318
|
+
file.endMeta = pendingEnd;
|
|
252
319
|
}
|
|
253
320
|
}
|
|
254
321
|
}
|
|
@@ -265,56 +332,164 @@ export class ReceiverPipeline {
|
|
|
265
332
|
pending = [];
|
|
266
333
|
this._pendingChunks.set(meta.fileId, pending);
|
|
267
334
|
}
|
|
268
|
-
pending.push({
|
|
335
|
+
pending.push({ meta, val: new Uint8Array(val) });
|
|
269
336
|
return;
|
|
270
337
|
}
|
|
271
338
|
|
|
272
|
-
await this.
|
|
339
|
+
await this._writeChunkRun(file, [{ meta, val }]);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async _handleChunks(items) {
|
|
343
|
+
if (!items || items.length === 0) return;
|
|
344
|
+
|
|
345
|
+
// Group items by fileId
|
|
346
|
+
const groups = new Map();
|
|
347
|
+
for (const item of items) {
|
|
348
|
+
const meta = item.meta || unpackChunkMeta(item.key);
|
|
349
|
+
let list = groups.get(meta.fileId);
|
|
350
|
+
if (!list) {
|
|
351
|
+
list = [];
|
|
352
|
+
groups.set(meta.fileId, list);
|
|
353
|
+
}
|
|
354
|
+
list.push({ meta, val: item.val });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const [fileId, chunkList] of groups.entries()) {
|
|
358
|
+
const file = this.activeFiles.get(fileId);
|
|
359
|
+
if (!file) {
|
|
360
|
+
if (this.completedFiles.has(fileId)) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
let pending = this._pendingChunks.get(fileId);
|
|
364
|
+
if (!pending) {
|
|
365
|
+
pending = [];
|
|
366
|
+
this._pendingChunks.set(fileId, pending);
|
|
367
|
+
}
|
|
368
|
+
for (const c of chunkList) {
|
|
369
|
+
pending.push({ meta: c.meta, val: new Uint8Array(c.val) });
|
|
370
|
+
}
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
await this._writeChunkRun(file, chunkList);
|
|
375
|
+
}
|
|
273
376
|
}
|
|
274
377
|
|
|
275
378
|
async _writeChunk(file, meta, val) {
|
|
276
|
-
|
|
277
|
-
|
|
379
|
+
return this._writeChunkRun(file, [{ meta, val }]);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async _writeChunkRun(file, items) {
|
|
383
|
+
if (!items || items.length === 0) return;
|
|
384
|
+
|
|
385
|
+
// 1. Unpack, verify CRC, and filter out resume hits
|
|
386
|
+
const toWrite = [];
|
|
387
|
+
for (const item of items) {
|
|
388
|
+
const meta = item.meta || unpackChunkMeta(item.key);
|
|
389
|
+
const val = item.val;
|
|
390
|
+
|
|
391
|
+
// Idempotency / resume hit check
|
|
392
|
+
if (file.resumeState.isVerified(meta.chunkIndex)) {
|
|
393
|
+
if (this.metrics) {
|
|
394
|
+
this.metrics.resumeHits++;
|
|
395
|
+
this.metrics.chunksVerified++;
|
|
396
|
+
}
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Per-chunk corruption detection
|
|
401
|
+
const t0 = Date.now();
|
|
402
|
+
const computedCrc = crc32(val);
|
|
278
403
|
if (this.metrics) {
|
|
279
|
-
this.metrics.
|
|
280
|
-
this.metrics.chunksVerified++;
|
|
404
|
+
this.metrics.checksumTimeMs += (Date.now() - t0);
|
|
281
405
|
}
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
406
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
407
|
+
if (computedCrc !== meta.checksum) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`Chunk ${meta.chunkIndex} corruption: CRC 0x${computedCrc.toString(16)} !== 0x${meta.checksum.toString(16)}`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
toWrite.push({ meta, val });
|
|
290
414
|
}
|
|
291
415
|
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
416
|
+
if (toWrite.length === 0) {
|
|
417
|
+
if (file.pendingEnd && (file.fileSize === 0 || file.resumeState.isComplete())) {
|
|
418
|
+
await this._finalizeFile(file);
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
296
421
|
}
|
|
297
422
|
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
423
|
+
// 2. Sort by offset to form maximal contiguous runs
|
|
424
|
+
toWrite.sort((a, b) => Number(a.meta.offset - b.meta.offset));
|
|
425
|
+
|
|
426
|
+
let currentRun = [toWrite[0]];
|
|
427
|
+
let currentStartOffset = Number(toWrite[0].meta.offset);
|
|
428
|
+
let expectedNextOffset = currentStartOffset + toWrite[0].meta.length;
|
|
429
|
+
|
|
430
|
+
const flushRun = async (run, startOffset) => {
|
|
431
|
+
// Artificial write delay for slow-receiver backpressure tests
|
|
432
|
+
if (this.writeDelayMs > 0) {
|
|
433
|
+
await new Promise((r) => setTimeout(r, this.writeDelayMs));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const writeStart = Date.now();
|
|
437
|
+
let runBytes = 0;
|
|
438
|
+
for (let i = 0; i < run.length; i++) {
|
|
439
|
+
runBytes += run[i].meta.length;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (run.length === 1) {
|
|
443
|
+
const item = run[0];
|
|
444
|
+
await file.fileHandle.write(item.val, 0, item.meta.length, Number(item.meta.offset));
|
|
445
|
+
} else {
|
|
446
|
+
const bufs = new Array(run.length);
|
|
447
|
+
for (let i = 0; i < run.length; i++) {
|
|
448
|
+
bufs[i] = run[i].val;
|
|
449
|
+
}
|
|
450
|
+
await file.fileHandle.writev(bufs, startOffset);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (this.metrics) {
|
|
454
|
+
this.metrics.diskWriteTimeMs += (Date.now() - writeStart);
|
|
455
|
+
this.metrics.fileBytesWritten += runBytes;
|
|
456
|
+
this.metrics.chunksCreated += run.length;
|
|
457
|
+
this.metrics.chunksVerified += run.length;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
for (let i = 0; i < run.length; i++) {
|
|
461
|
+
file.resumeState.markVerified(run[i].meta.chunkIndex);
|
|
462
|
+
file.chunksWrittenSinceSave++;
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
for (let i = 1; i < toWrite.length; i++) {
|
|
467
|
+
const item = toWrite[i];
|
|
468
|
+
const itemOffset = Number(item.meta.offset);
|
|
469
|
+
|
|
470
|
+
if (itemOffset === expectedNextOffset) {
|
|
471
|
+
currentRun.push(item);
|
|
472
|
+
expectedNextOffset = itemOffset + item.meta.length;
|
|
473
|
+
} else {
|
|
474
|
+
await flushRun(currentRun, currentStartOffset);
|
|
475
|
+
currentRun = [item];
|
|
476
|
+
currentStartOffset = itemOffset;
|
|
477
|
+
expectedNextOffset = itemOffset + item.meta.length;
|
|
478
|
+
}
|
|
301
479
|
}
|
|
302
480
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
await file.fileHandle.write(val, 0, meta.length, Number(meta.offset));
|
|
306
|
-
if (this.metrics) {
|
|
307
|
-
this.metrics.diskWriteTimeMs += (Date.now() - writeStart);
|
|
308
|
-
this.metrics.fileBytesWritten += meta.length;
|
|
309
|
-
this.metrics.chunksCreated++;
|
|
310
|
-
this.metrics.chunksVerified++;
|
|
481
|
+
if (currentRun.length > 0) {
|
|
482
|
+
await flushRun(currentRun, currentStartOffset);
|
|
311
483
|
}
|
|
312
484
|
|
|
485
|
+
// 3. Update progress reporter
|
|
313
486
|
if (this.progress) {
|
|
314
487
|
const diskTimeSec = Math.max(0.001, (this.metrics ? this.metrics.diskWriteTimeMs : 1) / 1000);
|
|
315
488
|
const writtenMB = (this.metrics ? this.metrics.fileBytesWritten : 0) / (1024 * 1024);
|
|
316
489
|
const diskMBs = Number((writtenMB / diskTimeSec).toFixed(1));
|
|
317
|
-
|
|
490
|
+
for (const item of toWrite) {
|
|
491
|
+
file.maxChunkSize = Math.max(file.maxChunkSize || 0, item.meta.length);
|
|
492
|
+
}
|
|
318
493
|
const realTotalChunks = file.maxChunkSize > 0 && file.fileSize > 0 ? Math.ceil(file.fileSize / file.maxChunkSize) : 0;
|
|
319
494
|
this.progress.update({
|
|
320
495
|
fileName: file.relativePath,
|
|
@@ -326,27 +501,56 @@ export class ReceiverPipeline {
|
|
|
326
501
|
});
|
|
327
502
|
}
|
|
328
503
|
|
|
329
|
-
|
|
330
|
-
file
|
|
504
|
+
// 4. Trigger debounced state save
|
|
505
|
+
this._triggerStateSave(file);
|
|
331
506
|
|
|
332
|
-
|
|
333
|
-
file.chunksWrittenSinceSave = 0;
|
|
334
|
-
await file.resumeState.save(file.statePath);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// If FILE_END arrived earlier while chunks were still arriving
|
|
507
|
+
// 5. If FILE_END arrived earlier while chunks were still arriving
|
|
338
508
|
if (file.pendingEnd && (file.fileSize === 0 || file.resumeState.isComplete())) {
|
|
339
509
|
await this._finalizeFile(file);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
_triggerStateSave(file, force = false) {
|
|
514
|
+
const now = Date.now();
|
|
515
|
+
const elapsed = now - file.lastStateSaveTime;
|
|
516
|
+
const shouldSave = force || file.chunksWrittenSinceSave >= 128 || (file.chunksWrittenSinceSave >= 16 && elapsed >= 1500);
|
|
517
|
+
if (!shouldSave) return;
|
|
518
|
+
|
|
519
|
+
if (file._stateSaving) {
|
|
520
|
+
file._pendingStateSave = true;
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
file.chunksWrittenSinceSave = 0;
|
|
525
|
+
file.lastStateSaveTime = now;
|
|
526
|
+
file._stateSaving = true;
|
|
527
|
+
|
|
528
|
+
file.resumeState.save(file.statePath)
|
|
529
|
+
.catch(() => {})
|
|
530
|
+
.finally(() => {
|
|
531
|
+
file._stateSaving = false;
|
|
532
|
+
if (file._pendingStateSave) {
|
|
533
|
+
file._pendingStateSave = false;
|
|
534
|
+
this._triggerStateSave(file, true);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async _drainFileStateSave(file) {
|
|
540
|
+
while (file._stateSaving) {
|
|
541
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
343
542
|
}
|
|
344
543
|
}
|
|
345
544
|
|
|
346
545
|
async _handleFileEnd(key) {
|
|
347
546
|
const meta = unpackFileEnd(key);
|
|
348
547
|
const file = this.activeFiles.get(meta.fileId);
|
|
349
|
-
if (!file)
|
|
548
|
+
if (!file) {
|
|
549
|
+
if (!this.completedFiles.has(meta.fileId)) {
|
|
550
|
+
this._pendingFileEnds.set(meta.fileId, meta);
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
350
554
|
|
|
351
555
|
if (meta.sha256) {
|
|
352
556
|
file.sha256 = meta.sha256;
|
|
@@ -354,9 +558,6 @@ export class ReceiverPipeline {
|
|
|
354
558
|
|
|
355
559
|
if (file.fileSize === 0 || file.resumeState.isComplete()) {
|
|
356
560
|
await this._finalizeFile(file);
|
|
357
|
-
if (this._pendingTransferEnd && this.activeFiles.size === 0) {
|
|
358
|
-
this._finishTransfer();
|
|
359
|
-
}
|
|
360
561
|
} else {
|
|
361
562
|
file.pendingEnd = true;
|
|
362
563
|
file.endMeta = meta;
|
|
@@ -364,12 +565,16 @@ export class ReceiverPipeline {
|
|
|
364
565
|
}
|
|
365
566
|
|
|
366
567
|
async _finalizeFile(file) {
|
|
568
|
+
// Await any in-flight background state save before final save
|
|
569
|
+
await this._drainFileStateSave(file);
|
|
570
|
+
|
|
367
571
|
// Save final state before rename
|
|
368
572
|
await file.resumeState.save(file.statePath);
|
|
369
573
|
|
|
370
574
|
// Sync disk buffers
|
|
371
575
|
await file.fileHandle.sync();
|
|
372
576
|
await file.fileHandle.close();
|
|
577
|
+
file.fileHandle = null;
|
|
373
578
|
|
|
374
579
|
// Verify whole-file checksum if available
|
|
375
580
|
const expectedSha = file.sha256;
|
|
@@ -387,6 +592,30 @@ export class ReceiverPipeline {
|
|
|
387
592
|
this.activeFiles.delete(file.fileId);
|
|
388
593
|
this.completedFiles.add(file.fileId);
|
|
389
594
|
this.completedFiles.add(file.destPath);
|
|
595
|
+
this._completedFileCount++;
|
|
596
|
+
|
|
597
|
+
this._checkTransferCompletion();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async close() {
|
|
601
|
+
for (const [fileId, file] of this.activeFiles.entries()) {
|
|
602
|
+
await this._drainFileStateSave(file);
|
|
603
|
+
if (file.chunksWrittenSinceSave > 0) {
|
|
604
|
+
try {
|
|
605
|
+
await file.resumeState.save(file.statePath);
|
|
606
|
+
} catch {}
|
|
607
|
+
file.chunksWrittenSinceSave = 0;
|
|
608
|
+
}
|
|
609
|
+
if (file.fileHandle) {
|
|
610
|
+
try {
|
|
611
|
+
await file.fileHandle.close();
|
|
612
|
+
} catch {}
|
|
613
|
+
file.fileHandle = null;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
this.activeFiles.clear();
|
|
617
|
+
this._pendingChunks.clear();
|
|
618
|
+
this._pendingFileEnds.clear();
|
|
390
619
|
}
|
|
391
620
|
|
|
392
621
|
async _computeFileSha256(filePath) {
|
|
@@ -395,7 +624,10 @@ export class ReceiverPipeline {
|
|
|
395
624
|
const stream = createReadStream(filePath);
|
|
396
625
|
stream.on('data', (d) => hash.update(d));
|
|
397
626
|
stream.on('end', () => resolve(hash.digest('hex')));
|
|
398
|
-
stream.on('error',
|
|
627
|
+
stream.on('error', (err) => {
|
|
628
|
+
stream.destroy(err);
|
|
629
|
+
reject(err);
|
|
630
|
+
});
|
|
399
631
|
});
|
|
400
632
|
}
|
|
401
633
|
}
|
package/src/sender/pipeline.js
CHANGED
|
@@ -104,8 +104,8 @@ export class SenderPipeline {
|
|
|
104
104
|
this.chunkSize = options.chunkSize || 1024 * 1024;
|
|
105
105
|
this.maxEntries = options.maxEntries || 8;
|
|
106
106
|
this.maxBytes = options.maxBytes || 8 * 1024 * 1024;
|
|
107
|
-
this.maxImmutableBuffers = options.maxImmutableBuffers ||
|
|
108
|
-
this.maxConcurrentFlushes = options.maxConcurrentFlushes || Math.min(this.maxImmutableBuffers,
|
|
107
|
+
this.maxImmutableBuffers = options.maxImmutableBuffers || 32;
|
|
108
|
+
this.maxConcurrentFlushes = options.maxConcurrentFlushes || Math.min(this.maxImmutableBuffers, 32);
|
|
109
109
|
this.metrics = metrics;
|
|
110
110
|
this.progress = progress;
|
|
111
111
|
this.knownResumeState = knownResumeState;
|
|
@@ -141,14 +141,27 @@ export class SenderPipeline {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
});
|
|
144
|
+
this._activeHandle = null;
|
|
145
|
+
this._abortWaiters = new Set();
|
|
144
146
|
this.aborted = false;
|
|
145
147
|
}
|
|
146
148
|
|
|
147
|
-
abort() {
|
|
149
|
+
async abort() {
|
|
148
150
|
this.aborted = true;
|
|
151
|
+
for (const waiter of this._abortWaiters) {
|
|
152
|
+
waiter();
|
|
153
|
+
}
|
|
154
|
+
this._abortWaiters.clear();
|
|
155
|
+
if (this._activeHandle) {
|
|
156
|
+
const h = this._activeHandle;
|
|
157
|
+
this._activeHandle = null;
|
|
158
|
+
try {
|
|
159
|
+
await h.close();
|
|
160
|
+
} catch {}
|
|
161
|
+
}
|
|
149
162
|
if (this.engine) {
|
|
150
163
|
try {
|
|
151
|
-
this.engine.
|
|
164
|
+
this.engine.reset();
|
|
152
165
|
} catch {}
|
|
153
166
|
}
|
|
154
167
|
}
|
|
@@ -157,7 +170,19 @@ export class SenderPipeline {
|
|
|
157
170
|
if (this.aborted) return;
|
|
158
171
|
while (this.engine.isBackpressured()) {
|
|
159
172
|
if (this.aborted) return;
|
|
160
|
-
await
|
|
173
|
+
await new Promise((resolve) => {
|
|
174
|
+
let done = false;
|
|
175
|
+
const waiter = () => {
|
|
176
|
+
if (!done) {
|
|
177
|
+
done = true;
|
|
178
|
+
this._abortWaiters.delete(waiter);
|
|
179
|
+
resolve();
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
this._abortWaiters.add(waiter);
|
|
183
|
+
this.engine.ready().then(waiter, waiter);
|
|
184
|
+
});
|
|
185
|
+
if (this.aborted) return;
|
|
161
186
|
await new Promise((r) => setImmediate(r));
|
|
162
187
|
}
|
|
163
188
|
if (this.aborted) return;
|
|
@@ -208,7 +233,8 @@ export class SenderPipeline {
|
|
|
208
233
|
fileId: file.fileId,
|
|
209
234
|
fileSize: file.size,
|
|
210
235
|
path: file.relativePath,
|
|
211
|
-
sha256: file.sha256 || ''
|
|
236
|
+
sha256: file.sha256 || '',
|
|
237
|
+
chunkSize: this.chunkSize
|
|
212
238
|
});
|
|
213
239
|
await this._append(Opcodes.FILE_BEGIN, fbegin);
|
|
214
240
|
|
|
@@ -216,6 +242,7 @@ export class SenderPipeline {
|
|
|
216
242
|
|
|
217
243
|
if (file.size > 0) {
|
|
218
244
|
const handle = await fs.open(filePath, 'r');
|
|
245
|
+
this._activeHandle = handle;
|
|
219
246
|
try {
|
|
220
247
|
for (let i = 0; i < totalFileChunks; i++) {
|
|
221
248
|
if (this.aborted) break;
|
|
@@ -282,7 +309,10 @@ export class SenderPipeline {
|
|
|
282
309
|
}
|
|
283
310
|
}
|
|
284
311
|
} finally {
|
|
285
|
-
|
|
312
|
+
this._activeHandle = null;
|
|
313
|
+
try {
|
|
314
|
+
await handle.close();
|
|
315
|
+
} catch {}
|
|
286
316
|
}
|
|
287
317
|
}
|
|
288
318
|
|