Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/estate-clock/src/report.rs

//! **What the last sync concluded**, kept where a control plane can read it.
//!
//! The defect that made this module necessary was not the clock. It was that
//! the clock's outcome existed only as a log line on a VGA console, so the
//! re-image that was supposed to fix it could only be judged by guessing. A
//! report is data: `gunnar-server` records one per attempt, `gunnar-grpc` serves
//! the boot one and the last one, and `gunnar control status` prints the last
//! one **without a credential** — because when the clock is wrong, a credential
//! is exactly what the server refuses.
//!
//! Process-global on purpose. There is one `CLOCK_REALTIME` per machine; a
//! report threaded through every service context would be several copies of a
//! single fact.

use std::sync::Mutex;

use crate::adjust::Action;
use crate::decide::{Decision, Poll};

/// **Why an attempt ran.** Three triggers, and only these (owner's ruling,
/// 2026-09-14) — the clock is not re-measured "constantly".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trigger {
    /// Before any listener binds (the appliance) or the first sync of the
    /// process (the front). Corrections are steps.
    Boot,
    /// The appliance refused a credential as stale: the refusal is itself the
    /// evidence that its clock and the front's disagree. Rate-limited and
    /// data-free — see [`crate::trigger`]. A running server: slew or step.
    SkewRefusal,
    /// The slow safety timer (`crate::trigger::SAFETY_INTERVAL`). A running
    /// server: slew or step.
    Timer,
}

impl Trigger {
    /// Stable strings: they go on the wire.
    pub fn as_str(self) -> &'static str {
        match self {
            Trigger::Boot => "boot",
            Trigger::SkewRefusal => "skew-refusal",
            Trigger::Timer => "timer",
        }
    }
}

/// The one-word result. Stable strings: they go on the wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
    /// No source configured. Nothing was asked.
    NotConfigured,
    /// `off`, by name.
    Off,
    /// The configuration was refused (the detail says why).
    Misconfigured,
    /// The clock was stepped.
    Stepped,
    /// A slew was started.
    Slewed,
    /// The sources agree with this clock.
    AlreadyRight,
    /// Too few sources answered.
    NoQuorum,
    /// The sources could not be reconciled.
    Disagree,
    /// A correction was decided and the kernel refused it.
    ApplyFailed,
}

impl Outcome {
    pub fn as_str(self) -> &'static str {
        match self {
            Outcome::NotConfigured => "not-configured",
            Outcome::Off => "off",
            Outcome::Misconfigured => "misconfigured",
            Outcome::Stepped => "stepped",
            Outcome::Slewed => "slewed",
            Outcome::AlreadyRight => "already-right",
            Outcome::NoQuorum => "no-quorum",
            Outcome::Disagree => "disagree",
            Outcome::ApplyFailed => "apply-failed",
        }
    }

    /// True when, after this attempt, the clock is known to agree with its
    /// sources (or is being slewed onto them). What a front checks before it
    /// signs time for anybody else.
    pub fn vouches(self) -> bool {
        matches!(
            self,
            Outcome::Stepped | Outcome::Slewed | Outcome::AlreadyRight
        )
    }
}

/// One configured source's part in an attempt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceReport {
    pub source: String,
    /// `source - local` when it answered.
    pub offset_ms: Option<i64>,
    /// Why it contributed nothing, when it did not.
    pub error: Option<String>,
}

/// One attempt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
    pub trigger: Trigger,
    /// This machine's clock when the report was made (after any step).
    pub at_unix_ms: i64,
    pub outcome: Outcome,
    /// The decided offset; 0 when there was none.
    pub offset_ms: i64,
    /// One sentence for a human.
    pub detail: String,
    pub sources: Vec<SourceReport>,
}

impl Report {
    /// A report with no sources: not configured, off, or misconfigured.
    pub fn without_sources(phase: Trigger, outcome: Outcome, detail: impl Into<String>) -> Report {
        Report {
            trigger: phase,
            at_unix_ms: crate::now_unix_ms(),
            outcome,
            offset_ms: 0,
            detail: detail.into(),
            sources: Vec::new(),
        }
    }

    /// The report for a poll and what was done about it. Pure apart from
    /// reading the clock for `at_unix_ms`.
    pub fn from_poll(
        phase: Trigger,
        poll: &Poll,
        action: Action,
        applied: Result<(), String>,
    ) -> Report {
        let mut sources: Vec<SourceReport> = poll
            .answers
            .iter()
            .map(|answer| match answer {
                Ok(sample) => SourceReport {
                    source: sample.source.clone(),
                    offset_ms: Some(sample.offset_ms),
                    error: None,
                },
                Err((source, why)) => SourceReport {
                    source: source.clone(),
                    offset_ms: None,
                    error: Some(why.clone()),
                },
            })
            .collect();
        for (source, claimed_ms) in &poll.verdict.insane {
            if let Some(row) = sources.iter_mut().find(|r| &r.source == source) {
                row.error = Some(format!(
                    "claims {claimed_ms} ms since the epoch, outside the sanity window"
                ));
            }
        }
        let (outcome, offset_ms, detail) = match (&poll.verdict.decision, action, applied) {
            (Decision::Correct { offset_ms, agreed }, Action::Step(_), Ok(())) => (
                Outcome::Stepped,
                *offset_ms,
                format!(
                    "clock stepped {:+} ms{} from {}",
                    offset_ms,
                    if phase != Trigger::Boot { " (live: beyond the slew ceiling)" } else { "" },
                    agreed.join(", ")
                ),
            ),
            (Decision::Correct { offset_ms, agreed }, Action::Slew(_), Ok(())) => (
                Outcome::Slewed,
                *offset_ms,
                format!("clock slewing {offset_ms:+} ms towards {}", agreed.join(", ")),
            ),
            (Decision::Correct { offset_ms, .. }, _, Err(why)) => (
                Outcome::ApplyFailed,
                *offset_ms,
                format!("a {offset_ms:+} ms correction was decided and could not be applied: {why}"),
            ),
            (Decision::Correct { offset_ms, .. }, Action::Leave, Ok(())) => (
                Outcome::AlreadyRight,
                *offset_ms,
                format!("{offset_ms:+} ms is below the correction threshold"),
            ),
            (Decision::AlreadyRight { offset_ms, agreed }, _, _) => (
                Outcome::AlreadyRight,
                *offset_ms,
                format!("clock agrees with {} ({offset_ms:+} ms)", agreed.join(", ")),
            ),
            (Decision::NoQuorum { had, needed }, _, _) => (
                Outcome::NoQuorum,
                0,
                format!(
                    "clock unchanged: {had} usable answer(s) from {} configured source(s), {needed} needed",
                    poll.answers.len()
                ),
            ),
            (Decision::Disagree { spread_ms, .. }, _, _) => (
                Outcome::Disagree,
                0,
                format!(
                    "clock unchanged: the sources disagree by {spread_ms} ms, and a disagreement is \
                     refused rather than averaged"
                ),
            ),
        };
        Report {
            trigger: phase,
            at_unix_ms: crate::now_unix_ms(),
            outcome,
            offset_ms,
            detail,
            sources,
        }
    }

    /// One line: `periodic stepped -7199500 ms at 1789…: <detail>`.
    pub fn summary(&self) -> String {
        format!(
            "{} {} {:+} ms at {} — {}",
            self.trigger.as_str(),
            self.outcome.as_str(),
            self.offset_ms,
            self.at_unix_ms,
            self.detail
        )
    }
}

struct Store {
    boot: Option<Report>,
    last: Option<Report>,
}

static STORE: Mutex<Store> = Mutex::new(Store {
    boot: None,
    last: None,
});

/// Keep `report` as the last one — and as the boot one, when it is.
pub fn record(report: Report) {
    let mut store = STORE
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    if report.trigger == Trigger::Boot {
        store.boot = Some(report.clone());
    }
    store.last = Some(report);
}

/// The most recent attempt, if any has run in this process.
pub fn last() -> Option<Report> {
    STORE.lock().unwrap_or_else(|p| p.into_inner()).last.clone()
}

/// The boot attempt, if it has run.
pub fn boot() -> Option<Report> {
    STORE.lock().unwrap_or_else(|p| p.into_inner()).boot.clone()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::adjust::choose;
    use crate::decide::Sample;

    fn sample(source: &str, offset_ms: i64) -> Result<Sample, (String, String)> {
        Ok(Sample {
            source: source.into(),
            source_unix_ms: 1_789_394_521_628,
            offset_ms,
        })
    }

    #[test]
    fn an_unreachable_peer_changes_nothing_and_is_reported_by_name() {
        let poll = Poll::over(vec![Err((
            "peer://10.13.8.99:50123".into(),
            "peer://10.13.8.99:50123 did not accept a connection: timed out".into(),
        ))]);
        let action = choose(Trigger::Boot, &poll.verdict.decision);
        assert_eq!(action, Action::Leave);
        let report = Report::from_poll(Trigger::Boot, &poll, action, Ok(()));
        assert_eq!(report.outcome, Outcome::NoQuorum);
        assert!(!report.outcome.vouches());
        assert_eq!(report.sources.len(), 1);
        assert!(report.sources[0]
            .error
            .as_deref()
            .unwrap()
            .contains("did not accept"));
        assert!(report.summary().contains("no-quorum"));
    }

    #[test]
    fn a_boot_step_and_a_periodic_slew_are_reported_as_what_they_were() {
        let poll = Poll::over(vec![sample("peer://10.13.8.99:50123", -7_199_500)]);
        let report = Report::from_poll(Trigger::Boot, &poll, Action::Step(-7_199_500), Ok(()));
        assert_eq!(
            (report.outcome, report.offset_ms),
            (Outcome::Stepped, -7_199_500)
        );
        assert!(report.outcome.vouches());

        let poll = Poll::over(vec![sample("peer://10.13.8.99:50123", 3_000)]);
        let report = Report::from_poll(Trigger::Timer, &poll, Action::Slew(3_000), Ok(()));
        assert_eq!(report.outcome, Outcome::Slewed);
    }

    #[test]
    fn a_correction_the_kernel_refused_is_reported_and_does_not_vouch() {
        let poll = Poll::over(vec![sample("peer://10.13.8.99:50123", -7_199_500)]);
        let report = Report::from_poll(
            Trigger::Boot,
            &poll,
            Action::Step(-7_199_500),
            Err("Operation not permitted".into()),
        );
        assert_eq!(report.outcome, Outcome::ApplyFailed);
        assert!(!report.outcome.vouches());
        assert!(report.detail.contains("Operation not permitted"));
    }

    #[test]
    fn the_store_keeps_the_boot_report_after_later_ones() {
        record(Report::without_sources(
            Trigger::Boot,
            Outcome::NotConfigured,
            "boot",
        ));
        record(Report::without_sources(
            Trigger::Timer,
            Outcome::Off,
            "later",
        ));
        assert_eq!(boot().unwrap().detail, "boot");
        // `last` is shared with every other test that syncs in this binary, so
        // only its presence is asserted; `boot` is recorded by this test alone.
        assert!(last().is_some());
    }
}