diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index f1c35ad1..4151cd78 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -4056,7 +4056,7 @@ impl Engine { 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 (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 + // 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 { diff --git a/app/igneum-app/src/extnode.rs b/app/igneum-app/src/extnode.rs index 9762c9e8..25764ac1 100644 --- a/app/igneum-app/src/extnode.rs +++ b/app/igneum-app/src/extnode.rs @@ -22,7 +22,7 @@ pub struct Check { 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 0.3.18 addition); + /// 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, } diff --git a/app/igneum-app/src/merge.rs b/app/igneum-app/src/merge.rs index 1d4c7ced..74a5e9ff 100644 --- a/app/igneum-app/src/merge.rs +++ b/app/igneum-app/src/merge.rs @@ -1,4 +1,4 @@ -//! 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 +//! 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.) //! diff --git a/app/igneum-app/src/update.rs b/app/igneum-app/src/update.rs index 16fdb0f5..ef2ce46e 100644 --- a/app/igneum-app/src/update.rs +++ b/app/igneum-app/src/update.rs @@ -40,9 +40,36 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { era * 146_097 + doe - 719_468 } +/// True once the node's exec follower holds a record (igneum_getExecStatus's executedTipHash is set). Any error or an +/// unreachable node reads false: the block sample waits rather than asks. +fn exec_has_record(evm_port: u16) -> bool { + let body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"igneum_getExecStatus\",\"params\":[]}"; + let out = crate::detect::run_timeout( + Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "5", "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", body]), + None, + Duration::from_secs(7), + ); + out.as_deref().map(exec_status_has_record).unwrap_or(false) +} + +/// The reading of an igneum_getExecStatus reply: a record is held when executedTipHash is a non-null string. +pub fn exec_status_has_record(reply: &str) -> bool { + serde_json::from_str::(reply) + .ok() + .and_then(|v| v.get("result")?.get("executedTipHash")?.as_str().map(|h| !h.is_empty())) + .unwrap_or(false) +} + /// The latest block's timestamp (unix seconds) from the node's Ethereum JSON-RPC (the execution layer mirrors the /// consensus block times). The first clock source: local time against what the peers produced. pub fn latest_block_time(evm_port: u16) -> Option { + // 0.3.18 (ledger N7, 7 October 2026): a node whose exec follower holds no record yet dies on eth_getBlockByNumber + // (rpc.rs indexes records[0] on an empty vector and the panic hook exits the process). The nodes before 0.3.18's + // fix are live on every machine, and this sample runs every 9 s once blocks arrive, so it asks igneum_getExecStatus + // first (it indexes nothing) and takes no block until the follower reports an executed tip. + if !exec_has_record(evm_port) { + return None; + } let body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}"; let out = crate::detect::run_timeout( Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "5", "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", body]), @@ -56,6 +83,15 @@ pub fn latest_block_time(evm_port: u16) -> Option { #[cfg(test)] mod tests { + /// Ledger N7: the clock sample must not ask eth_getBlockByNumber of a follower with no record + #[test] + fn exec_status_gate() { + assert!(super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"result":{"executedTip":"0x38a5","executedTipHash":"0xa3ae37ec31b9227c3855a1b25ba50ee9f6815bf00b0befcc8836b430baf04343"}}"#)); + assert!(!super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"result":{"executedTip":"0x0","executedTipHash":null}}"#)); + assert!(!super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}"#)); + assert!(!super::exec_status_has_record("")); + assert!(!super::exec_status_has_record("not json")); + } #[test] fn http_date() { assert_eq!(super::parse_http_date("Sun, 04 Oct 2026 11:17:47 GMT"), Some(1_791_112_667.0)); diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 2ecb2105..b2603b4d 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -465,7 +465,7 @@ var View = (function () { 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': - // 0.3.18: a node whose blocks never merge into the network (src/merge.rs) is behind with a tip that still moves + // 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' }; diff --git a/docs/plans/miner-ui-4.md b/docs/plans/miner-ui-4.md index e1514598..c79f0b72 100644 --- a/docs/plans/miner-ui-4.md +++ b/docs/plans/miner-ui-4.md @@ -114,3 +114,4 @@ Tests: UI 39; the app crate on the box 152 passed (the N4 pair, the shot path, t - Fix (src/watchdog.rs, src/engine.rs): a miner is judged only while the node is synced (the start, no-status and zero-rate clocks hold at now while it is not); a worker silent from its start with the node synced is one restart then a fault at 60 s (`START_S`), 90 s stays for a running one; a fault the node's readiness explains (`faulted_by_node`) is released by `Event::NodeSynced` when the node syncs, the card starts again with an Activity line; one Activity line per card at its first worker start. Test `a_worker_started_while_the_node_syncs_is_not_faulted_and_a_faulted_one_returns_at_sync` (recorded state), 173 box tests. - The pre-apply card set already persists (cards' enabled flags live in settings and the relaunch planned every enabled card); the fault, not the plan, was the failure. - Read-back changed the reading (relaunch-engine-and-workers.txt, 05:40 UK): PC 1's node reported synced=true from the first second; the miners started at +14, +25 and +38 s, subscribed, then every template fetch timed out at 5 s for 180 s while the node ran its finality catch-up (checkpoints 8053 to 8055 locking), so no status line was printed; the watchdog restarted each at 90 s and faulted each at 180 s. Rule added: "template fetch timed out" is the miner's heartbeat (`Event::TemplateTimeout`): the silence clocks start over from the line, the card reads "waiting for the node to answer block templates", one Activity line per episode, and the miner is judged again from its last line (60 s of nothing at all after it is still a restart). The synced-gate half stays. Test extended with the recorded sequence; 173 box tests. The node-side half (the template RPC blocking under the finality replay after a restart) is with the node lane. +- Renumbered again (main, 7 October 2026, 09:2x UK): 0.3.18 is an app-only cut on the 0.3.17 tree (the clock sample gated on igneum_getExecStatus, ledger N7, commit 0e6c1248); this tree ships as 0.3.19 (release-0.3.19, node release-0.3.19-node); decimals is 0.3.20. 0e6c1248's update.rs hunk is carried here so the branch is whole; the Mac check comes on the 0.3.19 apply.