diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index f0cb5916..697083d9 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -48,6 +48,10 @@ pub enum Cmd { WorkerBuilt(usize, Result), /// local minus the latest block's timestamp, seconds (from the node's EVM RPC) ClockSample(f64), + /// another node holds the app's ports: what it answered (src/extnode.rs) + ExternalNode(crate::extnode::Check), + /// the node's own description over RPC (igneum_getNodeInfo), every 30 s + NodeInfo(crate::extnode::Check), /// local minus an HTTPS Date header, seconds; None when the check failed ClockHttps(Option), ClockCheck, @@ -535,6 +539,7 @@ pub struct Engine { lines_tx: Sender, node: Option, node_external: bool, + node_info_at: Instant, node_restart_at: Option, /// resume rule (5 October 2026): when due, every enabled card must be mining or its name goes to the log resume_check_at: Option, @@ -654,6 +659,7 @@ impl Engine { lines_tx, node: None, node_external: false, + node_info_at: Instant::now() - Duration::from_secs(25), node_restart_at: None, resume_check_at: None, node_started_at: now, @@ -942,6 +948,15 @@ impl Engine { } } } + Cmd::ExternalNode(check) => self.external_node_decided(check), + Cmd::NodeInfo(info) => { + let mut st = self.st(); + if let Some(d) = info.digest.filter(|d| d.len() >= 16) { + if st.node.consensus_digest != d { st.node.consensus_digest = d; } + st.node.digest_source = "rpc".into(); + } + if let Some(v) = info.version { if !v.is_empty() && st.node.version.is_empty() { st.node.version = v; } } + } Cmd::ClockSample(d) => { self.clock_samples.push(d); if self.clock_samples.len() > 9 { @@ -1382,14 +1397,22 @@ impl Engine { let _ = std::fs::create_dir_all(&self.shared.runtime.log_dir); self.apply_power_limits("start"); if port_open(self.shared.runtime.rpc_port) { - self.node_external = true; - self.shared.event("info", &format!("a node already answers on 127.0.0.1:{}; using it (it is not stopped by this app)", self.shared.runtime.rpc_port)); - let mut st = self.st(); - st.node.state = "syncing".into(); - st.node.message = "external node".into(); - st.proving.verifier_reason = "external node: the app did not start it, so it set no verifier".into(); - st.proving.verifier_note = crate::verifier::note("unknown", "", "", true); + // another node holds the app's ports: check whose rules it runs before reading it (publish 2's read-back, + // 6 October 2026: the Mac read the hand node silently as its own); the answer comes back as Cmd::ExternalNode + let port = self.shared.runtime.rpc_port; + { + let mut st = self.st(); + st.node.state = "starting".into(); + st.node.source = String::new(); + st.node.message = format!("another node holds port {port} on this machine; checking its rules"); + } + self.shared.log(&format!("port {port} is held by another node; asking it for its chain id and rules before using it")); + let shared = self.shared.clone(); + let evm_port = self.shared.runtime.evm_port(); + std::thread::spawn(move || { shared.send(Cmd::ExternalNode(crate::extnode::probe(evm_port))); }); } else { + crate::extnode::prune_logs(&self.shared.runtime.log_dir, "node-", 10); + self.st().node.source = "app".into(); self.start_node(); } self.plan_miners(); @@ -1423,6 +1446,40 @@ impl Engine { /// The packager's consensus parameters (igneum-app.json `node_override_params`, for example the difficulty v2 /// activation height) written to /override-params.json for --override-params-file. None when the /// package pins nothing, so the node runs on the network's defaults as before. + /// The check of another node on the app's ports came back (src/extnode.rs): use it and say so, or refuse it + fn external_node_decided(&mut self, check: crate::extnode::Check) { + let port = self.shared.runtime.p2p_port; + let ours = self.node_override_file().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::(&t).ok()); + let d = crate::extnode::decide(port, ours.as_ref(), None, &check); + self.node_external = d.use_it; + { + let mut st = self.st(); + st.node.rules_check = d.verdict.into(); + st.node.port_note = d.line.clone(); + } + if d.use_it { + self.shared.event(if d.verdict == "match" { "info" } else { "error" }, &d.line); + let mut st = self.st(); + st.node.source = "external".into(); + st.node.state = "syncing".into(); + st.node.message = "another node on this machine".into(); + if let Some(dg) = check.digest.filter(|x| x.len() >= 16) { st.node.consensus_digest = dg; st.node.digest_source = "rpc".into(); } + if let Some(v) = check.version { if !v.is_empty() { st.node.version = v; } } + st.proving.verifier_reason = "external node: the app did not start it, so it set no verifier".into(); + st.proving.verifier_note = crate::verifier::note("unknown", "", "", true); + } else { + { + let mut st = self.st(); + st.node.source = "none".into(); + st.node.state = "stopped".into(); + st.node.synced = false; + st.node.message = d.line.clone(); + } + self.shared.event("error", &d.line); + self.shared.event("error", "mining waits: stop the other node or start this app with other ports, then open the app again"); + } + } + fn node_override_file(&self) -> Option { if self.node_override_unusable { return None; @@ -2988,6 +3045,12 @@ impl Engine { self.last_awake = now; crate::platform::keep_awake_tick(); } + if self.running && (self.node_external || self.node.is_some()) && now.duration_since(self.node_info_at) >= Duration::from_secs(30) { + self.node_info_at = now; + let shared = self.shared.clone(); + let evm_port = self.shared.runtime.evm_port(); + std::thread::spawn(move || { let c = crate::extnode::probe(evm_port); if c.digest.is_some() || c.version.is_some() { shared.send(Cmd::NodeInfo(c)); } }); + } if self.running { self.tick_node(now); self.tick_watch(now); @@ -3652,6 +3715,7 @@ impl Engine { if st.node.consensus_digest != d { st.node.consensus_digest = d; } + if st.node.digest_source.is_empty() { st.node.digest_source = "log".into(); } return; } if !text.contains("too far into the future") { diff --git a/app/igneum-app/src/extnode.rs b/app/igneum-app/src/extnode.rs new file mode 100644 index 00000000..ff96cb66 --- /dev/null +++ b/app/igneum-app/src/extnode.rs @@ -0,0 +1,171 @@ +//! 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 0.3.17 node lane; "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::process::Command; +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, + pub digest: Option, + pub network: Option, + pub version: Option, + pub overrides: Option, +} + +/// 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, theirs: &Check) -> Decision { + let mut checked = false; + 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 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 !k.ends_with("_daa") && !k.starts_with("exec_restart") { + continue; + } + 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 (it predates 0.3.17), so the app reads it as it is") } + } +} + +/// 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 { + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string(); + let out = crate::detect::run_timeout( + Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &format!("{}", limit.as_secs().max(1)), "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", &body]), + None, + limit + Duration::from_secs(2), + )?; + let v: Value = serde_json::from_str(&out).ok()?; + v.get("result").cloned().filter(|r| !r.is_null()) +} + +/// The other node's answers, with what each method gives: eth_chainId (every node), igneum_getNodeInfo (0.3.17). +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("overrides").cloned().filter(|v| v.is_object()); + } + c +} + +/// Keep the newest `keep` files named `...` 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)"); + // 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("fees_v1_activation_daa none")); + } + + #[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"); + } + + #[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 = 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); + } +} diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index b70e785a..34d1e53c 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -38,6 +38,7 @@ mod ember; mod powertask; mod watchdog; mod live; +mod extnode; use std::io::{BufRead, Write}; use std::sync::mpsc::channel; diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 8da9c2bf..b03d21af 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -24,6 +24,16 @@ pub struct NodeState { /// N4 (6 October 2026, the frozen tip): seconds since the sink block's header time, from the watch line's /// tip_age_s (the node lane, ca3-v4-0316); -1 until the node reports it. "synced" needs it at or under 120. pub tip_age_s: f64, + /// whose node this is (publish 2's read-back, 6 October 2026): "app" (started by this app), "external" (another + /// node on this machine holds the ports; used after a check), "none" (the ports are taken by a node on other rules + /// or another network, so no node is read), "" before the first start + pub source: String, + /// for an external node: match | mismatch | unknown (an older node that cannot answer the check) + pub rules_check: String, + /// the one line that says what happened at the ports, kept for the Node details + pub port_note: String, + /// where consensus_digest came from: "rpc" (igneum_getNodeInfo), "log" (the node's own stdout line), "" + pub digest_source: String, /// why the node is not "synced", in plain words ("" when synced): "behind" | "no peers" | "syncing" | "frozen" pub sync_cause: String, /// the miner's stall exits (code 45, "STALLED") since the node last started; the second one restarts the node diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 92089252..b295d58d 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -473,7 +473,7 @@ var View = (function () { case 'starting': return { word: 'starting', line: n.message || 'opening the database', tone: '' }; case 'restarting': return { word: 'restarting', line: 'restart in ' + (n.restart_in_s || 0) + ' s' + (n.message ? ' · ' + n.message : ''), tone: 'bad' }; case 'failed': return { word: 'failed', line: n.message || 'the node could not start', tone: 'bad' }; - case 'stopped': return { word: 'stopped', line: n.message || 'the node is not running', tone: 'bad' }; + case 'stopped': return { word: n.source === 'none' ? 'not started' : 'stopped', line: n.message || 'the node is not running', tone: 'bad' }; } return { word: n.state || 'starting', line: n.message || '', tone: '' }; } @@ -663,7 +663,7 @@ var View = (function () { if (n.state === 'behind') return { text: 'Node behind', tone: 'bad' }; if (n.state === 'no peers') return { text: 'No peers', tone: 'bad' }; if (n.state === 'failed') return { text: 'Node failed', tone: 'bad' }; - if (n.state === 'stopped') return { text: 'Node stopped', tone: 'bad' }; + if (n.state === 'stopped') return { text: n.source === 'none' ? 'Node not started' : 'Node stopped', tone: 'bad' }; if (m.state === 'waiting') return { text: 'Starting the worker', tone: '' }; if (m.state === 'idle') return { text: 'Not mining', tone: '' }; return { text: cap(m.state || 'starting'), tone: '' }; @@ -750,10 +750,18 @@ var View = (function () { if (src === 'site' || src === 'observer') return 'From Igneum’s observer. Your blocks are ringed.'; return ''; } + // whose node the app reads (publish 2's read-back, 6 October 2026): n.source app | external | none | "" + function nodeSource(n) { + n = n || {}; + if (n.source === 'app') return { text: 'this app’s own node', sub: n.port_note || '' }; + if (n.source === 'external') return { text: 'another node on this machine', sub: n.rules_check === 'match' ? 'same network and rules, checked' : n.rules_check === 'unknown' ? 'its rules could not be checked (it predates 0.3.17)' : (n.port_note || '') }; + if (n.source === 'none') return { text: 'no node', sub: n.port_note || 'the ports are taken by a node on other rules' }; + return { text: '', sub: '' }; + } // the confirmations' words (in place, never a dialog) function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; } function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; } - return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk }; + return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk }; })(); /* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ---------- One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned @@ -1599,6 +1607,7 @@ if (typeof document !== 'undefined') (function () { $('node-dot').className = 'dot' + (nl.tone === 'ok' ? ' live' : nl.tone === 'bad' ? ' bad' : ''); $('node-card').classList.toggle('bad', nl.tone === 'bad'); setText('n-state-v', nl.text.replace(/^Node /, '')); setText('n-state-m', nl.sub + (n.override_restart_wait ? ' ' + n.override_restart_wait + '.' : '')); + var ns = View.nodeSource(n); setText('n-source', ns.text); setText('n-source-m', ns.sub); setHidden('n-source', !ns.text); setText('n-blocks', withCommas(n.blocks)); setText('n-blocks-sub', View.heightLine(n)); setText('n-peers', String(n.peers)); setText('n-peers-sub', View.peersLine(n)); setText('n-version', n.version ? n.version.replace(/^igneumd\s*/, '') : '--'); diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index de7d6d61..5bd14791 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -204,6 +204,7 @@