crates/gunnar-server/src/main.rs
//! The `gunnar-server` binary — the appliance.
//!
//! Parsing lives in [`gunnar_common::cli`], the verbs in [`gunnar_server`]; this
//! file is the process entry point and nothing else. It dispatches only server
//! commands — a build of this crate links no client and no control plane.
//!
//! It reuses [`gunnar_server::run`], the one server dispatch, so `gunnar-cli`'s
//! `gunnar-solo` binary and this one route every server verb through the same
//! match (one implementation).
use clap::Parser;
/// The heap. One line, and the choice it names lives in
/// [`gunnar_server::ServerAllocator`] so this binary, `gunnar-solo`,
/// `gunnar-service` and the bench's `git-store-serve` cannot drift apart.
#[global_allocator]
static ALLOC: gunnar_server::ServerAllocator = gunnar_server::SERVER_ALLOCATOR;
// The build-thing RECEIPT, as a `#[used] #[link_section = ".nordisk.buildthing"]`
// static when this compilation is a declared cell, and a comment otherwise. The
// path comes from the env var `build.rs` published rather than from a name
// spelled here, so the file is named in exactly one place — the writer.
include!(env!("GUNNAR_BUILD_RECEIPT"));
/// Say `msg` on every console a human might be looking at: stderr (the
/// `/dev/console` PID 1 inherits — the serial line the proofs read) AND the VGA
/// text screen `/dev/tty0` when it exists.
///
/// MEASURED 2026-08-28 on UpCloud (SeaBIOS, VNC as the only console): the
/// appliance died as PID 1 with `Attempted to kill init! exitcode=0x00006500` —
/// exit 101, a Rust panic — and the panic text went to stderr = ttyS0, which no
/// cloud console shows. Locally the same disk served. A last word nobody can
/// read is no last word; so the panic hook and the top-level refusal both go
/// through here.
fn shout(msg: &str) {
use std::io::Write;
eprint!("{msg}");
// Outside tracing: handed to the boot log the installer reads, too (the
// panic and the top-level refusal are the lines it most needs).
gunnar_common::logqueue::tap_line(msg.trim_matches('\n'));
if let Ok(mut tty0) = std::fs::OpenOptions::new().write(true).open("/dev/tty0") {
let _ = tty0.write_all(msg.replace('\n', "\r\n").as_bytes());
}
}
fn main() {
// The boot log the installer collects keeps every line from here on
// (the ESP's BOOTLOG.TXT first — the install and this boot so far).
#[cfg(feature = "wg-client")]
gunnar_server::installer_listener::capture();
// DEV builds only: the same log, sealed to the installer's key, over UDP
// from the moment the NIC has an address — before the installer's link.
#[cfg(feature = "dev-narrate")]
gunnar_server::dev_narrate::start("gunnar-server");
let cli = gunnar_common::cli::Cli::parse();
// As PID 1 there is no parent to read the exit code and no serial line on a
// cloud VM: the panic message itself must reach the screen (`shout`), and is
// written down off the stripe (`slow::record_crash`). MEASURED 2026-09-23: the
// hook ran on the estate's twin, wrote its record, and the process died —
// `Attempted to kill init!`, an exit code and nothing else. So a panic on an
// appliance ends as a FAULT (`GN-99`): edda's panic hook (`appliance_pid1::fault`)
// writes `<data>/crash/fault.txt`, says the fault line through the narrator and
// restarts a serving box (unless it loops) or powers a booting one off.
//
// Armed on BOTH `is_init` and an installed narrator: the hook also runs in
// `gunnar-solo` on a laptop and inside every test binary, and powering off a
// developer's machine because a test panicked would be a considerably worse
// defect than the one being fixed. See `boot::install_panic_fault`.
gunnar_server::boot::install_panic_fault(Some(cli.data_root.clone()), shout);
gunnar_common::init_tracing(cli.verbose);
// The previous stop's record (the ESP survives a read-only /data), said once and removed.
gunnar_server::stop_record::say_previous();
let data_root = cli.data_root.clone();
let outcome = gunnar_server::run(cli.data_root, cli.command);
// The box's own duties (A28 M18: the pairing loop and the twin's follower, on threads of their
// own) and jera jobs (A32-14: the read sweeper, the live trial's watch) queue nothing more once
// the server has returned.
let duties = nordisk_pairing::duty::stop_all(std::time::Duration::from_secs(2))
+ gunnar_common::jobs::stop_all(std::time::Duration::from_secs(2));
if duties > 0 {
tracing::info!("gunnar: {duties} duty(ies) stopped");
}
// The console queue (`gunnar_common::logqueue`) is drained by its own
// thread; a return from `main` would take whatever it still holds.
gunnar_common::logqueue::flush(std::time::Duration::from_secs(5));
if let Err(err) = outcome {
// `{err:#}` prints the whole anyhow chain — the sentence that says what
// to do about it, not just "refusing to serve".
shout(&format!("gunnar: {err:#}\n"));
// **AN ORDINARY ERROR ENDS THE SAME WAY A PANIC DOES.** Until
// 2026-09-23 only the panic hook above sent a code; an `Err` out of
// `serve` reached this `exit(1)`, and as PID 1 that is the kernel's
// `Attempted to kill init!` — no code, no power-off, the tick simply
// stopped. `GN-01..04` were declared in `narrate::CODES` and never
// called. The code is read off the last boundary the narrator crossed
// (`narrate::code_after`), sent, waited on for the ear's acknowledgement
// and the box powers off cleanly. Same gate as the hook: PID 1 with a
// narrator installed, so a laptop's `gunnar-solo` still just exits.
if appliance_pid1::net::is_init() {
if let Some(n) = gunnar_server::narrate::narrator() {
let stage = n.stage();
gunnar_server::narrate::fault(
gunnar_server::narrate::code_after(stage),
stage,
&format!("{err:#}"),
);
}
}
std::process::exit(1);
}
// **A CLEAN STOP ON THE APPLIANCE POWERS OFF.** Until 2026-09-28 `main`
// returned here, and as PID 1 that is the kernel's `Attempted to kill
// init!` — then `panic=10` REBOOTS the box: a soft stop (the power
// button, `appliance_pid1::runtime::stop_request`) or a SIGTERM came back up as a start.
// Off the appliance (a laptop, a test, a container) `main` returns.
// **A LIVE UPDATE ends the graceful stop in a RE-EXEC** (owner 2026-10-12): synced, and
// korp-start (`/init`) again — PID 1 stays PID 1, /data stays mounted; korp-start boots the
// staged slot. Only an execve that failed falls through to the ordinary power-off.
#[cfg(all(feature = "live-app", target_os = "linux"))]
if appliance_pid1::stop::on_appliance()
&& gunnar_server::live_update::after_stop(gunnar_server::live_update::staged())
== gunnar_server::live_update::AfterStop::ReExec
{
shout(
"gunnar: stopped cleanly for a LIVE UPDATE — re-exec korp-start into the staged slot\n",
);
gunnar_common::logqueue::flush(std::time::Duration::from_secs(2));
let e = gunnar_server::live_update::reexec_now();
shout(&format!(
"gunnar: the live update's re-exec FAILED ({e}) — powering off as on any clean stop\n"
));
}
if appliance_pid1::stop::after_clean_stop(appliance_pid1::stop::on_appliance())
== appliance_pid1::stop::AfterStop::PowerOff
{
shout("gunnar: stopped cleanly — the data disk is synced and remounted read-only, then the power goes\n");
gunnar_common::logqueue::flush(std::time::Duration::from_secs(2));
// sync(2), this process's writers released, then the data disk REMOUNTED READ-ONLY
// — XFS's unmount record written, no recovery at the next mount (a freeze only if the
// remount is refused) — each bounded and said (edda appliance_pid1::power); then the
// power with no sync after it.
let ops: std::sync::Arc<dyn appliance_pid1::power::QuiesceOps> =
std::sync::Arc::new(appliance_pid1::power::Kernel);
let steps = appliance_pid1::power::quiesce_for_power_off(
ops,
&data_root,
std::time::Duration::from_secs(10),
&|l: &str| shout(&format!("gunnar: {l}\n")),
);
gunnar_common::logqueue::flush(std::time::Duration::from_secs(1));
// The last write before the power: the steps and the power-off time, on the ESP.
gunnar_server::stop_record::keep(&format!(
"gunnar: stopped cleanly — {}",
steps.join("; ")
));
appliance_pid1::power::power_off_quiesced();
}
}