teyyare 0.2.7 → 0.2.9
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 +97 -57
- package/bin/teyyare.js +36 -3
- package/package.json +6 -2
- package/src/core/protocol.js +39 -6
- package/src/index.js +119 -57
- package/src/receiver/pipeline.js +272 -57
- package/src/sender/pipeline.js +51 -9
- 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/test/e2e/e2e.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teyyare End-to-End (E2E) Test Suite
|
|
3
|
+
*
|
|
4
|
+
* Runs comprehensive end-to-end scenarios:
|
|
5
|
+
* 1. Single File Upload (send) with Multi-Channel TCP (32 MB)
|
|
6
|
+
* 2. Directory Transfer with Nested Hierarchy and Small Files
|
|
7
|
+
* 3. Single File Download (pull) with Multi-Channel TCP (32 MB)
|
|
8
|
+
* 4. Interruption and Resumption with Bounded Retransmission
|
|
9
|
+
*
|
|
10
|
+
* Designed to be monitored and profiled cleanly with:
|
|
11
|
+
* - node test/e2e/e2e.js
|
|
12
|
+
* - softscope test/e2e/e2e.js
|
|
13
|
+
* - softscope --live test/e2e/e2e.js
|
|
14
|
+
* - softscope --md test/e2e/e2e.js
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs/promises';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import net from 'node:net';
|
|
22
|
+
import crypto from 'node:crypto';
|
|
23
|
+
import { serve, send, pull, FileResumeState } from '../../src/index.js';
|
|
24
|
+
|
|
25
|
+
function getAvailablePort() {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const s = net.createServer();
|
|
28
|
+
s.listen(0, '127.0.0.1', () => {
|
|
29
|
+
const port = s.address().port;
|
|
30
|
+
s.close(() => resolve(port));
|
|
31
|
+
});
|
|
32
|
+
s.on('error', reject);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function computeSha256(filePath) {
|
|
37
|
+
const data = await fs.readFile(filePath);
|
|
38
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function runScenario1_SingleFileUpload(workDir) {
|
|
42
|
+
console.log('\n[1/4] Running Single File Upload (send) - 32 MB across 4 channels...');
|
|
43
|
+
const srcDir = path.join(workDir, 's1-src');
|
|
44
|
+
const dstDir = path.join(workDir, 's1-dst');
|
|
45
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
46
|
+
await fs.mkdir(dstDir, { recursive: true });
|
|
47
|
+
|
|
48
|
+
const size = 32 * 1024 * 1024;
|
|
49
|
+
const srcFile = path.join(srcDir, 'data-32mb.bin');
|
|
50
|
+
const data = crypto.randomBytes(size);
|
|
51
|
+
await fs.writeFile(srcFile, data);
|
|
52
|
+
const srcSha = crypto.createHash('sha256').update(data).digest('hex');
|
|
53
|
+
|
|
54
|
+
const port = await getAvailablePort();
|
|
55
|
+
const server = await serve({ port, destinationDir: dstDir });
|
|
56
|
+
|
|
57
|
+
const t0 = Date.now();
|
|
58
|
+
const metrics = await send({
|
|
59
|
+
sourcePath: srcFile,
|
|
60
|
+
host: '127.0.0.1',
|
|
61
|
+
port,
|
|
62
|
+
channels: 4,
|
|
63
|
+
showProgress: false
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await server.receiver.waitForCompletion();
|
|
67
|
+
const durationMs = Math.max(1, Date.now() - t0);
|
|
68
|
+
await server.close();
|
|
69
|
+
|
|
70
|
+
const dstFile = path.join(dstDir, 'data-32mb.bin');
|
|
71
|
+
if (!existsSync(dstFile)) throw new Error('Destination file does not exist');
|
|
72
|
+
const dstSha = await computeSha256(dstFile);
|
|
73
|
+
if (dstSha !== srcSha) throw new Error('SHA-256 mismatch in Scenario 1');
|
|
74
|
+
|
|
75
|
+
const mb = size / (1024 * 1024);
|
|
76
|
+
const throughput = mb / (durationMs / 1000);
|
|
77
|
+
return { name: 'Single File Upload (32 MB, 4 channels)', mb, durationMs, throughput };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function runScenario2_DirectoryUpload(workDir) {
|
|
81
|
+
console.log('\n[2/4] Running Directory Upload (send) with Nested Files & Small-File Packing...');
|
|
82
|
+
const srcDir = path.join(workDir, 's2-src');
|
|
83
|
+
const dstDir = path.join(workDir, 's2-dst');
|
|
84
|
+
await fs.mkdir(path.join(srcDir, 'nested', 'deep'), { recursive: true });
|
|
85
|
+
await fs.mkdir(dstDir, { recursive: true });
|
|
86
|
+
|
|
87
|
+
// Create varied files
|
|
88
|
+
await fs.writeFile(path.join(srcDir, 'empty.txt'), '');
|
|
89
|
+
await fs.writeFile(path.join(srcDir, 'small.txt'), 'Hello Teyyare ' + 'x'.repeat(2048));
|
|
90
|
+
await fs.writeFile(path.join(srcDir, 'nested', 'medium.bin'), crypto.randomBytes(4 * 1024 * 1024));
|
|
91
|
+
await fs.writeFile(path.join(srcDir, 'nested', 'deep', 'large.bin'), crypto.randomBytes(12 * 1024 * 1024));
|
|
92
|
+
|
|
93
|
+
const port = await getAvailablePort();
|
|
94
|
+
const server = await serve({ port, destinationDir: dstDir });
|
|
95
|
+
|
|
96
|
+
const t0 = Date.now();
|
|
97
|
+
const metrics = await send({
|
|
98
|
+
sourcePath: srcDir,
|
|
99
|
+
host: '127.0.0.1',
|
|
100
|
+
port,
|
|
101
|
+
channels: 4,
|
|
102
|
+
showProgress: false
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await server.receiver.waitForCompletion();
|
|
106
|
+
const durationMs = Math.max(1, Date.now() - t0);
|
|
107
|
+
await server.close();
|
|
108
|
+
|
|
109
|
+
// Verify all files exist and match
|
|
110
|
+
const checkFiles = [
|
|
111
|
+
'empty.txt',
|
|
112
|
+
'small.txt',
|
|
113
|
+
path.join('nested', 'medium.bin'),
|
|
114
|
+
path.join('nested', 'deep', 'large.bin')
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
let totalBytes = 0;
|
|
118
|
+
for (const rel of checkFiles) {
|
|
119
|
+
const f1 = path.join(srcDir, rel);
|
|
120
|
+
const f2 = path.join(dstDir, rel);
|
|
121
|
+
if (!existsSync(f2)) throw new Error(`Missing received file: ${rel}`);
|
|
122
|
+
const sha1 = await computeSha256(f1);
|
|
123
|
+
const sha2 = await computeSha256(f2);
|
|
124
|
+
if (sha1 !== sha2) throw new Error(`SHA mismatch for ${rel}`);
|
|
125
|
+
totalBytes += (await fs.stat(f1)).size;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const mb = totalBytes / (1024 * 1024);
|
|
129
|
+
const throughput = mb / (durationMs / 1000);
|
|
130
|
+
return { name: 'Directory Upload (Nested tree, 16+ MB)', mb, durationMs, throughput };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function runScenario3_SingleFilePull(workDir) {
|
|
134
|
+
console.log('\n[3/4] Running Single File Pull (download) - 32 MB across 4 channels...');
|
|
135
|
+
const serverDir = path.join(workDir, 's3-srv');
|
|
136
|
+
const clientDir = path.join(workDir, 's3-cli');
|
|
137
|
+
await fs.mkdir(serverDir, { recursive: true });
|
|
138
|
+
await fs.mkdir(clientDir, { recursive: true });
|
|
139
|
+
|
|
140
|
+
const size = 32 * 1024 * 1024;
|
|
141
|
+
const srvFile = path.join(serverDir, 'pull-32mb.bin');
|
|
142
|
+
const data = crypto.randomBytes(size);
|
|
143
|
+
await fs.writeFile(srvFile, data);
|
|
144
|
+
const srvSha = crypto.createHash('sha256').update(data).digest('hex');
|
|
145
|
+
|
|
146
|
+
const port = await getAvailablePort();
|
|
147
|
+
const server = await serve({ port, destinationDir: serverDir });
|
|
148
|
+
|
|
149
|
+
const t0 = Date.now();
|
|
150
|
+
const metrics = await pull({
|
|
151
|
+
sourceRemotePath: 'pull-32mb.bin',
|
|
152
|
+
host: '127.0.0.1',
|
|
153
|
+
port,
|
|
154
|
+
channels: 4,
|
|
155
|
+
destinationDir: clientDir,
|
|
156
|
+
showProgress: false
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const durationMs = Math.max(1, Date.now() - t0);
|
|
160
|
+
await server.close();
|
|
161
|
+
|
|
162
|
+
const clientFile = path.join(clientDir, 'pull-32mb.bin');
|
|
163
|
+
if (!existsSync(clientFile)) throw new Error('Pulled file does not exist');
|
|
164
|
+
const clientSha = await computeSha256(clientFile);
|
|
165
|
+
if (clientSha !== srvSha) throw new Error('SHA-256 mismatch in Scenario 3 (pull)');
|
|
166
|
+
|
|
167
|
+
const mb = size / (1024 * 1024);
|
|
168
|
+
const throughput = mb / (durationMs / 1000);
|
|
169
|
+
return { name: 'Single File Pull (32 MB, 4 channels)', mb, durationMs, throughput };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function runScenario4_InterruptedResume(workDir) {
|
|
173
|
+
console.log('\n[4/4] Running Interrupted Transfer & Resumption with Bounded Retransmission...');
|
|
174
|
+
const srcDir = path.join(workDir, 's4-src');
|
|
175
|
+
const dstDir = path.join(workDir, 's4-dst');
|
|
176
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
177
|
+
await fs.mkdir(dstDir, { recursive: true });
|
|
178
|
+
|
|
179
|
+
const size = 16 * 1024 * 1024; // 16 MB = 16 chunks
|
|
180
|
+
const srcFile = path.join(srcDir, 'resume-16mb.bin');
|
|
181
|
+
const data = crypto.randomBytes(size);
|
|
182
|
+
await fs.writeFile(srcFile, data);
|
|
183
|
+
const srcSha = crypto.createHash('sha256').update(data).digest('hex');
|
|
184
|
+
|
|
185
|
+
const dstFile = path.join(dstDir, 'resume-16mb.bin');
|
|
186
|
+
const partPath = `${dstFile}.teyyare-part`;
|
|
187
|
+
const statePath = `${dstFile}.teyyare-state`;
|
|
188
|
+
|
|
189
|
+
// Pre-seed 8 chunks (50%) in destination
|
|
190
|
+
const partialState = new FileResumeState({
|
|
191
|
+
transferId: 'e2e-resume-test',
|
|
192
|
+
fileId: 1,
|
|
193
|
+
fileSize: size,
|
|
194
|
+
chunkSize: 1024 * 1024,
|
|
195
|
+
sha256: srcSha,
|
|
196
|
+
verifiedChunks: new Set([0, 1, 2, 3, 4, 5, 6, 7])
|
|
197
|
+
});
|
|
198
|
+
await partialState.save(statePath);
|
|
199
|
+
|
|
200
|
+
const handle = await fs.open(partPath, 'w+');
|
|
201
|
+
await handle.write(data.subarray(0, 8 * 1024 * 1024), 0, 8 * 1024 * 1024, 0);
|
|
202
|
+
await handle.sync();
|
|
203
|
+
await handle.close();
|
|
204
|
+
|
|
205
|
+
// Start server and resume
|
|
206
|
+
const port = await getAvailablePort();
|
|
207
|
+
const server = await serve({ port, destinationDir: dstDir });
|
|
208
|
+
|
|
209
|
+
const knownResume = new Map([[1, partialState]]);
|
|
210
|
+
const t0 = Date.now();
|
|
211
|
+
const metrics = await send({
|
|
212
|
+
sourcePath: srcFile,
|
|
213
|
+
host: '127.0.0.1',
|
|
214
|
+
port,
|
|
215
|
+
channels: 4,
|
|
216
|
+
knownResumeState: knownResume,
|
|
217
|
+
showProgress: false
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await server.receiver.waitForCompletion();
|
|
221
|
+
const durationMs = Math.max(1, Date.now() - t0);
|
|
222
|
+
await server.close();
|
|
223
|
+
|
|
224
|
+
const finalSha = await computeSha256(dstFile);
|
|
225
|
+
if (finalSha !== srcSha) throw new Error('SHA mismatch after resume');
|
|
226
|
+
|
|
227
|
+
const snap = metrics.snapshot();
|
|
228
|
+
if (snap.resumeHits !== 8) throw new Error(`Expected 8 resume hits, got ${snap.resumeHits}`);
|
|
229
|
+
if (snap.chunksCreated !== 8) throw new Error(`Expected 8 chunks created, got ${snap.chunksCreated}`);
|
|
230
|
+
|
|
231
|
+
const mb = 8; // Only 8 MB actually re-transferred
|
|
232
|
+
const throughput = mb / (durationMs / 1000);
|
|
233
|
+
return { name: 'Resumed Transfer (50% skipped, 8 MB sent)', mb, durationMs, throughput };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function main() {
|
|
237
|
+
console.log('====================================================');
|
|
238
|
+
console.log(' TEYYARE END-TO-END (E2E) TEST SUITE ');
|
|
239
|
+
console.log('====================================================');
|
|
240
|
+
|
|
241
|
+
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teyyare-e2e-'));
|
|
242
|
+
const results = [];
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
results.push(await runScenario1_SingleFileUpload(workDir));
|
|
246
|
+
results.push(await runScenario2_DirectoryUpload(workDir));
|
|
247
|
+
results.push(await runScenario3_SingleFilePull(workDir));
|
|
248
|
+
results.push(await runScenario4_InterruptedResume(workDir));
|
|
249
|
+
|
|
250
|
+
console.log('\n' + '='.repeat(80));
|
|
251
|
+
console.log(' E2E EXECUTION SUMMARY ');
|
|
252
|
+
console.log('='.repeat(80));
|
|
253
|
+
console.log('| Scenario | Data (MB) | Time (ms) | Throughput | Status |');
|
|
254
|
+
console.log('|-----------------------------------------------|-----------|-----------|-------------|--------|');
|
|
255
|
+
for (const r of results) {
|
|
256
|
+
const name = r.name.padEnd(45);
|
|
257
|
+
const mb = r.mb.toFixed(1).padStart(9);
|
|
258
|
+
const ms = String(r.durationMs).padStart(9);
|
|
259
|
+
const speed = `${r.throughput.toFixed(1)} MB/s`.padStart(11);
|
|
260
|
+
console.log(`| ${name} | ${mb} | ${ms} | ${speed} | PASS |`);
|
|
261
|
+
}
|
|
262
|
+
console.log('='.repeat(80));
|
|
263
|
+
console.log('ALL E2E SCENARIOS PASSED SUCCESSFULLY!\n');
|
|
264
|
+
} finally {
|
|
265
|
+
await fs.rm(workDir, { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
main()
|
|
270
|
+
.then(() => {
|
|
271
|
+
process.exit(0);
|
|
272
|
+
})
|
|
273
|
+
.catch((err) => {
|
|
274
|
+
console.error('\nE2E Test Failed:', err);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
});
|
|
@@ -129,3 +129,54 @@ test('Integration - pull non-existent path throws remote error', async () => {
|
|
|
129
129
|
await fs.rm(testDir, { recursive: true, force: true });
|
|
130
130
|
}
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
test('Integration - pull with custom WAN tuning parameters (2MB chunk size)', async () => {
|
|
134
|
+
const testDir = path.resolve('tmp/pull-test-wan-tuning');
|
|
135
|
+
const serverDir = path.join(testDir, 'server');
|
|
136
|
+
const clientDir = path.join(testDir, 'client');
|
|
137
|
+
|
|
138
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
139
|
+
await fs.mkdir(serverDir, { recursive: true });
|
|
140
|
+
await fs.mkdir(clientDir, { recursive: true });
|
|
141
|
+
|
|
142
|
+
// Create 6 MB test file
|
|
143
|
+
const testFile = path.join(serverDir, 'wan-archive.bin');
|
|
144
|
+
const size = 6 * 1024 * 1024;
|
|
145
|
+
const data = crypto.randomBytes(size);
|
|
146
|
+
await fs.writeFile(testFile, data);
|
|
147
|
+
const sourceSha = crypto.createHash('sha256').update(data).digest('hex');
|
|
148
|
+
|
|
149
|
+
const port = 8824;
|
|
150
|
+
const server = await serve({ port, destinationDir: serverDir });
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
// Request 2 MB chunks (should result in exactly 3 chunks for 6 MB instead of 6 chunks of 1 MB)
|
|
154
|
+
const metrics = await pull({
|
|
155
|
+
sourceRemotePath: 'wan-archive.bin',
|
|
156
|
+
host: '127.0.0.1',
|
|
157
|
+
port,
|
|
158
|
+
destinationDir: clientDir,
|
|
159
|
+
pipelineOptions: {
|
|
160
|
+
chunkSize: 2 * 1024 * 1024,
|
|
161
|
+
maxEntries: 4,
|
|
162
|
+
maxBytes: 8 * 1024 * 1024
|
|
163
|
+
},
|
|
164
|
+
showProgress: false
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const pulledFile = path.join(clientDir, 'wan-archive.bin');
|
|
168
|
+
assert.strictEqual(await fs.stat(pulledFile).then(s => s.size), size);
|
|
169
|
+
|
|
170
|
+
const pulledSha = await computeSha256(pulledFile);
|
|
171
|
+
assert.strictEqual(pulledSha, sourceSha);
|
|
172
|
+
|
|
173
|
+
const snap = metrics.snapshot();
|
|
174
|
+
assert.strictEqual(snap.fileBytesWritten, size);
|
|
175
|
+
// With 2 MB chunks, 6 MB file has exactly 3 chunks!
|
|
176
|
+
assert.strictEqual(snap.chunksCreated, 3, 'Should create exactly 3 chunks of 2 MB each');
|
|
177
|
+
assert.strictEqual(snap.chunksVerified, 3, 'Should verify exactly 3 chunks of 2 MB each');
|
|
178
|
+
} finally {
|
|
179
|
+
await server.close();
|
|
180
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
@@ -95,3 +95,65 @@ test('Integration - resume after interruption at 50%, bounded retransmission, SH
|
|
|
95
95
|
|
|
96
96
|
await fs.rm(tmpDir, { recursive: true });
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
test('Integration - debounced asynchronous resume state saving drains cleanly', async () => {
|
|
100
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teyyare-debounce-test-'));
|
|
101
|
+
const destDir = path.join(tmpDir, 'dest');
|
|
102
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
103
|
+
|
|
104
|
+
const { ReceiverPipeline } = await import('../../src/receiver/pipeline.js');
|
|
105
|
+
const { packFileBegin, packChunkMeta, packFileEnd, Opcodes } = await import('../../src/core/protocol.js');
|
|
106
|
+
|
|
107
|
+
const receiver = new ReceiverPipeline({
|
|
108
|
+
destinationDir: destDir
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Begin a 64 MB file
|
|
112
|
+
const fileBeginBuf = packFileBegin({
|
|
113
|
+
fileId: 10,
|
|
114
|
+
fileSize: 64 * 1024 * 1024,
|
|
115
|
+
path: 'debounced.bin',
|
|
116
|
+
sha256: ''
|
|
117
|
+
});
|
|
118
|
+
await receiver._handleFileBegin(fileBeginBuf);
|
|
119
|
+
|
|
120
|
+
const file = receiver.activeFiles.get(10);
|
|
121
|
+
assert.ok(file, 'Active file must be registered');
|
|
122
|
+
|
|
123
|
+
// Simulate writing 40 chunks rapidly
|
|
124
|
+
for (let c = 0; c < 40; c++) {
|
|
125
|
+
const metaBuf = packChunkMeta({
|
|
126
|
+
fileId: 10,
|
|
127
|
+
chunkIndex: c,
|
|
128
|
+
offset: BigInt(c * 1024),
|
|
129
|
+
length: 1024,
|
|
130
|
+
checksum: 0 // dummy, won't check if we directly call _writeChunk
|
|
131
|
+
});
|
|
132
|
+
// mock CRC match
|
|
133
|
+
const chunkData = new Uint8Array(1024);
|
|
134
|
+
const { crc32 } = await import('../../src/core/crc32.js');
|
|
135
|
+
const crc = crc32(chunkData);
|
|
136
|
+
const validMeta = packChunkMeta({
|
|
137
|
+
fileId: 10,
|
|
138
|
+
chunkIndex: c,
|
|
139
|
+
offset: BigInt(c * 1024),
|
|
140
|
+
length: 1024,
|
|
141
|
+
checksum: crc
|
|
142
|
+
});
|
|
143
|
+
await receiver._writeChunk(file, (await import('../../src/core/protocol.js')).unpackChunkMeta(validMeta), chunkData);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
assert.strictEqual(file.resumeState.verifiedChunks.size, 40);
|
|
147
|
+
|
|
148
|
+
// Close receiver - should drain any state save cleanly without errors
|
|
149
|
+
await receiver.close();
|
|
150
|
+
|
|
151
|
+
const statePath = path.join(destDir, 'debounced.bin.teyyare-state');
|
|
152
|
+
// State must be readable and valid
|
|
153
|
+
const loaded = await FileResumeState.load(statePath);
|
|
154
|
+
if (loaded) {
|
|
155
|
+
assert.ok(loaded.verifiedChunks.size > 0, 'Saved state must contain verified chunks');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await fs.rm(tmpDir, { recursive: true });
|
|
159
|
+
});
|
|
@@ -62,3 +62,88 @@ test('Integration - end-to-end 10 MB single file transfer with SHA-256 verificat
|
|
|
62
62
|
|
|
63
63
|
await fs.rm(tmpDir, { recursive: true });
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
test('Integration - file pre-allocation allocates full file size upon FILE_BEGIN', async () => {
|
|
67
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teyyare-prealloc-'));
|
|
68
|
+
const destDir = path.join(tmpDir, 'dest');
|
|
69
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
70
|
+
|
|
71
|
+
const receiver = new (await import('../../src/receiver/pipeline.js')).ReceiverPipeline({
|
|
72
|
+
destinationDir: destDir
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const { packFileBegin } = await import('../../src/core/protocol.js');
|
|
76
|
+
const fileBeginBuf = packFileBegin({
|
|
77
|
+
fileId: 1,
|
|
78
|
+
fileSize: 50 * 1024 * 1024, // 50 MB
|
|
79
|
+
path: 'preallocated.bin',
|
|
80
|
+
sha256: ''
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await receiver._handleFileBegin(fileBeginBuf);
|
|
84
|
+
|
|
85
|
+
const partFile = path.join(destDir, 'preallocated.bin.teyyare-part');
|
|
86
|
+
assert.ok(existsSync(partFile), 'Part file must exist immediately after FILE_BEGIN');
|
|
87
|
+
const stat = await fs.stat(partFile);
|
|
88
|
+
assert.strictEqual(stat.size, 50 * 1024 * 1024, 'Part file must be pre-allocated to full size (50 MB)');
|
|
89
|
+
|
|
90
|
+
await receiver.close();
|
|
91
|
+
await fs.rm(tmpDir, { recursive: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('Integration - chunk coalescing writes contiguous runs using writev and verifies exact content', async () => {
|
|
95
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teyyare-coalesce-'));
|
|
96
|
+
const destDir = path.join(tmpDir, 'dest');
|
|
97
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
98
|
+
|
|
99
|
+
const { ReceiverPipeline } = await import('../../src/receiver/pipeline.js');
|
|
100
|
+
const { packFileBegin, packChunkMeta } = await import('../../src/core/protocol.js');
|
|
101
|
+
const { crc32 } = await import('../../src/core/crc32.js');
|
|
102
|
+
|
|
103
|
+
const receiver = new ReceiverPipeline({ destinationDir: destDir });
|
|
104
|
+
|
|
105
|
+
const fileBeginBuf = packFileBegin({
|
|
106
|
+
fileId: 42,
|
|
107
|
+
fileSize: 4 * 1024 * 1024,
|
|
108
|
+
path: 'coalesced.bin',
|
|
109
|
+
sha256: ''
|
|
110
|
+
});
|
|
111
|
+
await receiver._handleFileBegin(fileBeginBuf);
|
|
112
|
+
|
|
113
|
+
// Spy on writev
|
|
114
|
+
const file = receiver.activeFiles.get(42);
|
|
115
|
+
let writevCallCount = 0;
|
|
116
|
+
const originalWritev = file.fileHandle.writev.bind(file.fileHandle);
|
|
117
|
+
file.fileHandle.writev = async (...args) => {
|
|
118
|
+
writevCallCount++;
|
|
119
|
+
return originalWritev(...args);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Build a batch of 4 contiguous 1MB chunks
|
|
123
|
+
const chunkData = crypto.randomBytes(4 * 1024 * 1024);
|
|
124
|
+
const items = [];
|
|
125
|
+
for (let c = 0; c < 4; c++) {
|
|
126
|
+
const slice = chunkData.subarray(c * 1024 * 1024, (c + 1) * 1024 * 1024);
|
|
127
|
+
const key = packChunkMeta({
|
|
128
|
+
fileId: 42,
|
|
129
|
+
chunkIndex: c,
|
|
130
|
+
offset: BigInt(c * 1024 * 1024),
|
|
131
|
+
length: 1024 * 1024,
|
|
132
|
+
checksum: crc32(slice)
|
|
133
|
+
});
|
|
134
|
+
items.push({ key, val: slice });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Handle all 4 chunks together
|
|
138
|
+
await receiver._handleChunks(items);
|
|
139
|
+
|
|
140
|
+
// All 4 contiguous chunks must be coalesced into a single writev syscall!
|
|
141
|
+
assert.strictEqual(writevCallCount, 1, '4 contiguous chunks must be coalesced into exactly 1 writev syscall');
|
|
142
|
+
|
|
143
|
+
// Verify file content matches on disk
|
|
144
|
+
const written = await fs.readFile(file.partPath);
|
|
145
|
+
assert.ok(written.equals(chunkData), 'Written file content on disk must match original random bytes exactly');
|
|
146
|
+
|
|
147
|
+
await receiver.close();
|
|
148
|
+
await fs.rm(tmpDir, { recursive: true });
|
|
149
|
+
});
|
|
@@ -77,9 +77,29 @@ test('Protocol - pack and unpack transfer begin', () => {
|
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
test('Protocol - pack and unpack pull request', () => {
|
|
80
|
+
// 1. Basic pull request
|
|
80
81
|
const req = packPullRequest({ remotePath: '/root/cdn/app.zip' });
|
|
81
82
|
const unpacked = unpackPullRequest(req);
|
|
82
83
|
assert.strictEqual(unpacked.remotePath, '/root/cdn/app.zip');
|
|
84
|
+
assert.deepStrictEqual(unpacked.verifiedChunks, []);
|
|
85
|
+
assert.strictEqual(unpacked.chunkSize, undefined);
|
|
86
|
+
assert.strictEqual(unpacked.maxEntries, undefined);
|
|
87
|
+
assert.strictEqual(unpacked.maxBytes, undefined);
|
|
88
|
+
|
|
89
|
+
// 2. Pull request with WAN tuning parameters and verified chunks
|
|
90
|
+
const reqTuned = packPullRequest({
|
|
91
|
+
remotePath: '/var/data/bigfile.bin',
|
|
92
|
+
verifiedChunks: [0, 1, 4],
|
|
93
|
+
chunkSize: 2 * 1024 * 1024,
|
|
94
|
+
maxEntries: 16,
|
|
95
|
+
maxBytes: 32 * 1024 * 1024
|
|
96
|
+
});
|
|
97
|
+
const unpackedTuned = unpackPullRequest(reqTuned);
|
|
98
|
+
assert.strictEqual(unpackedTuned.remotePath, '/var/data/bigfile.bin');
|
|
99
|
+
assert.deepStrictEqual(unpackedTuned.verifiedChunks, [0, 1, 4]);
|
|
100
|
+
assert.strictEqual(unpackedTuned.chunkSize, 2097152);
|
|
101
|
+
assert.strictEqual(unpackedTuned.maxEntries, 16);
|
|
102
|
+
assert.strictEqual(unpackedTuned.maxBytes, 33554432);
|
|
83
103
|
});
|
|
84
104
|
|
|
85
105
|
test('Protocol - pack and unpack pull error', () => {
|