miner-ui-4 (0.3.18): a node whose blocks never merge reads behind and holds the miner

src/merge.rs, pure: every network sink more than the merge depth ahead and none of our blocks in the network's view for 120 s. Engine polls the observer's view every 30 s while mining and runs the check after sync_decision_v2; state behind, sync_cause not merging, one error event. Known-failed test first; 172 box tests, 42 UI tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 302d6e7055)
This commit is contained in:
igneum-labs 2026-10-07 03:16:20 +00:00
parent eb32bc3a80
commit 7d7041967c
6 changed files with 169 additions and 3 deletions

View file

@ -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<i64> },
/// local minus an HTTPS Date header, seconds; None when the check failed
ClockHttps(Option<f64>),
ClockCheck,
@ -547,6 +550,13 @@ pub struct Engine {
lines_tx: Sender<Line>,
node: Option<Proc>,
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<u64>,
merge_ours_seen: Option<Instant>,
merge_depth: u64,
merge_mining_since: Option<Instant>,
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<String> = 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() {

View file

@ -40,6 +40,7 @@ mod powertask;
mod watchdog;
mod live;
mod extnode;
mod merge;
use std::io::{BufRead, Write};
use std::sync::mpsc::channel;

View file

@ -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<u64>,
/// 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<f64>,
/// 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<i64>)> {
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);
}
}

View file

@ -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;

View file

@ -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');

View file

@ -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.