A node before the exec RPC bounds fix (every 0.3.17 node) dies when a method that resolves a block number or indexes the record vector is asked while its exec follower holds no record; PC 1 crash-looped on two callers in one night (eth_getBlockByNumber from the clock sample, then igneum_getAssignedShards from the prover loop: 'panicked at igneum/exec/src/rpc.rs:808:35: range start index 1 out of range for slice of length 0'). Every caller (prover.rs's evm_rpc, update.rs's clock sample, extnode's rpc for chainfacts and the external-node probe) now goes through execrpc::call: SAFE_ON_EMPTY methods go out, GATED ones wait for igneum_getExecStatus's executedTipHash, an unclassified method is refused. The test every_caller_goes_through_the_gate scans src/ for JSON-RPC requests built elsewhere and for unclassified exec method names. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
242 lines
14 KiB
Rust
242 lines
14 KiB
Rust
//! Another node on this machine (publish 2's read-back, 6 October 2026): the hand node on the Mac held 26610/26611,
|
|
//! the app's default RPC and p2p ports, the app read its state as its own and the digest field read empty. Now the
|
|
//! collision is loud on every surface and checked: the other node's chain id (eth_chainId on the EVM port) and its
|
|
//! consensus overrides (`igneum_getNodeInfo`, the node lane, 7 October 2026; "method not found" on an older node) against the
|
|
//! override file the app would start its own node with. A match: "Another node holds port N on this machine; using
|
|
//! it". A mismatch: "Node not started: port N is taken" and the app does not read that node. Unknown (an older node
|
|
//! that cannot answer): used, and said so. The same RPC gives the digest, so api/state carries it for an external node
|
|
//! too, read every 30 s instead of parsed from a stdout the app does not own.
|
|
use serde_json::{json, Value};
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
/// What the other node answered: None where it could not answer.
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct Check {
|
|
pub chain_id: Option<u64>,
|
|
pub digest: Option<String>,
|
|
pub network: Option<String>,
|
|
pub version: Option<String>,
|
|
/// the node's consensus params as it resolved them (`params` in igneum_getNodeInfo, every field by its override-file name)
|
|
pub overrides: Option<Value>,
|
|
/// "igneum-pow" or "stub" (ledger N5: a node built without the mining engine refuses every real block)
|
|
pub pow_engine: Option<String>,
|
|
/// the network's compiled merge depth in blue score (`blockrate.mergeDepth`, the node lane's addition of 7 October 2026);
|
|
/// None on a node without the object, and the app assumes 3,600 (1 bps)
|
|
pub merge_depth: Option<u64>,
|
|
}
|
|
|
|
/// The decision for the port-collision path.
|
|
#[derive(Debug, PartialEq)]
|
|
pub struct Decision {
|
|
pub use_it: bool,
|
|
/// match | mismatch | unknown
|
|
pub verdict: &'static str,
|
|
/// the event line, in a user's words
|
|
pub line: String,
|
|
}
|
|
|
|
/// Compare the other node with ours: `ours` is the override file the app would start its own node with (the
|
|
/// `*_activation_daa` and friends), `our_chain` the chain id this app's network uses when known.
|
|
pub fn decide(port: u16, ours: Option<&Value>, our_chain: Option<u64>, theirs: &Check) -> Decision { decide_on(port, ours, our_chain, None, theirs) }
|
|
|
|
/// `decide` with the network name too (devnet | simnet | testnet): the node lane's rule is that `network` must be equal.
|
|
pub fn decide_on(port: u16, ours: Option<&Value>, our_chain: Option<u64>, our_network: Option<&str>, theirs: &Check) -> Decision {
|
|
let mut checked = false;
|
|
if let (Some(a), Some(b)) = (our_network.filter(|s| !s.is_empty()), theirs.network.as_deref().filter(|s| !s.is_empty())) {
|
|
checked = true;
|
|
if a != b {
|
|
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network ({b}, ours {a})") };
|
|
}
|
|
}
|
|
if let (Some(a), Some(b)) = (our_chain, theirs.chain_id) {
|
|
checked = true;
|
|
if a != b {
|
|
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network (chain id {b}, ours {a})") };
|
|
}
|
|
}
|
|
if theirs.pow_engine.as_deref() == Some("stub") {
|
|
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node built without the mining engine (stub), which refuses every real block") };
|
|
}
|
|
// every key the manifest sets, against the node's resolved params (the node lane: compare values, never recompute the hash)
|
|
if let (Some(o), Some(t)) = (ours.and_then(|v| v.as_object()), theirs.overrides.as_ref().and_then(|v| v.as_object())) {
|
|
checked = true;
|
|
for (k, v) in o {
|
|
if t.get(k) != Some(v) {
|
|
let theirs_v = t.get(k).map(|x| x.to_string()).unwrap_or_else(|| "none".into());
|
|
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on other rules ({k} {theirs_v}, ours {v})") };
|
|
}
|
|
}
|
|
}
|
|
if checked {
|
|
Decision { use_it: true, verdict: "match", line: format!("Another node holds port {port} on this machine; using it (same network and rules; it is not stopped by this app)") }
|
|
} else {
|
|
Decision { use_it: true, verdict: "unknown", line: format!("Another node holds port {port} on this machine; using it. Its rules could not be checked (an older node that lacks igneum_getNodeInfo), so the app reads it as it is") }
|
|
}
|
|
}
|
|
|
|
/// How long the app waits after another node leaves its ports before starting its own (the Mac, 7 October 2026: the
|
|
/// hand node left at 00:18 UK and the app sat on "node stopped" all night, since the mode was decided once at launch).
|
|
pub const TAKEOVER_WAIT_S: f64 = 60.0;
|
|
|
|
/// What the app does about ports another node held, re-checked while attached (external) or refused (none).
|
|
#[derive(Debug, PartialEq)]
|
|
pub enum Step {
|
|
/// the other node still answers on the port
|
|
Stay,
|
|
/// the port has been closed for `for_s` seconds; the app waits out TAKEOVER_WAIT_S in case it comes back
|
|
Gone { for_s: f64 },
|
|
/// the port stayed closed for TAKEOVER_WAIT_S: start our own node on the freed ports
|
|
TakeOver,
|
|
}
|
|
|
|
/// `port_open` is whether the other node's RPC port answers now, `gone_since` the app's memory of when it stopped
|
|
/// answering (None while it answers). A node that comes back inside the wait is kept; the wait starts over.
|
|
pub fn step(port_open: bool, now_s: f64, gone_since: &mut Option<f64>) -> Step {
|
|
if port_open {
|
|
*gone_since = None;
|
|
return Step::Stay;
|
|
}
|
|
let since = *gone_since.get_or_insert(now_s);
|
|
let for_s = now_s - since;
|
|
if for_s >= TAKEOVER_WAIT_S {
|
|
*gone_since = None;
|
|
Step::TakeOver
|
|
} else {
|
|
Step::Gone { for_s }
|
|
}
|
|
}
|
|
|
|
/// One JSON-RPC call to the node's EVM port through curl (the engine carries no HTTP client; update.rs does the same).
|
|
pub fn rpc(evm_port: u16, method: &str, params: Value, limit: Duration) -> Option<Value> {
|
|
// one path (ledger N7): execrpc holds a records-indexing method until the node's exec follower has a record
|
|
crate::execrpc::call(evm_port, method, params, limit).ok().filter(|r| !r.is_null())
|
|
}
|
|
|
|
/// The other node's answers, with what each method gives: eth_chainId (every node), igneum_getNodeInfo (newer nodes).
|
|
pub fn probe(evm_port: u16) -> Check {
|
|
let mut c = Check::default();
|
|
if let Some(Value::String(h)) = rpc(evm_port, "eth_chainId", json!([]), Duration::from_secs(4)) {
|
|
c.chain_id = u64::from_str_radix(h.trim_start_matches("0x"), 16).ok();
|
|
}
|
|
if let Some(info) = rpc(evm_port, "igneum_getNodeInfo", json!([]), Duration::from_secs(4)) {
|
|
c.digest = info.get("digest").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
c.network = info.get("network").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
c.version = info.get("version").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
c.overrides = info.get("params").cloned().filter(|v| v.is_object());
|
|
c.pow_engine = info.get("powEngine").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
c.merge_depth = info.get("blockrate").and_then(|b| b.get("mergeDepth")).and_then(|v| v.as_u64());
|
|
}
|
|
c
|
|
}
|
|
|
|
/// Keep the newest `keep` files named `<prefix>...` in `dir`; the rest go (the node and miner logs grow one per start).
|
|
pub fn prune_logs(dir: &Path, prefix: &str, keep: usize) -> usize {
|
|
let Ok(rd) = std::fs::read_dir(dir) else { return 0 };
|
|
let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = rd
|
|
.flatten()
|
|
.filter(|e| e.file_name().to_string_lossy().starts_with(prefix) && e.path().extension().map(|x| x == "log").unwrap_or(false))
|
|
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (t, e.path())))
|
|
.collect();
|
|
files.sort_by(|a, b| b.0.cmp(&a.0));
|
|
let mut removed = 0;
|
|
for (_, p) in files.into_iter().skip(keep) {
|
|
if std::fs::remove_file(&p).is_ok() {
|
|
removed += 1;
|
|
}
|
|
}
|
|
removed
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn ours() -> Value { json!({ "difficulty_v2_activation_daa": 33000, "fees_v1_activation_daa": 210000, "exec_restart_number": 27276, "exec_restart_hash": "bb45cf0d" }) }
|
|
|
|
/// The Mac, 6 October 2026: the hand node held the ports; the app said nothing and read it as its own.
|
|
#[test]
|
|
fn a_node_on_our_rules_is_used_and_said_so() {
|
|
let theirs = Check { chain_id: Some(7_777), overrides: Some(ours()), digest: Some("1f4b".into()), ..Default::default() };
|
|
let d = decide(26611, Some(&ours()), Some(7_777), &theirs);
|
|
assert!(d.use_it);
|
|
assert_eq!(d.verdict, "match");
|
|
assert_eq!(d.line, "Another node holds port 26611 on this machine; using it (same network and rules; it is not stopped by this app)");
|
|
}
|
|
|
|
#[test]
|
|
fn a_node_on_another_network_or_other_rules_is_refused() {
|
|
let other_chain = Check { chain_id: Some(7_778), ..Default::default() };
|
|
let d = decide(26611, Some(&ours()), Some(7_777), &other_chain);
|
|
assert!(!d.use_it);
|
|
assert_eq!(d.verdict, "mismatch");
|
|
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)");
|
|
let mut theirs = ours(); theirs["fees_v1_activation_daa"] = json!(200000);
|
|
let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(theirs), ..Default::default() });
|
|
assert!(!d.use_it);
|
|
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)");
|
|
// the merge depth comes from blockrate.mergeDepth (compiled per network), never from params
|
|
assert_eq!(Check { merge_depth: Some(36_000), ..Default::default() }.merge_depth, Some(36_000));
|
|
// a stub engine is refused whatever else matches
|
|
assert!(!decide(26611, Some(&ours()), None, &Check { overrides: Some(ours()), pow_engine: Some("stub".into()), ..Default::default() }).use_it);
|
|
// an override we have that the other node lacks is a mismatch too
|
|
let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(json!({ "difficulty_v2_activation_daa": 33000 })), ..Default::default() });
|
|
assert!(!d.use_it);
|
|
assert!(d.line.contains("exec_restart_hash none"), "{}", d.line);
|
|
// the network name (node lane, igneum_getNodeInfo.network) must be equal
|
|
let d = decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("devnet".into()), overrides: Some(ours()), ..Default::default() });
|
|
assert!(!d.use_it);
|
|
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (devnet, ours testnet)");
|
|
assert!(decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("testnet".into()), overrides: Some(ours()), ..Default::default() }).use_it);
|
|
}
|
|
|
|
#[test]
|
|
fn an_older_node_that_cannot_answer_is_used_and_marked_unknown() {
|
|
let d = decide(26611, Some(&ours()), None, &Check::default());
|
|
assert!(d.use_it);
|
|
assert_eq!(d.verdict, "unknown");
|
|
assert!(d.line.starts_with("Another node holds port 26611 on this machine; using it. Its rules could not be checked"));
|
|
// a chain id alone, matching, is a check
|
|
assert_eq!(decide(26611, None, Some(1), &Check { chain_id: Some(1), ..Default::default() }).verdict, "match");
|
|
}
|
|
|
|
/// The Mac, 7 October 2026 00:18 UK: the hand node left and the app never started its own (today's app never recovers).
|
|
#[test]
|
|
fn an_external_node_that_goes_away_hands_the_ports_to_the_app_after_the_wait() {
|
|
let mut gone = None;
|
|
assert_eq!(step(true, 0.0, &mut gone), Step::Stay);
|
|
assert_eq!(gone, None);
|
|
assert_eq!(step(false, 100.0, &mut gone), Step::Gone { for_s: 0.0 });
|
|
assert_eq!(step(false, 130.0, &mut gone), Step::Gone { for_s: 30.0 });
|
|
assert_eq!(step(false, 160.0, &mut gone), Step::TakeOver);
|
|
assert_eq!(gone, None, "the memory resets once the app has its own node");
|
|
// a node that comes back inside the wait is kept, and the wait starts over
|
|
let mut gone = None;
|
|
assert_eq!(step(false, 0.0, &mut gone), Step::Gone { for_s: 0.0 });
|
|
assert_eq!(step(true, 30.0, &mut gone), Step::Stay);
|
|
assert_eq!(step(false, 40.0, &mut gone), Step::Gone { for_s: 0.0 });
|
|
assert_eq!(step(false, 99.0, &mut gone), Step::Gone { for_s: 59.0 });
|
|
assert_eq!(step(false, 100.0, &mut gone), Step::TakeOver);
|
|
}
|
|
|
|
#[test]
|
|
fn prune_keeps_the_newest_logs() {
|
|
let dir = std::env::temp_dir().join(format!("igneum-prune-test-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
for i in 0..6 {
|
|
let p = dir.join(format!("node-2026100{i}.log"));
|
|
std::fs::write(&p, "x").unwrap();
|
|
let t = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000 + i * 60);
|
|
let f = std::fs::File::options().write(true).open(&p).unwrap();
|
|
f.set_modified(t).unwrap();
|
|
}
|
|
std::fs::write(dir.join("miner-1.log"), "y").unwrap();
|
|
std::fs::write(dir.join("node-notes.txt"), "z").unwrap();
|
|
assert_eq!(prune_logs(&dir, "node-", 4), 2);
|
|
let mut left: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
|
left.sort();
|
|
assert_eq!(left, vec!["miner-1.log", "node-20261002.log", "node-20261003.log", "node-20261004.log", "node-20261005.log", "node-notes.txt"]);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|