ferrings 0.1.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/zcrx-smoke.js ADDED
@@ -0,0 +1,476 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const { spawnSync } = require('node:child_process');
5
+ const fs = require('node:fs');
6
+ const http = require('node:http');
7
+ const net = require('node:net');
8
+ const path = require('node:path');
9
+ const {
10
+ UringHttpServer,
11
+ UringTcpEchoServer,
12
+ UringTcpServer,
13
+ zcrxProbe
14
+ } = require('./');
15
+
16
+ async function runZcrxHardwareSmoke(options = {}) {
17
+ const config = normalizeSmokeOptions(options);
18
+ const report = baseReport(config);
19
+
20
+ try {
21
+ if (config.selfTest) {
22
+ runQueueStatsParserSelfTest();
23
+ report.status = 'self-test';
24
+ return report;
25
+ }
26
+
27
+ if (!config.interfaceName) {
28
+ report.status = 'skipped';
29
+ report.skippedReason = 'set ZCRX_INTERFACE or pass --interface before running this hardware test';
30
+ return report;
31
+ }
32
+
33
+ const probe = zcrxProbe({
34
+ interfaceName: config.interfaceName,
35
+ rxQueue: config.rxQueue,
36
+ rxBufferSize: config.rxBufferSize,
37
+ activeRegistration: true
38
+ });
39
+ report.probe = probe;
40
+
41
+ const activeSucceeded =
42
+ probe.activeRegistrationErrno === undefined &&
43
+ /succeeded/i.test(probe.activeRegistrationResult || '');
44
+ if (!activeSucceeded) {
45
+ throw new Error(`ZCRX active registration failed: ${probe.blockers.join('; ')}`);
46
+ }
47
+ if (!probe.ready) {
48
+ report.warnings.push(
49
+ `passive readiness blockers remain: ${probe.blockers.join('; ')}`
50
+ );
51
+ }
52
+
53
+ const queueCountersBefore = readSelectedRxQueueCounters(config.interfaceName, config.rxQueue);
54
+ report.queueCounters = {
55
+ before: countersForReport(queueCountersBefore),
56
+ after: null,
57
+ positiveDeltas: []
58
+ };
59
+ if (!queueCountersBefore.available) {
60
+ const message = `selected RX queue counter evidence unavailable: ${queueCountersBefore.reason}`;
61
+ if (config.requireRxQueueStats) {
62
+ throw new Error(message);
63
+ }
64
+ report.warnings.push(message);
65
+ }
66
+
67
+ await smokeHttp(report, config);
68
+ await smokeNativeEcho(report, config);
69
+ await smokeProgrammableTcp(report, config);
70
+
71
+ if (queueCountersBefore.available) {
72
+ const queueCountersAfter = readSelectedRxQueueCounters(config.interfaceName, config.rxQueue);
73
+ const deltas = queueCountersAfter.available
74
+ ? diffCounters(queueCountersBefore, queueCountersAfter)
75
+ : [];
76
+ const positiveDeltas = deltas.filter(({ delta }) => delta > 0n);
77
+ report.queueCounters.after = countersForReport(queueCountersAfter);
78
+ report.queueCounters.positiveDeltas = deltasForReport(positiveDeltas);
79
+ if (positiveDeltas.length === 0) {
80
+ throw new Error(
81
+ `ZCRX traffic completed but selected RX queue ${config.rxQueue} counters did not increase; ` +
82
+ `before counters: ${[...queueCountersBefore.counters.keys()].join(', ')}`
83
+ );
84
+ }
85
+ }
86
+
87
+ report.status = 'passed';
88
+ return report;
89
+ } catch (error) {
90
+ report.status = 'failed';
91
+ report.error = errorForReport(error);
92
+ error.report = report;
93
+ throw error;
94
+ } finally {
95
+ report.finishedAt = new Date().toISOString();
96
+ writeReport(report, config.reportPath);
97
+ }
98
+ }
99
+
100
+ function normalizeSmokeOptions(options) {
101
+ return {
102
+ interfaceName: options.interfaceName || process.env.ZCRX_INTERFACE,
103
+ rxQueue: numberOrDefault(options.rxQueue, process.env.ZCRX_RX_QUEUE, 0),
104
+ rxBufferSize: numberOrDefault(
105
+ options.rxBufferSize,
106
+ process.env.ZCRX_RX_BUFFER_SIZE,
107
+ 0
108
+ ),
109
+ bindHost: options.bindHost || process.env.ZCRX_BIND_HOST || '0.0.0.0',
110
+ connectHost:
111
+ options.connectHost ||
112
+ process.env.ZCRX_CONNECT_HOST ||
113
+ process.env.ZCRX_BIND_HOST ||
114
+ '127.0.0.1',
115
+ timeoutMs: numberOrDefault(options.timeoutMs, process.env.ZCRX_TIMEOUT_MS, 5000),
116
+ requireRxQueueStats:
117
+ options.requireRxQueueStats !== undefined
118
+ ? Boolean(options.requireRxQueueStats)
119
+ : process.env.ZCRX_REQUIRE_RX_QUEUE_STATS === '1',
120
+ reportPath: options.reportPath || process.env.ZCRX_REPORT_PATH,
121
+ selfTest: Boolean(options.selfTest)
122
+ };
123
+ }
124
+
125
+ function numberOrDefault(value, envValue, fallback) {
126
+ const candidate = value !== undefined ? value : envValue;
127
+ if (candidate === undefined || candidate === '') return fallback;
128
+ return Number(candidate);
129
+ }
130
+
131
+ function baseReport(config) {
132
+ return {
133
+ status: 'running',
134
+ startedAt: new Date().toISOString(),
135
+ finishedAt: null,
136
+ config: {
137
+ interfaceName: config.interfaceName,
138
+ rxQueue: config.rxQueue,
139
+ rxBufferSize: config.rxBufferSize,
140
+ bindHost: config.bindHost,
141
+ connectHost: config.connectHost,
142
+ timeoutMs: config.timeoutMs,
143
+ requireRxQueueStats: config.requireRxQueueStats
144
+ },
145
+ warnings: [],
146
+ probe: null,
147
+ queueCounters: null,
148
+ smokes: []
149
+ };
150
+ }
151
+
152
+ function parseEthtoolStats(output) {
153
+ const stats = new Map();
154
+ for (const line of output.split(/\r?\n/)) {
155
+ const match = line.match(/^\s*([^:]+):\s*(\d+)\s*$/);
156
+ if (!match) continue;
157
+ stats.set(match[1].trim(), BigInt(match[2]));
158
+ }
159
+ return stats;
160
+ }
161
+
162
+ function isSelectedRxQueueCounter(name, queue) {
163
+ const normalized = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
164
+ const queuePatterns = [
165
+ `rx_queue_${queue}_`,
166
+ `rx_q${queue}_`,
167
+ `rxq${queue}_`,
168
+ `rx_${queue}_`,
169
+ `rx${queue}_`,
170
+ `queue_${queue}_rx_`,
171
+ `queue${queue}_rx_`
172
+ ];
173
+ const mentionsSelectedQueue = queuePatterns.some((pattern) => normalized.includes(pattern));
174
+ const looksLikeTraffic = /(?:^|_)(?:packets|packet|pkts|pkt|bytes|octets)(?:_|$)/.test(normalized);
175
+ const looksLikeAuxiliary =
176
+ /(?:^|_)(?:drop|dropped|error|errors|xdp|alloc|recycle|refill|miss|missed)(?:_|$)/.test(normalized);
177
+ return mentionsSelectedQueue && looksLikeTraffic && !looksLikeAuxiliary;
178
+ }
179
+
180
+ function readSelectedRxQueueCounters(interfaceName, queue) {
181
+ const output = spawnSync('ethtool', ['-S', interfaceName], {
182
+ encoding: 'utf8',
183
+ maxBuffer: 1024 * 1024
184
+ });
185
+ if (output.error || output.status !== 0) {
186
+ return {
187
+ available: false,
188
+ counters: new Map(),
189
+ reason: output.error ? output.error.message : (output.stderr || 'ethtool -S failed').trim()
190
+ };
191
+ }
192
+
193
+ const stats = parseEthtoolStats(output.stdout || '');
194
+ const counters = new Map();
195
+ for (const [name, value] of stats) {
196
+ if (isSelectedRxQueueCounter(name, queue)) {
197
+ counters.set(name, value);
198
+ }
199
+ }
200
+
201
+ if (counters.size === 0) {
202
+ return {
203
+ available: false,
204
+ counters,
205
+ reason: `ethtool exposed no recognizable traffic counters for RX queue ${queue}`
206
+ };
207
+ }
208
+
209
+ return { available: true, counters, reason: null };
210
+ }
211
+
212
+ function diffCounters(before, after) {
213
+ const deltas = [];
214
+ for (const [name, beforeValue] of before.counters) {
215
+ const afterValue = after.counters.get(name);
216
+ if (afterValue === undefined || afterValue < beforeValue) continue;
217
+ const delta = afterValue - beforeValue;
218
+ deltas.push({ name, delta });
219
+ }
220
+ return deltas;
221
+ }
222
+
223
+ function countersForReport(snapshot) {
224
+ return {
225
+ available: snapshot.available,
226
+ reason: snapshot.reason,
227
+ counters: Object.fromEntries(
228
+ [...snapshot.counters].map(([name, value]) => [name, value.toString()])
229
+ )
230
+ };
231
+ }
232
+
233
+ function deltasForReport(deltas) {
234
+ return deltas.map(({ name, delta }) => ({ name, delta: delta.toString() }));
235
+ }
236
+
237
+ function assertZcrxTransportStats(smoke, info, minBytes) {
238
+ smoke.finalInfo = info;
239
+ assert.ok(info, `${smoke.name} final info should be available`);
240
+ assert.equal(info.zeroCopyReceive, true);
241
+ assert.equal(info.zcrxReady, true);
242
+ assert.ok(info.zcrxPackets >= 1, `${smoke.name} should receive at least one ZCRX packet`);
243
+ assert.ok(
244
+ info.zcrxBytes >= minBytes,
245
+ `${smoke.name} should account for at least ${minBytes} ZCRX bytes`
246
+ );
247
+ }
248
+
249
+ function withTimeout(promise, timeoutMs, label) {
250
+ let timer;
251
+ const timeout = new Promise((_, reject) => {
252
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
253
+ });
254
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
255
+ }
256
+
257
+ function requestHttp(port, config) {
258
+ return withTimeout(new Promise((resolve, reject) => {
259
+ const req = http.get(
260
+ {
261
+ host: config.connectHost,
262
+ port,
263
+ path: '/',
264
+ agent: false,
265
+ timeout: config.timeoutMs
266
+ },
267
+ (res) => {
268
+ let body = '';
269
+ res.setEncoding('utf8');
270
+ res.on('data', (chunk) => {
271
+ body += chunk;
272
+ });
273
+ res.on('end', () => resolve({ statusCode: res.statusCode, body }));
274
+ }
275
+ );
276
+ req.on('timeout', () => req.destroy(new Error('HTTP request timed out')));
277
+ req.on('error', reject);
278
+ }), config.timeoutMs, 'HTTP ZCRX request');
279
+ }
280
+
281
+ function tcpRoundTrip(port, payload, config) {
282
+ return withTimeout(new Promise((resolve, reject) => {
283
+ const socket = net.createConnection({ host: config.connectHost, port }, () => {
284
+ socket.write(Buffer.from(payload));
285
+ });
286
+ let body = Buffer.alloc(0);
287
+ socket.setTimeout(config.timeoutMs, () => socket.destroy(new Error('TCP request timed out')));
288
+ socket.on('data', (chunk) => {
289
+ body = Buffer.concat([body, chunk]);
290
+ socket.end();
291
+ });
292
+ socket.on('end', () => resolve(body.toString('utf8')));
293
+ socket.on('error', reject);
294
+ }), config.timeoutMs, 'TCP ZCRX round trip');
295
+ }
296
+
297
+ async function smokeHttp(report, config) {
298
+ const server = new UringHttpServer({
299
+ host: config.bindHost,
300
+ port: 0,
301
+ responseBody: 'zcrx http ok\n',
302
+ bufferCount: 256,
303
+ bufferSize: 4096,
304
+ useZeroCopyReceive: true,
305
+ zcrxInterfaceName: config.interfaceName,
306
+ zcrxRxQueue: config.rxQueue,
307
+ zcrxRxBufferSize: config.rxBufferSize
308
+ });
309
+ const info = server.start();
310
+ assert.equal(info.zeroCopyReceive, true);
311
+ assert.equal(info.zcrxReady, true);
312
+ assert.ok(info.zcrxRxBufferSize >= 512);
313
+ const smoke = {
314
+ name: 'http',
315
+ status: 'running',
316
+ startInfo: info,
317
+ finalInfo: null,
318
+ response: null,
319
+ error: null
320
+ };
321
+ report.smokes.push(smoke);
322
+ try {
323
+ const response = await requestHttp(info.port, config);
324
+ assert.equal(response.statusCode, 200);
325
+ assert.equal(response.body, 'zcrx http ok\n');
326
+ smoke.response = response;
327
+ assertZcrxTransportStats(smoke, server.info(), 1);
328
+ smoke.status = 'passed';
329
+ } catch (error) {
330
+ smoke.status = 'failed';
331
+ smoke.error = errorForReport(error);
332
+ throw error;
333
+ } finally {
334
+ server.stop();
335
+ }
336
+ }
337
+
338
+ async function smokeNativeEcho(report, config) {
339
+ const server = new UringTcpEchoServer({
340
+ host: config.bindHost,
341
+ port: 0,
342
+ bufferCount: 256,
343
+ bufferSize: 4096,
344
+ useZeroCopyReceive: true,
345
+ zcrxInterfaceName: config.interfaceName,
346
+ zcrxRxQueue: config.rxQueue,
347
+ zcrxRxBufferSize: config.rxBufferSize
348
+ });
349
+ const info = server.start();
350
+ assert.equal(info.zeroCopyReceive, true);
351
+ assert.equal(info.zcrxReady, true);
352
+ assert.ok(info.zcrxRxBufferSize >= 512);
353
+ const smoke = {
354
+ name: 'native-echo',
355
+ status: 'running',
356
+ startInfo: info,
357
+ finalInfo: null,
358
+ response: null,
359
+ error: null
360
+ };
361
+ report.smokes.push(smoke);
362
+ try {
363
+ const response = await tcpRoundTrip(info.port, 'zcrx native echo', config);
364
+ assert.equal(response, 'zcrx native echo');
365
+ smoke.response = response;
366
+ assertZcrxTransportStats(smoke, server.info(), Buffer.byteLength('zcrx native echo'));
367
+ smoke.status = 'passed';
368
+ } catch (error) {
369
+ smoke.status = 'failed';
370
+ smoke.error = errorForReport(error);
371
+ throw error;
372
+ } finally {
373
+ server.stop();
374
+ }
375
+ }
376
+
377
+ async function smokeProgrammableTcp(report, config) {
378
+ const server = new UringTcpServer({
379
+ host: config.bindHost,
380
+ port: 0,
381
+ bufferCount: 256,
382
+ bufferSize: 4096,
383
+ useZeroCopyReceive: true,
384
+ zcrxInterfaceName: config.interfaceName,
385
+ zcrxRxQueue: config.rxQueue,
386
+ zcrxRxBufferSize: config.rxBufferSize
387
+ });
388
+ const info = server.start((event) => {
389
+ if (event.eventType === 'data') {
390
+ assert.equal(event.data.toString('utf8'), 'zcrx programmable');
391
+ server.sendAndClose(event.connectionId, Buffer.from('zcrx programmable ok'));
392
+ }
393
+ });
394
+ assert.equal(info.zeroCopyReceive, true);
395
+ assert.equal(info.zcrxReady, true);
396
+ assert.ok(info.zcrxRxBufferSize >= 512);
397
+ const smoke = {
398
+ name: 'programmable-tcp',
399
+ status: 'running',
400
+ startInfo: info,
401
+ finalInfo: null,
402
+ response: null,
403
+ error: null
404
+ };
405
+ report.smokes.push(smoke);
406
+ try {
407
+ const response = await tcpRoundTrip(info.port, 'zcrx programmable', config);
408
+ assert.equal(response, 'zcrx programmable ok');
409
+ smoke.response = response;
410
+ assertZcrxTransportStats(smoke, server.info(), Buffer.byteLength('zcrx programmable'));
411
+ smoke.status = 'passed';
412
+ } catch (error) {
413
+ smoke.status = 'failed';
414
+ smoke.error = errorForReport(error);
415
+ throw error;
416
+ } finally {
417
+ server.stop();
418
+ }
419
+ }
420
+
421
+ function runQueueStatsParserSelfTest() {
422
+ const stats = parseEthtoolStats(`
423
+ NIC statistics:
424
+ rx_queue_0_packets: 10
425
+ rx_queue_0_bytes: 800
426
+ rx_queue_0_drops: 1
427
+ rx_queue_1_packets: 99
428
+ tx_queue_0_packets: 44
429
+ rxq0_pkts: 7
430
+ `);
431
+ assert.equal(stats.get('rx_queue_0_packets'), 10n);
432
+ assert.equal(isSelectedRxQueueCounter('rx_queue_0_packets', 0), true);
433
+ assert.equal(isSelectedRxQueueCounter('rx_queue_0_bytes', 0), true);
434
+ assert.equal(isSelectedRxQueueCounter('rxq0_pkts', 0), true);
435
+ assert.equal(isSelectedRxQueueCounter('rx_queue_0_drops', 0), false);
436
+ assert.equal(isSelectedRxQueueCounter('rx_queue_1_packets', 0), false);
437
+ assert.equal(isSelectedRxQueueCounter('tx_queue_0_packets', 0), false);
438
+ const before = { counters: new Map([['rx_queue_0_packets', 10n], ['rx_queue_0_bytes', 800n]]) };
439
+ const after = { counters: new Map([['rx_queue_0_packets', 12n], ['rx_queue_0_bytes', 936n]]) };
440
+ const deltas = diffCounters(before, after);
441
+ assert.deepEqual(deltas, [
442
+ { name: 'rx_queue_0_packets', delta: 2n },
443
+ { name: 'rx_queue_0_bytes', delta: 136n }
444
+ ]);
445
+ assert.deepEqual(countersForReport({ available: true, reason: null, counters: stats }).counters, {
446
+ rx_queue_0_packets: '10',
447
+ rx_queue_0_bytes: '800',
448
+ rx_queue_0_drops: '1',
449
+ rx_queue_1_packets: '99',
450
+ tx_queue_0_packets: '44',
451
+ rxq0_pkts: '7'
452
+ });
453
+ assert.deepEqual(deltasForReport(deltas), [
454
+ { name: 'rx_queue_0_packets', delta: '2' },
455
+ { name: 'rx_queue_0_bytes', delta: '136' }
456
+ ]);
457
+ }
458
+
459
+ function writeReport(report, reportPath) {
460
+ if (!reportPath) return;
461
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
462
+ fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
463
+ }
464
+
465
+ function errorForReport(error) {
466
+ return {
467
+ name: error && error.name ? error.name : 'Error',
468
+ message: error && error.message ? error.message : String(error),
469
+ stack: error && error.stack ? error.stack : undefined
470
+ };
471
+ }
472
+
473
+ module.exports = {
474
+ runQueueStatsParserSelfTest,
475
+ runZcrxHardwareSmoke
476
+ };