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>
This commit is contained in:
parent
59bbf46689
commit
302d6e7055
6 changed files with 169 additions and 3 deletions
|
|
@ -52,6 +52,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,
|
||||
|
|
@ -539,6 +542,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
|
||||
|
|
@ -663,6 +673,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),
|
||||
|
|
@ -955,7 +970,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();
|
||||
|
|
@ -3094,6 +3117,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();
|
||||
|
|
@ -3896,7 +3932,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) {
|
||||
|
|
@ -3923,9 +3976,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 {
|
||||
|
|
@ -3936,6 +3991,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() {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ mod powertask;
|
|||
mod watchdog;
|
||||
mod live;
|
||||
mod extnode;
|
||||
mod merge;
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::sync::mpsc::channel;
|
||||
|
|
|
|||
93
app/igneum-app/src/merge.rs
Normal file
93
app/igneum-app/src/merge.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue