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:
parent
4f6cfbfba7
commit
59bbf46689
6 changed files with 123 additions and 10 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue