diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index dc92379fb..e42dd83e4 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -56,6 +56,9 @@ pub enum Cmd { 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, @@ -547,6 +550,13 @@ 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 @@ -675,6 +685,11 @@ 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), @@ -970,7 +985,15 @@ 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) => { + if let Some(d) = info.overrides.as_ref().and_then(|p| p.get("merge_depth")).and_then(|v| v.as_u64()) { self.merge_depth = d; } if info.pow_engine.as_deref() == Some("stub") { let first = self.st().node.state != "stub"; let mut st = self.st(); @@ -3161,6 +3184,19 @@ 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(); @@ -4013,7 +4049,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, cause) = sync_decision_v2(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private, tip_age_s); + let (mut synced, mut cause) = sync_decision_v2(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private, tip_age_s); + // 0.3.18: 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) { @@ -4040,9 +4093,11 @@ impl Engine { 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" { "behind".into() } else if cause == "no peers" { "no peers".into() } else { "syncing".into() }; + 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 { @@ -4053,6 +4108,9 @@ impl Engine { "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)")); for m in self.miners.iter_mut() { diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 83bb08b55..797df40f1 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -40,6 +40,7 @@ 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..e1ffc85d8 --- /dev/null +++ b/app/igneum-app/src/merge.rs @@ -0,0 +1,93 @@ +//! Is this node's work merging into the network? (design note for 0.3.18, 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's params do not say (1 bps: 3,600 blocks, docs/fork-map.md f1). +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/ui/app.js b/app/igneum-app/ui/app.js index fc3711691..e250f158b 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -464,7 +464,10 @@ var View = (function () { switch (n.state) { 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': 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 'behind': + // 0.3.18: 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; diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs index ee1b0adb4..873b05598 100644 --- a/app/igneum-app/ui/view.test.mjs +++ b/app/igneum-app/ui/view.test.mjs @@ -432,6 +432,11 @@ test('N4 (6 October 2026): a frozen tip reads behind, never synced; the tip age assert.equal(V.toggle({ state: 'waiting', paused: false, cards: [card({ state: 'waiting' })] }, { synced: false, state: 'behind' }, {}).sub, 'waiting: the node is behind the chain'); }); +test('not merging (0.3.18 design note): behind with a moving tip, the miner held', () => { + const w = V.nodeWords({ state: 'behind', sync_cause: 'not merging', tip_age_s: 3, message: 'behind: your blocks are not merging into the network', blocks: 1, headers: 1, peers: 2 }, { severity: 'none' }, ''); + assert.equal(w.word, 'behind'); assert.equal(w.tone, 'bad'); assert.match(w.line, /not merging into the network/); assert.doesNotMatch(w.line, /No new block/); +}); + test('finality paused (ember-tune 1357d280): the words, no lock called final while paused', () => { const p = V.finalityWords({ paused: true, last_lock: 412, age_s: 1200, line: 'Finality paused since 18:39 UTC: under two thirds of the weight is signing' }); assert.equal(p.paused, true); assert.equal(p.age, 'paused'); assert.equal(p.note, 'Finality paused since 18:39 UTC: under two thirds of the weight is signing'); diff --git a/docs/plans/miner-ui-4.md b/docs/plans/miner-ui-4.md index ed1f94b6f..8fbb0d441 100644 --- a/docs/plans/miner-ui-4.md +++ b/docs/plans/miner-ui-4.md @@ -99,3 +99,9 @@ Tests: UI 39; the app crate on the box 152 passed (the N4 pair, the shot path, t - src/extnode.rs reads the node lane's `igneum_getNodeInfo` shape: `params` (every resolved consensus field, compared value by value against the manifest's keys), `network` (must be equal), `powEngine` ("stub" = refused on the port check, and a fault on the app's own node: node state `stub`, pill "Node fault", one error event). 168 box tests. - src/live.rs is Ember's merged module at the merge (nothing of this branch's lives there any more). - External node re-decided (the Mac, 7 October 2026 00:18 UK: the hand node left and the app sat on "node stopped" all night). While the app reads another node, or refused one, it checks the port every 5 s; gone for 60 s (`extnode::TAKEOVER_WAIT_S`) it starts its own node on the freed ports and says so in Activity; a node back inside the wait is kept. api/state node.mode own | external | none with node.mode_reason, shown under the Node details. Test `an_external_node_that_goes_away_hands_the_ports_to_the_app_after_the_wait` (169 box tests). + +## 10. 0.3.18 lane (7 October 2026, 03:20 UK): the merge check + +- Design note from the node lane's 30-s propagation reading: a node behind a slow link keeps mining its own chain and the N4 "synced" test cannot see it. New `src/merge.rs` (pure): `not_merging(MergeCheck)` is true when every sink the network reports is more than the merge depth ahead of ours (the node's `params.merge_depth` via igneum_getNodeInfo, else 3,600) and none of our blocks has appeared in the network's view for 120 s, after at least 120 s of mining. Known-failed test first (`a_node_whose_blocks_never_merge_reads_behind_and_holds_the_miner`). +- Engine: every 30 s while mining, the observer's /api/live (600-s window, this machine's lanes marked) gives the network's sink and the newest block of ours it shows (`merge::view_of`); the watch line's decision runs after sync_decision_v2; the state reads "behind" with sync_cause "not merging" and the message "behind: your blocks are not merging into the network"; the miner is held through the existing synced gate; one error event with the gap in blocks. Source owed: peers' own sinks (igneum_getNodeInfo over the peer list) when the node exposes them; the observer is the first view. +- UI: nodeWords' behind case names the cause. 42 UI tests, 172 box tests. Nothing of this on the Mac tonight.