teyyare 0.2.1 → 0.2.3

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
@@ -50,6 +50,11 @@ async function main() {
50
50
  process.exit(0);
51
51
  }
52
52
 
53
+ if (command === '--version' || command === '-v' || command === 'version') {
54
+ console.log('0.2.3');
55
+ process.exit(0);
56
+ }
57
+
53
58
  if (command === 'serve') {
54
59
  let port = 7421;
55
60
  let dir = process.cwd();
@@ -105,6 +110,7 @@ Backpressure: ${snap.backpressureCount}
105
110
  Protocol overhead:${snap.protocolOverheadPercent.toFixed(2)}%
106
111
  Peak RSS: ${(snap.peakRSS / (1024 * 1024)).toFixed(2)} MB
107
112
  `);
113
+ process.exit(0);
108
114
  } else if (command === 'pull') {
109
115
  const source = args[1];
110
116
  const target = args[2] || './';
@@ -135,8 +141,9 @@ Backpressure: ${snap.backpressureCount}
135
141
  Protocol overhead:${snap.protocolOverheadPercent.toFixed(2)}%
136
142
  Peak RSS: ${(snap.peakRSS / (1024 * 1024)).toFixed(2)} MB
137
143
  `);
144
+ process.exit(0);
138
145
  } else if (command === 'status') {
139
- console.log('\x1b[1m\x1b[36mTeyyare\x1b[0m v0.1.0 status: ready');
146
+ console.log('\x1b[1m\x1b[36mTeyyare\x1b[0m v0.2.3 status: ready');
140
147
  } else {
141
148
  console.error(`Unknown command: ${command}`);
142
149
  printUsage();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teyyare",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Throttled terminal progress renderer.
3
+ * Intelligent, accurate metrics for both Upload (send) and Download (pull) modes.
3
4
  */
4
5
 
5
6
  export class ProgressReporter {
@@ -7,10 +8,12 @@ export class ProgressReporter {
7
8
  * @param {object} [options]
8
9
  * @param {number} [options.intervalMs=150]
9
10
  * @param {boolean} [options.enabled=true]
11
+ * @param {'sender'|'receiver'} [options.role='sender']
10
12
  */
11
- constructor({ intervalMs = 150, enabled = true } = {}) {
13
+ constructor({ intervalMs = 150, enabled = true, role = 'sender' } = {}) {
12
14
  this.intervalMs = intervalMs;
13
15
  this.enabled = enabled && Boolean(process.stdout.isTTY || process.env.CI !== 'true');
16
+ this.role = role;
14
17
 
15
18
  this.fileName = '';
16
19
  this.bytesDone = 0;
@@ -23,16 +26,20 @@ export class ProgressReporter {
23
26
  this.muttafaQueue = 0;
24
27
  this.raptiyeRTT = 0;
25
28
  this.tcpPressure = 0;
29
+ this.resumeHits = 0;
30
+ this.diskSpeedMBs = 0;
26
31
 
27
32
  this.startTime = Date.now();
28
33
  this.lastRender = 0;
29
34
  this._timer = null;
30
35
  this._linesRendered = 0;
36
+ this._speedHistory = [];
31
37
  }
32
38
 
33
39
  start() {
34
40
  if (!this.enabled) return;
35
41
  this.startTime = Date.now();
42
+ this._speedHistory = [{ time: this.startTime, bytes: this.bytesDone }];
36
43
  this._timer = setInterval(() => this.render(), this.intervalMs);
37
44
  }
38
45
 
@@ -52,13 +59,15 @@ export class ProgressReporter {
52
59
  if (state.bytesDone !== undefined) this.bytesDone = state.bytesDone;
53
60
  if (state.totalBytes !== undefined) this.totalBytes = state.totalBytes;
54
61
  if (state.chunksDone !== undefined) this.chunksDone = state.chunksDone;
55
- if (state.totalChunks !== undefined) this.totalChunks = state.totalChunks;
62
+ if (state.totalChunks !== undefined && state.totalChunks > 0) this.totalChunks = state.totalChunks;
56
63
  if (state.generationId !== undefined) this.generationId = state.generationId;
57
64
  if (state.inflightGen !== undefined) this.inflightGen = state.inflightGen;
58
65
  if (state.inflightBytes !== undefined) this.inflightBytes = state.inflightBytes;
59
66
  if (state.muttafaQueue !== undefined) this.muttafaQueue = state.muttafaQueue;
60
67
  if (state.raptiyeRTT !== undefined) this.raptiyeRTT = state.raptiyeRTT;
61
68
  if (state.tcpPressure !== undefined) this.tcpPressure = state.tcpPressure;
69
+ if (state.resumeHits !== undefined) this.resumeHits = state.resumeHits;
70
+ if (state.diskSpeedMBs !== undefined) this.diskSpeedMBs = state.diskSpeedMBs;
62
71
  }
63
72
 
64
73
  formatBytes(bytes) {
@@ -77,39 +86,75 @@ export class ProgressReporter {
77
86
  render() {
78
87
  if (!this.enabled) return;
79
88
 
80
- const elapsedSec = Math.max(0.001, (Date.now() - this.startTime) / 1000);
81
- const throughputMBs = (this.bytesDone / (1024 * 1024)) / elapsedSec;
89
+ const now = Date.now();
90
+ const elapsedSec = Math.max(0.001, (now - this.startTime) / 1000);
91
+
92
+ // Track sliding window (last 2 seconds) for smooth, responsive current speed
93
+ this._speedHistory.push({ time: now, bytes: this.bytesDone });
94
+ while (this._speedHistory.length > 2 && (now - this._speedHistory[0].time) > 2000) {
95
+ this._speedHistory.shift();
96
+ }
97
+ const oldest = this._speedHistory[0];
98
+ const windowSec = Math.max(0.001, (now - oldest.time) / 1000);
99
+ const windowBytes = Math.max(0, this.bytesDone - oldest.bytes);
100
+
101
+ const avgSpeedMBs = (this.bytesDone / (1024 * 1024)) / elapsedSec;
102
+ const currentSpeedMBs = windowSec >= 0.4 ? (windowBytes / (1024 * 1024)) / windowSec : avgSpeedMBs;
103
+ const effectiveSpeed = currentSpeedMBs > 0.05 ? currentSpeedMBs : avgSpeedMBs;
104
+
82
105
  const percent = this.totalBytes > 0 ? ((this.bytesDone / this.totalBytes) * 100).toFixed(1) : '0.0';
83
106
 
84
107
  let etaStr = '--:--';
85
- if (throughputMBs > 0 && this.totalBytes > this.bytesDone) {
108
+ if (effectiveSpeed > 0 && this.totalBytes > this.bytesDone) {
86
109
  const remainingBytes = this.totalBytes - this.bytesDone;
87
- const remainingSec = Math.round(remainingBytes / (throughputMBs * 1024 * 1024));
110
+ const remainingSec = Math.round(remainingBytes / (effectiveSpeed * 1024 * 1024));
88
111
  const mm = String(Math.floor(remainingSec / 60)).padStart(2, '0');
89
112
  const ss = String(remainingSec % 60).padStart(2, '0');
90
113
  etaStr = `${mm}:${ss}`;
91
114
  }
92
115
 
93
- const lines = [
94
- '\x1b[1m\x1b[36mTeyyare\x1b[0m',
95
- '',
96
- `\x1b[1m${this.fileName || 'Transferring...'}\x1b[0m`,
97
- '',
98
- `${this.formatBytes(this.bytesDone)} / ${this.formatBytes(this.totalBytes)}`,
99
- `\x1b[32m${percent}%\x1b[0m`,
100
- '',
101
- `throughput \x1b[1m${throughputMBs.toFixed(1)} MB/s\x1b[0m`,
102
- `ETA ${etaStr}`,
103
- '',
104
- `chunks ${this.chunksDone} / ${this.totalChunks}`,
105
- `generation ${this.generationId}`,
106
- `inflight gen ${this.inflightGen}`,
107
- `inflight bytes ${this.formatBytes(this.inflightBytes)}`,
108
- '',
109
- `muttafa queue ${this.muttafaQueue}`,
110
- `raptiye RTT ${this.raptiyeRTT} ms`,
111
- `TCP pressure ${this.tcpPressure}`
112
- ];
116
+ let lines;
117
+ if (this.role === 'receiver') {
118
+ const diskStr = this.diskSpeedMBs > 0 ? `${this.diskSpeedMBs.toFixed(1)} MB/s` : `${effectiveSpeed.toFixed(1)} MB/s`;
119
+ lines = [
120
+ '\x1b[1m\x1b[36mTeyyare [Download]\x1b[0m',
121
+ '',
122
+ `\x1b[1m${this.fileName || 'Transferring...'}\x1b[0m`,
123
+ '',
124
+ `${this.formatBytes(this.bytesDone)} / ${this.formatBytes(this.totalBytes)}`,
125
+ `\x1b[32m${percent}%\x1b[0m`,
126
+ '',
127
+ `network speed \x1b[1m${effectiveSpeed.toFixed(1)} MB/s\x1b[0m`,
128
+ `disk speed \x1b[1m${diskStr}\x1b[0m`,
129
+ `ETA ${etaStr}`,
130
+ '',
131
+ `chunks ${this.chunksDone} / ${this.totalChunks || this.chunksDone}`,
132
+ `generation ${this.generationId}`,
133
+ `verified CRC32 ${this.chunksDone}`,
134
+ `resume hits ${this.resumeHits}`
135
+ ];
136
+ } else {
137
+ lines = [
138
+ '\x1b[1m\x1b[36mTeyyare [Upload]\x1b[0m',
139
+ '',
140
+ `\x1b[1m${this.fileName || 'Transferring...'}\x1b[0m`,
141
+ '',
142
+ `${this.formatBytes(this.bytesDone)} / ${this.formatBytes(this.totalBytes)}`,
143
+ `\x1b[32m${percent}%\x1b[0m`,
144
+ '',
145
+ `throughput \x1b[1m${effectiveSpeed.toFixed(1)} MB/s\x1b[0m`,
146
+ `ETA ${etaStr}`,
147
+ '',
148
+ `chunks ${this.chunksDone} / ${this.totalChunks || this.chunksDone}`,
149
+ `generation ${this.generationId}`,
150
+ `inflight gen ${this.inflightGen}`,
151
+ `inflight bytes ${this.formatBytes(this.inflightBytes)}`,
152
+ '',
153
+ `muttafa queue ${this.muttafaQueue}`,
154
+ `raptiye RTT ${this.raptiyeRTT} ms`,
155
+ `TCP pressure ${this.tcpPressure}`
156
+ ];
157
+ }
113
158
 
114
159
  if (process.stdout.isTTY) {
115
160
  if (this._linesRendered > 0) {
@@ -118,8 +163,7 @@ export class ProgressReporter {
118
163
  process.stdout.write(lines.join('\n') + '\n');
119
164
  this._linesRendered = lines.length;
120
165
  } else {
121
- // Non-interactive log
122
- process.stdout.write(`[Teyyare] ${percent}% | ${throughputMBs.toFixed(1)} MB/s | Chunks: ${this.chunksDone}/${this.totalChunks} | ETA: ${etaStr}\n`);
166
+ process.stdout.write(`[Teyyare] ${percent}% | ${effectiveSpeed.toFixed(1)} MB/s | Chunks: ${this.chunksDone}/${this.totalChunks || this.chunksDone} | ETA: ${etaStr}\n`);
123
167
  }
124
168
  }
125
169
  }
package/src/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import path from 'node:path';
6
6
  import { existsSync } from 'node:fs';
7
7
  import { TCPTransport, ReplicationPipeline } from 'raptiye';
8
+ import { readHeader, decodeMessage, HEADER_SIZE } from 'raptiye/src/protocol/wire.js';
8
9
  import { SenderPipeline } from './sender/pipeline.js';
9
10
  import { ReceiverPipeline } from './receiver/pipeline.js';
10
11
  import { TransferManifest } from './manifest/manifest.js';
@@ -22,6 +23,116 @@ import {
22
23
  unpackPullError
23
24
  } from './core/protocol.js';
24
25
 
26
+ // High-performance streaming TCP framer patch for TCPTransport
27
+ // Eliminates O(N^2) Buffer.concat and ensures clean socket lifecycle management
28
+ TCPTransport.prototype._attachSocketReader = function(socket) {
29
+ socket.setNoDelay(true);
30
+ const chunks = [];
31
+ let bufferedLength = 0;
32
+
33
+ socket.on('data', (chunk) => {
34
+ chunks.push(chunk);
35
+ bufferedLength += chunk.length;
36
+
37
+ while (bufferedLength >= HEADER_SIZE) {
38
+ let headerBuf = chunks[0];
39
+ if (headerBuf.length < HEADER_SIZE) {
40
+ const tempHeader = Buffer.allocUnsafe(HEADER_SIZE);
41
+ let off = 0;
42
+ for (let i = 0; off < HEADER_SIZE && i < chunks.length; i++) {
43
+ const c = chunks[i];
44
+ const toCopy = Math.min(c.length, HEADER_SIZE - off);
45
+ tempHeader.set(c.subarray(0, toCopy), off);
46
+ off += toCopy;
47
+ }
48
+ headerBuf = tempHeader;
49
+ }
50
+
51
+ let header;
52
+ try {
53
+ header = readHeader(headerBuf, 0);
54
+ } catch (err) {
55
+ socket.destroy(err);
56
+ return;
57
+ }
58
+
59
+ const totalLength = HEADER_SIZE + header.payloadLength;
60
+ if (bufferedLength < totalLength) {
61
+ break;
62
+ }
63
+
64
+ let rawFrame;
65
+ if (chunks[0].length >= totalLength) {
66
+ rawFrame = chunks[0].subarray(0, totalLength);
67
+ if (chunks[0].length === totalLength) {
68
+ chunks.shift();
69
+ } else {
70
+ chunks[0] = chunks[0].subarray(totalLength);
71
+ }
72
+ bufferedLength -= totalLength;
73
+ } else {
74
+ rawFrame = Buffer.allocUnsafe(totalLength);
75
+ let dstOff = 0;
76
+ let remaining = totalLength;
77
+ while (remaining > 0) {
78
+ const head = chunks[0];
79
+ if (head.length <= remaining) {
80
+ rawFrame.set(head, dstOff);
81
+ dstOff += head.length;
82
+ remaining -= head.length;
83
+ chunks.shift();
84
+ } else {
85
+ rawFrame.set(head.subarray(0, remaining), dstOff);
86
+ dstOff += remaining;
87
+ chunks[0] = head.subarray(remaining);
88
+ remaining = 0;
89
+ }
90
+ }
91
+ bufferedLength -= totalLength;
92
+ }
93
+
94
+ try {
95
+ const frame = new Uint8Array(rawFrame.buffer, rawFrame.byteOffset, rawFrame.byteLength);
96
+ const msg = decodeMessage(frame, 0, true);
97
+ if (msg.sourceNode) {
98
+ this._outboundSockets.set(msg.sourceNode, socket);
99
+ }
100
+ if (this._messageHandler) {
101
+ this._messageHandler(msg.sourceNode, msg);
102
+ }
103
+ } catch (err) {
104
+ console.error(`[TCPTransport ${this.id}] Error decoding frame:`, err.message);
105
+ }
106
+ }
107
+ });
108
+
109
+ socket.on('close', () => {
110
+ this._inboundSockets.delete(socket);
111
+ for (const [peer, s] of this._outboundSockets.entries()) {
112
+ if (s === socket) {
113
+ this._outboundSockets.delete(peer);
114
+ }
115
+ }
116
+ });
117
+
118
+ socket.on('error', () => {
119
+ socket.destroy();
120
+ });
121
+ };
122
+
123
+ // Reset stalled inflight receipts on connection drop or before starting new transfer
124
+ ReplicationPipeline.prototype.reset = function(err = new Error('Replication pipeline reset')) {
125
+ for (const pending of this._pendingReceipts.values()) {
126
+ pending.reject(err);
127
+ }
128
+ this._pendingReceipts.clear();
129
+ this.inflightBatches = 0;
130
+ this.inflightBytes = 0;
131
+ this.stats.inflightBatches = 0;
132
+ this.stats.inflightBytes = 0;
133
+ this._notifyCapacity();
134
+ };
135
+
25
136
  export {
26
137
  SenderPipeline,
27
138
  ReceiverPipeline,
@@ -108,16 +219,27 @@ export async function serve({
108
219
  }
109
220
 
110
221
  const manifest = await TransferManifest.build(resolved);
222
+ replication.reset();
111
223
  const sender = new SenderPipeline({
112
224
  replication,
113
225
  manifest,
114
226
  metrics
115
227
  });
116
- await sender.transfer();
228
+ setImmediate(async () => {
229
+ try {
230
+ await sender.transfer();
231
+ } catch (err) {
232
+ await submitSingleMutation(
233
+ replication,
234
+ Opcodes.PULL_ERROR,
235
+ packPullError({ errorCode: 500, message: err.message })
236
+ );
237
+ }
238
+ });
117
239
  return;
118
240
  }
119
241
 
120
- await receiver._processGenerationBatch(msg);
242
+ await receiver.enqueueBatch(msg);
121
243
  });
122
244
 
123
245
  return {
@@ -152,7 +274,7 @@ export async function send({
152
274
  knownResumeState = new Map()
153
275
  }) {
154
276
  const metrics = new MetricsCollector();
155
- const progress = showProgress ? new ProgressReporter() : null;
277
+ const progress = showProgress ? new ProgressReporter({ role: 'sender' }) : null;
156
278
 
157
279
  // Ephemeral sender port
158
280
  const senderPort = 10000 + Math.floor(Math.random() * 20000);
@@ -214,7 +336,7 @@ export async function pull({
214
336
  showProgress = true
215
337
  }) {
216
338
  const metrics = new MetricsCollector();
217
- const progress = showProgress ? new ProgressReporter() : null;
339
+ const progress = showProgress ? new ProgressReporter({ role: 'receiver' }) : null;
218
340
  if (progress) progress.start();
219
341
 
220
342
  const clientPort = 10000 + Math.floor(Math.random() * 20000);
@@ -42,14 +42,58 @@ export class ReceiverPipeline {
42
42
  this._finishResolvers = [];
43
43
  this._errorRejecters = [];
44
44
 
45
+ // Processing queue to ensure strictly ordered, serialized generation processing
46
+ this._batchQueue = [];
47
+ this._processingQueue = false;
48
+
45
49
  // Hook up Raptiye batch handler if autoHookBatch is true
46
50
  if (this.replication && autoHookBatch) {
47
51
  this.replication.onBatch(async (msg) => {
48
- await this._processGenerationBatch(msg);
52
+ return this.enqueueBatch(msg);
49
53
  });
50
54
  }
51
55
  }
52
56
 
57
+ /**
58
+ * Enqueue incoming generation batch to guarantee strict FIFO sequential processing.
59
+ * @param {object} msg
60
+ * @returns {Promise<void>}
61
+ */
62
+ enqueueBatch(msg) {
63
+ return new Promise((resolve, reject) => {
64
+ this._batchQueue.push({ msg, resolve, reject });
65
+ this._drainQueue();
66
+ });
67
+ }
68
+
69
+ async _drainQueue() {
70
+ if (this._processingQueue) return;
71
+ this._processingQueue = true;
72
+
73
+ try {
74
+ while (this._batchQueue.length > 0) {
75
+ const item = this._batchQueue.shift();
76
+ try {
77
+ await this._processGenerationBatch(item.msg);
78
+ item.resolve();
79
+ } catch (err) {
80
+ this.transferError = err;
81
+ for (const reject of this._errorRejecters) {
82
+ reject(err);
83
+ }
84
+ this._finishResolvers = [];
85
+ this._errorRejecters = [];
86
+ item.reject(err);
87
+ }
88
+ }
89
+ } finally {
90
+ this._processingQueue = false;
91
+ if (this._batchQueue.length > 0) {
92
+ this._drainQueue();
93
+ }
94
+ }
95
+ }
96
+
53
97
  async waitForCompletion() {
54
98
  if (this.transferError) throw this.transferError;
55
99
  if (this.transferFinished) return;
@@ -70,6 +114,12 @@ export class ReceiverPipeline {
70
114
  this.metrics.generationBytes += (metaBuf.byteLength + payloadBuf.byteLength);
71
115
  }
72
116
 
117
+ if (this.progress) {
118
+ this.progress.update({
119
+ generationId: msg.generationId || (this.metrics ? this.metrics.generations : 0)
120
+ });
121
+ }
122
+
73
123
  // Zero-copy unpacking of generation entries
74
124
  const entries = unpackGeneration(metaBuf, payloadBuf);
75
125
  const count = entries.length;
@@ -164,9 +214,12 @@ export class ReceiverPipeline {
164
214
  });
165
215
 
166
216
  if (this.progress) {
217
+ const fileSize = Number(info.fileSize);
218
+ const estimatedChunks = Math.max(1, Math.ceil(fileSize / (1024 * 1024)));
167
219
  this.progress.update({
168
220
  fileName: info.path,
169
- totalBytes: Number(info.fileSize)
221
+ totalBytes: fileSize,
222
+ totalChunks: estimatedChunks
170
223
  });
171
224
  }
172
225
  }
@@ -216,10 +269,18 @@ export class ReceiverPipeline {
216
269
  }
217
270
 
218
271
  if (this.progress) {
272
+ const diskTimeSec = Math.max(0.001, (this.metrics ? this.metrics.diskWriteTimeMs : 1) / 1000);
273
+ const writtenMB = (this.metrics ? this.metrics.fileBytesWritten : 0) / (1024 * 1024);
274
+ const diskMBs = Number((writtenMB / diskTimeSec).toFixed(1));
275
+ file.maxChunkSize = Math.max(file.maxChunkSize || 0, meta.length);
276
+ const realTotalChunks = file.maxChunkSize > 0 && file.fileSize > 0 ? Math.ceil(file.fileSize / file.maxChunkSize) : 0;
219
277
  this.progress.update({
220
278
  fileName: file.relativePath,
221
279
  bytesDone: this.metrics ? this.metrics.fileBytesWritten : 0,
222
- chunksDone: this.metrics ? this.metrics.chunksVerified : 0
280
+ chunksDone: this.metrics ? this.metrics.chunksVerified : 0,
281
+ totalChunks: realTotalChunks > 0 ? realTotalChunks : undefined,
282
+ resumeHits: this.metrics ? this.metrics.resumeHits : 0,
283
+ diskSpeedMBs: diskMBs
223
284
  });
224
285
  }
225
286
 
@@ -102,9 +102,10 @@ export class SenderPipeline {
102
102
  this.replication = replication;
103
103
  this.manifest = manifest;
104
104
  this.chunkSize = options.chunkSize || 1024 * 1024;
105
- this.maxEntries = options.maxEntries || 8;
106
- this.maxBytes = options.maxBytes || 8 * 1024 * 1024;
107
- this.maxImmutableBuffers = options.maxImmutableBuffers || 4;
105
+ this.maxEntries = options.maxEntries || 4;
106
+ this.maxBytes = options.maxBytes || 4 * 1024 * 1024;
107
+ this.maxImmutableBuffers = options.maxImmutableBuffers || 8;
108
+ this.maxConcurrentFlushes = options.maxConcurrentFlushes || Math.min(this.maxImmutableBuffers, 4);
108
109
  this.metrics = metrics;
109
110
  this.progress = progress;
110
111
  this.knownResumeState = knownResumeState;
@@ -125,6 +126,8 @@ export class SenderPipeline {
125
126
  maxEntries: this.maxEntries,
126
127
  maxBytes: this.maxBytes,
127
128
  maxImmutableBuffers: this.maxImmutableBuffers,
129
+ maxConcurrentFlushes: this.maxConcurrentFlushes,
130
+ ordered: true,
128
131
  backpressurePolicy: BackpressurePolicy.WAIT,
129
132
  onBackpressure: (active) => {
130
133
  if (this.metrics && active) {