Igneum Miner: clock-skew detection (the node's too-far-in-the-future refusals, the median of local minus block time over the EVM RPC, an HTTPS Date header), warn over 5 s, block Start and hold the miners over 10 s, one-click Sync clock per platform; bench-log entry and the node-change note (a 60 s slow node is silently dead)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
558e02e372
commit
ad3fb1c39f
13 changed files with 457 additions and 6 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,13 @@ pub enum Cmd {
|
|||
CheckUpdate,
|
||||
Updated(crate::state::UpdateState),
|
||||
WorkerBuilt(usize, Result<PathBuf, String>),
|
||||
/// 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<f64>),
|
||||
ClockCheck,
|
||||
ClockSync,
|
||||
ClockSynced(Result<String, String>),
|
||||
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<f64>,
|
||||
clock_node_at: Option<Instant>,
|
||||
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::<f64>().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<f64> = 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<f64> {
|
||||
let nums: Vec<f64> = text.split(|c: char| !c.is_ascii_digit()).filter(|s| s.len() >= 12).filter_map(|s| s.parse::<f64>().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::<String>()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, String> {
|
||||
#[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)]
|
||||
|
|
|
|||
|
|
@ -230,6 +230,14 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
Err("only http(s) links open".into())
|
||||
}
|
||||
}
|
||||
"/api/clock/sync" => {
|
||||
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 }))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<f64> {
|
||||
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<f64> {
|
||||
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<i64> = 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<f64> {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<div class="banner clock" id="clock-banner" hidden>
|
||||
<span id="clock-banner-text"></span>
|
||||
<button class="btn small primary" id="clock-sync">Sync clock</button>
|
||||
<span class="hint mono" id="clock-banner-hint"></span>
|
||||
</div>
|
||||
<div class="banner" id="update-banner" hidden>
|
||||
<span><b id="update-version">A new version</b> of Igneum Miner is ready.</span>
|
||||
<button class="btn small primary" id="update-get">Download</button>
|
||||
|
|
@ -112,6 +117,7 @@
|
|||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="err" id="start-blocked" hidden></p>
|
||||
<div class="cta">
|
||||
<button class="btn primary big" id="btn-start">Start mining</button>
|
||||
<button class="btn ghost" id="btn-address-back">Back</button>
|
||||
|
|
@ -171,6 +177,11 @@
|
|||
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span></div>
|
||||
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span></div>
|
||||
</div>
|
||||
<div class="clock-card" id="n-clock" hidden>
|
||||
<p class="clock-msg" id="n-clock-msg"></p>
|
||||
<div class="row"><button class="btn small primary" id="n-clock-sync">Sync clock</button><span class="note mono small" id="n-clock-result"></span></div>
|
||||
<p class="note" id="n-clock-hint"></p>
|
||||
</div>
|
||||
<p class="note" id="n-note"></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
BIN
docs/design/app-screens/mac-clock-address.png
Normal file
BIN
docs/design/app-screens/mac-clock-address.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
BIN
docs/design/app-screens/mac-clock-dashboard.png
Normal file
BIN
docs/design/app-screens/mac-clock-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 308 KiB |
23
docs/plans/node-changes.md
Normal file
23
docs/plans/node-changes.md
Normal file
|
|
@ -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.
|
||||
Loading…
Reference in a new issue