igneum/tools/harness/lib/net.mjs
igneum-labs af8ae429f8 Harness: port and data-dir overrides, per-load RSS deltas, cache-build counts; bench-log entry for ledger M30
tools/harness: IGNEUM_HARNESS_BASE_PORT and IGNEUM_HARNESS_TMP move the test
network's ports and data directory so two agents can run it at once; the u64
sentinel round-trip in overrideParams is fixed with the BigInt reviver from
tools/finality-attacks (ledger F25); s6 records rss_start, rss_delta and
cache_builds per load and reports per-load growth (the old row subtracted one
baseline taken before all three loads, which is how the mempool flood was
read as +270 MB); s7 counts "PoW cache built" lines beside every RSS sample;
--live-only skips the s7 simulator part.

docs/bench-log.md: the 4 October 2026 (night) entry: the floods' growth was
one 256 MiB PoW cache per epoch roll (the engine kept a cache per (epoch,
day) pair, KEEP 4), measured before and after the fork fix (fork branch
fud-memory, 796f758d): submit load +263/+257 MB with 1/1 builds before,
+9/+2 MB with 0/0 after; block flood 302 to 1,085 MB with 3/3 builds before,
300 to 315 MB with 0/0 after. Result JSON under
docs/benchmarks/memory-floods-2026-10-04/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 00:38:33 +00:00

163 lines
9.3 KiB
JavaScript

// Private test network of igneumd processes on 127.0.0.1, ports 27200 and up, data under /tmp/igneum-harness.
// Nothing here touches the live devnet (26610/26611, 26640/26641, 28640) or any port below 27200.
// IGNEUM_HARNESS_BASE_PORT and IGNEUM_HARNESS_TMP move the ports and the data directory, so two agents can run the
// harness at the same time (4 October 2026: 29500+ and a private directory for the memory-flood re-run).
//
// Topology is explicit: a node with `connect: [...]` dials only those addresses and accepts no inbound
// connections (kaspad/src/daemon.rs: connect_peers sets outbound target and inbound limit to 0); a node without
// `connect` listens and dials nothing (--outpeers=0, --nodnsseed). Loopback addresses are never gossiped
// (components/addressmanager/src/lib.rs add_address skips loopback), so no link forms that the scenario did not ask for.
// A cross-group link can run through a Proxy, which the scenario cuts and heals.
import { spawn } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { createServer, connect as tcpConnect } from 'node:net';
import { execSync } from 'node:child_process';
import { connectRpc } from './rpc.mjs';
export const ROOT = new URL('../../../', import.meta.url).pathname;
// --fast-time (or IGNEUM_FAST_TIME=1): every clock-like consensus parameter divided by 60 (infra/fast-time/README.md).
// The 60x file carries the PoW schedule fields, which only the devnet-v4 node and later know, so the binaries then
// default to the integration build of that line (vendor/igneum-node/target-integration) instead of the harness worktree.
export const FAST_TIME = process.argv.includes('--fast-time') || process.env.IGNEUM_FAST_TIME === '1';
export const FAST_TIME_FILE = `${ROOT}infra/fast-time/override-60x.json`;
export const WORKTREE = `${ROOT}vendor/igneum-node-harness`;
export const TARGET = process.env.IGNEUM_HARNESS_TARGET || (FAST_TIME ? `${ROOT}vendor/igneum-node/target-integration/release` : `${WORKTREE}/target/release`);
export const IGNEUMD = process.env.IGNEUMD || `${TARGET}/igneumd`;
export const PROBE = process.env.IGNEUM_P2P_PROBE || `${TARGET}/igneum-p2p-probe`;
export const SIM = process.env.IGNEUM_HARNESS_SIM || `${TARGET}/igneum-harness-sim`;
export const TMP = process.env.IGNEUM_HARNESS_TMP || '/tmp/igneum-harness';
export const BASE_PORT = parseInt(process.env.IGNEUM_HARNESS_BASE_PORT || '27200', 10);
if (!Number.isInteger(BASE_PORT) || BASE_PORT < 27200 || BASE_PORT > 64000) throw new Error(`IGNEUM_HARNESS_BASE_PORT ${process.env.IGNEUM_HARNESS_BASE_PORT} is not a port in 27200..64000`);
// u64::MAX ("never" for the height switches) is not a JavaScript number: keep it as a BigInt through the merge and
// write it back as the integer literal the node's parser wants (ledger F25; the same reviver as
// tools/finality-attacks/lib/net.mjs).
const bigReviver = (k, v, ctx) => (typeof v === 'number' && !Number.isSafeInteger(v) && ctx?.source ? BigInt(ctx.source) : v);
const bigStringify = (o) => JSON.stringify(o, (k, v) => (typeof v === 'bigint' ? `BIGINT:${v}` : v)).replace(/"BIGINT:(\d+)"/g, '$1');
export function readParamsFile(file) { return JSON.parse(readFileSync(file, 'utf8'), bigReviver); }
const started = []; // everything to stop at exit
export const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
export const sleep = (ms) => new Promise(r => setTimeout(r, ms));
export function overrideParams(extra = {}) {
mkdirSync(TMP, { recursive: true });
const file = `${TMP}/override-params.json`;
// skip_proof_of_work: the harness miner never hashes; every other rule (timestamps, DAA, GHOSTDAG, merge
// depth, mass, coinbase) runs unchanged. Devnet parameters otherwise (1 BPS, k 18, merge depth 3,600), or the
// 60x fast-time profile under --fast-time (merge depth 60, finality window 120 DAA, 60-block epochs).
const base = FAST_TIME ? readParamsFile(FAST_TIME_FILE) : {};
writeFileSync(file, bigStringify({ ...base, skip_proof_of_work: true, ...extra }));
return file;
}
// Consensus clock parameters the scenarios scale their durations by: merge depth in DAA seconds (3,600 on the
// devnet, 60 under --fast-time). A scenario cut "beyond merge depth" is mergeDepth + 100 s on the devnet and
// mergeDepth + 2 s at 60x.
export function clockParams() {
const o = FAST_TIME ? readParamsFile(FAST_TIME_FILE) : {};
const mergeDepth = Number(o.blockrate?.merge_depth ?? 3600);
return { fastTime: FAST_TIME, mergeDepth, scale: 3600 / mergeDepth };
}
export class Node {
constructor(index, { connect = [], extraArgs = [], name } = {}) {
this.index = index;
this.name = name || `n${index}`;
this.p2pPort = BASE_PORT + index * 10 + 1;
this.grpcPort = BASE_PORT + index * 10;
this.jsonPort = BASE_PORT + index * 10 + 2;
this.connect = connect; this.extraArgs = extraArgs;
this.dir = `${TMP}/${this.name}`;
this.logFile = `${this.dir}/node.log`;
this.proc = null; this.rpc = null; this.exited = null;
}
get p2p() { return `127.0.0.1:${this.p2pPort}`; }
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
get json() { return `ws://127.0.0.1:${this.jsonPort}`; }
args() {
const a = ['--devnet', '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`,
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${overrideParams()}`, '--loglevel=info', '--yes'];
if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0');
return a.concat(this.extraArgs);
}
async start() {
rmSync(this.dir, { recursive: true, force: true });
mkdirSync(this.dir, { recursive: true });
const out = (await import('node:fs')).openSync(this.logFile, 'a');
this.proc = spawn(IGNEUMD, this.args(), { stdio: ['ignore', out, out], env: { ...process.env, IGNEUMD_APPDIR: this.dir } });
this.exited = null;
this.proc.on('exit', (code, sig) => { this.exited = { code, sig, at: Date.now() }; });
started.push(this);
await sleep(800);
this.rpc = await connectRpc(this.json);
log(`${this.name} up pid ${this.proc.pid} p2p ${this.p2p} json ${this.json}`);
return this;
}
alive() { return this.proc && this.exited === null && !this.proc.killed; }
rssMb() {
if (!this.alive()) return null;
try { return Math.round(parseInt(execSync(`ps -o rss= -p ${this.proc.pid}`).toString().trim(), 10) / 1024); } catch { return null; }
}
async stop() {
if (this.rpc) { this.rpc.close(); this.rpc = null; }
if (this.proc && this.exited === null) {
this.proc.kill('SIGINT');
for (let i = 0; i < 100 && this.exited === null; i++) await sleep(100);
if (this.exited === null) this.proc.kill('SIGKILL');
}
}
logTail(n = 30) { try { return readFileSync(this.logFile, 'utf8').split('\n').slice(-n).join('\n'); } catch { return ''; } }
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
}
/// A TCP proxy standing in for one network link. `cut()` drops every connection and refuses new ones.
export class Proxy {
constructor(index, targetPort) {
this.port = BASE_PORT + 900 + index; this.targetPort = targetPort; this.open = true; this.socks = new Set(); this.server = null;
}
get addr() { return `127.0.0.1:${this.port}`; }
start() {
return new Promise((resolve) => {
this.server = createServer((client) => {
if (!this.open) { client.destroy(); return; }
const up = tcpConnect(this.targetPort, '127.0.0.1');
this.socks.add(client); this.socks.add(up);
client.pipe(up); up.pipe(client);
const bye = () => { client.destroy(); up.destroy(); this.socks.delete(client); this.socks.delete(up); };
client.on('error', bye); up.on('error', bye); client.on('close', bye); up.on('close', bye);
});
this.server.listen(this.port, '127.0.0.1', () => { started.push(this); resolve(this); });
});
}
cut() { this.open = false; for (const s of this.socks) s.destroy(); this.socks.clear(); }
heal() { this.open = true; }
async stop() { this.cut(); await new Promise(r => this.server ? this.server.close(() => r()) : r()); }
}
export async function stopAll() {
for (const s of started.splice(0).reverse()) { try { await s.stop(); } catch { } }
}
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
process.on('SIGTERM', async () => { await stopAll(); process.exit(143); });
export function assertBinaries() {
for (const b of [IGNEUMD]) if (!existsSync(b)) throw new Error(`missing ${b}; build the harness worktree first (see tools/harness/README.md)`);
}
export async function dagInfo(node) { return node.rpc.call('getBlockDagInfo'); }
export async function peers(node) { const r = await node.rpc.call('getConnectedPeerInfo'); return r.peerInfo || r.peer_info || []; }
/// Waits until every node's sink (the first virtual parent) is the same, or the timeout passes. Returns ms waited.
export async function waitSameSink(nodes, timeoutMs) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
const sinks = await Promise.all(nodes.map(async n => (await dagInfo(n)).sink));
if (sinks.every(s => s === sinks[0])) return Date.now() - t0;
await sleep(500);
}
return null;
}