use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Stage {
Entered,
Devtmpfs,
Console,
Cmdline,
Disks,
DataFs,
Archive,
Exec,
Config,
Network,
Lease,
Sink,
Listener,
Role,
Follow,
Serving,
}
impl Stage {
pub const fn token(self) -> &'static str {
match self {
Stage::Entered => "entered",
Stage::Devtmpfs => "devtmpfs",
Stage::Console => "console",
Stage::Cmdline => "cmdline",
Stage::Disks => "disks",
Stage::DataFs => "datafs",
Stage::Archive => "archive",
Stage::Exec => "exec",
Stage::Config => "config",
Stage::Network => "network",
Stage::Lease => "lease",
Stage::Sink => "sink",
Stage::Listener => "listener",
Stage::Role => "role",
Stage::Follow => "follow",
Stage::Serving => "serving",
}
}
}
pub const ORDER: [Stage; 16] = [
Stage::Entered,
Stage::Devtmpfs,
Stage::Console,
Stage::Cmdline,
Stage::Disks,
Stage::DataFs,
Stage::Archive,
Stage::Exec,
Stage::Config,
Stage::Network,
Stage::Lease,
Stage::Sink,
Stage::Listener,
Stage::Role,
Stage::Follow,
Stage::Serving,
];
pub const STAGE_MARKER: &str = "STAGE";
pub const TICK_MARKER: &str = "TICK";
pub const FAULT_MARKER: &str = "FAULT";
pub const ACK_MARKER: &str = "ACK";
pub const TICK_EVERY: Duration = Duration::from_secs(60);
pub const FAULT_RESENDS: usize = 3;
pub const FAULT_RESEND_EVERY: Duration = Duration::from_millis(400);
pub const ACK_BUDGET: Duration = Duration::from_secs(5);
pub const CODES: [(&str, &str); 6] = [
("GN-01", "the app could not read its configuration"),
("GN-02", "a declared listener could not be bound"),
("GN-03", "the role this box was given is not one it can take"),
("GN-04", "a twin could not follow its primary"),
("GN-05", "the app stopped serving with an error"),
("GN-99", "the app panicked"),
];
pub fn code_after(stage: Stage) -> &'static str {
match stage {
Stage::Entered
| Stage::Devtmpfs
| Stage::Console
| Stage::Cmdline
| Stage::Disks
| Stage::DataFs
| Stage::Archive
| Stage::Exec => "GN-01",
Stage::Config | Stage::Network | Stage::Lease | Stage::Sink => "GN-02",
Stage::Listener => "GN-03",
Stage::Role | Stage::Follow => "GN-04",
Stage::Serving => "GN-05",
}
}
pub fn code_meaning(code: &str) -> Option<&'static str> {
CODES.iter().find(|(c, _)| *c == code).map(|(_, m)| *m)
}
pub fn ack_line(code: &str) -> String {
format!("{ACK_MARKER} {code}")
}
pub fn ack_matches(datagram: &[u8], code: &str) -> bool {
let text = String::from_utf8_lossy(datagram);
let mut parts = text.split_whitespace();
parts.next() == Some(ACK_MARKER) && parts.next() == Some(code)
}
pub fn stage_line(stage: Stage, monotonic_ms: u128, detail: &str) -> String {
let detail = detail.trim();
if detail.is_empty() {
format!("{STAGE_MARKER} {} +{monotonic_ms}ms", stage.token())
} else {
format!("{STAGE_MARKER} {} +{monotonic_ms}ms {detail}", stage.token())
}
}
pub fn tick_line(stage: Stage, uptime_s: u64) -> String {
format!("{TICK_MARKER} stage={} up={uptime_s}s", stage.token())
}
pub fn fault_line(
code: &str,
stage: Stage,
monotonic_ms: u128,
reason: &str,
crash: Option<&Path>,
) -> String {
let reason = reason.replace(['\r', '\n'], " ");
let reason = reason.trim();
let crash = crash.map(|p| p.display().to_string()).unwrap_or_else(|| "-".to_string());
format!(
"{FAULT_MARKER} {code} stage={} +{monotonic_ms}ms reason={reason} crash={crash}",
stage.token()
)
}
pub const CRASH_RECORD: &str = "fault.txt";
const ESP_LOG: &str = "/boot/BOOTLOG.TXT";
const ESP_MOUNT: &str = "/boot";
const DATA_LOG: &str = "log/boot.log";
static NARRATOR: OnceLock<Narrator> = OnceLock::new();
pub struct Narrator {
t0: Instant,
stage: AtomicU8,
data: Mutex<Option<PathBuf>>,
memory: Mutex<Vec<u8>>,
}
pub fn install() {
let _ = NARRATOR.set(Narrator {
t0: Instant::now(),
stage: AtomicU8::new(Stage::Exec as u8),
data: Mutex::new(None),
memory: Mutex::new(Vec::new()),
});
}
pub fn narrator() -> Option<&'static Narrator> {
NARRATOR.get()
}
pub fn memory() -> Vec<u8> {
NARRATOR.get().map(Narrator::memory).unwrap_or_default()
}
pub fn at(stage: Stage, detail: &str) {
if let Some(n) = NARRATOR.get() {
n.at(stage, detail);
}
}
pub fn data_home(root: &Path) {
if let Some(n) = NARRATOR.get() {
*n.data.lock().expect("the narrator's data home") = Some(root.to_path_buf());
}
}
impl Narrator {
pub fn monotonic_ms(&self) -> u128 {
self.t0.elapsed().as_millis()
}
pub fn memory(&self) -> Vec<u8> {
self.memory.lock().map(|m| m.clone()).unwrap_or_default()
}
pub fn stage(&self) -> Stage {
let raw = self.stage.load(Ordering::Relaxed) as usize;
ORDER.get(raw).copied().unwrap_or(Stage::Exec)
}
pub fn at(&self, stage: Stage, detail: &str) {
self.stage.store(stage as u8, Ordering::Relaxed);
if stage == Stage::Serving {
appliance_pid1::fault::serving_now();
} else {
appliance_pid1::fault::reached(stage.token());
}
let line = stage_line(stage, self.monotonic_ms(), detail);
self.say(&line);
}
pub fn say(&self, line: &str) {
let rendered = format!("gunnar: {line}\r\n");
if let Ok(mut m) = self.memory.lock() {
m.extend_from_slice(rendered.as_bytes());
}
appliance_pid1::console::mirror_to_gfx_console(&rendered);
eprint!("{rendered}");
gunnar_common::logqueue::tap_line(rendered.trim_end());
append_to_esp(rendered.as_bytes());
if let Some(root) = self.data.lock().ok().and_then(|d| d.clone()) {
append_file(&root.join(DATA_LOG), rendered.as_bytes());
}
}
}
pub fn fault(code: &'static str, stage: Stage, reason: &str) -> ! {
let Some(n) = NARRATOR.get() else {
eprintln!("gunnar: {}", fault_line(code, stage, 0, reason, None));
std::process::exit(1);
};
n.stage.store(stage as u8, Ordering::Relaxed);
let ms = n.monotonic_ms();
let root = n.data.lock().ok().and_then(|d| d.clone());
let since_last = root
.as_ref()
.and_then(|r| std::fs::metadata(r.join(crate::slow::CRASH_DIR).join(CRASH_RECORD)).ok())
.and_then(|m| m.modified().ok())
.and_then(|t| t.elapsed().ok());
let crash = root.and_then(|root| write_crash_record(&root, code, stage, ms, reason, n));
let line = fault_line(code, stage, ms, reason, crash.as_deref());
n.say(&line);
if let Some(meaning) = code_meaning(code) {
n.say(&format!("{code}: {meaning}"));
}
match end_after(stage, since_last) {
End::Restart => {
n.say(&format!("{code}: the box was SERVING and its last fault is older than {} min — restarting, not powering off", RESTART_LOOP_WINDOW.as_secs() / 60));
restart_now()
}
End::PowerOff => power_off_now(),
}
}
pub const RESTART_LOOP_WINDOW: Duration = Duration::from_secs(30 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum End {
Restart,
PowerOff,
}
pub fn end_after(stage: Stage, since_last_fault: Option<Duration>) -> End {
match (stage, since_last_fault) {
(Stage::Serving, None) => End::Restart,
(Stage::Serving, Some(d)) if d >= RESTART_LOOP_WINDOW => End::Restart,
_ => End::PowerOff,
}
}
pub fn restart_now() -> ! {
#[cfg(target_os = "linux")]
{
rustix::fs::sync();
let _ = rustix::system::reboot(rustix::system::RebootCommand::Restart);
}
loop {
std::thread::sleep(Duration::from_secs(3600));
}
}
pub fn power_off_now() -> ! {
#[cfg(target_os = "linux")]
{
rustix::fs::sync();
let _ = rustix::system::reboot(rustix::system::RebootCommand::PowerOff);
}
loop {
std::thread::sleep(Duration::from_secs(3600));
}
}
pub fn reboot_now() -> ! {
#[cfg(target_os = "linux")]
{
rustix::fs::sync();
let _ = rustix::system::reboot(rustix::system::RebootCommand::Restart);
}
loop {
std::thread::sleep(Duration::from_secs(3600));
}
}
fn write_crash_record(
root: &Path,
code: &str,
stage: Stage,
ms: u128,
reason: &str,
n: &Narrator,
) -> Option<PathBuf> {
use std::io::Write as _;
let path = root.join(crate::slow::CRASH_DIR).join(CRASH_RECORD);
std::fs::create_dir_all(path.parent()?).ok()?;
let mut f = std::fs::File::create(&path).ok()?;
writeln!(f, "---- {} ----", fault_line(code, stage, ms, reason, None)).ok()?;
if let Some(meaning) = code_meaning(code) {
writeln!(f, "{code}: {meaning}").ok()?;
}
writeln!(f, "---- the boot narration up to this fault ----").ok()?;
if let Ok(m) = n.memory.lock() {
f.write_all(&m).ok()?;
}
f.sync_all().ok()?;
Some(path)
}
fn append_to_esp(bytes: &[u8]) {
if !Path::new(ESP_LOG).exists() {
return;
}
let flipped = remount(ESP_MOUNT, false).is_ok();
append_file(Path::new(ESP_LOG), bytes);
if flipped {
let _ = remount(ESP_MOUNT, true);
}
}
pub fn write_esp_file(path: &Path, bytes: &[u8]) -> bool {
use std::io::Write as _;
let flipped = remount(ESP_MOUNT, false).is_ok();
let written = std::fs::File::create(path).and_then(|mut f| f.write_all(bytes).and_then(|_| f.sync_all())).is_ok();
if flipped {
let _ = remount(ESP_MOUNT, true);
}
written
}
pub fn remove_esp_file(path: &Path) -> bool {
let flipped = remount(ESP_MOUNT, false).is_ok();
let removed = std::fs::remove_file(path).is_ok();
if flipped {
let _ = remount(ESP_MOUNT, true);
}
removed
}
fn append_file(path: &Path, bytes: &[u8]) {
use std::io::Write as _;
if let Ok(mut f) = std::fs::OpenOptions::new().append(true).create(true).open(path) {
let _ = f.write_all(bytes);
let _ = f.sync_all();
}
}
#[cfg(target_os = "linux")]
fn remount(mountpoint: &str, readonly: bool) -> std::io::Result<()> {
use rustix::mount::{mount_remount, MountFlags};
let mut flags = MountFlags::NOSUID | MountFlags::NODEV | MountFlags::NOEXEC;
if readonly {
flags |= MountFlags::RDONLY;
}
mount_remount(mountpoint, flags, "").map_err(std::io::Error::from)
}
#[cfg(not(target_os = "linux"))]
fn remount(_mountpoint: &str, _readonly: bool) -> std::io::Result<()> {
Ok(())
}
pub fn uptime_s() -> u64 {
std::fs::read_to_string("/proc/uptime")
.ok()
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.map(|f| f as u64)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_panics_crash_record_holds_the_narrators_memory() {
static N: OnceLock<Narrator> = OnceLock::new();
let n = N.get_or_init(|| Narrator { t0: Instant::now(), stage: AtomicU8::new(Stage::Exec as u8), data: Mutex::new(None), memory: Mutex::new(Vec::new()) });
n.say("STAGE serving +812ms l10-crash-narration");
assert_eq!(n.memory(), b"gunnar: STAGE serving +812ms l10-crash-narration\r\n");
if NARRATOR.get().is_none() {
assert!(memory().is_empty());
}
let boot = include_str!("boot.rs");
let hook = &boot[boot.find("pub fn install_panic_fault").unwrap()..];
let hook = &hook[..hook.find("\n}\n").unwrap()];
assert!(hook.contains("appliance_pid1::fault::narration_from(crate::narrate::memory)"), "{hook}");
appliance_pid1::fault::narration_from(|| N.get().map(Narrator::memory).unwrap_or_default());
let d = tempfile::tempdir().unwrap();
let p = appliance_pid1::fault::PanicFault { code: "GN-99", meaning: "the app panicked", data: Some(d.path().to_path_buf()) };
appliance_pid1::fault::panic_ending(&p, "panicked at src/x.rs:1: boom", ("serving", true), 9);
let text = std::fs::read_to_string(d.path().join(appliance_pid1::fault::CRASH_DIR).join(appliance_pid1::fault::CRASH_RECORD)).unwrap();
assert!(text.contains("gunnar: STAGE serving +812ms l10-crash-narration"), "{text}");
assert!(text.contains("GN-99: the app panicked"), "{text}");
appliance_pid1::fault::narration_from(memory);
}
#[test]
fn a_serving_fault_restarts_the_box_unless_it_is_a_loop() {
assert_eq!(end_after(Stage::Serving, None), End::Restart, "the first fault of a serving box: back by itself");
assert_eq!(end_after(Stage::Serving, Some(RESTART_LOOP_WINDOW)), End::Restart, "the last fault is old: not a loop");
assert_eq!(end_after(Stage::Serving, Some(Duration::from_secs(60))), End::PowerOff, "a fault a minute after the last: a loop, stop");
for s in ORDER.iter().copied().filter(|s| *s != Stage::Serving) {
assert_eq!(end_after(s, None), End::PowerOff, "{s:?}: a fault during the boot reboots into the same fault — power off");
}
}
#[test]
fn the_stage_tokens_are_unique_single_words() {
let mut seen: Vec<&str> = Vec::new();
for (i, s) in ORDER.iter().enumerate() {
assert_eq!(*s as usize, i, "the discriminant IS the index: the tick carries it as one byte");
assert!(!s.token().contains(' '), "{:?}", s.token());
assert!(!seen.contains(&s.token()), "two stages spell {}", s.token());
seen.push(s.token());
}
}
#[test]
fn the_wire_shapes_are_exactly_what_the_ear_matches() {
assert_eq!(stage_line(Stage::Lease, 6_340, "eth1 10.13.9.24/22"), "STAGE lease +6340ms eth1 10.13.9.24/22");
assert_eq!(tick_line(Stage::Serving, 720), "TICK stage=serving up=720s");
assert_eq!(
fault_line("GN-04", Stage::Follow, 12_345, "the primary refused", Some(Path::new("/data/crash/fault.txt"))),
"FAULT GN-04 stage=follow +12345ms reason=the primary refused crash=/data/crash/fault.txt"
);
assert_eq!(ack_line("GN-99"), "ACK GN-99");
}
#[test]
fn a_fault_is_one_line_even_when_the_reason_is_not() {
let l = fault_line(
"GN-99",
Stage::Follow,
1,
"panicked at\nsrc/runtime/shutdown.rs:51:\nCannot drop a runtime",
None,
);
assert_eq!(l.lines().count(), 1, "{l}");
assert!(l.contains("Cannot drop a runtime"), "{l}");
assert!(l.ends_with(" crash=-"), "{l}");
}
#[test]
fn an_err_at_every_stage_carries_a_declared_code_that_is_not_the_panics() {
for s in ORDER {
let code = code_after(s);
assert!(code_meaning(code).is_some(), "{code} for {:?} is not declared", s);
assert_ne!(code, "GN-99", "an ordinary Err at {:?} is not a panic", s);
}
assert_eq!(code_after(Stage::Exec), "GN-01", "nothing read yet: the configuration");
assert_eq!(code_after(Stage::Config), "GN-02", "config read; the next boundary is a bound listener");
assert_eq!(code_after(Stage::Sink), "GN-02");
assert_eq!(code_after(Stage::Listener), "GN-03", "bound; the next boundary is the role");
assert_eq!(code_after(Stage::Role), "GN-04", "role taken; a twin follows next");
assert_eq!(code_after(Stage::Follow), "GN-04");
assert_eq!(code_after(Stage::Serving), "GN-05", "up, and the serve loop returned an error");
assert_eq!(
[code_after(Stage::Exec), code_after(Stage::Config), code_after(Stage::Listener), code_after(Stage::Role)],
["GN-01", "GN-02", "GN-03", "GN-04"],
"the four boot codes are all reachable"
);
}
#[test]
fn an_ack_matches_its_code_and_only_its_code() {
assert!(ack_matches(b"ACK GN-99", "GN-99"));
assert!(ack_matches(b"ACK GN-99 heard\n", "GN-99"), "an ear may say more");
assert!(!ack_matches(b"ACK GN-01", "GN-99"));
assert!(!ack_matches(b"GN-99", "GN-99"), "a bare code is not an ack");
assert!(!ack_matches(b"NACK GN-99", "GN-99"));
}
#[test]
fn the_fault_codes_are_unique_and_stable_shaped() {
let mut seen: Vec<&str> = Vec::new();
for (c, meaning) in CODES {
assert!(c.len() == 5 && c.as_bytes()[2] == b'-', "a code is XX-NN: {c}");
assert!(!seen.contains(&c), "two faults spell {c}");
assert!(!meaning.is_empty());
assert_eq!(code_meaning(c), Some(meaning));
seen.push(c);
}
assert_eq!(code_meaning("ZZ-99"), None);
}
}