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

nordisk/edda

crates/nordisk-console-switch/src/lib.rs

//! **A console's administrator switch, the pure half, once** (RA2-15).
//!
//! gunnar-ui (the public site, the MCP connector) and holger-ui (the public site) each keep
//! an administrator's switch as their server's generic setting, the bytes `on` or `off`
//! under a name the console chooses. The server knows no setting name. The two consoles
//! had the same rules in two hand copies. They live here now, and each console keeps only
//! its own transport (gunnar's gRPC `Admin.GetSetting`, holger's `/-/settings` door).
//!
//! The rules (RA2-1):
//! * the stored position is the authority; the environment's (or the front's feed) is only
//!   the INITIAL position, taken while nothing is stored;
//! * the switch **fails closed**: until the store has answered it is OFF, and a stored value
//!   that is neither `on` nor `off` is OFF too (a damaged store);
//! * the console reads the stored position again every [`RECHECK`], each read with a
//!   [`READ_DEADLINE`], so a flip made elsewhere is seen with no restart.
//!
//! The follower of a stored setting is here too ([`Follow`]): the back-off, the last answer kept
//! through a store that does not answer, each alert said once, and a read older than a flip on
//! this console not taken; each console keeps only its read, its sleep and its words.
//!
//! std only: no JSON, no HTTP, no async runtime.

#![forbid(unsafe_code)]

use std::time::Duration;

/// How often a console reads the stored position again.
pub const RECHECK: Duration = Duration::from_secs(10);
/// How long one read may take before it counts as unanswered.
pub const READ_DEADLINE: Duration = Duration::from_secs(5);

/// The bytes a position is stored as.
pub fn stored(on: bool) -> &'static [u8] {
    if on { b"on" } else { b"off" }
}

/// The stored bytes as a position: `on` or `off`; anything else is no position.
pub fn position(value: &[u8]) -> Option<bool> {
    match value {
        b"on" => Some(true),
        b"off" => Some(false),
        _ => None,
    }
}

/// **What the store holds under a switch's name**: nothing, a position, or bytes that are
/// neither `on` nor `off` (a damaged value, or a store that says it is damaged).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stored {
    Unset,
    At(bool),
    Damaged,
}

/// The store's answer (`present`, `value`) as what it holds.
pub fn stored_of(present: bool, value: &[u8]) -> Stored {
    if !present {
        return Stored::Unset;
    }
    position(value).map_or(Stored::Damaged, Stored::At)
}

/// **The position the switch shows**, from its initial position and the last thing the
/// store answered (`None`: nothing yet). Fails closed: nothing read and a damaged store are
/// OFF; only "nothing stored" takes the initial position.
pub fn effective(initial: bool, last: Option<Stored>) -> bool {
    match last {
        None | Some(Stored::Damaged) => false,
        Some(Stored::Unset) => initial,
        Some(Stored::At(on)) => on,
    }
}

/// **What a read's answer asks the console to say** (each console words it in its own log).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Said {
    /// The store holds a value the console cannot use: OFF until an administrator sets it
    /// (an ALERT, said once per damage).
    Damaged,
    /// The first read failed and nothing was ever read: OFF until it is (an ALERT).
    Unread(String),
    /// A read failed after a good one: the last answer is kept (a warning).
    Kept(String),
    /// The store answers again after `failures` reads that did not.
    Again(u32),
}

/// **One step of following a stored setting**: how long until the next read, and what to say.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Step {
    pub pause: Duration,
    /// In the order said (a store answering again AND damaged says both).
    pub said: Vec<Said>,
}

/// **Following a stored setting for the life of a console** (the one loop both consoles ran by
/// hand): the last answer, kept through a store that does not answer; reads that fail back off
/// (`200 ms << failures`, at most the recheck); a read older than a flip made on this console is
/// not taken. Pure: the console reads, sleeps and applies; this says what the answers mean.
#[derive(Clone, Debug)]
pub struct Follow<S> {
    last: Option<S>,
    failures: u32,
}

impl<S: Clone + PartialEq> Default for Follow<S> {
    fn default() -> Self {
        Follow { last: None, failures: 0 }
    }
}

impl<S: Clone + PartialEq> Follow<S> {
    /// The last answer taken (`None`: nothing yet).
    pub fn last(&self) -> Option<&S> {
        self.last.as_ref()
    }

    /// **Take one read's answer**: `older_than_a_flip` when this console flipped the setting
    /// while the read was on its way; `damaged` names a stored value the console cannot use;
    /// `recheck` is how often a good read is repeated.
    pub fn answered(&mut self, answer: Result<S, String>, older_than_a_flip: bool, damaged: impl Fn(&S) -> bool, recheck: Duration) -> Step {
        match answer {
            Ok(stored) => {
                let mut said: Vec<Said> = (self.failures > 0).then_some(Said::Again(self.failures)).into_iter().collect();
                self.failures = 0;
                if !older_than_a_flip {
                    if damaged(&stored) && !self.last.as_ref().is_some_and(&damaged) {
                        said.push(Said::Damaged);
                    }
                    self.last = Some(stored);
                }
                Step { pause: recheck, said }
            }
            Err(why) => {
                self.failures = self.failures.saturating_add(1);
                let said = (self.failures == 1).then(|| if self.last.is_none() { Said::Unread(why) } else { Said::Kept(why) }).into_iter().collect();
                Step { pause: Duration::from_millis(200u64 << self.failures.min(6)).min(recheck), said }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_position_is_stored_as_on_or_off_and_read_back() {
        assert_eq!((stored(true), stored(false)), (&b"on"[..], &b"off"[..]));
        assert_eq!(position(b"on"), Some(true));
        assert_eq!(position(b"off"), Some(false));
        for other in [&b""[..], b"On", b"yes", b"1", b"on\n"] {
            assert_eq!(position(other), None, "{other:?}");
        }
    }

    #[test]
    fn the_store_holds_nothing_a_position_or_a_damaged_value() {
        assert_eq!(stored_of(false, b"on"), Stored::Unset, "absent wins over stale bytes");
        assert_eq!(stored_of(true, b"on"), Stored::At(true));
        assert_eq!(stored_of(true, b"off"), Stored::At(false));
        assert_eq!(stored_of(true, b"y"), Stored::Damaged);
    }

    #[test]
    fn until_the_store_answers_the_switch_is_off() {
        assert!(!effective(true, None), "nothing read yet: OFF, whatever the environment says");
        assert!(!effective(true, Some(Stored::Damaged)), "a damaged store: OFF");
        assert!(effective(true, Some(Stored::Unset)), "nothing stored: the initial position");
        assert!(!effective(false, Some(Stored::Unset)));
        assert!(!effective(true, Some(Stored::At(false))), "an administrator's OFF wins");
        assert!(effective(false, Some(Stored::At(true))), "and so does an ON");
    }

    /// ★ The loop both consoles kept by hand: a store that does not answer backs off and keeps
    /// the last answer; the first failure with nothing read is an ALERT; a damaged value is said
    /// once; a read older than this console's own flip is not taken.
    #[test]
    fn following_keeps_the_last_answer_backs_off_and_says_each_thing_once() {
        let r = RECHECK;
        let dmg = |s: &Stored| *s == Stored::Damaged;
        let mut f: Follow<Stored> = Follow::default();
        assert_eq!(f.last(), None);
        let one = f.answered(Err("down".into()), false, dmg, r);
        assert_eq!(one, Step { pause: Duration::from_millis(400), said: vec![Said::Unread("down".into())] });
        let two = f.answered(Err("down".into()), false, dmg, r);
        assert_eq!(two, Step { pause: Duration::from_millis(800), said: vec![] }, "said once");
        for _ in 0..10 {
            assert!(f.answered(Err("down".into()), false, dmg, r).pause <= r, "the back-off is capped at the recheck");
        }
        let back = f.answered(Ok(Stored::At(true)), false, dmg, r);
        assert_eq!(back, Step { pause: r, said: vec![Said::Again(12)] });
        assert_eq!(f.last(), Some(&Stored::At(true)));
        assert_eq!(f.answered(Err("blip".into()), false, dmg, r).said, vec![Said::Kept("blip".into())], "after a good read: kept");
        assert_eq!(f.last(), Some(&Stored::At(true)), "a failed read keeps the last answer");
        assert_eq!(f.answered(Ok(Stored::Damaged), false, dmg, r).said, vec![Said::Again(1), Said::Damaged], "answering again, and damaged");
        assert_eq!(f.answered(Ok(Stored::Damaged), false, dmg, r).said, vec![], "a damage is said once");
        let mut g: Follow<Stored> = Follow::default();
        assert_eq!(g.answered(Ok(Stored::Damaged), false, dmg, r).said, vec![Said::Damaged]);
        assert_eq!(g.answered(Ok(Stored::At(false)), true, dmg, r).pause, r);
        assert_eq!(g.last(), Some(&Stored::Damaged), "a read older than a flip on this console is not taken");
    }

    #[test]
    fn a_read_ends_before_the_next_one_starts() {
        assert!(READ_DEADLINE < RECHECK);
    }
}