teyyare 0.2.8 → 0.3.0

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/README.md CHANGED
@@ -26,7 +26,7 @@ Teyyare is the first real integration workload for both projects, proving that a
26
26
  replication / transport
27
27
  │
28
28
  ▼
29
- TCP
29
+ TCP (Multi-Channel)
30
30
  │
31
31
  ▼
32
32
  Remote Teyyare
@@ -37,7 +37,7 @@ Teyyare is the first real integration workload for both projects, proving that a
37
37
  flush
38
38
  │
39
39
  ▼
40
- Disk
40
+ Disk (writev + pre-allocation)
41
41
  ```
42
42
 
43
43
  ---
@@ -52,9 +52,9 @@ Teyyare is the first real integration workload for both projects, proving that a
52
52
 
53
53
  ---
54
54
 
55
- ## Data Flow & Zero-Copy Architecture
55
+ ## Architecture & Systems Optimizations
56
56
 
57
- ### Sender Hot Path
57
+ ### 1. Sender Hot Path
58
58
  ```text
59
59
  FileHandle.read()
60
60
  │
@@ -76,29 +76,46 @@ MutableGeneration
76
76
  FrozenGeneration [metaBuf, payloadArena]
77
77
  │
78
78
  ▼
79
- Raptiye.submitBatch() (scatter/gather sendv)
79
+ Raptiye.submitBatch() (In-Flight Sliding Window up to 128 MB)
80
80
  │
81
81
  ▼
82
- TCP (corked writev)
82
+ Multi-Channel TCP Pool (Zero-Allocation Round-Robin sendv)
83
83
  ```
84
84
 
85
- ### Receiver Hot Path
85
+ ### 2. Receiver Hot Path
86
86
  ```text
87
- TCP
87
+ Multi-Channel TCP
88
88
  │
89
89
  ▼
90
- Raptiye (frame decode + CRC32 verification)
90
+ Zero-Copy Pointer Framer (O(1) cursor, no shift(), scratch header reuse)
91
+ │
92
+ ▼
93
+ Raptiye (Frame decode + verification)
91
94
  │
92
95
  ▼
93
96
  FrozenGeneration (O(1) zero-copy view via FrozenArenaStore.fromBuffers)
94
97
  │
95
98
  ▼
96
- Teyyare chunk consumer
99
+ Chunk Coalescing Engine (Merges contiguous chunk runs)
100
+ │
101
+ ▼
102
+ FileHandle.writev(buffers, startOffset) (Direct native scatter-gather writes)
97
103
  │
98
104
  ▼
99
- FileHandle.write(payload, 0, length, offset) (Direct random-access write)
105
+ Debounced Asynchronous State Persistence (.teyyare-state)
100
106
  ```
101
107
 
108
+ ### 3. Key Systems Optimizations Implemented
109
+
110
+ - **Disk Pre-Allocation (`FileHandle.truncate(targetSize)`)**: Zero-fragmentation disk pre-allocation upon receiving `FILE_BEGIN`, eliminating filesystem dynamic block allocation latency during high-speed writes.
111
+ - **Native Scatter-Gather Writes (`writev`)**: Receiver coalesces contiguous chunks arriving in the same generation batch into a single system call via `writev`, reducing write syscalls by ~87.5%.
112
+ - **Debounced Asynchronous State Saving**: `.teyyare-state` persistence is debounced to disk (every 250ms or 32 chunks) and drained cleanly before final file rename, completely eliminating blocking JSON serialization from the hot-path.
113
+ - **In-Flight ACK Pipelining (Sliding Window)**: Supports up to 32 concurrent batches and 128 MB in-flight capacity. Senders stream generations continuously across 4–8 parallel TCP channels without stalling on single-batch ACKs.
114
+ - **Pointer-Based Zero-Copy TCP Framer**: Eliminated $O(N)$ array `shift()` operations and repeated `Buffer.allocUnsafe` calls on the TCP stream reader using an $O(1)$ cursor and amortized array compaction.
115
+ - **Zero-Allocation Channel Selection**: Eliminated `pool.filter` garbage collector churn in `TCPTransport.prototype.sendv` and `send` using an in-place circular lookup.
116
+ - **Dynamic WAN Tuning**: `PULL_REQUEST` carries client-specified `--chunk-size` (e.g. 2 MB) and `--batch-size` (e.g. 16 MB) to saturate high-latency, high-bandwidth WAN connections.
117
+ - **Multi-Channel Out-of-Order Packet Resilience**: Resilient state reconciliation for `TRANSFER_BEGIN`, `FILE_BEGIN`, `FILE_END`, and `TRANSFER_END` packets arriving out-of-order across parallel TCP sockets.
118
+
102
119
  ---
103
120
 
104
121
  ## CLI Usage
@@ -109,72 +126,95 @@ FileHandle.write(payload, 0, length, offset) (Direct random-access write)
109
126
  teyyare serve --port 7421 --dir /destination/folder
110
127
  ```
111
128
 
112
- ### Send File or Directory
129
+ ### Send File or Directory (Push)
113
130
  ```bash
114
- # Single file transfer
115
- teyyare send ./large-file.bin 127.0.0.1:7421:/tmp/large-file.bin
131
+ # Single file transfer with 4 parallel channels
132
+ teyyare send ./large-file.bin 127.0.0.1:7421:/tmp/large-file.bin -c 4
116
133
 
117
- # Directory transfer with nested files
118
- teyyare send ./dist server.local:7421:/var/www/app
134
+ # Directory transfer with nested hierarchy
135
+ teyyare send ./dist server.local:7421:/var/www/app -c 4
119
136
  ```
120
137
 
121
- ### Progress Display
122
- ```text
123
- Teyyare
138
+ ### Pull File or Directory (Download)
139
+ ```bash
140
+ # Single file download from remote server with 8 channels and 2MB WAN chunks
141
+ teyyare pull 38.242.216.102:7421:/root/cdn/app.zip ./ -c 8 --chunk-size 2M --batch-size 16M
142
+
143
+ # Directory pull
144
+ teyyare pull server:7421:/backup/db.tar.zst ./ -c 4
145
+ ```
146
+
147
+ ### CLI Options
148
+
149
+ | Flag | Description | Default |
150
+ |---|---|---|
151
+ | `-c, --channels <n>` | Number of parallel TCP socket channels | `4` (remote), `1` (local) |
152
+ | `--chunk-size <size>` | Chunk size, e.g. `512K`, `1M`, `2M`, `4M` | `2M` (remote), `1M` (local) |
153
+ | `--batch-size <size>` | Generation batch size, e.g. `8M`, `16M` | `8x chunk size` |
154
+ | `--port <port>` | Server port number | `7421` |
155
+ | `--dir <path>` | Root directory for `serve` command | Current directory (`./`) |
156
+
157
+ ---
124
158
 
125
- backup.tar.zst
159
+ ## Progress Display
126
160
 
127
- 6.42 / 10.00 GB
128
- 64.2%
161
+ ```text
162
+ Teyyare [Download]
163
+
164
+ app.zip
129
165
 
130
- throughput 312 MB/s
131
- ETA 00:11
166
+ 333.54 MB / 333.54 MB
167
+ 100.0%
132
168
 
133
- chunks 6574 / 10240
134
- generation 822
135
- inflight gen 4
136
- inflight bytes 31.8 MB
169
+ network speed 324.5 MB/s
170
+ disk speed 1240.2 MB/s
171
+ channels 8
172
+ ETA --:--
137
173
 
138
- muttafa queue 2
139
- raptiye RTT 18 ms
140
- TCP pressure 0
174
+ chunks 167 / 167
175
+ generation 21
176
+ verified CRC32 167
177
+ resume hits 0
141
178
  ```
142
179
 
143
180
  ---
144
181
 
145
- ## Benchmarks & Ablation Analysis
182
+ ## Test Suite & Softscope Profiling
146
183
 
147
- Run the benchmark suite with:
184
+ ### 1. Unit & Integration Tests
185
+ Run all 24 automated tests covering protocol encoding, generation ownership, backpressure, chaos recovery, and multi-channel transfers:
148
186
  ```bash
149
- npm run bench
187
+ npm test
150
188
  ```
151
189
 
152
- ### 1. Layer Ablation (50 MB transfer)
153
- | Layer | Description | Throughput (MB/s) | Duration |
154
- |---|---|---|---|
155
- | **A** | raw fs -> TCP -> fs | **581.4 MB/s** | 86ms |
156
- | **B** | fs -> Muttafa -> TCP -> fs | **306.7 MB/s** | 163ms |
157
- | **C** | fs -> Raptiye -> fs | **85.5 MB/s** | 585ms |
158
- | **D** | Teyyare (Muttafa + Raptiye + fs) | **38.0 MB/s** | 1320ms |
190
+ ### 2. End-to-End (E2E) Test Suite
191
+ Runs 4 comprehensive end-to-end scenarios (Single Upload, Directory Upload, Single Pull, 50% Resumed Transfer):
192
+ ```bash
193
+ npm run e2e
194
+ ```
159
195
 
160
- ### 2. Chunk & Generation Size Matrix (16 MB transfer)
161
- | Chunk Size | Generation Size | Throughput (MB/s) | Duration (ms) | Peak RSS (MB) |
162
- |---|---|---|---|---|
163
- | **64 KiB** | 4 MiB | 39.7 MB/s | 403ms | 660.8 MB |
164
- | **256 KiB** | 4 MiB | 34.9 MB/s | 458ms | 679.6 MB |
165
- | **1 MiB** | 4 MiB | **40.1 MB/s** | 399ms | 757.7 MB |
166
- | **1 MiB** | 8 MiB | **40.0 MB/s** | 400ms | 718.2 MB |
167
- | **4 MiB** | 16 MiB | 38.6 MB/s | 414ms | 745.3 MB |
196
+ ### 3. Softscope Profiling
197
+ Profile CPU bottlenecks, hot call paths, and memory allocations using [`softscope`](https://github.com/litepacks/softscope):
198
+ ```bash
199
+ # Markdown summary to stdout
200
+ npm run e2e:md
201
+
202
+ # Full interactive terminal dashboard (TUI)
203
+ npm run e2e:profile
204
+
205
+ # Live metrics during execution
206
+ npm run e2e:live
207
+ ```
168
208
 
169
209
  ---
170
210
 
171
- ## Test Suite
211
+ ## Benchmark & Throughput Results
172
212
 
173
- Run the full test suite with:
174
- ```bash
175
- npm test
176
- ```
213
+ ### End-to-End Suite Summary (`test/e2e/e2e.js`)
177
214
 
178
- Includes:
179
- - **Unit Tests**: Packed binary chunk metadata protocol, resume state bitmap, generation lifecycle & ownership assertions.
180
- - **Integration Tests**: 10 MB single file transfer, directory transfer with small-file packing, resume after 50% interruption with bounded retransmission, slow receiver disk write backpressure with bounded RSS plateau, and deterministic chaos recovery.
215
+ | Scenario | Transfer Size | Duration | Throughput | Verification |
216
+ |---|---|---|---|---|
217
+ | **Single File Upload** (4 channels) | 32.0 MB | 121ms | **264.5 MB/s** | SHA-256 Match |
218
+ | **Directory Upload** (Nested tree) | 16.0 MB | 104ms | **153.9 MB/s** | All files verified |
219
+ | **Single File Pull** (4 channels) | 32.0 MB | 89ms | **359.6 MB/s** | SHA-256 Match |
220
+ | **Resumed Transfer** (50% skipped) | 8.0 MB | 49ms | **163.3 MB/s** | 0 retransmitted bytes |
package/bin/teyyare.js CHANGED
@@ -4,6 +4,28 @@
4
4
  * Teyyare CLI
5
5
  */
6
6
 
7
+ import os from 'node:os';
8
+ import { spawn } from 'node:child_process';
9
+
10
+ // Option C: Ensure libuv threadpool has sufficient C++ workers for multi-channel I/O.
11
+ // Node.js defaults to only 4 threads. Multi-channel file transfers (writev, read, sync)
12
+ // require at least 16 threads to avoid C++ threadpool contention.
13
+ const RECOMMENDED_POOL_SIZE = Math.min(128, Math.max(16, (os.availableParallelism ? os.availableParallelism() : 8) * 2));
14
+ if (!process.env.TEYYARE_BOOTSTRAPPED && (!process.env.UV_THREADPOOL_SIZE || Number(process.env.UV_THREADPOOL_SIZE) < 16)) {
15
+ process.env.UV_THREADPOOL_SIZE = String(RECOMMENDED_POOL_SIZE);
16
+ process.env.TEYYARE_BOOTSTRAPPED = '1';
17
+ const child = spawn(process.execPath, process.argv.slice(1), {
18
+ stdio: 'inherit',
19
+ env: process.env
20
+ });
21
+ child.on('exit', (code, signal) => {
22
+ if (signal) process.kill(process.pid, signal);
23
+ else process.exit(code ?? 0);
24
+ });
25
+ // Hold parent until child process finishes
26
+ await new Promise(() => {});
27
+ }
28
+
7
29
  import path from 'node:path';
8
30
  import { serve, send, pull } from '../src/index.js';
9
31
 
@@ -61,13 +83,13 @@ async function main() {
61
83
  const args = process.argv.slice(2);
62
84
  const command = args[0];
63
85
 
64
- if (!command || command === '--help' || command === '-h') {
86
+ if (!command || args.includes('--help') || args.includes('-h')) {
65
87
  printUsage();
66
88
  process.exit(0);
67
89
  }
68
90
 
69
91
  if (command === '--version' || command === '-v' || command === 'version') {
70
- console.log('0.2.7');
92
+ console.log('0.2.8');
71
93
  process.exit(0);
72
94
  }
73
95
 
@@ -203,7 +225,8 @@ Peak RSS: ${(snap.peakRSS / (1024 * 1024)).toFixed(2)} MB
203
225
  `);
204
226
  process.exit(0);
205
227
  } else if (command === 'status') {
206
- console.log('\x1b[1m\x1b[36mTeyyare\x1b[0m v0.2.7 status: ready');
228
+ const poolSize = Number(process.env.UV_THREADPOOL_SIZE) || 4;
229
+ console.log(`\x1b[1m\x1b[36mTeyyare\x1b[0m v0.2.9 status: ready (libuv threadpool: ${poolSize})`);
207
230
  } else {
208
231
  console.error(`Unknown command: ${command}`);
209
232
  printUsage();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teyyare",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
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",
@@ -8,12 +8,12 @@
8
8
  "teyyare": "bin/teyyare.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "node --test test/**/*.test.js",
12
- "bench": "node bench/run-all.js",
13
- "e2e": "node test/e2e/e2e.js",
14
- "e2e:profile": "softscope test/e2e/e2e.js",
15
- "e2e:live": "softscope --live test/e2e/e2e.js",
16
- "e2e:md": "softscope --md test/e2e/e2e.js"
11
+ "test": "UV_THREADPOOL_SIZE=16 node --test test/**/*.test.js",
12
+ "bench": "UV_THREADPOOL_SIZE=16 node bench/run-all.js",
13
+ "e2e": "UV_THREADPOOL_SIZE=16 node test/e2e/e2e.js",
14
+ "e2e:profile": "UV_THREADPOOL_SIZE=16 softscope test/e2e/e2e.js",
15
+ "e2e:live": "UV_THREADPOOL_SIZE=16 softscope --live test/e2e/e2e.js",
16
+ "e2e:md": "UV_THREADPOOL_SIZE=16 softscope --md test/e2e/e2e.js"
17
17
  },
18
18
  "keywords": [
19
19
  "file-transfer",
@@ -17,6 +17,7 @@ export class ProgressReporter {
17
17
 
18
18
  this.fileName = '';
19
19
  this.bytesDone = 0;
20
+ this.networkBytesDone = 0;
20
21
  this.totalBytes = 0;
21
22
  this.chunksDone = 0;
22
23
  this.totalChunks = 0;
@@ -35,12 +36,18 @@ export class ProgressReporter {
35
36
  this._timer = null;
36
37
  this._linesRendered = 0;
37
38
  this._speedHistory = [];
39
+ this._netSpeedHistory = [];
40
+ this._emaNetSpeed = 0;
41
+ this._emaSpeed = 0;
38
42
  }
39
43
 
40
44
  start() {
41
45
  if (!this.enabled) return;
42
46
  this.startTime = Date.now();
43
47
  this._speedHistory = [{ time: this.startTime, bytes: this.bytesDone }];
48
+ this._netSpeedHistory = [{ time: this.startTime, bytes: this.networkBytesDone || this.bytesDone }];
49
+ this._emaNetSpeed = 0;
50
+ this._emaSpeed = 0;
44
51
  this._timer = setInterval(() => this.render(), this.intervalMs);
45
52
  }
46
53
 
@@ -58,6 +65,7 @@ export class ProgressReporter {
58
65
  update(state = {}) {
59
66
  if (state.fileName !== undefined) this.fileName = state.fileName;
60
67
  if (state.bytesDone !== undefined) this.bytesDone = state.bytesDone;
68
+ if (state.networkBytesDone !== undefined) this.networkBytesDone = state.networkBytesDone;
61
69
  if (state.totalBytes !== undefined) this.totalBytes = state.totalBytes;
62
70
  if (state.chunksDone !== undefined) this.chunksDone = state.chunksDone;
63
71
  if (state.totalChunks !== undefined && state.totalChunks > 0) this.totalChunks = state.totalChunks;
@@ -91,7 +99,7 @@ export class ProgressReporter {
91
99
  const now = Date.now();
92
100
  const elapsedSec = Math.max(0.001, (now - this.startTime) / 1000);
93
101
 
94
- // Track sliding window (last 2 seconds) for smooth, responsive current speed
102
+ // 1. Committed bytes speed history (disk commit rate & upload throughput)
95
103
  this._speedHistory.push({ time: now, bytes: this.bytesDone });
96
104
  while (this._speedHistory.length > 2 && (now - this._speedHistory[0].time) > 2000) {
97
105
  this._speedHistory.shift();
@@ -101,15 +109,45 @@ export class ProgressReporter {
101
109
  const windowBytes = Math.max(0, this.bytesDone - oldest.bytes);
102
110
 
103
111
  const avgSpeedMBs = (this.bytesDone / (1024 * 1024)) / elapsedSec;
104
- const currentSpeedMBs = windowSec >= 0.4 ? (windowBytes / (1024 * 1024)) / windowSec : avgSpeedMBs;
105
- const effectiveSpeed = currentSpeedMBs > 0.05 ? currentSpeedMBs : avgSpeedMBs;
112
+ const currentSpeedMBs = windowSec >= 0.3 ? (windowBytes / (1024 * 1024)) / windowSec : avgSpeedMBs;
113
+ const rawSpeed = currentSpeedMBs > 0.05 ? currentSpeedMBs : avgSpeedMBs;
114
+
115
+ // Apply Exponential Moving Average (EMA, alpha = 0.20) to filter micro-pauses and jitter
116
+ if (!this._emaSpeed || this._emaSpeed === 0) {
117
+ this._emaSpeed = rawSpeed;
118
+ } else {
119
+ this._emaSpeed = (0.20 * rawSpeed) + (0.80 * this._emaSpeed);
120
+ }
121
+ const effectiveSpeed = this._emaSpeed;
122
+
123
+ // 2. Real wire network bytes speed history (streaming sockets)
124
+ const netBytes = this.networkBytesDone > 0 ? this.networkBytesDone : this.bytesDone;
125
+ this._netSpeedHistory.push({ time: now, bytes: netBytes });
126
+ while (this._netSpeedHistory.length > 2 && (now - this._netSpeedHistory[0].time) > 2000) {
127
+ this._netSpeedHistory.shift();
128
+ }
129
+ const netOldest = this._netSpeedHistory[0];
130
+ const netWindowSec = Math.max(0.001, (now - netOldest.time) / 1000);
131
+ const netWindowBytes = Math.max(0, netBytes - netOldest.bytes);
132
+ const netAvgSpeedMBs = (netBytes / (1024 * 1024)) / elapsedSec;
133
+ const netInstantSpeedMBs = netWindowSec >= 0.3 ? (netWindowBytes / (1024 * 1024)) / netWindowSec : netAvgSpeedMBs;
134
+ const netRawSpeed = netInstantSpeedMBs > 0.05 ? netInstantSpeedMBs : netAvgSpeedMBs;
135
+
136
+ // Smooth network wire rate with Exponential Moving Average (EMA, alpha = 0.20)
137
+ if (!this._emaNetSpeed || this._emaNetSpeed === 0) {
138
+ this._emaNetSpeed = netRawSpeed;
139
+ } else {
140
+ this._emaNetSpeed = (0.20 * netRawSpeed) + (0.80 * this._emaNetSpeed);
141
+ }
142
+ const networkSpeedMBs = this._emaNetSpeed;
106
143
 
107
144
  const percent = this.totalBytes > 0 ? ((this.bytesDone / this.totalBytes) * 100).toFixed(1) : '0.0';
108
145
 
146
+ const activeSpeed = (this.role === 'receiver' && networkSpeedMBs > 0) ? networkSpeedMBs : effectiveSpeed;
109
147
  let etaStr = '--:--';
110
- if (effectiveSpeed > 0 && this.totalBytes > this.bytesDone) {
148
+ if (activeSpeed > 0 && this.totalBytes > this.bytesDone) {
111
149
  const remainingBytes = this.totalBytes - this.bytesDone;
112
- const remainingSec = Math.round(remainingBytes / (effectiveSpeed * 1024 * 1024));
150
+ const remainingSec = Math.round(remainingBytes / (activeSpeed * 1024 * 1024));
113
151
  const mm = String(Math.floor(remainingSec / 60)).padStart(2, '0');
114
152
  const ss = String(remainingSec % 60).padStart(2, '0');
115
153
  etaStr = `${mm}:${ss}`;
@@ -117,7 +155,7 @@ export class ProgressReporter {
117
155
 
118
156
  let lines;
119
157
  if (this.role === 'receiver') {
120
- const diskStr = this.diskSpeedMBs > 0 ? `${this.diskSpeedMBs.toFixed(1)} MB/s` : `${effectiveSpeed.toFixed(1)} MB/s`;
158
+ const diskStr = `${effectiveSpeed.toFixed(1)} MB/s`;
121
159
  lines = [
122
160
  '\x1b[1m\x1b[36mTeyyare [Download]\x1b[0m',
123
161
  '',
@@ -126,7 +164,7 @@ export class ProgressReporter {
126
164
  `${this.formatBytes(this.bytesDone)} / ${this.formatBytes(this.totalBytes)}`,
127
165
  `\x1b[32m${percent}%\x1b[0m`,
128
166
  '',
129
- `network speed \x1b[1m${effectiveSpeed.toFixed(1)} MB/s\x1b[0m`,
167
+ `network speed \x1b[1m${networkSpeedMBs.toFixed(1)} MB/s\x1b[0m`,
130
168
  `disk speed \x1b[1m${diskStr}\x1b[0m`,
131
169
  `channels ${this.channels}`,
132
170
  `ETA ${etaStr}`,
@@ -167,7 +205,8 @@ export class ProgressReporter {
167
205
  process.stdout.write(lines.join('\n') + '\n');
168
206
  this._linesRendered = lines.length;
169
207
  } else {
170
- process.stdout.write(`[Teyyare] ${percent}% | ${effectiveSpeed.toFixed(1)} MB/s | Chunks: ${this.chunksDone}/${this.totalChunks || this.chunksDone} | ETA: ${etaStr}\n`);
208
+ const reportSpeed = (this.role === 'receiver' && networkSpeedMBs > 0) ? networkSpeedMBs : effectiveSpeed;
209
+ process.stdout.write(`[Teyyare] ${percent}% | ${reportSpeed.toFixed(1)} MB/s | Chunks: ${this.chunksDone}/${this.totalChunks || this.chunksDone} | ETA: ${etaStr}\n`);
171
210
  }
172
211
  }
173
212
  }
package/src/index.js CHANGED
@@ -51,6 +51,10 @@ TCPTransport.prototype._attachSocketReader = function(socket) {
51
51
  const headerScratch = Buffer.allocUnsafe(HEADER_SIZE);
52
52
 
53
53
  socket.on('data', (chunk) => {
54
+ this.bytesReceived = (this.bytesReceived || 0) + chunk.length;
55
+ if (this.onDataBytes) {
56
+ this.onDataBytes(chunk.length);
57
+ }
54
58
  chunks.push(chunk);
55
59
  bufferedLength += chunk.length;
56
60
 
@@ -180,11 +184,19 @@ TCPTransport.prototype._attachSocketReader = function(socket) {
180
184
  }
181
185
  });
182
186
 
187
+ socket.on('end', () => {
188
+ socket.destroy();
189
+ });
190
+
183
191
  socket.on('error', () => {
184
192
  socket.destroy();
185
193
  });
186
194
  };
187
195
 
196
+ function isSocketReady(s) {
197
+ return Boolean(s && !s.destroyed && s.writable && !s.readableEnded && !s.writableEnded && s.readyState === 'open');
198
+ }
199
+
188
200
  TCPTransport.prototype.connectChannels = async function(peer, channelCount = 4) {
189
201
  if (channelCount <= 1) return;
190
202
  const addr = this.peerAddresses.get(peer);
@@ -193,7 +205,7 @@ TCPTransport.prototype.connectChannels = async function(peer, channelCount = 4)
193
205
  }
194
206
 
195
207
  const pool = this._getPeerPool(peer);
196
- const activeSockets = pool.filter((s) => !s.destroyed && s.writable);
208
+ const activeSockets = pool.filter(isSocketReady);
197
209
  const needed = channelCount - activeSockets.length;
198
210
  if (needed <= 0) return;
199
211
 
@@ -243,30 +255,75 @@ TCPTransport.prototype.connectChannels = async function(peer, channelCount = 4)
243
255
  await new Promise((r) => setTimeout(r, 20));
244
256
  };
245
257
 
258
+ const origGetOrCreate = TCPTransport.prototype._getOrCreateSocket;
259
+ TCPTransport.prototype._getOrCreateSocket = function(peer) {
260
+ if (!this.started) {
261
+ return Promise.reject(new Error(`[TCPTransport ${this.id}] Cannot connect: transport closed`));
262
+ }
263
+ return origGetOrCreate.call(this, peer).then((socket) => {
264
+ const pool = this._getPeerPool(peer);
265
+ if (!pool.includes(socket)) {
266
+ pool.push(socket);
267
+ }
268
+ return socket;
269
+ });
270
+ };
271
+
246
272
  TCPTransport.prototype.sendv = function(peer, buffers) {
273
+ if (!this.started) return;
247
274
  if (!this._channelIndex) this._channelIndex = new Map();
248
275
 
249
276
  const pool = this._peerSocketPool ? this._peerSocketPool.get(peer) : null;
277
+ if (pool && pool.length > 0) {
278
+ // Purge dead sockets from pool
279
+ for (let i = pool.length - 1; i >= 0; i--) {
280
+ if (!isSocketReady(pool[i])) {
281
+ pool.splice(i, 1);
282
+ }
283
+ }
284
+ }
285
+
250
286
  if (pool && pool.length > 0) {
251
287
  let idx = this._channelIndex.get(peer) || 0;
252
288
  const poolLen = pool.length;
289
+ let bestSocket = null;
290
+ let bestWritableLength = Infinity;
291
+ let bestIndex = idx;
292
+
253
293
  for (let attempts = 0; attempts < poolLen; attempts++) {
254
- const s = pool[(idx + attempts) % poolLen];
255
- if (!s.destroyed && s.writable) {
256
- this._channelIndex.set(peer, idx + attempts + 1);
257
- s.cork();
258
- for (let i = 0; i < buffers.length; i++) {
259
- s.write(buffers[i]);
294
+ const candidateIdx = (idx + attempts) % poolLen;
295
+ const s = pool[candidateIdx];
296
+ if (isSocketReady(s)) {
297
+ const qLen = s.writableLength || 0;
298
+ // Priority 1: Pick idle/drained socket immediately
299
+ if (qLen === 0) {
300
+ bestSocket = s;
301
+ bestIndex = candidateIdx;
302
+ break;
260
303
  }
261
- s.uncork();
262
- return;
304
+ // Priority 2: Track least-buffered socket to prevent head-of-line convoy stalls
305
+ if (qLen < bestWritableLength) {
306
+ bestWritableLength = qLen;
307
+ bestSocket = s;
308
+ bestIndex = candidateIdx;
309
+ }
310
+ }
311
+ }
312
+
313
+ if (bestSocket) {
314
+ this._channelIndex.set(peer, (bestIndex + 1) % poolLen);
315
+ bestSocket.cork();
316
+ for (let i = 0; i < buffers.length; i++) {
317
+ bestSocket.write(buffers[i]);
263
318
  }
319
+ bestSocket.uncork();
320
+ return;
264
321
  }
265
322
  }
266
323
 
267
324
  this._getOrCreateSocket(peer)
268
325
  .then((socket) => {
269
- if (!socket.destroyed && socket.writable) {
326
+ if (isSocketReady(socket)) {
270
327
  socket.cork();
271
328
  for (let i = 0; i < buffers.length; i++) {
272
329
  socket.write(buffers[i]);
@@ -278,25 +335,56 @@ TCPTransport.prototype.sendv = function(peer, buffers) {
278
335
  };
279
336
 
280
337
  TCPTransport.prototype.send = function(peer, buffer) {
338
+ if (!this.started) return;
281
339
  if (!this._channelIndex) this._channelIndex = new Map();
282
340
 
283
341
  const pool = this._peerSocketPool ? this._peerSocketPool.get(peer) : null;
342
+ if (pool && pool.length > 0) {
343
+ // Purge dead sockets from pool
344
+ for (let i = pool.length - 1; i >= 0; i--) {
345
+ if (!isSocketReady(pool[i])) {
346
+ pool.splice(i, 1);
347
+ }
348
+ }
349
+ }
350
+
284
351
  if (pool && pool.length > 0) {
285
352
  let idx = this._channelIndex.get(peer) || 0;
286
353
  const poolLen = pool.length;
354
+ let bestSocket = null;
355
+ let bestWritableLength = Infinity;
356
+ let bestIndex = idx;
357
+
287
358
  for (let attempts = 0; attempts < poolLen; attempts++) {
288
- const s = pool[(idx + attempts) % poolLen];
289
- if (!s.destroyed && s.writable) {
290
- this._channelIndex.set(peer, idx + attempts + 1);
291
- s.write(buffer);
292
- return;
359
+ const candidateIdx = (idx + attempts) % poolLen;
360
+ const s = pool[candidateIdx];
361
+ if (isSocketReady(s)) {
362
+ const qLen = s.writableLength || 0;
363
+ // Priority 1: Pick idle/drained socket immediately
364
+ if (qLen === 0) {
365
+ bestSocket = s;
366
+ bestIndex = candidateIdx;
367
+ break;
368
+ }
369
+ // Priority 2: Track least-buffered socket to prevent head-of-line convoy stalls
370
+ if (qLen < bestWritableLength) {
371
+ bestWritableLength = qLen;
372
+ bestSocket = s;
373
+ bestIndex = candidateIdx;
374
+ }
293
375
  }
294
376
  }
377
+
378
+ if (bestSocket) {
379
+ this._channelIndex.set(peer, (bestIndex + 1) % poolLen);
380
+ bestSocket.write(buffer);
381
+ return;
382
+ }
295
383
  }
296
384
 
297
385
  this._getOrCreateSocket(peer)
298
386
  .then((socket) => {
299
- if (!socket.destroyed && socket.writable) {
387
+ if (isSocketReady(socket)) {
300
388
  socket.write(buffer);
301
389
  }
302
390
  })
@@ -305,6 +393,7 @@ TCPTransport.prototype.send = function(peer, buffer) {
305
393
 
306
394
  const origClose = TCPTransport.prototype.close;
307
395
  TCPTransport.prototype.close = async function() {
396
+ this.started = false;
308
397
  if (this._peerSocketPool) {
309
398
  for (const pool of this._peerSocketPool.values()) {
310
399
  for (const socket of pool) {
@@ -503,6 +592,7 @@ export async function serve({
503
592
  await s.abort();
504
593
  }
505
594
  replication.reset();
595
+ receiver.reset();
506
596
  };
507
597
 
508
598
  replication.onBatch(async (msg) => {
@@ -551,9 +641,18 @@ export async function serve({
551
641
  }));
552
642
  }
553
643
 
644
+ // Detect peer channels for optimal pipeline scaling
645
+ const pool = transport._peerSocketPool ? transport._peerSocketPool.get(entries[0].sourceNode || 1) : null;
646
+ const detectedChannels = pool && pool.length > 0 ? pool.length : 1;
647
+ const effectiveBatchBytes = maxBytes || ((chunkSize || 1024 * 1024) * (maxEntries || 8));
648
+
649
+ // Dynamically scale replication in-flight window for remote pull client
650
+ replication.maxInflightBytes = Math.max(128 * 1024 * 1024, detectedChannels * effectiveBatchBytes * 3);
651
+ replication.maxInflightBatches = Math.max(32, detectedChannels * 4);
652
+
554
653
  const senderOpts = {
555
- maxImmutableBuffers: 32,
556
- maxConcurrentFlushes: 32
654
+ maxImmutableBuffers: Math.max(32, detectedChannels * 4),
655
+ maxConcurrentFlushes: Math.max(32, detectedChannels * 4)
557
656
  };
558
657
  if (chunkSize) senderOpts.chunkSize = chunkSize;
559
658
  if (maxEntries) senderOpts.maxEntries = maxEntries;
@@ -647,20 +746,46 @@ export async function send({
647
746
  await transport.connectChannels(2, channels);
648
747
  }
649
748
 
749
+ const isRemote = host !== '127.0.0.1' && host !== 'localhost';
750
+ const defaultChunkSize = isRemote ? 2 * 1024 * 1024 : 1024 * 1024;
751
+ const defaultMaxEntries = 8;
752
+ const defaultMaxBytes = defaultChunkSize * defaultMaxEntries;
753
+
754
+ const effectiveChunkSize = pipelineOptions.chunkSize || defaultChunkSize;
755
+ const effectiveMaxEntries = pipelineOptions.maxEntries || defaultMaxEntries;
756
+ const effectiveMaxBytes = pipelineOptions.maxBytes || defaultMaxBytes;
757
+
758
+ const scaledInflightBytes = pipelineOptions.maxInflightBytes || Math.max(128 * 1024 * 1024, channels * effectiveMaxBytes * 3);
759
+ const scaledInflightBatches = pipelineOptions.maxInflightBatches || Math.max(32, channels * 4);
760
+
650
761
  const replication = new ReplicationPipeline({
651
762
  transport,
652
763
  localNode: 1,
653
764
  remoteNode: 2,
654
- maxInflightBatches: pipelineOptions.maxInflightBatches || 32,
655
- maxInflightBytes: pipelineOptions.maxInflightBytes || 128 * 1024 * 1024
765
+ maxInflightBatches: scaledInflightBatches,
766
+ maxInflightBytes: scaledInflightBytes
656
767
  });
657
768
 
658
769
  const manifest = await TransferManifest.build(sourcePath);
659
770
 
771
+ const effectiveSenderOptions = {
772
+ chunkSize: effectiveChunkSize,
773
+ maxEntries: effectiveMaxEntries,
774
+ maxBytes: effectiveMaxBytes,
775
+ ...pipelineOptions
776
+ };
777
+
778
+ if (!effectiveSenderOptions.maxImmutableBuffers && channels > 1) {
779
+ effectiveSenderOptions.maxImmutableBuffers = Math.max(32, channels * 4);
780
+ }
781
+ if (!effectiveSenderOptions.maxConcurrentFlushes && channels > 1) {
782
+ effectiveSenderOptions.maxConcurrentFlushes = effectiveSenderOptions.maxImmutableBuffers;
783
+ }
784
+
660
785
  const sender = new SenderPipeline({
661
786
  replication,
662
787
  manifest,
663
- options: pipelineOptions,
788
+ options: effectiveSenderOptions,
664
789
  metrics,
665
790
  progress,
666
791
  knownResumeState
@@ -669,6 +794,7 @@ export async function send({
669
794
  try {
670
795
  await sender.transfer();
671
796
  } finally {
797
+ if (progress) progress.stop();
672
798
  await sender.abort();
673
799
  metrics.stop();
674
800
  await transport.close();
@@ -727,12 +853,15 @@ export async function pull({
727
853
  await transport.connectChannels(2, channels);
728
854
  }
729
855
 
856
+ const scaledInflightBytes = pipelineOptions.maxInflightBytes || Math.max(128 * 1024 * 1024, channels * effectiveMaxBytes * 3);
857
+ const scaledInflightBatches = pipelineOptions.maxInflightBatches || Math.max(32, channels * 4);
858
+
730
859
  const replication = new ReplicationPipeline({
731
860
  transport,
732
861
  localNode: 1,
733
862
  remoteNode: 2,
734
- maxInflightBatches: pipelineOptions.maxInflightBatches || 32,
735
- maxInflightBytes: pipelineOptions.maxInflightBytes || 128 * 1024 * 1024
863
+ maxInflightBatches: scaledInflightBatches,
864
+ maxInflightBytes: scaledInflightBytes
736
865
  });
737
866
 
738
867
  const receiver = new ReceiverPipeline({
@@ -83,6 +83,7 @@ export class MetricsCollector {
83
83
  currentRSS: mem.rss,
84
84
  heapUsed: mem.heapUsed,
85
85
  external: mem.external,
86
+ threadpoolSize: Number(process.env.UV_THREADPOOL_SIZE) || 4,
86
87
 
87
88
  // Teyyare
88
89
  fileBytesRead: this.fileBytesRead,
@@ -52,6 +52,18 @@ export class ReceiverPipeline {
52
52
  this._batchQueue = [];
53
53
  this._processingQueue = false;
54
54
 
55
+ this.networkBytesReceived = 0;
56
+ if (this.replication && this.replication.transport) {
57
+ const prevOnDataBytes = this.replication.transport.onDataBytes;
58
+ this.replication.transport.onDataBytes = (bytes) => {
59
+ if (prevOnDataBytes) prevOnDataBytes(bytes);
60
+ this.networkBytesReceived += bytes;
61
+ if (this.progress) {
62
+ this.progress.update({ networkBytesDone: this.networkBytesReceived });
63
+ }
64
+ };
65
+ }
66
+
55
67
  // Hook up Raptiye batch handler if autoHookBatch is true
56
68
  if (this.replication && autoHookBatch) {
57
69
  this.replication.onBatch(async (msg) => {
@@ -100,6 +112,24 @@ export class ReceiverPipeline {
100
112
  }
101
113
  }
102
114
 
115
+ reset() {
116
+ this.currentTransferId = null;
117
+ this.transferFinished = false;
118
+ this.transferError = null;
119
+ this._pendingTransferEnd = false;
120
+ this.expectedFileCount = null;
121
+ this._completedFileCount = 0;
122
+ this.completedFiles.clear();
123
+ this.activeFiles.clear();
124
+ this._pendingChunks.clear();
125
+ this._pendingFileEnds.clear();
126
+ this._batchQueue = [];
127
+ this._processingQueue = false;
128
+ this.networkBytesReceived = 0;
129
+ this._finishResolvers = [];
130
+ this._errorRejecters = [];
131
+ }
132
+
103
133
  async waitForCompletion() {
104
134
  if (this.transferError) throw this.transferError;
105
135
  if (this.transferFinished) return;
@@ -421,7 +451,7 @@ export class ReceiverPipeline {
421
451
  }
422
452
 
423
453
  // 2. Sort by offset to form maximal contiguous runs
424
- toWrite.sort((a, b) => Number(a.meta.offset - b.meta.offset));
454
+ toWrite.sort((a, b) => (a.meta.offset < b.meta.offset ? -1 : (a.meta.offset > b.meta.offset ? 1 : 0)));
425
455
 
426
456
  let currentRun = [toWrite[0]];
427
457
  let currentStartOffset = Number(toWrite[0].meta.offset);
@@ -484,9 +514,6 @@ export class ReceiverPipeline {
484
514
 
485
515
  // 3. Update progress reporter
486
516
  if (this.progress) {
487
- const diskTimeSec = Math.max(0.001, (this.metrics ? this.metrics.diskWriteTimeMs : 1) / 1000);
488
- const writtenMB = (this.metrics ? this.metrics.fileBytesWritten : 0) / (1024 * 1024);
489
- const diskMBs = Number((writtenMB / diskTimeSec).toFixed(1));
490
517
  for (const item of toWrite) {
491
518
  file.maxChunkSize = Math.max(file.maxChunkSize || 0, item.meta.length);
492
519
  }
@@ -496,8 +523,7 @@ export class ReceiverPipeline {
496
523
  bytesDone: this.metrics ? this.metrics.fileBytesWritten : 0,
497
524
  chunksDone: this.metrics ? this.metrics.chunksVerified : 0,
498
525
  totalChunks: realTotalChunks > 0 ? realTotalChunks : undefined,
499
- resumeHits: this.metrics ? this.metrics.resumeHits : 0,
500
- diskSpeedMBs: diskMBs
526
+ resumeHits: this.metrics ? this.metrics.resumeHits : 0
501
527
  });
502
528
  }
503
529
 
@@ -20,6 +20,25 @@ import {
20
20
  import { extractGenerationBuffers } from '../core/generation.js';
21
21
  import { FileResumeState } from '../resume/state.js';
22
22
 
23
+ /**
24
+ * Reusable bump allocator that returns its ArrayBuffer to a pool upon reset/release.
25
+ */
26
+ class PooledBumpAllocator extends BumpAllocator {
27
+ constructor(capacity, pool = null) {
28
+ super(capacity);
29
+ this._pool = pool;
30
+ }
31
+
32
+ reset() {
33
+ super.reset();
34
+ if (this._pool && this._pool.length < 16 && !this._pool.includes(this)) {
35
+ this._pool.push(this);
36
+ }
37
+ }
38
+ }
39
+
40
+ const defaultAllocatorPool = [];
41
+
23
42
  class RaptiyeSink extends Sink {
24
43
  /**
25
44
  * @param {import('raptiye').ReplicationPipeline} replication
@@ -109,6 +128,8 @@ export class SenderPipeline {
109
128
  this.metrics = metrics;
110
129
  this.progress = progress;
111
130
  this.knownResumeState = knownResumeState;
131
+ this._allocatorPool = options.allocatorPool || defaultAllocatorPool;
132
+ this._readScratch = null;
112
133
 
113
134
  this.transferId = crypto.randomUUID();
114
135
 
@@ -119,10 +140,22 @@ export class SenderPipeline {
119
140
  this.sink = new RaptiyeSink(this.replication, this.metrics, this.progress);
120
141
  this.engine = new MutationEngine({
121
142
  sink: this.sink,
122
- createStore: () => new ArenaStore({
123
- chunkSize: Math.max(64, this.maxEntries),
124
- allocator: new BumpAllocator(arenaCapacity)
125
- }),
143
+ createStore: () => {
144
+ let allocator = null;
145
+ if (this._allocatorPool && this._allocatorPool.length > 0) {
146
+ const idx = this._allocatorPool.findIndex((a) => a.capacity >= arenaCapacity);
147
+ if (idx !== -1) {
148
+ allocator = this._allocatorPool.splice(idx, 1)[0];
149
+ }
150
+ }
151
+ if (!allocator) {
152
+ allocator = new PooledBumpAllocator(arenaCapacity, this._allocatorPool);
153
+ }
154
+ return new ArenaStore({
155
+ chunkSize: Math.max(64, this.maxEntries),
156
+ allocator
157
+ });
158
+ },
126
159
  maxEntries: this.maxEntries,
127
160
  maxBytes: this.maxBytes,
128
161
  maxImmutableBuffers: this.maxImmutableBuffers,
@@ -148,6 +181,7 @@ export class SenderPipeline {
148
181
 
149
182
  async abort() {
150
183
  this.aborted = true;
184
+ this._readScratch = null;
151
185
  for (const waiter of this._abortWaiters) {
152
186
  waiter();
153
187
  }
@@ -264,8 +298,14 @@ export class SenderPipeline {
264
298
  continue;
265
299
  }
266
300
 
267
- // Direct read into fresh chunk buffer
268
- const chunkBuf = new Uint8Array(chunkLen);
301
+ // Direct read into reusable chunk buffer (Muttafa copies synchronously into its arena on append)
302
+ if (!this._readScratch || this._readScratch.byteLength < chunkLen) {
303
+ this._readScratch = new Uint8Array(Math.max(this.chunkSize, chunkLen));
304
+ }
305
+ const chunkBuf = (chunkLen === this._readScratch.byteLength)
306
+ ? this._readScratch
307
+ : this._readScratch.subarray(0, chunkLen);
308
+
269
309
  const readStart = Date.now();
270
310
  await handle.read(chunkBuf, 0, chunkLen, Number(offset));
271
311
  if (this.metrics) {
@@ -340,5 +380,6 @@ export class SenderPipeline {
340
380
  });
341
381
  this.progress.stop();
342
382
  }
383
+ this._readScratch = null;
343
384
  }
344
385
  }