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);
}
}