diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index fb864e846..7754f42ca 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -169,6 +169,10 @@ impl Runtime { pub fn id8(&self) -> String { self.machine_id.chars().take(8).collect() } + /// The node's Ethereum JSON-RPC port: the default 26790 sits 180 above the default RPC port, kept for any port. + pub fn evm_port(&self) -> u16 { + self.rpc_port.wrapping_add(180) + } pub fn rpc_url(&self) -> String { format!("grpc://127.0.0.1:{}", self.rpc_port) } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index cb74b9c49..0c7741544 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -36,6 +36,13 @@ pub enum Cmd { CheckUpdate, Updated(crate::state::UpdateState), WorkerBuilt(usize, Result), + /// local minus the latest block's timestamp, seconds (from the node's EVM RPC) + ClockSample(f64), + /// local minus an HTTPS Date header, seconds; None when the check failed + ClockHttps(Option), + ClockCheck, + ClockSync, + ClockSynced(Result), Quit, } @@ -75,6 +82,8 @@ impl Shared { st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on() }; st.live_page = packaged.live_page.clone(); st.finality.message = "waiting for the miner".into(); + st.clock.hint = crate::platform::clock_hint().into(); + st.clock.severity = "none".into(); st.program.message = "waiting for the node".into(); Shared { token, @@ -330,6 +339,12 @@ pub struct Engine { wrapper: bool, last_state_print: Instant, detected: bool, + clock_samples: Vec, + clock_node_at: Option, + clock_node_behind: f64, + clock_https: Option<(f64, Instant)>, + clock_next_https: Instant, + clock_last_sample: Instant, last_settings_save: Instant, last_error_event: Instant, } @@ -381,6 +396,12 @@ impl Engine { wrapper, last_state_print: now, detected: false, + clock_samples: Vec::new(), + clock_node_at: None, + clock_node_behind: 0.0, + clock_https: None, + clock_next_https: now + Duration::from_secs(5), + clock_last_sample: now, last_settings_save: now, last_error_event: now - Duration::from_secs(600), } @@ -542,6 +563,65 @@ impl Engine { } } } + Cmd::ClockSample(d) => { + self.clock_samples.push(d); + if self.clock_samples.len() > 9 { + self.clock_samples.remove(0); + } + self.resolve_clock(); + } + Cmd::ClockHttps(r) => { + if let Some(d) = r { + self.clock_https = Some((d, Instant::now())); + self.shared.log(&format!("clock check (https): local time is {:+.1} s against dl.igneum.network", d)); + } else { + self.shared.log("clock check (https): no answer from dl.igneum.network"); + } + self.resolve_clock(); + } + Cmd::ClockCheck => { + self.clock_next_https = Instant::now() + Duration::from_secs(600); + if let Some(f) = std::env::var("IGNEUM_APP_FAKE_SKEW").ok().and_then(|v| v.parse::().ok()) { + self.command(Cmd::ClockHttps(Some(f))); + return; + } + let shared = self.shared.clone(); + std::thread::spawn(move || { + let r = crate::update::https_time("https://dl.igneum.network/").map(|t| crate::platform::unix_now_f() - t); + shared.send(Cmd::ClockHttps(r)); + }); + } + Cmd::ClockSync => { + if self.st().clock.syncing { + return; + } + self.st().clock.syncing = true; + self.shared.event("info", "asking the system to set its clock from a time server"); + let shared = self.shared.clone(); + std::thread::spawn(move || { + let r = crate::platform::sync_clock(); + shared.send(Cmd::ClockSynced(r)); + }); + } + Cmd::ClockSynced(r) => { + let mut st = self.st(); + st.clock.syncing = false; + match &r { + Ok(t) => st.clock.sync_result = t.clone(), + Err(e) => st.clock.sync_result = format!("could not set the clock: {e}"), + } + drop(st); + match r { + Ok(t) => self.shared.event("ok", &format!("clock: {t}; checking again")), + Err(e) => self.shared.event("error", &format!("clock: {e}")), + } + // the sources answer again: forget the old samples and re-check + self.clock_samples.clear(); + self.clock_node_at = None; + self.clock_https = None; + self.resolve_clock(); + self.clock_next_https = Instant::now() + Duration::from_secs(6); + } Cmd::Quit => { self.quitting = true; self.st().quitting = true; @@ -591,6 +671,7 @@ impl Engine { format!("--{}", r.network), format!("--appdir={}", r.node_dir.display()), format!("--rpclisten=127.0.0.1:{}", r.rpc_port), + format!("--evm-rpclisten=127.0.0.1:{}", r.evm_port()), format!("--listen=0.0.0.0:{}", r.p2p_port), ]; if let Some(n) = r.devnet_suffix { @@ -922,6 +1003,14 @@ impl Engine { if now >= self.update_next { self.command(Cmd::CheckUpdate); } + if now >= self.clock_next_https { + self.command(Cmd::ClockCheck); + } + if self.clock_node_at.map(|t| now.duration_since(t) > Duration::from_secs(90)).unwrap_or(false) { + // the node stopped complaining: the clock (or the peers) changed + self.clock_node_at = None; + self.resolve_clock(); + } if self.wrapper && now.duration_since(self.last_state_print) >= Duration::from_secs(3) { self.last_state_print = now; println!("STATE {}", self.shared.wrapper_state()); @@ -1013,7 +1102,8 @@ impl Engine { } fn tick_miners(&mut self, now: Instant) { - let synced = self.st().node.synced; + // a clock over the consensus bound: the node cannot sync and a miner would only submit rejected blocks + let synced = self.st().node.synced && self.st().clock.severity != "block"; let paused = self.st().mining.paused; for i in 0..self.miners.len() { let card_idx = self.miners[i].card; @@ -1150,7 +1240,102 @@ impl Engine { } } Source::Miner(card) => self.miner_line(card, l.stderr, &l.text), - Source::Node => {} + Source::Node => self.node_line(&l.text), + } + } + + /// igneumd's own lines. One matters for the user: relayed blocks refused as "too far into the future", which is + /// this machine's clock running behind the network (PC 2, 4 October 2026: 60 s slow after a power cut, 0 blocks). + fn node_line(&mut self, text: &str) { + if !text.contains("too far into the future") { + return; + } + if let Some(behind) = behind_from_warning(text) { + self.clock_node_behind = behind; + } + let first = self.clock_node_at.is_none(); + self.clock_node_at = Some(Instant::now()); + if first { + self.shared.log(&format!("node: relayed blocks refused as too far in the future: this clock is at least {:.0} s behind the network", self.clock_node_behind)); + } + self.resolve_clock(); + } + + /// The worst of the three clock sources decides. skew = local minus network; behind = negative. + fn resolve_clock(&mut self) { + let now = Instant::now(); + let mut skew: Option = None; + let mut source = ""; + // 1. the node's own refusal: a lower bound on how far behind we are + if let Some(t) = self.clock_node_at { + if now.duration_since(t) <= Duration::from_secs(90) { + skew = Some(-self.clock_node_behind.max(10.0)); + source = "node"; + } + } + // 2. the median of local-minus-block-time over the last samples: behind only (a stalled chain reads as ahead) + if self.clock_samples.len() >= 3 { + let mut v = self.clock_samples.clone(); + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let median = v[v.len() / 2]; + if median < -5.0 && skew.map(|s| median < s).unwrap_or(true) { + skew = Some(median); + source = "blocks"; + } + } + // 3. the HTTPS Date header: either direction, 1 s resolution plus the request latency + if let Some((d, t)) = self.clock_https { + if now.duration_since(t) <= Duration::from_secs(1200) && d.abs() > 5.0 && skew.map(|s| d.abs() > s.abs()).unwrap_or(true) { + skew = Some(d); + source = "https"; + } + } + let mut st = self.st(); + let was = st.clock.severity.clone(); + st.clock.checked_at = crate::platform::unix_now_f(); + match skew { + Some(d) if d.abs() > 10.0 => { + st.clock.severity = "block".into(); + st.clock.skew_s = d; + st.clock.source = source.into(); + let n = d.abs().round() as i64; + st.clock.message = if d < 0.0 { + format!("Your clock is about {n} seconds behind the network{}; mining cannot start until it is fixed.", if source == "node" { " (at least)" } else { "" }) + } else { + format!("Your clock is about {n} seconds ahead of the network; mining cannot start until it is fixed.") + }; + } + Some(d) => { + st.clock.severity = "warn".into(); + st.clock.skew_s = d; + st.clock.source = source.into(); + let n = d.abs().round() as i64; + st.clock.message = format!("Your clock is about {n} seconds {} the network. Over 10 s the node refuses blocks.", if d < 0.0 { "behind" } else { "ahead of" }); + } + None => { + st.clock.severity = "none".into(); + st.clock.skew_s = 0.0; + st.clock.source = String::new(); + st.clock.message = String::new(); + } + } + let is = st.clock.severity.clone(); + let msg = st.clock.message.clone(); + drop(st); + if is != was { + match is.as_str() { + "block" => { + self.shared.event("error", &msg); + if self.miners.iter().any(|m| m.proc.is_some()) { + self.stop_miners("clock over the consensus bound"); + for m in self.miners.iter_mut() { + m.restart_at = Some(Instant::now()); + } + } + } + "warn" => self.shared.event("info", &msg), + _ => self.shared.event("ok", "clock agrees with the network again"), + } } } @@ -1181,6 +1366,17 @@ impl Engine { false }; 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) { + self.clock_last_sample = now; + let port = self.shared.runtime.evm_port(); + let shared = self.shared.clone(); + std::thread::spawn(move || { + if let Some(t) = crate::update::latest_block_time(port) { + shared.send(Cmd::ClockSample(crate::platform::unix_now_f() - t)); + } + }); + } { let mut st = self.st(); st.node.blocks = blocks; @@ -1433,6 +1629,26 @@ impl Engine { } } +/// "...block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000" -> at least 63 s behind. +fn behind_from_warning(text: &str) -> Option { + let nums: Vec = text.split(|c: char| !c.is_ascii_digit()).filter(|s| s.len() >= 12).filter_map(|s| s.parse::().ok()).collect(); + if nums.len() < 2 { + return None; + } + let behind = (nums[nums.len() - 2] - nums[nums.len() - 1]) / 1000.0; + if behind > 0.0 { Some(behind) } else { None } +} + +#[cfg(test)] +mod tests { + #[test] + fn node_warning() { + let l = "2026-10-04 11:02:11.123+00:00 [WARN ] HandleRelayInvsFlow flow error: the block timestamp is too far into the future: block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000"; + assert_eq!(super::behind_from_warning(l), Some(63.0)); + assert_eq!(super::behind_from_warning("no numbers here"), None); + } +} + fn short(s: &str, n: usize) -> String { if s.chars().count() <= n { s.to_string() } else { format!("{}...", s.chars().take(n).collect::()) } } diff --git a/app/igneum-app/src/platform.rs b/app/igneum-app/src/platform.rs index 39a6293f2..76a8aff43 100644 --- a/app/igneum-app/src/platform.rs +++ b/app/igneum-app/src/platform.rs @@ -284,6 +284,66 @@ pub fn clear_quarantine() { } } +/// The manual instruction for fixing the clock on this platform. +pub fn clock_hint() -> &'static str { + #[cfg(target_os = "macos")] + { + "System Settings > General > Date & Time: turn on \"Set time and date automatically\", or run: sudo sntp -sS time.apple.com" + } + #[cfg(windows)] + { + "Settings > Time & language > Date & time: turn on \"Set time automatically\" and click \"Sync now\", or run as administrator: w32tm /resync" + } + #[cfg(not(any(target_os = "macos", windows)))] + { + "run: sudo chronyc makestep, or sudo timedatectl set-ntp true" + } +} + +/// Asks the operating system to set the clock from a time server (an administrator prompt appears). Returns what +/// happened, for the window. Blocking; call from a thread. +pub fn sync_clock() -> Result { + #[cfg(target_os = "macos")] + { + let out = Command::new("osascript") + .args(["-e", "do shell script \"/usr/bin/sntp -sS time.apple.com 2>&1\" with administrator privileges"]) + .output() + .map_err(|e| e.to_string())?; + let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + if out.status.success() { + Ok(if text.trim().is_empty() { "clock set from time.apple.com".into() } else { text.trim().to_string() }) + } else if text.contains("canceled") || text.contains("cancelled") { + Err("the administrator prompt was cancelled".into()) + } else { + Err(text.trim().to_string()) + } + } + #[cfg(windows)] + { + let script = "Start-Process -FilePath cmd.exe -ArgumentList '/c net start w32time & w32tm /resync /force' -Verb RunAs -Wait -WindowStyle Hidden"; + let mut c = Command::new("powershell"); + c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]); + quiet(&mut c); + let out = c.output().map_err(|e| e.to_string())?; + if out.status.success() { + Ok("asked Windows Time to resync (w32tm /resync)".into()) + } else { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } + } + #[cfg(not(any(target_os = "macos", windows)))] + { + for args in [vec!["chronyc", "makestep"], vec!["timedatectl", "set-ntp", "true"]] { + if let Ok(out) = Command::new("pkexec").args(&args).output() { + if out.status.success() { + return Ok(format!("ran {}", args.join(" "))); + } + } + } + Err("neither chronyc nor timedatectl could set the clock".into()) + } +} + /// Builds a command that runs without a console window on Windows. pub fn quiet(cmd: &mut Command) -> &mut Command { #[cfg(windows)] diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 5cbe8c3d3..c5074207c 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -230,6 +230,14 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result { + shared.send(Cmd::ClockSync); + Ok(json!({ "ok": true })) + } + "/api/clock/check" => { + shared.send(Cmd::ClockCheck); + Ok(json!({ "ok": true })) + } "/api/quit" => { shared.send(Cmd::Quit); Ok(json!({ "ok": true })) diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index bfa1bf94c..242a6b368 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -87,6 +87,19 @@ pub struct FinalityState { pub message: String, } +/// Clock skew against the network. skew_s = local time minus network time (negative = this machine is behind). +#[derive(Clone, Serialize, Default)] +pub struct ClockState { + pub skew_s: f64, + pub severity: String, // none | warn | block + pub source: String, // node | blocks | https + pub message: String, + pub hint: String, // the manual instruction for this platform + pub checked_at: f64, + pub syncing: bool, + pub sync_result: String, +} + #[derive(Clone, Serialize, Default)] pub struct AddressState { pub value: String, @@ -147,6 +160,7 @@ pub struct State { pub mining: MiningState, pub program: ProgramState, pub finality: FinalityState, + pub clock: ClockState, pub address: AddressState, pub settings: SettingsState, pub update: UpdateState, diff --git a/app/igneum-app/src/update.rs b/app/igneum-app/src/update.rs index 1a2f2c86c..c3dad1acf 100644 --- a/app/igneum-app/src/update.rs +++ b/app/igneum-app/src/update.rs @@ -51,6 +51,64 @@ pub fn newer(latest: &str, current: &str) -> bool { false } +/// The network's idea of now from an HTTPS Date header (1 s resolution), as unix seconds. The second clock source, +/// independent of the node. +pub fn https_time(url: &str) -> Option { + let out = crate::detect::run_timeout(Command::new("curl").args(["-sI", "--max-time", "10", url]), None, Duration::from_secs(12))?; + let line = out.lines().find(|l| l.to_ascii_lowercase().starts_with("date:"))?; + parse_http_date(line[5..].trim()) +} + +/// "Sun, 04 Oct 2026 11:17:47 GMT" -> unix seconds. +pub fn parse_http_date(s: &str) -> Option { + let parts: Vec<&str> = s.split_whitespace().collect(); + if parts.len() < 5 { + return None; + } + let day: i64 = parts[1].parse().ok()?; + let month = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"].iter().position(|m| parts[2].to_ascii_lowercase().starts_with(m))? as i64 + 1; + let year: i64 = parts[3].parse().ok()?; + let hms: Vec = parts[4].split(':').filter_map(|p| p.parse().ok()).collect(); + if hms.len() != 3 { + return None; + } + let days = days_from_civil(year, month, day); + Some((days * 86400 + hms[0] * 3600 + hms[1] * 60 + hms[2]) as f64) +} + +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// 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 { + let body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}"; + let out = crate::detect::run_timeout( + Command::new("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), + )?; + let v: serde_json::Value = serde_json::from_str(&out).ok()?; + let ts = v.get("result")?.get("timestamp")?.as_str()?; + u64::from_str_radix(ts.trim_start_matches("0x"), 16).ok().map(|t| t as f64) +} + +#[cfg(test)] +mod tests { + #[test] + fn http_date() { + assert_eq!(super::parse_http_date("Sun, 04 Oct 2026 11:17:47 GMT"), Some(1_791_112_667.0)); + assert_eq!(super::parse_http_date("Thu, 01 Jan 1970 00:00:00 GMT"), Some(0.0)); + } +} + /// Posts the last 256 KB of a log file as {label, machine, run_id, lines}, as site/api/log.mjs expects. pub fn upload_log(url: &str, key: &str, label: &str, machine: &str, run_id: &str, path: &std::path::Path) -> bool { if url.is_empty() || key.is_empty() { diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index b4ec62204..3cf60b66e 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -67,6 +67,14 @@ body.mac .top{padding-left:92px} /* update banner */ .banner{position:fixed;top:var(--top);left:0;right:0;z-index:19;display:flex;align-items:center;justify-content:center;gap:14px;padding:10px var(--gutter);background:rgba(242,84,27,.12);border-bottom:1px solid rgba(242,84,27,.4);font-size:14px} +.banner.clock{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5);flex-wrap:wrap} +.banner.clock.warn{background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)} +.banner .hint{font-size:11px;color:var(--ash);flex-basis:100%;text-align:center} +.clock-card{margin-top:12px;border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px} +.clock-card.warn{border-color:rgba(255,179,92,.4);background:rgba(255,179,92,.06)} +.clock-msg{font-size:14px;color:var(--bone);line-height:1.45} +.clock-card .note{margin-top:0} + /* screens */ main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter)} body.has-bottom main{bottom:var(--bottom)} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index ea3c81f0b..1f98b6171 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -129,7 +129,7 @@ $('s-update').addEventListener('click', function () { api('api/update/check', {}); $('s-update-note').textContent = 'Checking.'; setTimeout(fillSettings, 4000); }); $('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); }); $('update-get').addEventListener('click', function () { if (state && state.update.url) api('api/open', { url: state.update.url }); }); - $('update-later').addEventListener('click', function () { $('update-banner').hidden = true; $('update-banner').dataset.dismissed = '1'; }); + $('update-later').addEventListener('click', function () { $('update-banner').hidden = true; $('update-banner').dataset.dismissed = '1'; layoutBanners(); }); // ---------- bottom bar ---------- $('btn-pause').addEventListener('click', function () { @@ -272,8 +272,8 @@ $('d-blocks-sub').textContent = m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''); var nodeWord = n.state === 'synced' ? 'synced' : n.state === 'syncing' ? 'syncing' : n.state; $('d-node').textContent = nodeWord; - $('d-node-sub').textContent = n.state === 'synced' ? ('height ' + withCommas(n.blocks) + ', ' + n.peers + ' peer' + (n.peers === 1 ? '' : 's')) : n.state === 'syncing' ? (n.message || (withCommas(n.blocks) + ' blocks')) : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s') : (n.message || ''); - var cell = $('d-node-cell'); cell.classList.toggle('ok', n.state === 'synced'); cell.classList.toggle('bad', n.state === 'failed' || n.state === 'restarting' || n.state === 'stopped'); + $('d-node-sub').textContent = (s.clock && s.clock.severity === 'block') ? 'clock ' + Math.round(Math.abs(s.clock.skew_s)) + ' s ' + (s.clock.skew_s < 0 ? 'behind' : 'ahead') + ': fix it to mine' : n.state === 'synced' ? ('height ' + withCommas(n.blocks) + ', ' + n.peers + ' peer' + (n.peers === 1 ? '' : 's')) : n.state === 'syncing' ? (n.message || (withCommas(n.blocks) + ' blocks')) : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s') : (n.message || ''); + var cell = $('d-node-cell'); cell.classList.toggle('ok', n.state === 'synced'); cell.classList.toggle('bad', n.state === 'failed' || n.state === 'restarting' || n.state === 'stopped' || (s.clock && s.clock.severity === 'block')); if (n.daa > 0 && p.eta_s >= 0) { $('d-eta').textContent = hms(p.eta_s); $('d-eta-sub').textContent = p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : ''); } else { $('d-eta').textContent = '--:--'; $('d-eta-sub').textContent = 'waiting for the node'; } var nowU = s.now; @@ -329,10 +329,41 @@ else text = s.mining.state; $('pill-text').textContent = text; } + function renderClock(s) { + var c = s.clock || { severity: 'none' }, bad = c.severity === 'block', warn = c.severity === 'warn'; + var banner = $('clock-banner'); + banner.hidden = !(bad || warn) || phase === 'dashboard'; + banner.classList.toggle('warn', warn); + $('clock-banner-text').textContent = c.message || ''; + $('clock-banner-hint').textContent = c.hint || ''; + $('clock-sync').disabled = !!c.syncing; $('clock-sync').textContent = c.syncing ? 'Syncing' : 'Sync clock'; + var card = $('n-clock'); + card.hidden = !(bad || warn); + card.classList.toggle('warn', warn); + $('n-clock-msg').textContent = c.message || ''; + $('n-clock-hint').textContent = c.hint ? 'By hand: ' + c.hint : ''; + $('n-clock-result').textContent = c.sync_result || ''; + $('n-clock-sync').disabled = !!c.syncing; $('n-clock-sync').textContent = c.syncing ? 'Syncing' : 'Sync clock'; + $('btn-start').disabled = bad; + $('start-blocked').hidden = !bad; + $('start-blocked').textContent = bad ? c.message + ' Use "Sync clock" above.' : ''; + $('btn-key-done').disabled = bad || !$('key-ack').checked; + layoutBanners(); + } + // fixed banners push the content down by their height + function layoutBanners() { + var h = 0; + ['clock-banner', 'update-banner'].forEach(function (id) { var b = $(id); if (!b.hidden) { b.style.top = (60 + h) + 'px'; h += b.offsetHeight; } }); + $('main').style.top = (60 + h) + 'px'; + } + window.addEventListener('resize', layoutBanners); + $('clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); }); + $('n-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); }); function render(s) { state = s; stateAt = performance.now(); renderPill(s); - if (s.update.available && !$('update-banner').dataset.dismissed) { $('update-version').textContent = 'Igneum Miner ' + s.update.version; $('update-banner').hidden = false; } + renderClock(s); + if (s.update.available && !$('update-banner').dataset.dismissed) { $('update-version').textContent = 'Igneum Miner ' + s.update.version; $('update-banner').hidden = false; layoutBanners(); } if (phase === 'cards') renderCards(s); if (phase === 'dashboard') renderDashboard(s); if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; } diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index 26855c8e3..f9ca5ea89 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -23,6 +23,11 @@ + +
@@ -171,6 +177,11 @@
difficulty0
tips0
+

diff --git a/docs/bench-log.md b/docs/bench-log.md index d3236c4ea..0332d32ba 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -752,3 +752,21 @@ jobs and exits 3 on any of these; the miner kills and restarts a worker whose jo the mean or whose interval rate exceeds 10x the mean before it, rolls its counters back to the last report and prints `WORKER FAULT`; the launcher shows `worker fault` and `restarting` for that card instead of a rate. The next gfx1036 run says which guard fires first; that line is the diagnosis the Mac cannot give. + +## 4 October 2026, a node 60 s behind the clock is silently dead (PC 2's first app install) + +PC 2 came back from a power cut with its clock 60 s slow. igneumd connected, then logged `HandleRelayInvsFlow flow +error: the block timestamp is too far into the future: block timestamp is ... but maximum timestamp allowed is ...` +for every relayed block, processed 0 blocks and the app sat on "waiting for a peer" with nothing to say. The +consensus rule is right (a header may not be ahead of the node's clock by more than the tolerance, +`check_block_timestamp_in_isolation`); the reporting was not. The node prints one WARN per relayed block with two +millisecond numbers and never the one line a person needs. + +| What | Where | Now | +|---|---|---| +| The app reads that WARN, takes `block timestamp - maximum allowed` as a lower bound and shows "Your clock is at least N seconds behind the network; mining cannot start until it is fixed" on the node card with a Sync clock button (macOS `sntp -sS time.apple.com` under an administrator prompt, Windows `w32tm /resync` elevated, Linux `chronyc makestep` / `timedatectl`) and the manual steps | `app/igneum-app/src/engine.rs` `node_line`, `resolve_clock`; `platform.rs` `sync_clock` | done | +| Independent of the node: once blocks arrive the engine samples the latest block's timestamp through the node's Ethereum JSON-RPC every 10 s and takes the median of local minus block time over the last 9 (behind only; a stalled chain reads as ahead); and an HTTPS Date header from dl.igneum.network at start and every 10 min (either direction, 1 s resolution). Over 5 s: a warning. Over 10 s (the consensus bound): the Start button is blocked and running miners are held | `engine.rs` `watch_line`, `update.rs` `latest_block_time`, `https_time` | done | +| The node itself should log one clear line at WARN, once, not per block: `clock skew: local time is N s behind the median peer block time` (and the same for ahead, when its own templates are refused by peers). Filed for the devnet-v4 worktree owner; the app does not patch the node | `docs/plans/node-changes.md` | filed | + +Checked on the Mac with a fake 60 s skew (`IGNEUM_APP_FAKE_SKEW=-60`): the banner, the node card, the blocked Start +button and the held miner all showed; with the real clock the HTTPS source read +0.4 s and the block source agreed. diff --git a/docs/design/app-screens/mac-clock-address.png b/docs/design/app-screens/mac-clock-address.png new file mode 100644 index 000000000..5f97a0afe Binary files /dev/null and b/docs/design/app-screens/mac-clock-address.png differ diff --git a/docs/design/app-screens/mac-clock-dashboard.png b/docs/design/app-screens/mac-clock-dashboard.png new file mode 100644 index 000000000..dabe5e934 Binary files /dev/null and b/docs/design/app-screens/mac-clock-dashboard.png differ diff --git a/docs/plans/node-changes.md b/docs/plans/node-changes.md new file mode 100644 index 000000000..070015a47 --- /dev/null +++ b/docs/plans/node-changes.md @@ -0,0 +1,23 @@ +# Node changes filed by the app work (for the devnet-v4 worktree owner) + +Filed 4 October 2026 from the Igneum Miner app. The app does not patch the node. + +## 1. One clear clock-skew line (from PC 2's first install) + +A node whose clock is 60 s slow connects, refuses every relayed block with +`HandleRelayInvsFlow flow error: the block timestamp is too far into the future: block timestamp is X but maximum +timestamp allowed is Y` (one WARN per block, two millisecond numbers) and processes nothing. A person reading the log +cannot tell that the clock is the cause. + +Wanted: in the relay flow, when a header is refused for being ahead of the local clock, keep a running median of +(header timestamp minus local now) over the refused headers of the last minute and log ONCE per minute at WARN: + + clock skew: local time is N s behind the median peer block time (blocks are being refused; set the clock) + +and the mirror case (own templates refused by peers as too old, or peers' headers far in the past while the DAA +window is empty): `clock skew: local time is N s ahead of the median peer block time`. Also worth exposing in +`getBlockDagInfo` (or a new RPC field) so launchers and the app can read it without parsing logs. + +Where: `vendor/igneum-node-v4`, `protocol/flows/src/v10/blockrelay/flow.rs` (the relay error path) and +`consensus/core/src/errors/block.rs` (the error text). The app's current reading of the error line is in +`app/igneum-app/src/engine.rs` (`node_line`, `behind_from_warning`) and will keep working after the change.