Skip to main content

gunnar_server/
narrate.rs

1//! **The app's half of the boot narration: stages, ticks and fault codes.**
2//!
3//! # The measurement this exists for
4//!
5//! 2026-09-23, the live estate. The twin was installed with the narration
6//! channel ON (`korp.log_udp=10.13.2.127:5140`) and it delivered **66
7//! datagrams**: three application lines and sixty-three kernel records.
8//! Network up at 6.34 s, xfs recovered, `end of hand-off` at 6.35 s — and then
9//! silence, for ever. The twin then read its config, bound its listeners, took
10//! its role, held its certificate, started following its primary, and panicked
11//! (`Cannot drop a runtime in a context where blocking is not allowed`). gunnar
12//! is PID 1, so the kernel panicked with it and the box stopped.
13//!
14//! **Everything that mattered happened after the last datagram.** The rung
15//! waiting on that box timed out at 600 s with an empty status, and the only
16//! account anybody got was a VNC screenshot taken by hand.
17//!
18//! `boot::ship_boot_narration` was never meant to be the whole story: it is the
19//! hand-off, the moment the DHCP lease makes the wire usable, and it says so —
20//! `end of hand-off`. Nothing said anything after it. This module is what
21//! speaks from there on.
22//!
23//! # What it does
24//!
25//! * **[`at`]** — one line at every boundary the app crosses, with a monotonic
26//!   stamp: config read, every listener BOUND with its address, the role, a
27//!   twin's primary and certificate, serving.
28//! * **[`tick`](Narrator::tick)** — one short line a minute with uptime and the
29//!   current stage, so silence means dead rather than idle, and the ear can say
30//!   "last tick N s ago" instead of "nothing has ever arrived" (which cannot
31//!   tell "never started" from "died at minute 12").
32//! * **[`fault`]** — a short STABLE code, the stage, one line of reason and the
33//!   crash-record path; re-sent because this is UDP; acknowledged by the ear;
34//!   then a CLEAN POWER-OFF. No kernel panic, no reboot loop. The rung refuses
35//!   by that code.
36//!
37//! # Three readers, one story
38//!
39//! Every line goes to the console, to `BOOTLOG.TXT` on the ESP (which
40//! `korp-start` left mounted read-only at `/boot`, flipped read-write for one
41//! append at a time — the same flip the rollback floor makes) and to
42//! `/data/log/boot.log`. A fault also writes `/data/crash/fault.txt`. A box
43//! that dies before its DHCP lease has said nothing on the wire and everything
44//! on the other two.
45//!
46//! # The off switch
47//!
48//! `korp.log_udp=` on the kernel command line, and nothing else. Unset ⇒
49//! [`armed`] is false, no socket is bound, no tick thread exists and not one
50//! datagram is sent; the ESP and `/data` lines cost nothing anybody can hear
51//! and are kept, because a box that powers itself off has to leave the reason
52//! somewhere. The estate's steady state ships media with no `log_udp` param, so
53//! a box that narrates for ever cannot be produced by forgetting to turn
54//! something off — only by asking for it.
55//!
56//! # One vocabulary, three spellings
57//!
58//! `korp-installer::narrate` (korp-start), this module and the ear
59//! (`private-gunnar-ops/xtask/src/bootnarr.rs`) each spell these constants.
60//! There is no dependency edge between the three repositories, which is the
61//! same arrangement the secret-class door already lives under and for the same
62//! reason. What keeps them honest is that the wire format is a TOKEN and not
63//! prose: a spelling that drifts stops matching, and the ear says so loudly
64//! rather than quietly judging something else.
65
66use std::path::{Path, PathBuf};
67use std::sync::atomic::{AtomicU8, Ordering};
68use std::sync::{Mutex, OnceLock};
69use std::time::{Duration, Instant};
70
71// ── the vocabulary ───────────────────────────────────────────────────────────
72
73/// A boundary in the boot, named. The first eight are `korp-start`'s, before
74/// the exec; this process emits the last eight. One enum because it is one
75/// story on one wire.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
77pub enum Stage {
78    /// PID 1's first instruction.
79    Entered,
80    /// `devtmpfs` is on `/dev`.
81    Devtmpfs,
82    /// fds 0-2 take a byte.
83    Console,
84    /// The kernel command line, as parsed.
85    Cmdline,
86    /// The disks seen, and which was chosen.
87    Disks,
88    /// The data filesystem is mounted.
89    DataFs,
90    /// The app archive is open and its digest is known.
91    Archive,
92    /// The exec: which binary, which version, which args.
93    Exec,
94    /// This process read its configuration.
95    Config,
96    /// The network link is up.
97    Network,
98    /// A DHCP lease, with the address. The first stage the wire can carry live.
99    Lease,
100    /// The narration sink is resolved and the backlog has been flushed.
101    Sink,
102    /// One listener is BOUND, with its address. Once per listener.
103    Listener,
104    /// The role this box took.
105    Role,
106    /// A twin: the primary it follows and the certificate it holds.
107    Follow,
108    /// Serving. The end of the boot story and the beginning of the ticks.
109    Serving,
110}
111
112impl Stage {
113    /// The wire token: one lower-case word, matched rather than read.
114    pub const fn token(self) -> &'static str {
115        match self {
116            Stage::Entered => "entered",
117            Stage::Devtmpfs => "devtmpfs",
118            Stage::Console => "console",
119            Stage::Cmdline => "cmdline",
120            Stage::Disks => "disks",
121            Stage::DataFs => "datafs",
122            Stage::Archive => "archive",
123            Stage::Exec => "exec",
124            Stage::Config => "config",
125            Stage::Network => "network",
126            Stage::Lease => "lease",
127            Stage::Sink => "sink",
128            Stage::Listener => "listener",
129            Stage::Role => "role",
130            Stage::Follow => "follow",
131            Stage::Serving => "serving",
132        }
133    }
134}
135
136/// Every stage, in the order a healthy boot crosses them. Index = discriminant,
137/// which is what lets the tick thread carry the stage in one atomic byte.
138pub const ORDER: [Stage; 16] = [
139    Stage::Entered,
140    Stage::Devtmpfs,
141    Stage::Console,
142    Stage::Cmdline,
143    Stage::Disks,
144    Stage::DataFs,
145    Stage::Archive,
146    Stage::Exec,
147    Stage::Config,
148    Stage::Network,
149    Stage::Lease,
150    Stage::Sink,
151    Stage::Listener,
152    Stage::Role,
153    Stage::Follow,
154    Stage::Serving,
155];
156
157/// The marker a stage line begins with.
158pub const STAGE_MARKER: &str = "STAGE";
159/// The marker a tick line begins with.
160pub const TICK_MARKER: &str = "TICK";
161/// The marker a fault line begins with.
162pub const FAULT_MARKER: &str = "FAULT";
163/// What the ear sends back.
164pub const ACK_MARKER: &str = "ACK";
165
166/// One line a minute. Short on purpose: it is sent for the life of the box.
167pub const TICK_EVERY: Duration = Duration::from_secs(60);
168
169/// How many times a fault datagram is sent. One is a coin flip on UDP from a
170/// box that is about to power itself off.
171pub const FAULT_RESENDS: usize = 3;
172/// The gap between the re-sends.
173pub const FAULT_RESEND_EVERY: Duration = Duration::from_millis(400);
174
175/// **How long a faulting box waits to be heard, and no longer: five seconds.**
176///
177/// Chosen and stated. Long enough for three datagrams and a reply to cross a
178/// utility network (sub-millisecond) with four orders of magnitude of slack;
179/// short enough that a box with no ear waits about as long as a human blinks
180/// before it powers off. Unheard is NOT a reason to stay up — the whole point
181/// is that the box stops rather than loops.
182pub const ACK_BUDGET: Duration = Duration::from_secs(5);
183
184/// The stable fault codes this process can emit. `KS-*` are `korp-start`'s and
185/// live in `korp_installer::narrate`; these are the app's. Add codes, never
186/// renumber one — a rung refuses BY the code.
187pub const CODES: [(&str, &str); 6] = [
188    ("GN-01", "the app could not read its configuration"),
189    ("GN-02", "a declared listener could not be bound"),
190    (
191        "GN-03",
192        "the role this box was given is not one it can take",
193    ),
194    ("GN-04", "a twin could not follow its primary"),
195    ("GN-05", "the app stopped serving with an error"),
196    ("GN-99", "the app panicked"),
197];
198
199/// **The code an ordinary `Err` out of the serve path carries**, read off the
200/// last boundary the narrator crossed.
201///
202/// Until 2026-09-23 only the panic hook sent a code (`GN-99`). An `Err` out of
203/// `serve` went `std::process::exit(1)`, and as PID 1 that is the kernel's
204/// `Attempted to kill init!` — no code, no power-off, the tick simply stops.
205/// `GN-01..04` were declared and never called. This is what calls them: the
206/// stage names the boundary that WAS crossed, so the failure lies in the step
207/// after it.
208///
209/// * nothing past `exec` — the configuration was never read: `GN-01`;
210/// * `config`, `network`, `lease`, `sink` — the next boundary is a bound
211///   listener, so this is the bind: `GN-02`;
212/// * `listener` — the next boundary is the role: `GN-03`;
213/// * `role`, `follow` — a twin that could not follow (a primary crosses from
214///   `role` straight to `serving` with nothing fallible between): `GN-04`;
215/// * `serving` — the box was up and its serve loop returned an error: `GN-05`.
216pub fn code_after(stage: Stage) -> &'static str {
217    match stage {
218        Stage::Entered
219        | Stage::Devtmpfs
220        | Stage::Console
221        | Stage::Cmdline
222        | Stage::Disks
223        | Stage::DataFs
224        | Stage::Archive
225        | Stage::Exec => "GN-01",
226        Stage::Config | Stage::Network | Stage::Lease | Stage::Sink => "GN-02",
227        Stage::Listener => "GN-03",
228        Stage::Role | Stage::Follow => "GN-04",
229        Stage::Serving => "GN-05",
230    }
231}
232
233/// What a code means, in one clause.
234pub fn code_meaning(code: &str) -> Option<&'static str> {
235    CODES.iter().find(|(c, _)| *c == code).map(|(_, m)| *m)
236}
237
238/// The acknowledgement for `code`.
239pub fn ack_line(code: &str) -> String {
240    format!("{ACK_MARKER} {code}")
241}
242
243/// Does this datagram acknowledge `code`? Tolerant of an ear that says more
244/// after the code; strict about the code itself.
245pub fn ack_matches(datagram: &[u8], code: &str) -> bool {
246    let text = String::from_utf8_lossy(datagram);
247    let mut parts = text.split_whitespace();
248    parts.next() == Some(ACK_MARKER) && parts.next() == Some(code)
249}
250
251/// `STAGE <token> +<ms>ms <detail>`.
252pub fn stage_line(stage: Stage, monotonic_ms: u128, detail: &str) -> String {
253    let detail = detail.trim();
254    if detail.is_empty() {
255        format!("{STAGE_MARKER} {} +{monotonic_ms}ms", stage.token())
256    } else {
257        format!(
258            "{STAGE_MARKER} {} +{monotonic_ms}ms {detail}",
259            stage.token()
260        )
261    }
262}
263
264/// `TICK stage=<token> up=<s>s`.
265pub fn tick_line(stage: Stage, uptime_s: u64) -> String {
266    format!("{TICK_MARKER} stage={} up={uptime_s}s", stage.token())
267}
268
269/// `FAULT <code> stage=<token> +<ms>ms reason=<one line> crash=<path>`.
270///
271/// The reason is flattened to one line: a newline would forge a second line at
272/// every reader here, and all three are line-oriented.
273pub fn fault_line(
274    code: &str,
275    stage: Stage,
276    monotonic_ms: u128,
277    reason: &str,
278    crash: Option<&Path>,
279) -> String {
280    let reason = reason.replace(['\r', '\n'], " ");
281    let reason = reason.trim();
282    let crash = crash
283        .map(|p| p.display().to_string())
284        .unwrap_or_else(|| "-".to_string());
285    format!(
286        "{FAULT_MARKER} {code} stage={} +{monotonic_ms}ms reason={reason} crash={crash}",
287        stage.token()
288    )
289}
290
291/// Where the fault record goes on the data disk. `crate::slow::CRASH_DIR` is
292/// the same directory; this is the fixed file inside it, one per boot, so a
293/// reboot loop cannot fill `/data` with a file per attempt.
294pub const CRASH_RECORD: &str = "fault.txt";
295
296// ── the narrator ─────────────────────────────────────────────────────────────
297
298/// The file `korp-start` appends this boot's narration to on the ESP.
299const ESP_LOG: &str = "/boot/BOOTLOG.TXT";
300/// The mountpoint that file lives on, flipped read-write for one append.
301const ESP_MOUNT: &str = "/boot";
302/// The durable log `korp-start` opened on the data home, which this process
303/// continues rather than starting a second one beside.
304const DATA_LOG: &str = "log/boot.log";
305
306/// The one narrator, installed once by [`install`].
307static NARRATOR: OnceLock<Narrator> = OnceLock::new();
308
309/// **The app's narrator.** One per process, installed at the top of the serve
310/// path and read from everywhere afterwards — including the panic hook, which
311/// takes no arguments and is why this is a process global rather than a value
312/// threaded through the call graph.
313pub struct Narrator {
314    /// The boot's monotonic zero for this process. The kernel's uptime is the
315    /// number on the tick; this is the number on the stage lines, so they are
316    /// stamped from the exec rather than from the power-on.
317    t0: Instant,
318    /// The last boundary crossed. An atomic because the tick thread reads it.
319    stage: AtomicU8,
320    /// The data home, when there is one — where the crash record goes.
321    data: Mutex<Option<PathBuf>>,
322    /// Everything narrated so far, for the crash record. A boot narrates a few
323    /// KiB; unbounded is fine and bounded would lose the beginning, which is
324    /// the part a fault at minute twelve needs.
325    memory: Mutex<Vec<u8>>,
326}
327
328/// **Install the narrator.** Since 2026-09-24 there is no CLEAR-TEXT UDP wire:
329/// the boot narration over `korp.log_udp` is RETIRED, because the installer
330/// collects each VM's whole boot log over its own mutual TLS
331/// (`crate::installer_listener`). Every line goes to the console, the ESP's
332/// `BOOTLOG.TXT`, `/data`'s boot log and the log the installer reads — and on a
333/// DEV build (feature `dev-narrate`, 2026-09-30) that same log also leaves
334/// SEALED to the installer's key over udp/51822 from the moment the NIC has an
335/// address, before the installer's link exists (`crate::dev_narrate`).
336///
337/// Idempotent: the first caller wins.
338pub fn install() {
339    let _ = NARRATOR.set(Narrator {
340        t0: Instant::now(),
341        // The exec happened, so this process starts where korp-start stopped.
342        stage: AtomicU8::new(Stage::Exec as u8),
343        data: Mutex::new(None),
344        memory: Mutex::new(Vec::new()),
345    });
346}
347
348/// The installed narrator, if there is one. Everything here is a no-op before
349/// [`install`] — a unit test, `gunnar-solo` on a laptop, `serve --check`.
350pub fn narrator() -> Option<&'static Narrator> {
351    NARRATOR.get()
352}
353
354/// **Everything narrated so far** (empty before [`install`]): what a panic's
355/// crash record holds under its fault line (`boot::install_panic_fault` names
356/// it to edda's `fault::narration_from`).
357pub fn memory() -> Vec<u8> {
358    NARRATOR.get().map(Narrator::memory).unwrap_or_default()
359}
360
361/// **Cross a boundary, and say so on every reader.** The free function every
362/// call site uses; a no-op before [`install`].
363pub fn at(stage: Stage, detail: &str) {
364    if let Some(n) = NARRATOR.get() {
365        n.at(stage, detail);
366    }
367}
368
369/// Tell the narrator where the data home is, so a fault can leave a crash
370/// record on it. Called once the stores are open.
371pub fn data_home(root: &Path) {
372    if let Some(n) = NARRATOR.get() {
373        *n.data.lock().expect("the narrator's data home") = Some(root.to_path_buf());
374    }
375}
376
377impl Narrator {
378    /// Milliseconds since this process started narrating.
379    pub fn monotonic_ms(&self) -> u128 {
380        self.t0.elapsed().as_millis()
381    }
382
383    /// Everything this narrator said so far, as rendered (`gunnar: …\r\n`).
384    pub fn memory(&self) -> Vec<u8> {
385        self.memory.lock().map(|m| m.clone()).unwrap_or_default()
386    }
387
388    /// The last boundary crossed.
389    pub fn stage(&self) -> Stage {
390        let raw = self.stage.load(Ordering::Relaxed) as usize;
391        ORDER.get(raw).copied().unwrap_or(Stage::Exec)
392    }
393
394    /// Cross a boundary: remember it, then say it on all three readers.
395    pub fn at(&self, stage: Stage, detail: &str) {
396        self.stage.store(stage as u8, Ordering::Relaxed);
397        // And to edda's PID 1, which a panic (`GN-99`, `boot::install_panic_fault`)
398        // reads its stage and its restart-or-power-off off.
399        if stage == Stage::Serving {
400            appliance_pid1::fault::serving_now();
401        } else {
402            appliance_pid1::fault::reached(stage.token());
403        }
404        let line = stage_line(stage, self.monotonic_ms(), detail);
405        self.say(&line);
406    }
407
408    /// **One line, on every reader.** The console (which on the appliance is
409    /// the serial line teed with the VGA screen), the wire, `BOOTLOG.TXT` on
410    /// the ESP and `/data`'s boot log. Every failure is swallowed: this is
411    /// diagnostics, and a narration that could refuse a boot would be worse
412    /// than no narration.
413    pub fn say(&self, line: &str) {
414        let rendered = format!("gunnar: {line}\r\n");
415        if let Ok(mut m) = self.memory.lock() {
416            m.extend_from_slice(rendered.as_bytes());
417        }
418        // The console first: it is the reader that needs nothing to exist.
419        appliance_pid1::console::mirror_to_gfx_console(&rendered);
420        eprint!("{rendered}");
421        gunnar_common::logqueue::tap_line(rendered.trim_end());
422        append_to_esp(rendered.as_bytes());
423        if let Some(root) = self.data.lock().ok().and_then(|d| d.clone()) {
424            append_file(&root.join(DATA_LOG), rendered.as_bytes());
425        }
426    }
427}
428
429/// **A fatal fault: the code, the record, the ear's word, then a clean stop.**
430///
431/// In this order, and the order is the point:
432///
433/// 1. the crash record on `/data/crash/fault.txt`, holding the whole narration
434///    so far under the fault line — so the fault line can NAME it;
435/// 2. the fault line on every reader, including the two that need no network;
436/// 3. [`FAULT_RESENDS`] datagrams, spaced, because this is UDP;
437/// 4. a bounded wait for `ACK <code>` ([`ACK_BUDGET`], five seconds);
438/// 5. `sync` and power off — or, for a box that was serving, restart ([`end_after`]).
439///
440/// **Not a panic, and a reboot only once it serves.** gunnar is PID 1: a panic is
441/// the kernel's `Attempted to kill init!`, which names an exit code and nothing
442/// else, and a reboot into the same fault is a loop, which is how a fault
443/// becomes a mystery — so a fault during the boot powers off. A SERVING box
444/// restarts unless its last fault was recent ([`end_after`]). With the narration off it still writes the code to the two file
445/// readers and still powers off cleanly.
446///
447/// Never returns.
448pub fn fault(code: &'static str, stage: Stage, reason: &str) -> ! {
449    let Some(n) = NARRATOR.get() else {
450        // Nothing was installed: a unit test, or a fault before the serve path
451        // began. Say it where a process without a narrator can say it, and
452        // stop — but by exiting, not by powering off a developer's laptop.
453        eprintln!("gunnar: {}", fault_line(code, stage, 0, reason, None));
454        std::process::exit(1);
455    };
456    n.stage.store(stage as u8, Ordering::Relaxed);
457    let ms = n.monotonic_ms();
458    let root = n.data.lock().ok().and_then(|d| d.clone());
459    // The previous fault's age, read BEFORE this one overwrites its record.
460    let since_last = root
461        .as_ref()
462        .and_then(|r| std::fs::metadata(r.join(crate::slow::CRASH_DIR).join(CRASH_RECORD)).ok())
463        .and_then(|m| m.modified().ok())
464        .and_then(|t| t.elapsed().ok());
465    let crash = root.and_then(|root| write_crash_record(&root, code, stage, ms, reason, n));
466    let line = fault_line(code, stage, ms, reason, crash.as_deref());
467    n.say(&line);
468    if let Some(meaning) = code_meaning(code) {
469        n.say(&format!("{code}: {meaning}"));
470    }
471    match end_after(stage, since_last) {
472        End::Restart => {
473            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));
474            restart_now()
475        }
476        End::PowerOff => power_off_now(),
477    }
478}
479
480/// How recent the previous fault must be for a fault on a SERVING box to count
481/// as a loop (and power off instead of restarting).
482pub const RESTART_LOOP_WINDOW: Duration = Duration::from_secs(30 * 60);
483
484/// How a fault ends the box.
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub enum End {
487    /// `sync` and reboot: the box comes back by itself.
488    Restart,
489    /// `sync` and power off: a person decides (see [`fault`]).
490    PowerOff,
491}
492
493/// **How a fault at `stage` ends, given how long ago the previous one was.**
494///
495/// During the boot a reboot is a reboot into the same fault — a loop that turns
496/// a fault into a mystery — so it powers off, as it always did. Once the box
497/// SERVES, a fault is one request task's panic or one listener's error, and
498/// powering off leaves the forge down until a person starts it, with nothing
499/// alerting anybody (finalization 2026-09-28, row h-panic-poweroff; UpCloud
500/// leaves a powered-off server stopped). So a serving box restarts — unless its
501/// previous fault was within [`RESTART_LOOP_WINDOW`], which is a loop again.
502/// `since_last_fault` is read off the crash record's mtime on `/data` (it
503/// survives the restart); `None` = no earlier record.
504pub fn end_after(stage: Stage, since_last_fault: Option<Duration>) -> End {
505    match (stage, since_last_fault) {
506        (Stage::Serving, None) => End::Restart,
507        (Stage::Serving, Some(d)) if d >= RESTART_LOOP_WINDOW => End::Restart,
508        _ => End::PowerOff,
509    }
510}
511
512/// `sync(2)` then `reboot(RB_AUTOBOOT)`, and park if the kernel refuses.
513pub fn restart_now() -> ! {
514    #[cfg(target_os = "linux")]
515    {
516        rustix::fs::sync();
517        let _ = rustix::system::reboot(rustix::system::RebootCommand::Restart);
518    }
519    loop {
520        std::thread::sleep(Duration::from_secs(3600));
521    }
522}
523
524/// `sync(2)` then `reboot(RB_POWER_OFF)`, and park if the kernel refuses.
525///
526/// A PID 1 that returns from `main` is `Attempted to kill init!` all over
527/// again, so the park is the last resort rather than the plan.
528pub fn power_off_now() -> ! {
529    #[cfg(target_os = "linux")]
530    {
531        // `sync(2)` first: the crash record and the two log appends above were
532        // fsync'd by their writers, but the box is about to lose power and
533        // anything else in flight has one chance.
534        rustix::fs::sync();
535        let _ = rustix::system::reboot(rustix::system::RebootCommand::PowerOff);
536    }
537    loop {
538        std::thread::sleep(Duration::from_secs(3600));
539    }
540}
541
542/// `sync(2)` then `reboot(RB_AUTOBOOT)`, and park if the kernel refuses — the
543/// restart a box restored from its twin owes (`twin::restart_after_restore`).
544pub fn reboot_now() -> ! {
545    #[cfg(target_os = "linux")]
546    {
547        rustix::fs::sync();
548        let _ = rustix::system::reboot(rustix::system::RebootCommand::Restart);
549    }
550    loop {
551        std::thread::sleep(Duration::from_secs(3600));
552    }
553}
554
555/// Write `<data>/crash/fault.txt`: the fault line, what the code means, and the
556/// narration that led to it. Best effort — the caller is on its way to a
557/// power-off and a failed write must not change that.
558fn write_crash_record(
559    root: &Path,
560    code: &str,
561    stage: Stage,
562    ms: u128,
563    reason: &str,
564    n: &Narrator,
565) -> Option<PathBuf> {
566    use std::io::Write as _;
567    let path = root.join(crate::slow::CRASH_DIR).join(CRASH_RECORD);
568    std::fs::create_dir_all(path.parent()?).ok()?;
569    let mut f = std::fs::File::create(&path).ok()?;
570    writeln!(f, "---- {} ----", fault_line(code, stage, ms, reason, None)).ok()?;
571    if let Some(meaning) = code_meaning(code) {
572        writeln!(f, "{code}: {meaning}").ok()?;
573    }
574    writeln!(f, "---- the boot narration up to this fault ----").ok()?;
575    if let Ok(m) = n.memory.lock() {
576        f.write_all(&m).ok()?;
577    }
578    f.sync_all().ok()?;
579    Some(path)
580}
581
582/// Append to `BOOTLOG.TXT` on the ESP, flipping `/boot` read-write for exactly
583/// one append and straight back — the same flip `korp-start` and the rollback
584/// floor make, and for the same reason: nothing may hold a writer open across
585/// a remount to read-only, which answers `EBUSY`.
586///
587/// Every failure is swallowed. A box with no ESP mounted (a container, a
588/// laptop, `gunnar-solo`) simply has no such reader.
589fn append_to_esp(bytes: &[u8]) {
590    if !Path::new(ESP_LOG).exists() {
591        return;
592    }
593    let flipped = remount(ESP_MOUNT, false).is_ok();
594    append_file(Path::new(ESP_LOG), bytes);
595    if flipped {
596        let _ = remount(ESP_MOUNT, true);
597    }
598}
599
600/// **Write one small file on the ESP** (a marker), `/boot` flipped read-write
601/// for exactly that write, the way [`append_to_esp`] flips it. `false` when it
602/// could not be written — the caller decides what that means.
603pub fn write_esp_file(path: &Path, bytes: &[u8]) -> bool {
604    use std::io::Write as _;
605    let flipped = remount(ESP_MOUNT, false).is_ok();
606    let written = std::fs::File::create(path)
607        .and_then(|mut f| f.write_all(bytes).and_then(|_| f.sync_all()))
608        .is_ok();
609    if flipped {
610        let _ = remount(ESP_MOUNT, true);
611    }
612    written
613}
614
615/// **Remove one small file from the ESP**, `/boot` flipped read-write for exactly that
616/// removal (the next boot's cleanup of a record [`write_esp_file`] left).
617pub fn remove_esp_file(path: &Path) -> bool {
618    let flipped = remount(ESP_MOUNT, false).is_ok();
619    let removed = std::fs::remove_file(path).is_ok();
620    if flipped {
621        let _ = remount(ESP_MOUNT, true);
622    }
623    removed
624}
625
626/// Open-append-fsync-close. No descriptor is held between lines, so the mount
627/// under it can go back to read-only immediately.
628fn append_file(path: &Path, bytes: &[u8]) {
629    use std::io::Write as _;
630    if let Ok(mut f) = std::fs::OpenOptions::new()
631        .append(true)
632        .create(true)
633        .open(path)
634    {
635        let _ = f.write_all(bytes);
636        let _ = f.sync_all();
637    }
638}
639
640/// Flip a mountpoint between read-only and read-write in place.
641#[cfg(target_os = "linux")]
642fn remount(mountpoint: &str, readonly: bool) -> std::io::Result<()> {
643    use rustix::mount::{mount_remount, MountFlags};
644    let mut flags = MountFlags::NOSUID | MountFlags::NODEV | MountFlags::NOEXEC;
645    if readonly {
646        flags |= MountFlags::RDONLY;
647    }
648    mount_remount(mountpoint, flags, "").map_err(std::io::Error::from)
649}
650
651#[cfg(not(target_os = "linux"))]
652fn remount(_mountpoint: &str, _readonly: bool) -> std::io::Result<()> {
653    Ok(())
654}
655
656/// Seconds since the kernel started — the one clock `korp-start` and this
657/// process share, so their ticks and stamps sit on the same number line.
658pub fn uptime_s() -> u64 {
659    std::fs::read_to_string("/proc/uptime")
660        .ok()
661        .and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
662        .map(|f| f as u64)
663        .unwrap_or_default()
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    /// ★ **A panic's crash record holds Gunnar's narration again** (lane L10,
671    /// appliance-pid1 0.1.5): edda's panic hook wrote `crash/fault.txt` with an
672    /// empty narration; `boot::install_panic_fault` now names the narrator's
673    /// memory to `fault::narration_from`, so the record carries every line the
674    /// narrator said, under the fault line, as `narrate::fault`'s own record does.
675    #[test]
676    fn a_panics_crash_record_holds_the_narrators_memory() {
677        static N: OnceLock<Narrator> = OnceLock::new();
678        let n = N.get_or_init(|| Narrator {
679            t0: Instant::now(),
680            stage: AtomicU8::new(Stage::Exec as u8),
681            data: Mutex::new(None),
682            memory: Mutex::new(Vec::new()),
683        });
684        n.say("STAGE serving +812ms l10-crash-narration");
685        assert_eq!(
686            n.memory(),
687            b"gunnar: STAGE serving +812ms l10-crash-narration\r\n"
688        );
689        // Not installed in a test process: the global is empty, never a panic.
690        if NARRATOR.get().is_none() {
691            assert!(memory().is_empty());
692        }
693        // Gunnar's wiring: the hook's installer names `narrate::memory` …
694        let boot = include_str!("boot.rs");
695        let hook = &boot[boot.find("pub fn install_panic_fault").unwrap()..];
696        let hook = &hook[..hook.find("\n}\n").unwrap()];
697        assert!(
698            hook.contains("appliance_pid1::fault::narration_from(crate::narrate::memory)"),
699            "{hook}"
700        );
701        // … and what such a source answers is what the panic's record holds.
702        appliance_pid1::fault::narration_from(|| N.get().map(Narrator::memory).unwrap_or_default());
703        let d = tempfile::tempdir().unwrap();
704        let p = appliance_pid1::fault::PanicFault {
705            code: "GN-99",
706            meaning: "the app panicked",
707            data: Some(d.path().to_path_buf()),
708        };
709        appliance_pid1::fault::panic_ending(
710            &p,
711            "panicked at src/x.rs:1: boom",
712            ("serving", true),
713            9,
714        );
715        let text = std::fs::read_to_string(
716            d.path()
717                .join(appliance_pid1::fault::CRASH_DIR)
718                .join(appliance_pid1::fault::CRASH_RECORD),
719        )
720        .unwrap();
721        assert!(
722            text.contains("gunnar: STAGE serving +812ms l10-crash-narration"),
723            "{text}"
724        );
725        assert!(text.contains("GN-99: the app panicked"), "{text}");
726        appliance_pid1::fault::narration_from(memory);
727    }
728
729    /// **A serving box that faults comes back by itself; a loop still stops.**
730    /// (finalization 2026-09-28, row h-panic-poweroff.) Until now every fault
731    /// powered the box off — right during the boot, where a reboot is a loop,
732    /// and wrong once it SERVES: one panic in one request task left the forge
733    /// down until a person started it, and nothing alerted. A second fault
734    /// within [`RESTART_LOOP_WINDOW`] of the last one is a loop and powers off,
735    /// exactly as before.
736    #[test]
737    fn a_serving_fault_restarts_the_box_unless_it_is_a_loop() {
738        assert_eq!(
739            end_after(Stage::Serving, None),
740            End::Restart,
741            "the first fault of a serving box: back by itself"
742        );
743        assert_eq!(
744            end_after(Stage::Serving, Some(RESTART_LOOP_WINDOW)),
745            End::Restart,
746            "the last fault is old: not a loop"
747        );
748        assert_eq!(
749            end_after(Stage::Serving, Some(Duration::from_secs(60))),
750            End::PowerOff,
751            "a fault a minute after the last: a loop, stop"
752        );
753        for s in ORDER.iter().copied().filter(|s| *s != Stage::Serving) {
754            assert_eq!(
755                end_after(s, None),
756                End::PowerOff,
757                "{s:?}: a fault during the boot reboots into the same fault — power off"
758            );
759        }
760    }
761
762    /// The tokens are a closed set, unique, and one word each — the ear
763    /// matches on them, so a token with a space in it is a token that splits.
764    #[test]
765    fn the_stage_tokens_are_unique_single_words() {
766        let mut seen: Vec<&str> = Vec::new();
767        for (i, s) in ORDER.iter().enumerate() {
768            assert_eq!(
769                *s as usize, i,
770                "the discriminant IS the index: the tick carries it as one byte"
771            );
772            assert!(!s.token().contains(' '), "{:?}", s.token());
773            assert!(!seen.contains(&s.token()), "two stages spell {}", s.token());
774            seen.push(s.token());
775        }
776    }
777
778    /// **The vocabulary parity check.** These spellings exist three times, in
779    /// three repositories with no dependency edge between them. If one drifts,
780    /// the ear stops matching and says nothing useful — which is the failure
781    /// this whole module exists to end. So the exact wire shapes are asserted
782    /// here, as literals, rather than built from the constants that would drift
783    /// with them.
784    #[test]
785    fn the_wire_shapes_are_exactly_what_the_ear_matches() {
786        assert_eq!(
787            stage_line(Stage::Lease, 6_340, "eth1 10.13.9.24/22"),
788            "STAGE lease +6340ms eth1 10.13.9.24/22"
789        );
790        assert_eq!(tick_line(Stage::Serving, 720), "TICK stage=serving up=720s");
791        assert_eq!(
792            fault_line("GN-04", Stage::Follow, 12_345, "the primary refused", Some(Path::new("/data/crash/fault.txt"))),
793            "FAULT GN-04 stage=follow +12345ms reason=the primary refused crash=/data/crash/fault.txt"
794        );
795        assert_eq!(ack_line("GN-99"), "ACK GN-99");
796    }
797
798    /// A fault is ONE line whatever the reason was — the twin's panic message
799    /// has three lines in it, and a fault that forged two more lines at every
800    /// reader would be a fault the ear judged as something else.
801    #[test]
802    fn a_fault_is_one_line_even_when_the_reason_is_not() {
803        let l = fault_line(
804            "GN-99",
805            Stage::Follow,
806            1,
807            "panicked at\nsrc/runtime/shutdown.rs:51:\nCannot drop a runtime",
808            None,
809        );
810        assert_eq!(l.lines().count(), 1, "{l}");
811        assert!(l.contains("Cannot drop a runtime"), "{l}");
812        // The field is stated as absent rather than omitted: a field that
813        // sometimes disappears is a field the ear has to guess at.
814        assert!(l.ends_with(" crash=-"), "{l}");
815    }
816
817    /// **Every death path has a code.** An `Err` at any stage maps to one of
818    /// the declared codes — never `GN-99`, which is the panic's and would make
819    /// a refused configuration read as a crash — and the four boot codes are
820    /// each reachable from the boundary they sit behind.
821    #[test]
822    fn an_err_at_every_stage_carries_a_declared_code_that_is_not_the_panics() {
823        for s in ORDER {
824            let code = code_after(s);
825            assert!(
826                code_meaning(code).is_some(),
827                "{code} for {:?} is not declared",
828                s
829            );
830            assert_ne!(code, "GN-99", "an ordinary Err at {:?} is not a panic", s);
831        }
832        assert_eq!(
833            code_after(Stage::Exec),
834            "GN-01",
835            "nothing read yet: the configuration"
836        );
837        assert_eq!(
838            code_after(Stage::Config),
839            "GN-02",
840            "config read; the next boundary is a bound listener"
841        );
842        assert_eq!(code_after(Stage::Sink), "GN-02");
843        assert_eq!(
844            code_after(Stage::Listener),
845            "GN-03",
846            "bound; the next boundary is the role"
847        );
848        assert_eq!(
849            code_after(Stage::Role),
850            "GN-04",
851            "role taken; a twin follows next"
852        );
853        assert_eq!(code_after(Stage::Follow), "GN-04");
854        assert_eq!(
855            code_after(Stage::Serving),
856            "GN-05",
857            "up, and the serve loop returned an error"
858        );
859        assert_eq!(
860            [
861                code_after(Stage::Exec),
862                code_after(Stage::Config),
863                code_after(Stage::Listener),
864                code_after(Stage::Role)
865            ],
866            ["GN-01", "GN-02", "GN-03", "GN-04"],
867            "the four boot codes are all reachable"
868        );
869    }
870
871    /// The acknowledgement matches its own code and nothing else's.
872    #[test]
873    fn an_ack_matches_its_code_and_only_its_code() {
874        assert!(ack_matches(b"ACK GN-99", "GN-99"));
875        assert!(
876            ack_matches(b"ACK GN-99 heard\n", "GN-99"),
877            "an ear may say more"
878        );
879        assert!(!ack_matches(b"ACK GN-01", "GN-99"));
880        assert!(!ack_matches(b"GN-99", "GN-99"), "a bare code is not an ack");
881        assert!(!ack_matches(b"NACK GN-99", "GN-99"));
882    }
883
884    /// Every code is unique, shaped the way a matcher expects, and means
885    /// something. A rung refuses BY these, so they are a contract.
886    #[test]
887    fn the_fault_codes_are_unique_and_stable_shaped() {
888        let mut seen: Vec<&str> = Vec::new();
889        for (c, meaning) in CODES {
890            assert!(
891                c.len() == 5 && c.as_bytes()[2] == b'-',
892                "a code is XX-NN: {c}"
893            );
894            assert!(!seen.contains(&c), "two faults spell {c}");
895            assert!(!meaning.is_empty());
896            assert_eq!(code_meaning(c), Some(meaning));
897            seen.push(c);
898        }
899        assert_eq!(code_meaning("ZZ-99"), None);
900    }
901}