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 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 23:31:42 +00:00
parent 4f6cfbfba7
commit 59bbf46689
6 changed files with 123 additions and 10 deletions

View file

@ -539,6 +539,10 @@ pub struct Engine {
lines_tx: Sender<Line>, lines_tx: Sender<Line>,
node: Option<Proc>, node: Option<Proc>,
node_external: bool, 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<f64>,
node_info_at: Instant, node_info_at: Instant,
node_restart_at: Option<Instant>, node_restart_at: Option<Instant>,
/// resume rule (5 October 2026): when due, every enabled card must be mining or its name goes to the log /// 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, lines_tx,
node: None, node: None,
node_external: false, node_external: false,
node_refused: false,
external_gone_since: None,
node_info_at: Instant::now() - Duration::from_secs(25), node_info_at: Instant::now() - Duration::from_secs(25),
node_restart_at: None, node_restart_at: None,
resume_check_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))); }); std::thread::spawn(move || { shared.send(Cmd::ExternalNode(crate::extnode::probe(evm_port))); });
} else { } else {
crate::extnode::prune_logs(&self.shared.runtime.log_dir, "node-", 10); 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.start_node();
} }
self.plan_miners(); self.plan_miners();
@ -1462,6 +1468,9 @@ impl Engine {
let network = self.shared.runtime.network.clone(); let network = self.shared.runtime.network.clone();
let d = crate::extnode::decide_on(port, ours.as_ref(), None, Some(network.as_str()), &check); let d = crate::extnode::decide_on(port, ours.as_ref(), None, Some(network.as_str()), &check);
self.node_external = d.use_it; 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(); let mut st = self.st();
st.node.rules_check = d.verdict.into(); st.node.rules_check = d.verdict.into();
@ -1470,7 +1479,6 @@ impl Engine {
if d.use_it { if d.use_it {
self.shared.event(if d.verdict == "match" { "info" } else { "error" }, &d.line); self.shared.event(if d.verdict == "match" { "info" } else { "error" }, &d.line);
let mut st = self.st(); let mut st = self.st();
st.node.source = "external".into();
st.node.state = "syncing".into(); st.node.state = "syncing".into();
st.node.message = "another node on this machine".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(); } 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 { } else {
{ {
let mut st = self.st(); let mut st = self.st();
st.node.source = "none".into();
st.node.state = "stopped".into(); st.node.state = "stopped".into();
st.node.synced = false; st.node.synced = false;
st.node.message = d.line.clone(); 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<std::path::PathBuf> { fn node_override_file(&self) -> Option<std::path::PathBuf> {
if self.node_override_unusable { if self.node_override_unusable {
return None; return None;
@ -3311,12 +3350,24 @@ impl Engine {
} }
fn tick_node(&mut self, now: Instant) { fn tick_node(&mut self, now: Instant) {
if self.node_external { if self.node_external || self.node_refused {
if !port_open(self.shared.runtime.rpc_port) && now.duration_since(self.last_sync_check) >= Duration::from_secs(5) { // 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; self.last_sync_check = now;
let mut st = self.st(); let port = self.shared.runtime.rpc_port;
st.node.state = "stopped".into(); let first = self.external_gone_since.is_none();
st.node.message = "the external node went away".into(); 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; return;
} }

View file

@ -73,6 +73,38 @@ pub fn decide_on(port: u16, ours: Option<&Value>, our_chain: Option<u64>, 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<f64>) -> 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). /// 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<Value> { pub fn rpc(evm_port: u16, method: &str, params: Value, limit: Duration) -> Option<Value> {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string(); 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"); 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] #[test]
fn prune_keeps_the_newest_logs() { fn prune_keeps_the_newest_logs() {
let dir = std::env::temp_dir().join(format!("igneum-prune-test-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("igneum-prune-test-{}", std::process::id()));

View file

@ -34,6 +34,12 @@ pub struct NodeState {
pub port_note: String, pub port_note: String,
/// where consensus_digest came from: "rpc" (igneum_getNodeInfo), "log" (the node's own stdout line), "" /// where consensus_digest came from: "rpc" (igneum_getNodeInfo), "log" (the node's own stdout line), ""
pub digest_source: String, 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" /// why the node is not "synced", in plain words ("" when synced): "behind" | "no peers" | "syncing" | "frozen"
pub sync_cause: String, pub sync_cause: String,
/// the miner's stall exits (code 45, "STALLED") since the node last started; the second one restarts the node /// the miner's stall exits (code 45, "STALLED") since the node last started; the second one restarts the node

View file

@ -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 | "" // whose node the app reads (publish 2's read-back, 6 October 2026): n.source app | external | none | ""
function nodeSource(n) { function nodeSource(n) {
n = 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 === '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: '' }; return { text: '', sub: '' };
} }
// the confirmations' words (in place, never a dialog) // the confirmations' words (in place, never a dialog)

View file

@ -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: '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({ 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: '' }); 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' }, ''); 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.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' }); 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' });

View file

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