diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 70545e3ff..5e82582ea 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -48,6 +48,13 @@ 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), + /// the network's view for the merge check (src/merge.rs): the highest blue score the observer shows, and the unix + /// ms of the newest block it marks as ours + NetView { sink_blue: u64, ours_ts_ms: Option }, /// local minus an HTTPS Date header, seconds; None when the check failed ClockHttps(Option), ClockCheck, @@ -120,6 +127,7 @@ impl Shared { st.setup_done = settings.setup_done; st.phase = if settings.setup_done { "dashboard".into() } else { "welcome".into() }; st.node.state = "stopped".into(); + st.node.tip_age_s = -1.0; st.mining.state = "idle".into(); st.mining.paused = settings.paused; st.mining.accepted_total = settings.accepted_total; @@ -534,6 +542,18 @@ pub struct Engine { lines_tx: Sender, node: Option, node_external: bool, + /// the merge check (src/merge.rs): the network's sinks as last seen, when one of our blocks last appeared in the + /// network's view, the merge depth from the node's params, when mining with a synced node began, the last poll + merge_sinks: Vec, + merge_ours_seen: Option, + merge_depth: u64, + merge_mining_since: Option, + merge_view_at: Instant, + /// the ports are held by a node this app refused (other rules or network): re-checked, the app takes the ports when it leaves + node_refused: bool, + /// when the other node's port stopped answering (engine seconds), None while it answers + external_gone_since: Option, + 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, @@ -653,6 +673,14 @@ impl Engine { lines_tx, node: None, node_external: false, + merge_sinks: Vec::new(), + merge_ours_seen: None, + merge_depth: 0, + merge_mining_since: None, + merge_view_at: now, + node_refused: false, + external_gone_since: None, + node_info_at: Instant::now() - Duration::from_secs(25), node_restart_at: None, resume_check_at: None, node_started_at: now, @@ -941,6 +969,33 @@ impl Engine { } } } + Cmd::ExternalNode(check) => self.external_node_decided(check), + Cmd::NetView { sink_blue, ours_ts_ms } => { + self.merge_sinks = vec![sink_blue]; + if let Some(ms) = ours_ts_ms { + let age = (crate::platform::unix_now_f() - ms as f64 / 1000.0).max(0.0); + self.merge_ours_seen = Some(Instant::now() - Duration::from_secs_f64(age.min(86_400.0))); + } + } + Cmd::NodeInfo(info) => { + // the merge depth is compiled per network (not in params): blockrate.mergeDepth when the node says, else 3,600 + if let Some(d) = info.merge_depth { self.merge_depth = d; } + if info.pow_engine.as_deref() == Some("stub") { + let first = self.st().node.state != "stub"; + let mut st = self.st(); + st.node.state = "stub".into(); + st.node.synced = false; + st.node.message = "this node was built without the mining engine (stub) and refuses every real block".into(); + drop(st); + if first { self.shared.event("error", "the node runs the stub engine, not igneum-pow: it refuses every real block. Reinstall the app (ledger N5)"); } + } + 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 { @@ -1381,14 +1436,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.set_node_mode("app", "own", "the ports were free at launch, so the app started its own node"); self.start_node(); } self.plan_miners(); @@ -1422,6 +1485,74 @@ 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 network = self.shared.runtime.network.clone(); + let d = crate::extnode::decide_on(port, ours.as_ref(), None, Some(network.as_str()), &check); + self.node_external = d.use_it; + self.node_refused = !d.use_it; + self.external_gone_since = None; + self.set_node_mode(if d.use_it { "external" } else { "none" }, if d.use_it { "external" } else { "none" }, &d.line); + { + 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.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.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"); + } + } + + /// Record which node the app reads (api/state node.source, node.mode, node.mode_reason). + fn set_node_mode(&self, source: &str, mode: &str, reason: &str) { + let mut st = self.st(); + st.node.source = source.into(); + st.node.mode = mode.into(); + st.node.mode_reason = reason.into(); + } + + /// The other node left its ports for TAKEOVER_WAIT_S: the app starts its own node on them and says so. + fn take_over_ports(&mut self) { + let port = self.shared.runtime.rpc_port; + let was = if self.node_external { "the node this app was reading" } else { "the node this app refused" }; + self.node_external = false; + self.node_refused = false; + self.external_gone_since = None; + let reason = format!("{was} left port {port} for {} s, so the app started its own node", crate::extnode::TAKEOVER_WAIT_S as u64); + self.shared.event("info", &format!("The other node went away; this app starts its own node on port {port}")); + self.set_node_mode("app", "own", &reason); + { + let mut st = self.st(); + st.node.rules_check = String::new(); + st.node.port_note = reason.clone(); + st.node.synced = false; + st.node.consensus_digest = String::new(); + st.node.digest_source = String::new(); + st.node.version = String::new(); + } + self.node_last_reading = None; + crate::extnode::prune_logs(&self.shared.runtime.log_dir, "node-", 10); + self.start_node(); + } + fn node_override_file(&self) -> Option { if self.node_override_unusable { return None; @@ -1469,6 +1600,9 @@ impl Engine { let mut st = self.st(); st.node.pid = p.pid(); st.node.state = "starting".into(); + st.node.stall_exits = 0; + st.node.tip_age_s = -1.0; + st.node.sync_cause = String::new(); st.node.starts = self.node_starts; st.node.message = "opening the database".into(); st.node.restart_in_s = 0; @@ -1664,6 +1798,11 @@ impl Engine { match procs::spawn(Source::Miner(card_idx), &self.bins.miner, &args, cwd.as_deref(), &log, &self.lines_tx, &envs) { Ok(p) => { self.shared.log(&format!("miner {} started (pid {}): {}", self.miners[i].label, p.pid(), p.cmdline)); + if self.miners[i].starts == 1 { + // one Activity line per card at its first start (an OTA relaunch shows every card coming back) + let name = self.st().mining.cards.get(self.miners[i].card).map(|c| c.name.clone()).unwrap_or_else(|| self.miners[i].label.clone()); + self.shared.event("info", &format!("{name}: worker starting (pid {})", p.pid())); + } let mut st = self.st(); if let Some(c) = st.mining.cards.get_mut(card_idx) { c.pid = p.pid(); @@ -2984,6 +3123,25 @@ impl Engine { self.last_awake = now; crate::platform::keep_awake_tick(); } + if self.running && self.node.is_some() && !self.miners.is_empty() && now.duration_since(self.merge_view_at) >= Duration::from_secs(30) { + self.merge_view_at = now; + let shared = self.shared.clone(); + let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&self.shared.packaged.live_page)); + let ids: std::collections::HashSet = self.st().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect(); + if !live_api.is_empty() { + std::thread::spawn(move || { + if let Some((sink_blue, ours_ts_ms)) = crate::merge::view_of(&crate::live::fetch(&live_api, 600, &ids)) { + shared.send(Cmd::NetView { sink_blue, ours_ts_ms }); + } + }); + } + } + 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); @@ -3234,12 +3392,24 @@ impl Engine { } fn tick_node(&mut self, now: Instant) { - if self.node_external { - if !port_open(self.shared.runtime.rpc_port) && now.duration_since(self.last_sync_check) >= Duration::from_secs(5) { + if self.node_external || self.node_refused { + // re-decided every 5 s: the other node leaving hands the ports to this app after the wait (7 October 2026) + if now.duration_since(self.last_sync_check) >= Duration::from_secs(5) { self.last_sync_check = now; - let mut st = self.st(); - st.node.state = "stopped".into(); - st.node.message = "the external node went away".into(); + let port = self.shared.runtime.rpc_port; + let first = self.external_gone_since.is_none(); + match crate::extnode::step(port_open(port), self.secs(now), &mut self.external_gone_since) { + crate::extnode::Step::Stay => {} + crate::extnode::Step::Gone { for_s } => { + let left = (crate::extnode::TAKEOVER_WAIT_S - for_s).max(0.0) as u64; + if first && self.node_external { self.shared.event("info", &format!("The other node on port {port} went away; if it stays away, this app starts its own node in {left} s")); } + let mut st = self.st(); + st.node.state = "stopped".into(); + st.node.synced = false; + st.node.message = format!("the other node on port {port} went away; this app starts its own node in {left} s unless it comes back"); + } + crate::extnode::Step::TakeOver => self.take_over_ports(), + } } return; } @@ -3385,6 +3555,30 @@ impl Engine { } else if verdict != crate::watchdog::Action::None { // exit 43: the miner gave up on its worker; once more, then the card is faulted self.watchdog_verdict(i, verdict, &tail); + } else if is_stall_exit(code, &tail) { + // N4: the miner found no new template for its stall span and stopped hashing and voting. Once: + // the miner restarts and the node gets another look. Twice since the node started: the node + // itself restarts and re-dials its peers (the seed list), because the miner alone cannot fix a + // node that fell off the network. + let n = { let mut st = self.st(); st.node.stall_exits += 1; st.node.stall_exits }; + let label = self.miners[i].label.clone(); + for l in &tail { + self.shared.log(&format!(" miner: {l}")); + } + if n >= 2 { + self.shared.event("error", &format!("{label}: no new block to work on, twice; the node restarts and re-dials its peers")); + self.st().node.stall_exits = 0; + self.restart_node("the miner saw no new block twice", Duration::from_secs(2)); + } else { + let delay = jitter_secs(crate::platform::unix_now() + i as u64); + self.miners[i].restart_at = Some(now + Duration::from_secs(delay)); + self.shared.event("error", &format!("{label}: no new block to work on for the stall span; the miner restarts in {delay} s (a second stall restarts the node)")); + if let Some(c) = self.st().mining.cards.get_mut(card_idx) { + c.state = "restarting".into(); + c.hash_now = 0.0; + c.message = "no new block to work on; waiting for the node".into(); + } + } } else { self.miners[i].restarts += 1; let delay = jitter_secs(crate::platform::unix_now() + i as u64); @@ -3624,6 +3818,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") { @@ -3729,6 +3924,8 @@ impl Engine { let blue = kv_u64(text, "blue").unwrap_or(0); let difficulty = kv_f64(text, "difficulty").unwrap_or(0.0); let flag = kv(text, "synced").map(|s| s == "true"); + // N4: the sink header's age from the node (ca3-v4-0316 appends tip_age_s after synced=); -1 on an older node + let tip_age_s = kv(text, "tip_age_s").and_then(|s| s.parse::().ok()).unwrap_or(-1.0); let was_synced = self.st().node.synced; // Derived from every reading, never a one-shot transition (PC 2, 4 October 2026: the node caught up after a // clock fix and mined at 118 MH/s while the card still said "syncing", because getInfo's flag stayed false). @@ -3741,7 +3938,24 @@ impl Engine { let stable_s = self.sync_stable_since.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(0.0); let accepted_recent = self.last_accepted.map(|t| now.duration_since(t) <= Duration::from_secs(60)).unwrap_or(false); let private = self.shared.runtime.unsynced_mining && self.shared.runtime.peers.is_empty(); - let synced = sync_decision(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private); + let (mut synced, mut cause) = sync_decision_v2(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private, tip_age_s); + // 7 October 2026: a synced-looking node whose blocks never merge into the network reads "behind" and holds the miner + if synced && !private { + let since = *self.merge_mining_since.get_or_insert(now); + let check = crate::merge::MergeCheck { + our_blue: blue, + peer_sinks: self.merge_sinks.clone(), + merge_depth: self.merge_depth, + ours_seen_ago_s: self.merge_ours_seen.map(|t| now.duration_since(t).as_secs_f64()), + mining_for_s: now.duration_since(since).as_secs_f64(), + }; + if crate::merge::not_merging(&check) { + synced = false; + cause = "not merging"; + } + } else if !synced { + self.merge_mining_since = None; + } self.sync_prev = Some(blocks); // the first clock source: local time against the latest block the peers produced, once blocks arrive if blocks > 0 && (peers > 0 || self.shared.runtime.peers.is_empty()) && now.duration_since(self.clock_last_sample) >= Duration::from_secs(9) { @@ -3764,20 +3978,39 @@ impl Engine { st.node.blue = blue; st.node.difficulty = difficulty; st.node.synced = synced; - st.node.state = if synced { "synced".into() } else { "syncing".into() }; + st.node.tip_age_s = tip_age_s; + st.node.sync_cause = cause.to_string(); + // the state word: "behind" for a frozen tip, "no peers" for none, "syncing" for the rest (N4: never + // "synced" on a tip that stopped moving) + st.node.state = if synced { "synced".into() } else if cause == "frozen" || cause == "not merging" { "behind".into() } else if cause == "no peers" { "no peers".into() } else { "syncing".into() }; st.node.message = if synced { String::new() + } else if cause == "not merging" { + crate::merge::NOT_MERGING.to_string() + } else if cause == "frozen" { + format!("no new block for {} s with {peers} peer{}; the chain may have moved on without this node", tip_age_s.round() as i64, if peers == 1 { "" } else { "s" }) } else if peers == 0 { - "waiting for a peer".into() + "waiting for a peer on our chain".into() } else if headers > blocks + 2 { format!("{blocks} of {headers} blocks") } else { "caught up, waiting for the next block".into() }; } + if !synced && was_synced && cause == "not merging" { + self.shared.event("error", &format!("{}; the network's sink is {} blocks past ours, so the miner is held until this node merges again", crate::merge::NOT_MERGING, self.merge_sinks.iter().max().copied().unwrap_or(0).saturating_sub(blue))); + } if synced && !was_synced { self.shared.event("ok", &format!("node synced: {blocks} blocks, {peers} peer(s)")); + let t = self.secs(now); + let names: Vec = self.st().mining.cards.iter().map(|c| c.name.clone()).collect(); for m in self.miners.iter_mut() { + // a card faulted for silence while the node was not ready starts again now (PC 1, 7 October 2026) + if m.watch.faulted_by_node() { + m.watch.event(t, crate::watchdog::Event::NodeSynced); + let name = names.get(m.card).cloned().unwrap_or_else(|| m.label.clone()); + self.shared.event("info", &format!("{name}: starts again now the node is synced (it was faulted for silence while the node was syncing)")); + } if m.proc.is_none() && m.restart_at.is_none() && m.watch.faulted().is_none() { m.restart_at = Some(now); } @@ -3793,6 +4026,22 @@ impl Engine { let Some(i) = self.miners.iter().position(|m| m.card == card) else { return }; let label = self.miners[i].label.clone(); let card_name = self.st().mining.cards.get(card).map(|c| c.name.clone()).unwrap_or_else(|| label.clone()); + if text.contains("template fetch timed out") { + // PC 1, 7 October 2026 04:51Z: the node reported synced while its finality replay blocked the template + // RPC for three minutes; the miner printed only these lines, and the watchdog faulted every card for + // silence. The line is the miner's heartbeat: the card waits on the node, with one Activity line per episode + let now = Instant::now(); + let t = self.secs(now); + let first = !self.miners[i].watch.templates_blocked(); + self.miners[i].watch.event(t, crate::watchdog::Event::TemplateTimeout); + if first { + self.shared.event("info", &format!("{card_name}: the node is not answering block templates yet; the miner keeps asking (the node catches up after a restart)")); + } + if let Some(c) = self.st().mining.cards.get_mut(card) { + c.message = "waiting for the node to answer block templates".into(); + } + return; + } if stderr { if text.contains(" rejected nonce=") { if let Some(run) = self.sweep.as_mut() { @@ -4306,6 +4555,38 @@ pub fn sync_decision(r: &Reading, moving: bool, stable_s: f64, accepted_recent: caught_up && (moving || stable_s >= 60.0) } +/// The tip age at which a node that still says "synced" is treated as frozen (ledger N4, 6 October 2026: a home +/// miner's one same-digest peer relayed nothing for 53 minutes while the app read "mining, synced"). +pub const TIP_FROZEN_S: f64 = 120.0; + +/// N4: what "synced" means on every surface. The old decision, AND the tip moved in the last 120 s (the watch +/// line's tip_age_s, the sink header's age; -1 = the node does not report it yet, which gates nothing so an older node +/// keeps today's words), AND at least one peer (the handshake refuses a peer with another digest, so `peers` already +/// counts peers on our chain). Returns (synced, cause): the cause is "" when synced, else "frozen" (a peer, caught up, +/// no new block for over 120 s), "no peers", "syncing", or "behind" (the node's own flag false while the count moves). +pub fn sync_decision_v2(r: &Reading, moving: bool, stable_s: f64, accepted_recent: bool, private: bool, tip_age_s: f64) -> (bool, &'static str) { + if private { + return (true, ""); + } + let frozen = tip_age_s > TIP_FROZEN_S && !accepted_recent; + if r.peers == 0 && !accepted_recent { + return (false, "no peers"); + } + if frozen { + return (false, "frozen"); + } + if sync_decision(r, moving, stable_s, accepted_recent, private) { + return (true, ""); + } + (false, if r.headers > r.blocks + 2 { "syncing" } else { "behind" }) +} + +/// The miner's stall exit (the node lane, ca3-v4-0316): code 45, or a tail line starting "STALLED '". +pub const STALL_EXIT_CODE: i32 = 45; +pub fn is_stall_exit(code: i32, tail: &[String]) -> bool { + code == STALL_EXIT_CODE || tail.iter().any(|l| l.contains(" STALLED '") || l.starts_with("STALLED '")) +} + /// "...block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000" -> at least 63 s behind. fn behind_from_warning(text: &str) -> Option { let nums: Vec = text.split(|c: char| !c.is_ascii_digit()).filter(|s| s.len() >= 12).filter_map(|s| s.parse::().ok()).collect(); @@ -4464,7 +4745,7 @@ mod tests { assert_eq!(super::power_control_after_prompt(&Err("the elevated step exited with code 2".into())), None); } - use super::{digest_from_line, parse_race, switches_of, sync_decision, Reading}; + use super::{is_stall_exit, sync_decision_v2, digest_from_line, parse_race, switches_of, sync_decision, Reading}; #[test] fn digest_comes_from_the_nodes_own_line_only() { @@ -4536,6 +4817,44 @@ mod tests { assert!(sync_decision(&r(0, 0, 0, None), false, 0.0, false, true)); } + /// Ledger N4 (6 October 2026): a home miner's node kept one same-digest peer that relayed nothing for 53 minutes; + /// today's rule said "synced" the whole time. The known-failed case first, then the fix. + #[test] + fn n4_a_frozen_tip_is_never_synced() { + // the frozen tip: caught up, one peer, the count stable for an hour, getInfo's flag true. Today: synced. + let frozen = r(133_000, 133_000, 1, Some(true)); + assert!(sync_decision(&frozen, false, 3_180.0, false, false), "the old rule says synced on the frozen tip (the N4 failure)"); + // the fix: the tip age gates it + let (ok, cause) = sync_decision_v2(&frozen, false, 3_180.0, false, false, 3_180.0); + assert!(!ok); + assert_eq!(cause, "frozen"); + // the same node moving: synced + assert_eq!(sync_decision_v2(&frozen, true, 0.0, false, false, 4.0), (true, "")); + // exactly at the bound is still synced; one second over is not + assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, 120.0), (true, "")); + assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, 121.0), (false, "frozen")); + // an older node that does not report the age (-1) keeps today's words + assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, -1.0), (true, "")); + // our own block accepted in the last minute proves the tip moves, whatever the reported age + assert_eq!(sync_decision_v2(&frozen, false, 60.0, true, false, 900.0), (true, "")); + // no peer at all: "no peers", not "frozen" and never synced + assert_eq!(sync_decision_v2(&r(133_000, 133_000, 0, Some(true)), false, 600.0, false, false, 500.0), (false, "no peers")); + // still downloading: "syncing" + assert_eq!(sync_decision_v2(&r(100, 700, 1, Some(false)), true, 0.0, false, false, 2.0), (false, "syncing")); + // caught up but the flag false and the count not yet stable: "behind" + assert_eq!(sync_decision_v2(&r(700, 700, 1, Some(false)), false, 10.0, false, false, 5.0), (false, "behind")); + // a private test node is always synced + assert_eq!(sync_decision_v2(&r(0, 0, 0, None), false, 0.0, false, true, 9_999.0), (true, "")); + } + + #[test] + fn n4_the_stall_exit_is_code_45_or_the_stalled_line() { + assert!(is_stall_exit(45, &[])); + assert!(is_stall_exit(1, &["1791327000.123 STALLED 'nvidia-1ccfe586-1': no new template for 300 s (tip d3dc2f78, daa 197752); the node has fallen off the network or stopped moving; hashing and voting paused, exiting with code 45 so the launcher restarts (and after a second stall, restarts the node)".to_string()])); + assert!(!is_stall_exit(1, &["worker fault: rejected nonce".to_string()])); + assert!(!is_stall_exit(42, &[])); + } + #[test] fn node_warning() { let l = "2026-10-04 11:02:11.123+00:00 [WARN ] HandleRelayInvsFlow flow error: the block timestamp is too far into the future: block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000"; diff --git a/app/igneum-app/src/extnode.rs b/app/igneum-app/src/extnode.rs new file mode 100644 index 000000000..25764ac1c --- /dev/null +++ b/app/igneum-app/src/extnode.rs @@ -0,0 +1,249 @@ +//! 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::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, + /// the node's consensus params as it resolved them (`params` in igneum_getNodeInfo, every field by its override-file name) + pub overrides: Option, + /// "igneum-pow" or "stub" (ledger N5: a node built without the mining engine refuses every real block) + pub pow_engine: Option, + /// 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, +} + +/// 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 { 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, 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) -> 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 { + 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 (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 `...` 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 = 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/live.rs b/app/igneum-app/src/live.rs index c1eab9367..68f7b9dd6 100644 --- a/app/igneum-app/src/live.rs +++ b/app/igneum-app/src/live.rs @@ -16,8 +16,8 @@ static CACHE: Mutex> = Mutex::new(None); const TTL: Duration = Duration::from_secs(2); /// The observer's reply with this machine's lane marked. `ids` are the 8-character vote key ids of this machine's -/// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` names -/// where the data came from. Anything else passes through untouched, so the module's contract holds. +/// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` is "site" +/// (the 0.3.16 engine's live.rs answers "node" when igneumd carries igneum_getRecentBlocks). Anything else passes through untouched, so the module's contract holds. pub fn shape(mut reply: Value, ids: &HashSet) -> Value { let mut yours = 0u64; if let Some(blocks) = reply.get_mut("blocks").and_then(|b| b.as_array_mut()) { @@ -31,7 +31,7 @@ pub fn shape(mut reply: Value, ids: &HashSet) -> Value { } if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) { st.insert("you_blocks".into(), json!(yours)); - st.insert("source".into(), json!("observer")); + st.insert("source".into(), json!("site")); } reply } @@ -71,6 +71,7 @@ pub fn fetch(live_api: &str, window_s: u32, ids: &HashSet) -> Value { mod tests { use super::*; + // a fixture in the observer's shape (site/api/live.mjs): three miners, this machine is 8fafda27, one of its blocks on // the selected chain and proven, one excluded; a locked and a pending checkpoint fn fixture() -> Value { @@ -109,7 +110,7 @@ mod tests { assert_eq!(out["finality"]["checkpoints"].as_array().unwrap().len(), 2); assert_eq!(out["finality"]["checkpoints"][0]["state"], "locked"); assert_eq!(out["state"]["you_blocks"], 2); - assert_eq!(out["state"]["source"], "observer"); + assert_eq!(out["state"]["source"], "site"); assert_eq!(out["state"]["height"], 198490); assert_eq!(out["ok"], true); } diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index b70e785ad..b04ad5f2b 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -38,6 +38,8 @@ mod ember; mod powertask; mod watchdog; mod live; +mod extnode; +mod merge; use std::io::{BufRead, Write}; use std::sync::mpsc::channel; diff --git a/app/igneum-app/src/merge.rs b/app/igneum-app/src/merge.rs new file mode 100644 index 000000000..74a5e9ff3 --- /dev/null +++ b/app/igneum-app/src/merge.rs @@ -0,0 +1,96 @@ +//! Is this node's work merging into the network? (design note, 7 October 2026, from the node lane's 30-s +//! propagation reading: a node behind a slow link keeps mining its own chain, its relayed blocks never merge, and the +//! N4 "synced" test (tip moving, a peer present) cannot see it.) +//! +//! The decision is pure. The engine feeds it our sink's blue score (the watch line's `blue=`), the sinks the network +//! reports (the observer's view, and any peer that answers igneum_getNodeInfo), the merge depth from the node's params, +//! how long ago one of our blocks was last seen in the network's view, and how long we have been mining. + +/// How long our blocks may stay out of the network's view before the node reads "behind" (two minutes). +pub const NOT_MERGING_S: f64 = 120.0; +/// The merge depth when the node does not say (node lane, 7 October 2026: 1 bps x MERGE_DEPTH_DURATION = 3,600 in blue +/// score on the devnet and every 1-bps network; 36,000 at 10 bps; read from igneum_getNodeInfo's `blockrate.mergeDepth` +/// once the node carries it). Peer sinks: nothing exposes them yet; `igneum_getPeers` with lastDeliveredBlueScore is on +/// the 0.3.19 list, and the observer is the only network view until then. +pub const DEFAULT_MERGE_DEPTH: u64 = 3_600; +/// The words every surface uses for this state. +pub const NOT_MERGING: &str = "behind: your blocks are not merging into the network"; + +#[derive(Debug, Clone, Default)] +pub struct MergeCheck { + /// our node's sink blue score + pub our_blue: u64, + /// the sinks' blue scores the network reports (observer, peers); empty = no view, no decision + pub peer_sinks: Vec, + /// the merge depth in blue score (the node's params, else DEFAULT_MERGE_DEPTH) + pub merge_depth: u64, + /// seconds since one of our blocks last appeared in the network's view; None = never seen + pub ours_seen_ago_s: Option, + /// how long this machine has been mining with a synced node, in seconds + pub mining_for_s: f64, +} + +/// True when every sink the network reports is more than the merge depth ahead of ours and none of our blocks has +/// appeared in the network's view for NOT_MERGING_S (so the miner's blocks can no longer merge: hold it). +pub fn not_merging(c: &MergeCheck) -> bool { + if c.peer_sinks.is_empty() || c.mining_for_s < NOT_MERGING_S { + return false; + } + let depth = if c.merge_depth == 0 { DEFAULT_MERGE_DEPTH } else { c.merge_depth }; + let all_ahead = c.peer_sinks.iter().all(|&s| s > c.our_blue.saturating_add(depth)); + let ours_absent = c.ours_seen_ago_s.map(|a| a >= NOT_MERGING_S).unwrap_or(true); + all_ahead && ours_absent +} + +/// The network's view out of an observer reply (crate::live::fetch's shape): the highest blue score it shows, and the +/// newest timestamp (unix ms) of a block it marks as ours (miner "you"). None when the reply carries no blocks. +pub fn view_of(reply: &serde_json::Value) -> Option<(u64, Option)> { + let blocks = reply.get("blocks")?.as_array()?; + if blocks.is_empty() { + return None; + } + let sink = blocks.iter().filter_map(|b| b.get("blue_score").and_then(|v| v.as_u64())).max().unwrap_or(0); + let ours = blocks.iter().filter(|b| b.get("miner").and_then(|m| m.as_str()) == Some("you")).filter_map(|b| b.get("ts").and_then(|v| v.as_i64())).max(); + Some((sink, ours)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn c() -> MergeCheck { MergeCheck { our_blue: 100_000, peer_sinks: vec![104_000, 103_800], merge_depth: 3_600, ours_seen_ago_s: None, mining_for_s: 600.0 } } + + /// The slow-link node (node lane, 30-s propagation reading): tip moving, a peer present, its own chain, nothing + /// merging. Today's sync test calls it synced; this one holds the miner. + #[test] + fn a_node_whose_blocks_never_merge_reads_behind_and_holds_the_miner() { + assert!(not_merging(&c()), "every peer more than the merge depth ahead and ours never seen"); + assert!(not_merging(&MergeCheck { ours_seen_ago_s: Some(130.0), ..c() }), "ours last seen over two minutes ago"); + assert_eq!(NOT_MERGING, "behind: your blocks are not merging into the network"); + } + + #[test] + fn a_merging_node_is_left_alone() { + assert!(!not_merging(&MergeCheck { ours_seen_ago_s: Some(20.0), ..c() }), "our block appeared in the network's view"); + assert!(!not_merging(&MergeCheck { peer_sinks: vec![104_000, 101_000], ..c() }), "one peer within the merge depth"); + assert!(!not_merging(&MergeCheck { peer_sinks: vec![103_600], ..c() }), "exactly the merge depth is not over it"); + assert!(!not_merging(&MergeCheck { peer_sinks: vec![], ..c() }), "no network view, no decision"); + assert!(!not_merging(&MergeCheck { mining_for_s: 30.0, ..c() }), "not mining long enough to judge"); + assert!(not_merging(&MergeCheck { merge_depth: 0, peer_sinks: vec![103_601], ..c() }), "a missing depth falls back to 3,600"); + } + + #[test] + fn the_observer_reply_gives_the_sink_and_our_newest_block() { + let r = json!({ "blocks": [ + { "blue_score": 194_690, "ts": 1_791_301_600_000i64, "miner": "8fafda27" }, + { "blue_score": 194_692, "ts": 1_791_301_602_000i64, "miner": "you" }, + { "blue_score": null, "ts": 1_791_301_601_500i64, "miner": "you" }, + { "blue_score": 194_691, "ts": 1_791_301_601_000i64, "miner": "deadbeef" } ] }); + assert_eq!(view_of(&r), Some((194_692, Some(1_791_301_602_000)))); + let none_ours = json!({ "blocks": [{ "blue_score": 5, "ts": 1, "miner": "x" }] }); + assert_eq!(view_of(&none_ours), Some((5, None))); + assert_eq!(view_of(&json!({ "blocks": [] })), None); + assert_eq!(view_of(&json!({ "ok": false })), None); + } +} diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 9db8176c7..2be34c31a 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -139,6 +139,15 @@ fn json_resp(stream: &mut TcpStream, status: u16, v: Value) { respond(stream, status, "application/json; charset=utf-8", v.to_string().as_bytes(), false); } +/// Where a screenshot lands: `/shots/igneum--.png`, the name reduced to [a-z0-9-] (at most +/// 24 characters, "shot" when empty) so a caller cannot steer the host outside the folder. +pub fn shot_path(data_root: &std::path::Path, name: &str, unix: u64) -> std::path::PathBuf { + let safe: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').map(|c| c.to_ascii_lowercase()).take(24).collect(); + let safe = if safe.is_empty() { "shot".to_string() } else { safe }; + data_root.join("shots").join(format!("igneum-{safe}-{unix}.png")) +} + + fn query_param(q: &str, key: &str) -> Option { q.split('&').find_map(|kv| { let (k, v) = kv.split_once('=')?; @@ -368,6 +377,17 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result", beside URL, STATE and EXIT) and the host writes a PNG of + // what it shows to that path under /shots/, where a collect job's glob "shots/*.png" picks it up. + // Read-only: nothing about mining changes. A caller without a host (a bare engine) gets the path and no file. + "/api/shot" => { + let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let path = shot_path(&crate::platform::data_root(), name, crate::platform::unix_now()); + if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } + println!("SHOT {}", path.display()); + Ok(json!({ "ok": true, "path": path.display().to_string(), "note": "the window host writes the PNG within a few seconds; a bare engine without a host writes nothing" })) + } "/api/quit" => { // the caller is on 127.0.0.1 and holds the token: the installer, the OTA apply, a script that read app.url shared.send(Cmd::Quit("POST /api/quit (a local caller with the token: the installer, the OTA apply, or a script that read app.url)")); @@ -376,3 +396,17 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result Err("unknown api".into()), } } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn a_shot_lands_under_the_data_root_with_a_safe_name() { + let root = std::path::Path::new("/tmp/igneum-data"); + assert_eq!(shot_path(root, "overview", 1791327000), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-overview-1791327000.png")); + assert_eq!(shot_path(root, "../../etc/passwd", 1), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-etcpasswd-1.png")); + assert_eq!(shot_path(root, "", 2), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-shot-2.png")); + assert_eq!(shot_path(root, "Cards Light!", 3), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-cardslight-3.png")); + assert!(shot_path(root, &"x".repeat(80), 4).file_name().unwrap().to_string_lossy().len() < 48); + } +} diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 196652162..aba451b40 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -21,6 +21,29 @@ pub struct NodeState { pub version: String, pub last_reading_age_s: f64, pub message: String, + /// 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, + /// which node this app reads: "own" (it started one), "external" (another node on this machine holds the ports), + /// "none" (the ports are taken by a node it refuses), "" before the first decision. Re-decided when the other + /// node goes away (7 October 2026): after 60 s the app starts its own on the freed ports. + pub mode: String, + /// why the mode is what it is, one line + pub mode_reason: 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 + pub stall_exits: u32, /// a consensus switch the signed manifest announced (difficulty v2 activation DAA); 0 = none pub consensus_switch_daa: u64, pub override_restart_wait: String, diff --git a/app/igneum-app/src/watchdog.rs b/app/igneum-app/src/watchdog.rs index 369ad8222..6ae01f539 100644 --- a/app/igneum-app/src/watchdog.rs +++ b/app/igneum-app/src/watchdog.rs @@ -17,6 +17,11 @@ /// No status line from a running miner for this long: restart it. pub const NO_STATUS_S: f64 = 90.0; +/// A worker that gives no status within this many seconds of its start, with the node synced, is one restart then +/// faulted (PC 1, 7 October 2026 04:51Z: the row asks for a fault line at 60 s). +pub const START_S: f64 = 60.0; +/// The start of a fault reason the node's readiness, not the card, explains: released when the node syncs. +const NODE_FAULTS: [&str; 2] = ["no status line from the miner", "the worker gave no status within"]; /// Hash rate 0 while the node is synced and the worker is ready for this long: restart the miner. pub const ZERO_RATE_S: f64 = 60.0; /// The miner is restarting its own worker: the app waits this long for `ready` before it steps in. @@ -86,6 +91,14 @@ pub enum Event<'a> { Stopped, /// The user changed the card's settings: a faulted card may try again. Reset, + /// The node became synced: a card faulted for silence while the node was not ready tries again (PC 1, 7 October + /// 2026 04:51Z: every card faulted "no status line" during the post-relaunch sync and never came back). + NodeSynced, + /// The miner printed "template fetch timed out": it is alive and the node is not answering templates (PC 1, + /// 7 October 2026 04:51Z: the node reported synced from the first second while its finality replay blocked the + /// template RPC for three minutes; with no template the miner prints no status line). Silence is not the card's + /// fault while this goes on; the miner is judged again once templates flow. + TemplateTimeout, } #[derive(Debug, Clone, PartialEq)] @@ -109,6 +122,15 @@ pub struct CardWatch { restarts: u32, faulted: Option, last_fault: String, + /// the miner's last line was a template timeout (the node not answering), cleared by the next status line + templates_blocked: bool, +} + +impl CardWatch { + /// True while the miner's last word was a template timeout: the node, not the card, holds the hashing. + pub fn templates_blocked(&self) -> bool { + self.templates_blocked + } } impl CardWatch { @@ -162,6 +184,7 @@ impl CardWatch { Action::None } Event::Status(s) => { + self.templates_blocked = false; self.last_status_s = Some(now_s); if s.hash_now > 0.0 { self.zero_since = None; @@ -207,9 +230,30 @@ impl CardWatch { *self = Self::default(); Action::None } + Event::TemplateTimeout => { + // the miner's own heartbeat: every silence clock starts over from this line + self.started_s = Some(now_s); + if self.last_status_s.is_some() { + self.last_status_s = Some(now_s); + } + self.zero_since = None; + self.templates_blocked = true; + Action::None + } + Event::NodeSynced => { + if self.faulted.as_deref().map(|f| NODE_FAULTS.iter().any(|p| f.starts_with(p))).unwrap_or(false) { + *self = Self::default(); + } + Action::None + } } } + /// True when the card is faulted for a reason the node's readiness explains (released by Event::NodeSynced). + pub fn faulted_by_node(&self) -> bool { + self.faulted.as_deref().map(|f| NODE_FAULTS.iter().any(|p| f.starts_with(p))).unwrap_or(false) + } + /// Called every engine tick while the miner process is alive. pub fn tick(&mut self, now_s: f64, node_synced: bool) -> Action { if self.faulted.is_some() { @@ -223,14 +267,23 @@ impl CardWatch { } return Action::None; } + if !node_synced { + // a miner is judged only while the node is synced: with no template it cannot print status, so the silence + // clocks (start, no status, zero rate) hold at now until the node is back (PC 1, 7 October 2026) + self.started_s = Some(now_s); + if self.last_status_s.is_some() { + self.last_status_s = Some(now_s); + } + self.zero_since = None; + return Action::None; + } + if !self.ready && self.last_status_s.is_none() && now_s - started > START_S { + return self.escalate(format!("the worker gave no status within {} s of starting", START_S as u64)); + } let last = self.last_status_s.unwrap_or(started); if now_s - last > NO_STATUS_S { return self.escalate(format!("no status line from the miner for {} s", NO_STATUS_S as u64)); } - if !node_synced { - // a zero rate while the node syncs is expected; the timer starts again once it is synced - self.zero_since = None; - } if node_synced && self.ready { if let Some(z) = self.zero_since { if now_s - z >= ZERO_RATE_S { @@ -488,6 +541,70 @@ mod tests { assert!(matches!(w.tick(240.0, true), Action::Restart(_))); } + /// PC 1, 7 October 2026 04:51:43Z (docs/plans/release-0.3.17.md, the PC 1 row): the app relaunched after the OTA + /// apply, started its workers while the node was still syncing, and every card ended "faulted: no status line from + /// the miner for 90 s (restarted once already)", pid 0, 0.0 MH/s from then on. Today's watchdog clocks the silence + /// while the node is not synced and never releases the fault. + #[test] + fn a_worker_started_while_the_node_syncs_is_not_faulted_and_a_faulted_one_returns_at_sync() { + // the recorded sequence (relaunch-engine-and-workers.txt): node synced=true from the first second, the miner + // started at +14 s, subscribed, then every template fetch timed out at 5 s for 180 s; no status line was printed + let mut r = CardWatch::new(); + r.event(14.0, Event::Started); + let mut t = 19.0; + while t <= 200.0 { + r.event(t, Event::TemplateTimeout); + assert_eq!(r.tick(t + 1.0, true), Action::None, "at {t}: a miner whose template fetches time out is not faulted on silence"); + assert!(r.templates_blocked()); + t += 5.0; + } + // the node answers templates again: the miner prints status and is healthy + healthy(&mut r, 210.0, 400.0); + assert!(!r.templates_blocked()); + assert_eq!(r.faulted(), None); + // a miner that stops printing even timeouts is judged again from its last line + let mut q = CardWatch::new(); + q.event(0.0, Event::Started); + q.event(50.0, Event::TemplateTimeout); + assert_eq!(q.tick(109.0, true), Action::None); + assert!(matches!(q.tick(111.0, true), Action::Restart(_)), "60 s of nothing at all after the last timeout line"); + + let mut w = CardWatch::new(); + w.event(0.0, Event::Started); + // the node reads unsynced for 200 s (the relaunch's sync): no verdict at all + let mut t = 5.0; + while t <= 200.0 { + assert_eq!(w.tick(t, false), Action::None, "at {t}: a miner is judged only while the node is synced"); + t += 10.0; + } + // the node syncs; the worker answers inside 60 s and is healthy + assert_eq!(w.tick(210.0, true), Action::None); + w.event(230.0, Event::Ready); + healthy(&mut w, 240.0, 400.0); + assert_eq!(w.faulted(), None); + // the recorded state: a card faulted for silence while the node was not ready + let mut f = CardWatch::new(); + f.event(0.0, Event::Started); + assert!(matches!(f.tick(100.0, true), Action::Restart(_))); + f.event(101.0, Event::Started); + let a = f.tick(200.0, true); + assert!(matches!(a, Action::Fault(ref r) if (r.starts_with("no status line from the miner") || r.starts_with("the worker gave no status within")) && r.contains("restarted once already")), "{a:?}"); + assert!(f.faulted_by_node()); + f.event(300.0, Event::NodeSynced); + assert_eq!(f.faulted(), None, "the node syncing releases a silence fault"); + assert_eq!(f.watchdog_restarts(), 0); + // a fault the card owns (zero rate while synced) stays through NodeSynced + let mut z = CardWatch::new(); + z.event(0.0, Event::Started); + z.event(1.0, Event::Ready); + let zero = parse_status(STATUS_OK.replace("now=124.10 MH/s", "now=0.00 MH/s").as_str()).unwrap(); + for t in [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0] { z.event(t, Event::Status(&zero)); z.tick(t, true); } + assert!(z.faulted().is_none() || !z.faulted_by_node()); + let before = z.faulted().map(|s| s.to_string()); + z.event(500.0, Event::NodeSynced); + assert_eq!(z.faulted().map(|s| s.to_string()), before); + } + #[test] fn no_status_for_90s_restarts() { let mut w = CardWatch::new(); @@ -502,10 +619,11 @@ mod tests { #[test] fn no_status_from_the_start() { + // a worker silent from its start, with the node synced, is judged at START_S (60 s), not NO_STATUS_S let mut w = CardWatch::new(); w.event(0.0, Event::Started); - assert_eq!(w.tick(89.0, true), Action::None); - assert!(matches!(w.tick(91.0, true), Action::Restart(_))); + assert_eq!(w.tick(59.0, true), Action::None); + assert!(matches!(w.tick(61.0, true), Action::Restart(ref r) if r.contains("gave no status within 60 s"))); } #[test] diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 1d5976093..b2603b4d5 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -344,8 +344,9 @@ var View = (function () { // MH per watt: the engine's live figure, else rate over draw function effOf(cd) { if (cd.eff_mhw > 0) return cd.eff_mhw; return cd.power_w > 0 && cd.hash_now > 0 ? cd.hash_now / cd.power_w : 0; } function effText(e) { return e > 0 ? (e >= 10 ? e.toFixed(1) : e.toFixed(2)) + ' MH/W' : ''; } - // the watts drawn now by the present cards - function wattsTotal(cards) { return (cards || []).filter(present).reduce(function (a, c) { return a + (c.power_w > 0 ? c.power_w : 0); }, 0); } + // the watts drawn now by the cards that are mining or tuning (an idle card's standby draw is not the fleet's cost; + // the engine's mining.watts_total counts the same way) + function wattsTotal(cards) { return (cards || []).filter(function (c) { return present(c) && (c.state === 'mining' || c.state === 'tuning'); }).reduce(function (a, c) { return a + (c.power_w > 0 ? c.power_w : 0); }, 0); } // why a row has no power or temperature cell: one sentence, once, instead of three n/a function noReading(cd) { if (cd.power_w > 0 || cd.temp_gpu > 0) return ''; @@ -450,7 +451,7 @@ var View = (function () { if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a card on in Cards first' : 'no card this app can use', cls: '', disabled: true, act: '' }; if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' }; if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' }; - if (m.state === 'waiting') return { label: 'Stop mining', sub: n && n.synced ? 'card starting' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' }; + if (m.state === 'waiting') return { label: 'Stop mining', sub: n && n.synced ? 'card starting' : n && n.state === 'behind' ? 'waiting: the node is behind the chain' : n && n.state === 'no peers' ? 'waiting: no peers on our chain' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' }; return { label: 'Stop mining', sub: m.state || 'starting', cls: 'stop', disabled: false, act: 'pause' }; } // the row's state word while the miner is paused: the card is on, the miner is not @@ -461,7 +462,13 @@ var View = (function () { var bad = clock && clock.severity === 'block'; if (bad) return { word: n.state === 'synced' ? 'synced' : n.state, line: 'Your clock is off by ' + skewWord(clock.skew_s) + '. Mining waits until it is synced.', tone: 'bad' }; switch (n.state) { - case 'synced': return { word: 'synced', line: 'This node has every block the network has made.' + (n.last_reading_age_s >= 0 ? ' Read ' + Math.round(n.last_reading_age_s) + ' s ago.' : ''), tone: 'ok' }; + case 'synced': return { word: 'synced', line: 'This node has every block the network has made.' + (n.tip_age_s >= 0 ? ' Last block ' + Math.round(n.tip_age_s) + ' s ago.' : n.last_reading_age_s >= 0 ? ' Read ' + Math.round(n.last_reading_age_s) + ' s ago.' : ''), tone: 'ok' }; + // N4 (6 October 2026): a tip that stopped moving is never "synced"; the node says why + case 'behind': + // 7 October 2026: a node whose blocks never merge into the network (src/merge.rs) is behind with a tip that still moves + if (n.sync_cause === 'not merging') return { word: 'behind', line: 'Your blocks are not merging into the network: every other node is more than the merge depth ahead. Mining pauses until this node merges again; a slow link is the usual cause.', tone: 'bad' }; + return { word: 'behind', line: (n.tip_age_s >= 0 ? 'No new block for ' + tipAge(n.tip_age_s) + '. ' : '') + 'The chain may have moved on without this node. Mining pauses until it catches up; the node re-dials its peers by itself.', tone: 'bad' }; + case 'no peers': return { word: 'no peers', line: 'No other node on our chain is connected. The node keeps dialling the seed list; mining waits.', tone: 'bad' }; case 'syncing': var pct = n.headers > n.blocks && n.headers > 0 ? Math.floor(n.blocks / n.headers * 100) : 0; var line = n.headers > n.blocks ? (eta || n.message || 'downloading blocks') + ' · ' + withCommas(n.blocks) + ' of ' + withCommas(n.headers) + ' (' + pct + '%)' : (n.message || 'caught up, waiting for the next block'); @@ -469,15 +476,19 @@ 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' }; + case 'stub': return { word: 'stub engine', line: 'This node was built without the mining engine, so it refuses every real block. Reinstall the app.', tone: 'bad' }; } return { word: n.state || 'starting', line: n.message || '', tone: '' }; } - // the one node line on Mine: "Node synced · 6 peers · 109,859 blocks" + // "12 s" / "4 min" / "53 min" / "2 h" for a tip age + function tipAge(s) { s = Math.max(0, Math.round(s)); return s < 90 ? s + ' s' : s < 5400 ? Math.round(s / 60) + ' min' : Math.round(s / 3600) + ' h'; } + // the one node line on Mine: "Node synced · 6 peers · 109,859 blocks · last block 4 s ago" function nodeLine(n, clock, eta) { var w = nodeWords(n, clock, eta), parts = ['Node ' + w.word]; if (n.peers > 0) parts.push(n.peers + ' peer' + (n.peers === 1 ? '' : 's')); if (n.blocks > 0) parts.push(withCommas(n.blocks) + ' blocks'); + if (n.tip_age_s >= 0 && (n.state === 'synced' || n.state === 'behind')) parts.push('last block ' + tipAge(n.tip_age_s) + ' ago'); return { text: parts.join(' · '), sub: w.line, tone: w.tone }; } function skewWord(sk) { sk = Math.round(Math.abs(sk || 0)); return sk < 120 ? sk + ' s' : sk < 7200 ? Math.floor(sk / 60) + ' min ' + (sk % 60) + ' s' : Math.floor(sk / 3600) + ' h ' + Math.floor(sk % 3600 / 60) + ' min'; } @@ -653,8 +664,11 @@ var View = (function () { if (n.state === 'syncing') return { text: 'Syncing the node', tone: '' }; if (n.state === 'starting') return { text: 'Starting the node', tone: '' }; if (n.state === 'restarting') return { text: 'Node restarting', tone: 'bad' }; + 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 (n.state === 'stub') return { text: 'Node fault', 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: '' }; @@ -695,7 +709,7 @@ var View = (function () { } return out; } - out.label = st.sweep === false ? 'Not tuned: Ember Tune is off' : 'Not tuned yet'; out.sub = 'starts after 2 min of steady mining'; return out; + out.label = st.sweep === false ? 'Not tuned: Ember Tune is off' : 'Not tuned yet'; out.sub = st.sweep === false ? 'Tune starts one by hand' : 'starts after 2 min of steady mining'; return out; } // the Tuning strip's fleet saving: watts and £ a day summed over the tuned cards function fleetSaving(cards, s, now, pence) { @@ -705,10 +719,63 @@ var View = (function () { var ppd = poundsPerDay(w, pence); return 'Ember saves ' + Math.round(w) + ' W' + (ppd !== null ? ', ' + money(ppd) + ' a day' : '') + ' across ' + n + ' tuned card' + (n === 1 ? '' : 's'); } + // ---- the engine's 0.3.16 fields (ember-tune fd03f35): mining.watts_total, mining.pounds_per_day, address.balance_wei + // (string | null), address.balance_age_s (-1 until read), address.balance_note, address.price_gbp_per_ign + // (null until a signed manifest carries a price), the chain scene's reply.source ("node" | "site") ---- + function ign(wei) { var n = Number(wei); return isNaN(n) ? null : n / 1e18; } + // the Earnings balance row: {text, sub, pounds} ; the UI shows "not read yet" until the first read + function balanceWords(a, now) { + a = a || {}; + if (a.balance_wei === undefined) return { text: '', sub: '', pounds: null }; + if (a.balance_wei === null || a.balance_wei === '') return { text: 'not read yet', sub: a.balance_note || 'the node answers the balance once it is synced', pounds: null }; + var v = ign(a.balance_wei); if (v === null) return { text: 'not read yet', sub: a.balance_note || '', pounds: null }; + var age = a.balance_age_s >= 0 ? ' · read ' + ago(a.balance_age_s) : ''; + var price = a.price_gbp_per_ign; + return { text: (v >= 1000 ? withCommas(v) : v.toFixed(4)) + ' IGN', sub: 'in the wallet' + age + (a.balance_note ? ' · ' + a.balance_note : ''), pounds: price > 0 ? v * price : null }; + } + // the fleet watts and £ a day: the engine's figures when it carries them, else the UI's own sums + function fleetWatts(m, cards) { return m && m.watts_total > 0 ? m.watts_total : wattsTotal(cards); } + function fleetPounds(m, cards, pence) { if (m && m.pounds_per_day > 0) return m.pounds_per_day; return poundsPerDay(fleetWatts(m, cards), pence); } + // is a lane key this machine's? The cards' ids are the first 8 hex of each vote key hash; a source may shorten the + // hash to another length (the 0.3.16 engine keeps 12), so either prefix of the other counts + function isMine(miner, cards) { + var m = String(miner || '').replace(/^0x/, '').toLowerCase(); if (!m) return false; + var head = m.split('..')[0]; // the 0.3.16 engine shortens a long hash to "head6..tail6"; the site keeps 8 hex + return (cards || []).some(function (c) { return (c.ids || []).some(function (id) { + id = String(id).replace(/^0x/, '').toLowerCase(); if (!id) return false; + var n = Math.min(head.length, id.length); return n >= 6 && head.slice(0, n) === id.slice(0, n); + }); }); + } + // the chain scene's note from the reply's source + function liveNote(reply) { + if (!reply) return ''; + if (reply.pending) return 'connecting'; + var src = reply.source || ((reply.state || {}).source); + if (src === 'node') return 'From your node. Your blocks are ringed.'; + if (src === 'site' || src === 'observer') return 'From Igneum’s observer. Your blocks are ringed.'; + return ''; + } + // finality (ember-tune 1357d280, folded in at the rebase onto a9eb58f1): f = state.finality. While the network's finality is paused + // (under two thirds of the weight signing) the node line's sub-text and the lock row say so, and no surface calls a + // lock "final" (main, Q83/Q84). {paused, age, note} + function finalityWords(f) { + f = f || {}; + if (f.paused) return { paused: true, age: 'paused', note: f.line || 'Finality paused: under two thirds of the weight is signing' }; + if (f.last_lock > 0) return { paused: false, age: rel(f.age_s), note: 'A lock is a point the miners have agreed can never be undone. Checkpoints lock at 2/3 of the voting weight.' }; + return { paused: false, age: 'n/a', note: f.message || 'Locks appear once the miner votes on checkpoints.' }; + } + // 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.mode_reason || 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 (an older node)' : (n.port_note || '') }; + if (n.source === 'none') return { text: 'no node', sub: n.port_note || n.mode_reason || '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, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, 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, finalityWords: finalityWords, 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 @@ -1318,7 +1385,8 @@ if (typeof document !== 'undefined') (function () { function mountDag() { if (dag || typeof IgneumDag === 'undefined' || !$('dag-live')) return; try { - dag = IgneumDag.mount($('dag-live'), { compact: window.innerWidth < 720, window: 120, url: 'api/live', mine: 'you', tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'), onState: function (st, why) { + var mine = function (b) { if (!b) return false; if (b.miner === 'you') return true; if (!state) return false; return View.isMine(b.miner, state.mining.cards); }; + dag = IgneumDag.mount($('dag-live'), { compact: window.innerWidth < 720, window: 120, url: 'api/live', mine: mine, tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'), onData: function (d) { var n = View.liveNote(d); if (n && dagMode !== 'failed') setText('dag-note', n); }, onState: function (st, why) { var live = st === 'live' || st === 'stale'; if (dagMode !== st) { dagMode = st; setText('dag-state', st === 'live' ? 'live' : st === 'stale' ? 'behind' : st === 'failed' ? 'your blocks only' : 'connecting'); setText('dag-note', st === 'failed' ? 'The full chain view needs the next update. This is the last 10 minutes of your own blocks.' : st === 'stale' ? (why || '') : ''); } $('dag-live').hidden = !live; $('dag-tip').hidden = true; $('dag-pause').hidden = !live; $('dag').hidden = live; @@ -1499,9 +1567,9 @@ if (typeof document !== 'undefined') (function () { // the live total: rate, watts, £ a day, blocks setText('t-hash', m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1)); setText('t-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'card starting' : 'waiting for the node') : m.state === 'idle' ? 'no card mining' : m.state); - var watts = View.wattsTotal(m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : ''; + var watts = View.fleetWatts(m, m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : ''; setHidden('t-power-cell', !(watts > 0)); setText('t-power', String(Math.round(watts))); setText('t-power-sub', eff || 'drawn now'); - var ppd = View.poundsPerDay(watts, price); + var ppd = View.fleetPounds(m, m.cards, price); setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, pence per kWh' : 'electricity at ' + price + 'p/kWh'); setText('t-blocks', withCommas(m.accepted_session)); setText('t-blocks-sub', 'this run · ' + withCommas(m.accepted_total) + ' lifetime' + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : '')); // the rows: every card, on or off; rebuilt only when their words change and never under a hand on a slider @@ -1548,11 +1616,12 @@ if (typeof document !== 'undefined') (function () { var switchesSig = ''; function renderNode(s) { var n = s.node, f = s.finality, p = s.program, eta = syncEta(n, s.now); - var nl = View.nodeLine(n, s.clock, eta); - setText('node-line-text', nl.text); setText('node-sub', nl.sub); + var nl = View.nodeLine(n, s.clock, eta), fw = View.finalityWords(f); + setText('node-line-text', nl.text); setText('node-sub', fw.paused && nl.tone !== 'bad' ? fw.note : nl.sub); $('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*/, '') : '--'); @@ -1569,8 +1638,9 @@ if (typeof document !== 'undefined') (function () { switchesSig = sig; $('n-switches').innerHTML = (n.consensus_switches || []).map(function (x) { return '' + esc(x.name) + (x.daa <= n.daa ? ' (applied)' : '') + '' + withCommas(x.daa) + ''; }).join(''); } - if (f.last_lock > 0) { setText('f-lock', '#' + withCommas(f.last_lock) + ' · ' + rel(f.age_s)); setText('f-note', 'a point the miners agreed can never be undone. It locks at two thirds of the last 30 days of mining'); } - else { setText('f-lock', 'none yet'); setText('f-note', f.message || 'checkpoints lock once this machine and the others vote'); } + setText('f-lock', (f.last_lock > 0 ? '#' + withCommas(f.last_lock) : 'none yet') + ' · ' + fw.age); + setText('f-note', fw.paused ? fw.note : f.last_lock > 0 ? 'a point the miners agreed can never be undone. It locks at two thirds of the last 30 days of mining' : (f.message || 'checkpoints lock once this machine and the others vote')); + $('f-lock').classList.toggle('warn', !!fw.paused); setText('f-votes', withCommas(f.votes)); } @@ -1580,7 +1650,9 @@ if (typeof document !== 'undefined') (function () { var ign = pv.paid_wei && Number(pv.paid_wei) ? (Number(pv.paid_wei) / 1e18).toFixed(4) : '0.0000'; setText('e-ign', ign + ' IGN'); setText('e-ign-sub', 'from ' + (pv.paid || 0) + ' proof' + (pv.paid === 1 ? '' : 's') + (pv.segments_paid ? ' and ' + pv.segments_paid + ' segment' + (pv.segments_paid === 1 ? '' : 's') : '')); setText('e-blocks', withCommas(m.accepted_total) + ' block' + (m.accepted_total === 1 ? '' : 's')); setText('e-blocks-sub', 'lifetime · ' + withCommas(m.accepted_session) + ' this run, ' + uptime(s.uptime_s)); - var watts = View.wattsTotal(m.cards), ppd = View.poundsPerDay(watts, price); + var watts = View.fleetWatts(m, m.cards), ppd = View.fleetPounds(m, m.cards, price); + var bw = View.balanceWords(a, s.now); + if (bw.text) { setText('e-earned', bw.pounds !== null ? money(bw.pounds) : bw.text); setText('e-earned-sub', bw.pounds !== null ? 'at the signed price · ' + bw.text + ' ' + bw.sub : (bw.text === 'not read yet' ? 'balance ' + bw.sub : bw.sub + '. Nothing is bought or sold on devnet')); } setText('e-cost', ppd === null ? (watts > 0 ? Math.round(watts) + ' W' : 'no draw') : money(ppd) + ' a day'); setText('e-cost-sub', ppd === null ? (watts > 0 ? 'electricity: set a price to see the cost' : 'electricity: no card reports its draw') : 'electricity at ' + price + 'p/kWh for ' + Math.round(watts) + ' W'); setHidden('e-price-btn', !(watts > 0 && ppd === null)); diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index de7d6d616..5bd14791a 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -204,6 +204,7 @@