igneum/infra/fast-time/peer-drop.mjs
igneum-labs 3b1ab0fd20 0.3.18 (node): the relay ceiling gone at the document's own case (ledger N3 closed), the N4 node half's gate, the stale-block nuisance guard (ledger N6) with its gate on both halves, the 30-s controller finding named as owed; the harnesses for all of them
7.2: the pipelined relay (fork bb397f99) at 10 bps and a 3-s propagation delay holds 10.01 blocks/s with the
difficulty 7.7 percent over the hash-implied value, the hub taking 7,746 blocks via relay against 4,015 via submit;
the same run at a batch of 1 (the 0.3.17 flow) takes 445 against 801 and reads 0.351. N3 closed in the ledger,
network.md 5.11 and CLAUDE.md's gate line. The 1 bps, 30-s case did not merge on either flow (3 blocks via relay in
40 minutes, no IBD, no error, one reconnect per spoke): at a 30-s delay the blocks in flight exceed the k bound, and
its cause is owed its own reading (7.3).

7.7: the N4 node half's gate (peer-drop.mjs): a still sink dropped its silent peer at 720 s and re-dialled at 750 s;
a moving sink kept the same peer through 155 sinks; the harness's failed shape fails.

7.8 and ledger N6: the stale-block nuisance guard (fork 319da644, f2b25fdf, a22cb261), the shipper's record (310 to
551 connections in an hour from four stale datadirs), the two halves and the knob; the gate (nuisance-peer.mjs, a
node mining stub-engine blocks with proof of work off and restarting on the shared rule with its datadir): the
sender half flat with the skip line, the known-failed shape climbing (3 connections, 2 refusals in 300 s), its
mirror failing, the receiver half flat with one refusal and the ten-minute ban. Also recorded: the vote-or-burn
harness's devnet finality object (--finality devnet) for the replay running to about 05:00Z.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 02:45:01 +00:00

145 lines
10 KiB
JavaScript

#!/usr/bin/env node
// Ledger N4, the node half (fork 96619b7d): a peer that delivered no block for PEER_STALL (600 s) while our own sink stood
// still over the same span is dropped by the ping loop, so the connection manager dials another. The record: a
// desktop node kept one same-digest peer for 53 minutes that relayed nothing, its tip frozen the whole time.
//
// --case still node A with NO miner (its sink stands still at genesis), one peer B (addpeer) that mines nothing and
// relays nothing. PASS = A's log carries `P2P, peer ...: no block from this peer for N s while our sink
// stood at ... (ledger N4); dropping it to dial another peer` within PEER_STALL + one ping interval
// (600 + 120 s, read with 60 s of margin: 780 s), and A's connection count drops and comes back (the
// addpeer is re-dialled). `--expect drop`.
// --case moving node A WITH a CPU miner (its sink moves), the same silent peer B, watched for the same 780 s.
// PASS = no drop line, the peer stays connected throughout. `--expect quiet`. `--case moving --expect drop`
// is the harness's own failed shape and must FAIL.
//
// node infra/fast-time/peer-drop.mjs --case still|moving [--expect drop|quiet] [--secs 780]
// IGNEUMD, IGNEUM_MINER name the binaries (defaults: vendor/igneum-node-0316/target-0316/release).
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
const ROOT = new URL('../../', import.meta.url).pathname;
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
const BIN = process.env.IGNEUM_0316_BIN || `${ROOT}vendor/igneum-node-0316/target-0316/release`;
const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`;
const CPU_MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`;
const TMP = '/tmp/igneum-fast-time-pdrop';
const BASE = 30390, SUFFIX = 993; // 30390 to 30402: clear of every other fast-time harness
const NEVER = '18446744073709551615';
const args = process.argv.slice(2);
const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? Number(args[i + 1]) : dflt; };
const sflag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
const CASE = sflag('case') || 'still';
const EXPECT = sflag('expect') || (CASE === 'still' ? 'drop' : 'quiet');
const SECS = flag('secs', 780);
const PEER_STALL = 600, PING_INTERVAL = 120;
const OUT = sflag('out') || `${ROOT}docs/plans/counter-asic-3-gate/peer-drop-${CASE}-expect-${EXPECT}.json`;
if (!['still', 'moving'].includes(CASE) || !['drop', 'quiet'].includes(EXPECT)) { console.error('usage: --case still|moving [--expect drop|quiet]'); process.exit(2); }
const started = [];
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
for (const b of [IGNEUMD, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
try { spawnSync('pkill', ['-f', `devnet-suffix=${SUFFIX}`]); } catch { }
await sleep(1500);
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
const baseText = readFileSync(FILE, 'utf8');
const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
export function mergeOverrideText(text, fields) {
let out = text;
for (const k of Object.keys(fields)) out = out.replace(new RegExp(`\\s*"${k}":\\s*[^,}\\n]+,?`), '');
const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${typeof v === 'string' && !/^\d+$/.test(v) ? JSON.stringify(v) : v}`).join(', ');
return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`);
}
const DAY_MS = field('pow_day_ms');
const override = `${TMP}/override.json`;
writeFileSync(override, mergeOverrideText(baseText, { genesis_bits: 0x1f010000, skip_proof_of_work: false, program_class_v3_activation_daa: NEVER, program_class_v4_activation_daa: NEVER }));
log(`case ${CASE}: expect ${EXPECT}, PEER_STALL ${PEER_STALL} s, ping interval ${PING_INTERVAL} s, watch ${SECS} s`);
class Node {
constructor(i, connect = null) {
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2;
this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
}
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
async start() {
mkdirSync(this.dir, { recursive: true });
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--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=${override}`, '--loglevel=info', '--yes'];
// --addpeer: a dropped peer is dialled again, which is the re-dial the case watches for
if (this.connect) a.push(`--addpeer=127.0.0.1:${this.connect}`); else a.push('--outpeers=0');
const out = openSync(this.logFile, 'a');
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out] });
started.push(this.proc);
await sleep(1500);
if (this.proc.exitCode != null) throw new Error(`n${this.i} exited ${this.proc.exitCode}: ${this.grepLog(/ERROR|Error|error|refused|invalid/).slice(-3).join(' | ')}`);
for (let i = 0; i < 20; i++) {
try { this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`); await this.rpc.call('getBlockDagInfo'); break; } catch { this.rpc = null; await sleep(500); }
}
if (!this.rpc) throw new Error(`n${this.i}: the RPC did not answer within 10 s`);
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}${this.connect ? ` addpeer ${this.connect}` : ''}`);
return this;
}
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
async peers() { try { const r = await this.rpc.call('getConnectedPeerInfo'); const arr = Object.values(r).find(Array.isArray); return arr ? arr.length : null; } catch { return null; } }
}
function miner(name, grpc) {
const out = openSync(`${TMP}/${name}.log`, 'a');
const p = spawn(CPU_MINER, ['mine', grpc, '1', String(SECS + 60), name, '--engine', 'igneum-pow', '--payout-label', name, '--status-secs', '60', '--no-vote', '--stall-secs', '0'], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_POW_DAY_MS: String(DAY_MS) } });
started.push(p);
return p;
}
async function stopAll() {
for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } }
await sleep(1500);
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
}
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
// B first (the silent peer, no miner), then A dialling it
const b = await new Node(1).start();
const a = await new Node(0, b.p2pPort).start();
if (CASE === 'moving') miner('mover', a.grpc);
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
const samples = [];
let dropAt = null, redialAt = null, minPeers = null, lastReport = 0;
const DROP_RE = /no block from this peer for \d+ s while our sink stood at \S+ for \d+ s \(ledger N4\); dropping it/;
while (Date.now() - t0 < SECS * 1000) {
await sleep(5000);
const peers = await a.peers();
const d = await a.rpc.call('getBlockDagInfo').catch(() => null);
const drops = a.grepLog(DROP_RE);
const connects = a.grepLog(/P2P Connected to outgoing peer/).length;
if (drops.length && dropAt == null) { dropAt = +since(); log(`DROP at ${since()} s: ${drops[0].replace(/^.*?P2P, /, 'P2P, ').slice(0, 220)}`); }
if (dropAt != null && redialAt == null && connects >= 2) { redialAt = +since(); log(`re-dialled at ${since()} s (${connects} outgoing connections so far)`); }
if (peers != null) minPeers = minPeers == null ? peers : Math.min(minPeers, peers);
samples.push({ t: +since(), peers, blocks: d?.blockCount ?? null, sink: d ? String(d.sink).slice(0, 8) : null, drops: drops.length, connects });
if (Date.now() - lastReport > 60000) { lastReport = Date.now(); log(`t=${since()} s A peers ${peers} blocks ${d?.blockCount} sink ${samples.at(-1).sink} drops ${drops.length} outgoing connections ${connects}`); }
}
const drops = a.grepLog(DROP_RE);
const connects = a.grepLog(/P2P Connected to outgoing peer/).length;
const sinks = new Set(samples.map(s => s.sink).filter(Boolean));
const checks = {
drop_line_on_a: drops.length > 0,
drop_within_stall_plus_ping: dropAt != null && dropAt <= PEER_STALL + PING_INTERVAL + 60,
redialled_after_drop: redialAt != null,
sink_stood_still: sinks.size <= 1,
peer_kept_throughout: drops.length === 0 && samples.every(s => s.peers == null || s.peers >= 1),
sink_moved: sinks.size > 5,
};
const good = EXPECT === 'drop'
? checks.drop_line_on_a && checks.drop_within_stall_plus_ping && checks.redialled_after_drop
: checks.peer_kept_throughout;
const summary = { pass: good, case: CASE, expect: EXPECT, secs: SECS, peer_stall: PEER_STALL, ping_interval: PING_INTERVAL, drop_at_s: dropAt, redial_at_s: redialAt, drop_lines: drops.length, drop_example: drops[0]?.replace(/^.*?P2P, /, 'P2P, ').slice(0, 300) ?? null, outgoing_connections: connects, min_peers: minPeers, distinct_sinks: sinks.size, checks, samples, node: IGNEUMD, miner: CPU_MINER };
mkdirSync(OUT.replace(/\/[^/]+$/, ''), { recursive: true });
writeFileSync(OUT, JSON.stringify(summary, null, 2));
const judged = EXPECT === 'drop' ? ['drop_line_on_a', 'drop_within_stall_plus_ping', 'redialled_after_drop'] : ['peer_kept_throughout'];
const fails = judged.filter(k => !checks[k]);
log(`SUMMARY ${good ? 'PASS' : 'FAIL'} (case ${CASE}, expect ${EXPECT}): ${drops.length} drop lines${dropAt != null ? ` (first at ${dropAt} s)` : ''}, ${redialAt != null ? `re-dialled at ${redialAt} s` : 'no re-dial'}, ${connects} outgoing connections, min peers ${minPeers}, ${sinks.size} distinct sinks over ${SECS} s${fails.length ? `; FAILED CHECK ${fails.join(', ')}` : ''}`);
log(`summary: ${OUT}`);
await stopAll();
process.exit(good ? 0 : 1);