teyyare 0.2.5 → 0.2.6

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 CHANGED
@@ -13,15 +13,20 @@ function printUsage() {
13
13
 
14
14
  \x1b[1mUSAGE:\x1b[0m
15
15
  teyyare serve [--port 7421] [--dir <path>]
16
- teyyare send <source> <destination>
17
- teyyare pull <source> <destination>
16
+ teyyare send <source> <destination> [-c <channels>]
17
+ teyyare pull <source> [destination] [-c <channels>]
18
18
  teyyare status
19
19
 
20
+ \x1b[1mOPTIONS:\x1b[0m
21
+ -c, --channels <n> Parallel TCP connections (default: 4 for remote, 1 for local)
22
+ --port <port> Port number (default: 7421)
23
+ --dir <path> Serve root directory (default: current dir)
24
+
20
25
  \x1b[1mEXAMPLES:\x1b[0m
21
26
  teyyare serve --port 7421
22
27
  teyyare send ./10gb.bin localhost:/tmp/10gb.bin
23
- teyyare send ./dist 127.0.0.1:7421:/var/www/app
24
- teyyare pull server:7421:/backup/db.tar.zst ./
28
+ teyyare send ./dist 127.0.0.1:7421:/var/www/app -c 4
29
+ teyyare pull server:7421:/backup/db.tar.zst ./ -c 4
25
30
  `);
26
31
  }
27
32
 
@@ -51,7 +56,7 @@ async function main() {
51
56
  }
52
57
 
53
58
  if (command === '--version' || command === '-v' || command === 'version') {
54
- console.log('0.2.5');
59
+ console.log('0.2.6');
55
60
  process.exit(0);
56
61
  }
57
62
 
@@ -82,8 +87,18 @@ async function main() {
82
87
  // Keep alive
83
88
  await new Promise(() => {});
84
89
  } else if (command === 'send') {
85
- const source = args[1];
86
- const target = args[2];
90
+ let channels = undefined;
91
+ const positional = [];
92
+ for (let i = 1; i < args.length; i++) {
93
+ if ((args[i] === '-c' || args[i] === '--channels') && args[i + 1]) {
94
+ channels = Number(args[++i]);
95
+ } else {
96
+ positional.push(args[i]);
97
+ }
98
+ }
99
+
100
+ const source = positional[0];
101
+ const target = positional[1];
87
102
 
88
103
  if (!source || !target) {
89
104
  console.error('Error: teyyare send requires <source> and <destination>');
@@ -96,6 +111,7 @@ async function main() {
96
111
  sourcePath: source,
97
112
  host,
98
113
  port,
114
+ channels,
99
115
  showProgress: true
100
116
  });
101
117
 
@@ -112,8 +128,18 @@ Peak RSS: ${(snap.peakRSS / (1024 * 1024)).toFixed(2)} MB
112
128
  `);
113
129
  process.exit(0);
114
130
  } else if (command === 'pull') {
115
- const source = args[1];
116
- const target = args[2] || './';
131
+ let channels = undefined;
132
+ const positional = [];
133
+ for (let i = 1; i < args.length; i++) {
134
+ if ((args[i] === '-c' || args[i] === '--channels') && args[i + 1]) {
135
+ channels = Number(args[++i]);
136
+ } else {
137
+ positional.push(args[i]);
138
+ }
139
+ }
140
+
141
+ const source = positional[0];
142
+ const target = positional[1] || './';
117
143
 
118
144
  if (!source) {
119
145
  console.error('Error: teyyare pull requires <source> and [destination]');
@@ -126,6 +152,7 @@ Peak RSS: ${(snap.peakRSS / (1024 * 1024)).toFixed(2)} MB
126
152
  sourceRemotePath: remotePath,
127
153
  host,
128
154
  port,
155
+ channels,
129
156
  destinationDir: target,
130
157
  showProgress: true
131
158
  });
@@ -143,7 +170,7 @@ Peak RSS: ${(snap.peakRSS / (1024 * 1024)).toFixed(2)} MB
143
170
  `);
144
171
  process.exit(0);
145
172
  } else if (command === 'status') {
146
- console.log('\x1b[1m\x1b[36mTeyyare\x1b[0m v0.2.5 status: ready');
173
+ console.log('\x1b[1m\x1b[36mTeyyare\x1b[0m v0.2.6 status: ready');
147
174
  } else {
148
175
  console.error(`Unknown command: ${command}`);
149
176
  printUsage();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teyyare",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "High-performance resumable file transfer engine and CLI built on muttafa and raptiye",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -28,6 +28,7 @@ export class ProgressReporter {
28
28
  this.tcpPressure = 0;
29
29
  this.resumeHits = 0;
30
30
  this.diskSpeedMBs = 0;
31
+ this.channels = 1;
31
32
 
32
33
  this.startTime = Date.now();
33
34
  this.lastRender = 0;
@@ -68,6 +69,7 @@ export class ProgressReporter {
68
69
  if (state.tcpPressure !== undefined) this.tcpPressure = state.tcpPressure;
69
70
  if (state.resumeHits !== undefined) this.resumeHits = state.resumeHits;
70
71
  if (state.diskSpeedMBs !== undefined) this.diskSpeedMBs = state.diskSpeedMBs;
72
+ if (state.channels !== undefined) this.channels = state.channels;
71
73
  }
72
74
 
73
75
  formatBytes(bytes) {
@@ -126,6 +128,7 @@ export class ProgressReporter {
126
128
  '',
127
129
  `network speed \x1b[1m${effectiveSpeed.toFixed(1)} MB/s\x1b[0m`,
128
130
  `disk speed \x1b[1m${diskStr}\x1b[0m`,
131
+ `channels ${this.channels}`,
129
132
  `ETA ${etaStr}`,
130
133
  '',
131
134
  `chunks ${this.chunksDone} / ${this.totalChunks || this.chunksDone}`,
@@ -143,6 +146,7 @@ export class ProgressReporter {
143
146
  `\x1b[32m${percent}%\x1b[0m`,
144
147
  '',
145
148
  `throughput \x1b[1m${effectiveSpeed.toFixed(1)} MB/s\x1b[0m`,
149
+ `channels ${this.channels}`,
146
150
  `ETA ${etaStr}`,
147
151
  '',
148
152
  `chunks ${this.chunksDone} / ${this.totalChunks || this.chunksDone}`,
package/src/index.js CHANGED
@@ -3,9 +3,10 @@
3
3
  */
4
4
 
5
5
  import path from 'node:path';
6
+ import net from 'node:net';
6
7
  import { existsSync } from 'node:fs';
7
8
  import { TCPTransport, ReplicationPipeline, BatchReceipt } from 'raptiye';
8
- import { readHeader, decodeMessage, HEADER_SIZE, writeHeader } from 'raptiye/src/protocol/wire.js';
9
+ import { readHeader, decodeMessage, encodeMessage, HEADER_SIZE, writeHeader } from 'raptiye/src/protocol/wire.js';
9
10
  import { MessageType } from 'raptiye/src/types.js';
10
11
  import { crc32Buffers } from './core/crc32.js';
11
12
  import { SenderPipeline } from './sender/pipeline.js';
@@ -25,8 +26,19 @@ import {
25
26
  unpackPullError
26
27
  } from './core/protocol.js';
27
28
 
28
- // High-performance streaming TCP framer patch for TCPTransport
29
- // Eliminates O(N^2) Buffer.concat and ensures clean socket lifecycle management
29
+ // High-performance streaming TCP framer & Multi-Channel socket pool patch for TCPTransport
30
+ TCPTransport.prototype._getPeerPool = function(peer) {
31
+ if (!this._peerSocketPool) {
32
+ this._peerSocketPool = new Map();
33
+ }
34
+ let pool = this._peerSocketPool.get(peer);
35
+ if (!pool) {
36
+ pool = [];
37
+ this._peerSocketPool.set(peer, pool);
38
+ }
39
+ return pool;
40
+ };
41
+
30
42
  TCPTransport.prototype._attachSocketReader = function(socket) {
31
43
  socket.setNoDelay(true);
32
44
  if (socket._readableState) socket._readableState.highWaterMark = 4 * 1024 * 1024;
@@ -102,6 +114,10 @@ TCPTransport.prototype._attachSocketReader = function(socket) {
102
114
  const msg = decodeMessage(frame, 0, false);
103
115
  if (msg.sourceNode) {
104
116
  this._outboundSockets.set(msg.sourceNode, socket);
117
+ const pool = this._getPeerPool(msg.sourceNode);
118
+ if (!pool.includes(socket)) {
119
+ pool.push(socket);
120
+ }
105
121
  }
106
122
  if (this._messageHandler) {
107
123
  this._messageHandler(msg.sourceNode, msg);
@@ -119,6 +135,14 @@ TCPTransport.prototype._attachSocketReader = function(socket) {
119
135
  this._outboundSockets.delete(peer);
120
136
  }
121
137
  }
138
+ if (this._peerSocketPool) {
139
+ for (const [peer, pool] of this._peerSocketPool.entries()) {
140
+ const idx = pool.indexOf(socket);
141
+ if (idx !== -1) {
142
+ pool.splice(idx, 1);
143
+ }
144
+ }
145
+ }
122
146
  });
123
147
 
124
148
  socket.on('error', () => {
@@ -126,6 +150,136 @@ TCPTransport.prototype._attachSocketReader = function(socket) {
126
150
  });
127
151
  };
128
152
 
153
+ TCPTransport.prototype.connectChannels = async function(peer, channelCount = 4) {
154
+ if (channelCount <= 1) return;
155
+ const addr = this.peerAddresses.get(peer);
156
+ if (!addr) {
157
+ throw new Error(`[TCPTransport ${this.id}] No address registered for peer ${peer}`);
158
+ }
159
+
160
+ const pool = this._getPeerPool(peer);
161
+ const activeSockets = pool.filter((s) => !s.destroyed && s.writable);
162
+ const needed = channelCount - activeSockets.length;
163
+ if (needed <= 0) return;
164
+
165
+ const connectPromises = [];
166
+ for (let c = 0; c < needed; c++) {
167
+ const p = new Promise((resolve, reject) => {
168
+ const socket = net.createConnection({ host: addr.host, port: addr.port }, () => {
169
+ this._attachSocketReader(socket);
170
+ if (!this._outboundSockets.has(peer)) {
171
+ this._outboundSockets.set(peer, socket);
172
+ }
173
+ const currentPool = this._getPeerPool(peer);
174
+ if (!currentPool.includes(socket)) {
175
+ currentPool.push(socket);
176
+ }
177
+
178
+ // Send registration HEARTBEAT frame so remote peer associates this socket with this node
179
+ try {
180
+ const hbChunks = encodeMessage({
181
+ type: MessageType.HEARTBEAT,
182
+ sourceNode: this.id,
183
+ destNode: peer,
184
+ leaderId: this.id,
185
+ leaderCommit: 0n
186
+ });
187
+ socket.cork();
188
+ for (let i = 0; i < hbChunks.length; i++) {
189
+ socket.write(hbChunks[i]);
190
+ }
191
+ socket.uncork();
192
+ } catch (err) {
193
+ // Non-fatal
194
+ }
195
+
196
+ resolve(socket);
197
+ });
198
+
199
+ socket.on('error', (err) => {
200
+ reject(err);
201
+ });
202
+ });
203
+ connectPromises.push(p);
204
+ }
205
+
206
+ await Promise.all(connectPromises);
207
+ // Short pause to allow remote peer to process initial registration frames
208
+ await new Promise((r) => setTimeout(r, 20));
209
+ };
210
+
211
+ TCPTransport.prototype.sendv = function(peer, buffers) {
212
+ if (!this._channelIndex) this._channelIndex = new Map();
213
+
214
+ const pool = this._peerSocketPool ? this._peerSocketPool.get(peer) : null;
215
+ if (pool && pool.length > 0) {
216
+ const active = pool.filter((s) => !s.destroyed && s.writable);
217
+ if (active.length > 0) {
218
+ let idx = this._channelIndex.get(peer) || 0;
219
+ const socket = active[idx % active.length];
220
+ this._channelIndex.set(peer, idx + 1);
221
+
222
+ socket.cork();
223
+ for (let i = 0; i < buffers.length; i++) {
224
+ socket.write(buffers[i]);
225
+ }
226
+ socket.uncork();
227
+ return;
228
+ }
229
+ }
230
+
231
+ this._getOrCreateSocket(peer)
232
+ .then((socket) => {
233
+ if (!socket.destroyed && socket.writable) {
234
+ socket.cork();
235
+ for (let i = 0; i < buffers.length; i++) {
236
+ socket.write(buffers[i]);
237
+ }
238
+ socket.uncork();
239
+ }
240
+ })
241
+ .catch(() => {});
242
+ };
243
+
244
+ TCPTransport.prototype.send = function(peer, buffer) {
245
+ if (!this._channelIndex) this._channelIndex = new Map();
246
+
247
+ const pool = this._peerSocketPool ? this._peerSocketPool.get(peer) : null;
248
+ if (pool && pool.length > 0) {
249
+ const active = pool.filter((s) => !s.destroyed && s.writable);
250
+ if (active.length > 0) {
251
+ let idx = this._channelIndex.get(peer) || 0;
252
+ const socket = active[idx % active.length];
253
+ this._channelIndex.set(peer, idx + 1);
254
+ socket.write(buffer);
255
+ return;
256
+ }
257
+ }
258
+
259
+ this._getOrCreateSocket(peer)
260
+ .then((socket) => {
261
+ if (!socket.destroyed && socket.writable) {
262
+ socket.write(buffer);
263
+ }
264
+ })
265
+ .catch(() => {});
266
+ };
267
+
268
+ const origClose = TCPTransport.prototype.close;
269
+ TCPTransport.prototype.close = async function() {
270
+ if (this._peerSocketPool) {
271
+ for (const pool of this._peerSocketPool.values()) {
272
+ for (const socket of pool) {
273
+ if (!socket.destroyed) {
274
+ socket.destroy();
275
+ }
276
+ }
277
+ }
278
+ this._peerSocketPool.clear();
279
+ }
280
+ return origClose.call(this);
281
+ };
282
+
129
283
  // Fast SIMD-accelerated BATCH_DATA encoder
130
284
  function fastEncodeBatchData(msg) {
131
285
  const bufs = msg.buffers || [];
@@ -401,12 +555,14 @@ export async function send({
401
555
  sourcePath,
402
556
  host = '127.0.0.1',
403
557
  port = 7421,
558
+ channels = (host === '127.0.0.1' || host === 'localhost' ? 1 : 4),
404
559
  pipelineOptions = {},
405
560
  showProgress = true,
406
561
  knownResumeState = new Map()
407
562
  }) {
408
563
  const metrics = new MetricsCollector();
409
564
  const progress = showProgress ? new ProgressReporter({ role: 'sender' }) : null;
565
+ if (progress) progress.update({ channels });
410
566
 
411
567
  // Ephemeral sender port
412
568
  const senderPort = 10000 + Math.floor(Math.random() * 20000);
@@ -418,6 +574,9 @@ export async function send({
418
574
  });
419
575
 
420
576
  await transport.start();
577
+ if (channels > 1) {
578
+ await transport.connectChannels(2, channels);
579
+ }
421
580
 
422
581
  const replication = new ReplicationPipeline({
423
582
  transport,
@@ -454,6 +613,7 @@ export async function send({
454
613
  * @param {string} options.sourceRemotePath
455
614
  * @param {string} [options.host='127.0.0.1']
456
615
  * @param {number} [options.port=7421]
616
+ * @param {number} [options.channels]
457
617
  * @param {string} [options.destinationDir='./']
458
618
  * @param {object} [options.pipelineOptions]
459
619
  * @param {boolean} [options.showProgress=true]
@@ -463,13 +623,17 @@ export async function pull({
463
623
  sourceRemotePath,
464
624
  host = '127.0.0.1',
465
625
  port = 7421,
626
+ channels = (host === '127.0.0.1' || host === 'localhost' ? 1 : 4),
466
627
  destinationDir = './',
467
628
  pipelineOptions = {},
468
629
  showProgress = true
469
630
  }) {
470
631
  const metrics = new MetricsCollector();
471
632
  const progress = showProgress ? new ProgressReporter({ role: 'receiver' }) : null;
472
- if (progress) progress.start();
633
+ if (progress) {
634
+ progress.update({ channels });
635
+ progress.start();
636
+ }
473
637
 
474
638
  const clientPort = 10000 + Math.floor(Math.random() * 20000);
475
639
  const transport = new TCPTransport({
@@ -480,6 +644,9 @@ export async function pull({
480
644
  });
481
645
 
482
646
  await transport.start();
647
+ if (channels > 1) {
648
+ await transport.connectChannels(2, channels);
649
+ }
483
650
 
484
651
  const replication = new ReplicationPipeline({
485
652
  transport,
@@ -37,6 +37,8 @@ export class ReceiverPipeline {
37
37
 
38
38
  this.activeFiles = new Map(); // fileId -> { destPath, partPath, statePath, fileHandle, resumeState, ... }
39
39
  this.completedFiles = new Set();
40
+ this._pendingChunks = new Map(); // fileId -> Array<{ key, val }>
41
+ this._pendingTransferEnd = false;
40
42
  this.transferFinished = false;
41
43
  this.transferError = null;
42
44
  this._finishResolvers = [];
@@ -135,6 +137,7 @@ export class ReceiverPipeline {
135
137
  case Opcodes.TRANSFER_BEGIN: {
136
138
  this.transferFinished = false;
137
139
  this.transferError = null;
140
+ this._pendingTransferEnd = false;
138
141
  const tinfo = unpackTransferBegin(key);
139
142
  break;
140
143
  }
@@ -155,12 +158,11 @@ export class ReceiverPipeline {
155
158
  }
156
159
 
157
160
  case Opcodes.TRANSFER_END: {
158
- this.transferFinished = true;
159
- for (const resolve of this._finishResolvers) {
160
- resolve();
161
+ if (this.activeFiles.size === 0) {
162
+ this._finishTransfer();
163
+ } else {
164
+ this._pendingTransferEnd = true;
161
165
  }
162
- this._finishResolvers = [];
163
- this._errorRejecters = [];
164
166
  break;
165
167
  }
166
168
 
@@ -179,6 +181,15 @@ export class ReceiverPipeline {
179
181
  }
180
182
  }
181
183
 
184
+ _finishTransfer() {
185
+ this.transferFinished = true;
186
+ for (const resolve of this._finishResolvers) {
187
+ resolve();
188
+ }
189
+ this._finishResolvers = [];
190
+ this._errorRejecters = [];
191
+ }
192
+
182
193
  async _handleFileBegin(key) {
183
194
  const info = unpackFileBegin(key);
184
195
  const destPath = path.resolve(this.destinationDir, info.path);
@@ -200,7 +211,7 @@ export class ReceiverPipeline {
200
211
 
201
212
  const fileHandle = await fs.open(partPath, 'a+');
202
213
 
203
- this.activeFiles.set(info.fileId, {
214
+ const file = {
204
215
  fileId: info.fileId,
205
216
  relativePath: info.path,
206
217
  destPath,
@@ -210,8 +221,12 @@ export class ReceiverPipeline {
210
221
  resumeState,
211
222
  fileSize: Number(info.fileSize),
212
223
  sha256: info.sha256,
213
- chunksWrittenSinceSave: 0
214
- });
224
+ chunksWrittenSinceSave: 0,
225
+ pendingEnd: false,
226
+ endMeta: null
227
+ };
228
+
229
+ this.activeFiles.set(info.fileId, file);
215
230
 
216
231
  if (this.progress) {
217
232
  const fileSize = Number(info.fileSize);
@@ -226,16 +241,38 @@ export class ReceiverPipeline {
226
241
  resumeHits: resumeState.verifiedCount
227
242
  });
228
243
  }
244
+
245
+ // Flush any pending chunks that arrived out-of-order before FILE_BEGIN
246
+ const pending = this._pendingChunks.get(info.fileId);
247
+ if (pending && pending.length > 0) {
248
+ this._pendingChunks.delete(info.fileId);
249
+ for (const item of pending) {
250
+ const meta = unpackChunkMeta(item.key);
251
+ await this._writeChunk(file, meta, item.val);
252
+ }
253
+ }
229
254
  }
230
255
 
231
256
  async _handleChunk(key, val) {
232
257
  const meta = unpackChunkMeta(key);
233
258
  const file = this.activeFiles.get(meta.fileId);
234
259
  if (!file) {
235
- // Stale or out-of-order chunk from an interrupted previous session - ignore safely
260
+ if (this.completedFiles.has(meta.fileId)) {
261
+ return;
262
+ }
263
+ let pending = this._pendingChunks.get(meta.fileId);
264
+ if (!pending) {
265
+ pending = [];
266
+ this._pendingChunks.set(meta.fileId, pending);
267
+ }
268
+ pending.push({ key, val: new Uint8Array(val) });
236
269
  return;
237
270
  }
238
271
 
272
+ await this._writeChunk(file, meta, val);
273
+ }
274
+
275
+ async _writeChunk(file, meta, val) {
239
276
  // Idempotency / resume hit check
240
277
  if (file.resumeState.isVerified(meta.chunkIndex)) {
241
278
  if (this.metrics) {
@@ -296,6 +333,14 @@ export class ReceiverPipeline {
296
333
  file.chunksWrittenSinceSave = 0;
297
334
  await file.resumeState.save(file.statePath);
298
335
  }
336
+
337
+ // If FILE_END arrived earlier while chunks were still arriving
338
+ if (file.pendingEnd && (file.fileSize === 0 || file.resumeState.isComplete())) {
339
+ await this._finalizeFile(file);
340
+ if (this._pendingTransferEnd && this.activeFiles.size === 0) {
341
+ this._finishTransfer();
342
+ }
343
+ }
299
344
  }
300
345
 
301
346
  async _handleFileEnd(key) {
@@ -303,6 +348,22 @@ export class ReceiverPipeline {
303
348
  const file = this.activeFiles.get(meta.fileId);
304
349
  if (!file) return;
305
350
 
351
+ if (meta.sha256) {
352
+ file.sha256 = meta.sha256;
353
+ }
354
+
355
+ if (file.fileSize === 0 || file.resumeState.isComplete()) {
356
+ await this._finalizeFile(file);
357
+ if (this._pendingTransferEnd && this.activeFiles.size === 0) {
358
+ this._finishTransfer();
359
+ }
360
+ } else {
361
+ file.pendingEnd = true;
362
+ file.endMeta = meta;
363
+ }
364
+ }
365
+
366
+ async _finalizeFile(file) {
306
367
  // Save final state before rename
307
368
  await file.resumeState.save(file.statePath);
308
369
 
@@ -311,7 +372,7 @@ export class ReceiverPipeline {
311
372
  await file.fileHandle.close();
312
373
 
313
374
  // Verify whole-file checksum if available
314
- const expectedSha = meta.sha256 || file.sha256;
375
+ const expectedSha = file.sha256;
315
376
  if (expectedSha) {
316
377
  const actualSha = await this._computeFileSha256(file.partPath);
317
378
  if (actualSha !== expectedSha) {
@@ -323,7 +384,8 @@ export class ReceiverPipeline {
323
384
  await fs.rename(file.partPath, file.destPath);
324
385
  await FileResumeState.cleanup(file.statePath);
325
386
 
326
- this.activeFiles.delete(meta.fileId);
387
+ this.activeFiles.delete(file.fileId);
388
+ this.completedFiles.add(file.fileId);
327
389
  this.completedFiles.add(file.destPath);
328
390
  }
329
391
 
@@ -0,0 +1,108 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { serve, send, pull } from '../../src/index.js';
8
+
9
+ async function computeSha256(filePath) {
10
+ const content = await fs.readFile(filePath);
11
+ return crypto.createHash('sha256').update(content).digest('hex');
12
+ }
13
+
14
+ test('Integration - multi-channel parallel pull transfer (4 channels) with SHA-256 verification', async () => {
15
+ const testDir = path.resolve('tmp/multichannel-pull-test');
16
+ const serverDir = path.join(testDir, 'server');
17
+ const clientDir = path.join(testDir, 'client');
18
+
19
+ await fs.rm(testDir, { recursive: true, force: true });
20
+ await fs.mkdir(serverDir, { recursive: true });
21
+ await fs.mkdir(clientDir, { recursive: true });
22
+
23
+ // Generate 16 MB test file
24
+ const testFile = path.join(serverDir, 'parallel-16mb.bin');
25
+ const size = 16 * 1024 * 1024;
26
+ const data = crypto.randomBytes(size);
27
+ await fs.writeFile(testFile, data);
28
+ const sourceSha = await computeSha256(testFile);
29
+
30
+ const port = 8921;
31
+ const server = await serve({ port, destinationDir: serverDir });
32
+
33
+ try {
34
+ const metrics = await pull({
35
+ sourceRemotePath: 'parallel-16mb.bin',
36
+ host: '127.0.0.1',
37
+ port,
38
+ channels: 4,
39
+ destinationDir: clientDir,
40
+ showProgress: false
41
+ });
42
+
43
+ const pulledFile = path.join(clientDir, 'parallel-16mb.bin');
44
+ assert.ok(existsSync(pulledFile), 'Pulled file must exist');
45
+ assert.strictEqual(await fs.stat(pulledFile).then((s) => s.size), size, 'Size must match');
46
+
47
+ const pulledSha = await computeSha256(pulledFile);
48
+ assert.strictEqual(pulledSha, sourceSha, 'SHA-256 checksum must match source exactly');
49
+
50
+ // Verify atomic rename and state cleanup
51
+ assert.strictEqual(existsSync(`${pulledFile}.teyyare-part`), false, 'Part file must be cleaned up');
52
+ assert.strictEqual(existsSync(`${pulledFile}.teyyare-state`), false, 'State file must be cleaned up');
53
+
54
+ const snap = metrics.snapshot();
55
+ assert.strictEqual(snap.fileBytesWritten, size);
56
+ assert.strictEqual(snap.chunksVerified, 16);
57
+ assert.ok(snap.throughputMBs > 0, 'Throughput must be positive');
58
+ } finally {
59
+ await server.close();
60
+ await fs.rm(testDir, { recursive: true, force: true });
61
+ }
62
+ });
63
+
64
+ test('Integration - multi-channel parallel send transfer (4 channels) with SHA-256 verification', async () => {
65
+ const testDir = path.resolve('tmp/multichannel-send-test');
66
+ const sourceDir = path.join(testDir, 'source');
67
+ const destDir = path.join(testDir, 'dest');
68
+
69
+ await fs.rm(testDir, { recursive: true, force: true });
70
+ await fs.mkdir(sourceDir, { recursive: true });
71
+ await fs.mkdir(destDir, { recursive: true });
72
+
73
+ // Generate 12 MB test file
74
+ const testFile = path.join(sourceDir, 'upload-12mb.bin');
75
+ const size = 12 * 1024 * 1024;
76
+ const data = crypto.randomBytes(size);
77
+ await fs.writeFile(testFile, data);
78
+ const sourceSha = await computeSha256(testFile);
79
+
80
+ const port = 8922;
81
+ const server = await serve({ port, destinationDir: destDir });
82
+
83
+ try {
84
+ const metrics = await send({
85
+ sourcePath: testFile,
86
+ host: '127.0.0.1',
87
+ port,
88
+ channels: 4,
89
+ showProgress: false
90
+ });
91
+
92
+ await server.receiver.waitForCompletion();
93
+
94
+ const receivedFile = path.join(destDir, 'upload-12mb.bin');
95
+ assert.ok(existsSync(receivedFile), 'Received file must exist');
96
+ assert.strictEqual(await fs.stat(receivedFile).then((s) => s.size), size, 'Size must match');
97
+
98
+ const receivedSha = await computeSha256(receivedFile);
99
+ assert.strictEqual(receivedSha, sourceSha, 'SHA-256 checksum must match source');
100
+
101
+ const snap = metrics.snapshot();
102
+ assert.strictEqual(snap.fileBytesRead, size);
103
+ assert.strictEqual(snap.chunksCreated, 12);
104
+ } finally {
105
+ await server.close();
106
+ await fs.rm(testDir, { recursive: true, force: true });
107
+ }
108
+ });