From 59bbf46689aca900864554885e766bca64c87dcd Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:31:42 +0000 Subject: [PATCH] miner-ui-4: an external node that goes away hands the ports to the app after 60 s The mode (own | external | none) is re-decided every 5 s while the app reads or refuses another node; gone for 60 s, the app starts its own node and says so in Activity. node.mode and node.mode_reason in api/state and on the Node details. Pure extnode::step with the goes-away test first. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/engine.rs | 67 +++++++++++++++++++++++++++++---- app/igneum-app/src/extnode.rs | 51 +++++++++++++++++++++++++ app/igneum-app/src/state.rs | 6 +++ app/igneum-app/ui/app.js | 4 +- app/igneum-app/ui/view.test.mjs | 4 ++ docs/plans/miner-ui-4.md | 1 + 6 files changed, 123 insertions(+), 10 deletions(-) diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 8d2c9151..a7fc23e0 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -539,6 +539,10 @@ pub struct Engine { lines_tx: Sender, node: Option, node_external: bool, + /// 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 @@ -659,6 +663,8 @@ impl Engine { lines_tx, node: None, node_external: false, + node_refused: false, + external_gone_since: None, node_info_at: Instant::now() - Duration::from_secs(25), node_restart_at: None, resume_check_at: None, @@ -1421,7 +1427,7 @@ impl Engine { 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.st().node.source = "app".into(); + 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(); @@ -1462,6 +1468,9 @@ impl Engine { 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(); @@ -1470,7 +1479,6 @@ impl Engine { if d.use_it { self.shared.event(if d.verdict == "match" { "info" } else { "error" }, &d.line); let mut st = self.st(); - st.node.source = "external".into(); 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(); } @@ -1480,7 +1488,6 @@ impl Engine { } else { { let mut st = self.st(); - st.node.source = "none".into(); st.node.state = "stopped".into(); st.node.synced = false; st.node.message = d.line.clone(); @@ -1490,6 +1497,38 @@ impl Engine { } } + /// 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; @@ -3311,12 +3350,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; } diff --git a/app/igneum-app/src/extnode.rs b/app/igneum-app/src/extnode.rs index 19612b1a..e4d512f1 100644 --- a/app/igneum-app/src/extnode.rs +++ b/app/igneum-app/src/extnode.rs @@ -73,6 +73,38 @@ pub fn decide_on(port: u16, ours: Option<&Value>, our_chain: Option, our_ne } } +/// 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(); @@ -169,6 +201,25 @@ mod tests { 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())); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index b03d21af..aba451b4 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -34,6 +34,12 @@ pub struct NodeState { 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 diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 9d6a852e..fc371169 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -764,9 +764,9 @@ var View = (function () { // 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.port_note || '' }; + 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 (it predates 0.3.17)' : (n.port_note || '') }; - if (n.source === 'none') return { text: 'no node', sub: n.port_note || 'the ports are taken by a node on other rules' }; + 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) diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs index 783e1723..ee1b0adb 100644 --- a/app/igneum-app/ui/view.test.mjs +++ b/app/igneum-app/ui/view.test.mjs @@ -452,6 +452,10 @@ test('whose node (publish 2): the app’s own, another node on this machine chec assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'unknown' }), { text: 'another node on this machine', sub: 'its rules could not be checked (it predates 0.3.17)' }); assert.deepEqual(V.nodeSource({ source: 'none', port_note: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' }), { text: 'no node', sub: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' }); assert.deepEqual(V.nodeSource({}), { text: '', sub: '' }); + // 7 October 2026: the mode is re-decided; after the other node leaves for 60 s the app runs its own and says why + assert.deepEqual(V.nodeSource({ source: 'app', mode: 'own', mode_reason: 'the node this app was reading left port 26611 for 60 s, so the app started its own node' }), { text: 'this app’s own node', sub: 'the node this app was reading left port 26611 for 60 s, so the app started its own node' }); + const gone = V.nodeWords({ state: 'stopped', source: 'external', message: 'the other node on port 26611 went away; this app starts its own node in 45 s unless it comes back', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, ''); + assert.equal(gone.word, 'stopped'); assert.match(gone.line, /starts its own node in 45 s/); const taken = V.nodeWords({ state: 'stopped', source: 'none', message: 'Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, ''); assert.equal(taken.word, 'not started'); assert.equal(taken.tone, 'bad'); assert.match(taken.line, /^Node not started: port 26611 is taken/); assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stopped', source: 'none' } }), { text: 'Node not started', tone: 'bad' }); diff --git a/docs/plans/miner-ui-4.md b/docs/plans/miner-ui-4.md index 12d3754b..ed1f94b6 100644 --- a/docs/plans/miner-ui-4.md +++ b/docs/plans/miner-ui-4.md @@ -98,3 +98,4 @@ Tests: UI 39; the app crate on the box 152 passed (the N4 pair, the shot path, t - Rebased onto a9eb58f1 (origin/release-0.3.15). ui/ comes wholly from this branch; Ember's finality-paused hunk (ember-tune 1357d280) is folded in as `View.finalityWords(f)`: while finality is paused the node sub-line and the lock row say "paused" and nothing calls a lock final. Test in view.test.mjs (41 UI tests). - 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).