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

nordisk/gunnar

crates/gunnar-front/src/stop.rs

//! **The front stops when it is asked to** (release-3 test2 on UpCloud: the installer's
//! `restart_front` sent a SOFT stop — the ACPI power button — and gunnar-front heard
//! nothing: no line, UpCloud hard-stopped it after 60 s, and the next boot's kernel said
//! `XFS (vda3): Starting recovery`, an unclean stop). gunnar-server has heard the button
//! since 2026-09-28 (`appliance_pid1::runtime::stop_request`); the front's PID 1 now does too: the stop is said
//! once at INFO naming its source, the data disk is synced and quiesced, the log flushed,
//! and the appliance powers off (anywhere else the process exits).

use std::future::Future;
use std::path::Path;
use std::time::Duration;

/// How long the stop waits for the disk to quiesce before it goes on without it: a cloud
/// hard-stops a box some time after its soft stop (UpCloud: 60 s), and a stop that says
/// nothing until the disk answers is the stop that was not heard.
pub const QUIESCE_WITHIN: Duration = Duration::from_secs(10);

/// **Wait for `stop`, then stop cleanly**: the stop said FIRST — before anything touches
/// the disk (the REAL test2 run 5: the button was heard, then nothing: the line came after
/// a quiesce that froze /data) — then `quiesce` run on a plain thread and waited for at most
/// `within` on a channel, never on the runtime's timers (a runtime whose workers block on a
/// frozen disk never fires its timeout), and the result said. Answers the last line and
/// what comes next. Pure over its inputs, so a test drives it without a power button.
pub async fn stop_cleanly(
    stop: impl Future<Output = &'static str>,
    quiesce: impl FnOnce() -> String + Send + 'static,
    appliance: bool,
    within: Duration,
) -> (String, appliance_pid1::stop::AfterStop) {
    let source = stop.await;
    let after = appliance_pid1::stop::after_clean_stop(appliance);
    let next = match after {
        appliance_pid1::stop::AfterStop::PowerOff => "powering off",
        appliance_pid1::stop::AfterStop::Return => "the process exits",
    };
    tracing::info!(
        source,
        "front: stopping cleanly on {source} — the disk is quiesced next, then {next}"
    );
    // The front's own jobs (A28 M18, A32-14) end first: nothing looks, pulls or recharges after this
    // line. A duty thread (none in the front today) is stopped too.
    let jobs = gunnar_common::jobs::stop_all(Duration::from_secs(2))
        + nordisk_pairing::duty::stop_all(Duration::from_secs(1));
    tracing::info!(source, "front: {jobs} job(s) stopped");
    let (tx, rx) = std::sync::mpsc::channel();
    // A28 M18, stays a thread: it exists to be outlived — the stop waits `within` for it, never longer.
    let _ = std::thread::Builder::new()
        .name("front-quiesce".into())
        .spawn(move || {
            let _ = tx.send(quiesce());
        });
    let quiesced = rx.recv_timeout(within).unwrap_or_else(|_| {
        format!(
            "the disk not quiesced within {} s — the stop goes on",
            within.as_secs_f32()
        )
    });
    let line = format!("front: stopped cleanly on {source} — {quiesced}; {next}");
    tracing::info!(source, "{line}");
    (line, after)
}

/// How long vm1 lets its disk log (`<data>/log/door.log`, written every second) take the
/// stop line before the disk is frozen — so the next boot's previous-boot lines carry it.
pub const DISK_LOG_CATCHES_UP: Duration = Duration::from_millis(1500);

/// **The appliance's quiesce** (edda `appliance_pid1::power::quiesce_for_power_off`):
/// the stop line onto the disk log first, then `sync(2)`, this process's writers on `data`
/// released and `data` REMOUNTED READ-ONLY — XFS's unmount record written, no recovery at
/// the next mount (the freeze only if the remount is refused) — each bounded and said. The
/// caller then flushes the console and calls `power_off_quiesced` (no sync after it).
pub fn quiesce_for_power_off(data: &Path) -> String {
    std::thread::sleep(DISK_LOG_CATCHES_UP);
    let ops: std::sync::Arc<dyn appliance_pid1::power::QuiesceOps> =
        std::sync::Arc::new(appliance_pid1::power::Kernel);
    appliance_pid1::power::quiesce_for_power_off(ops, data, QUIESCE_WITHIN, &|l: &str| {
        tracing::info!("{l}")
    })
    .join("; ")
}

/// **Quiesce the data disk** before the power goes: `syncfs(2)` on the filesystem holding
/// `root` — that disk only, never a host-wide `sync(2)` (dwarf under load: 1 min 53 s) —
/// then, only when the power goes next (`freeze`: the appliance), freeze it (FIFREEZE),
/// which leaves its log clean, so the next mount needs no recovery. Never frozen where the
/// process merely exits (a laptop's /data would stay frozen). Best effort: said, never fatal.
pub fn quiesce(root: &Path, freeze: bool) -> String {
    #[cfg(target_os = "linux")]
    {
        use std::os::fd::AsRawFd as _;
        let dir = match std::fs::File::open(root) {
            Ok(d) => d,
            Err(e) => return format!("{} not quiesced (not opened: {e})", root.display()),
        };
        // SAFETY: syncfs(2) on a live directory fd.
        let synced = if unsafe { libc_syncfs(dir.as_raw_fd()) } == 0 {
            format!("{}: its filesystem synced", root.display())
        } else {
            format!(
                "{}: its filesystem not synced ({})",
                root.display(),
                std::io::Error::last_os_error()
            )
        };
        if !freeze {
            return synced;
        }
        // FIFREEZE = _IOWR('X', 119, int)
        const FIFREEZE: u64 = 0xC004_5877;
        let mut arg: i32 = 0;
        // SAFETY: an ioctl on a live directory fd with a pointer to a live int.
        if unsafe { libc_ioctl(dir.as_raw_fd(), FIFREEZE, &mut arg) } == 0 {
            format!("{synced} and frozen (its log is clean)")
        } else {
            format!("{synced}, not frozen ({})", std::io::Error::last_os_error())
        }
    }
    #[cfg(not(target_os = "linux"))]
    {
        let _ = (root, freeze);
        "nothing to quiesce here".to_string()
    }
}

#[cfg(target_os = "linux")]
extern "C" {
    #[link_name = "ioctl"]
    fn libc_ioctl(fd: i32, request: u64, arg: *mut i32) -> i32;
    #[link_name = "syncfs"]
    fn libc_syncfs(fd: i32) -> i32;
}

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

    /// A stop request ends the front with ONE line naming its source, the disk quiesced
    /// first; the appliance powers off, anything else returns.
    #[tokio::test]
    async fn a_soft_stop_is_heard_said_once_and_the_disk_quiesced_before_the_power_goes() {
        let (tx, rx) = std::sync::mpsc::channel();
        let (line, after) = stop_cleanly(
            async { "ACPI power button" },
            move || {
                let _ = tx.send(());
                "the data disk synced and frozen".to_string()
            },
            true,
            QUIESCE_WITHIN,
        )
        .await;
        let quiesced = rx.try_recv().is_ok();
        assert!(quiesced, "the disk is quiesced before anything else");
        assert!(
            line.contains("ACPI power button")
                && line.contains("synced and frozen")
                && line.contains("powering off"),
            "{line}"
        );
        assert_eq!(after, appliance_pid1::stop::AfterStop::PowerOff);
        let (_, after) =
            stop_cleanly(async { "SIGTERM" }, String::new, false, QUIESCE_WITHIN).await;
        assert_eq!(
            after,
            appliance_pid1::stop::AfterStop::Return,
            "a process box, a laptop: the process exits"
        );
        let d = std::env::temp_dir();
        assert!(
            quiesce(&d, false).contains("synced") && !quiesce(&d, false).contains("frozen"),
            "synced where the process only exits (never frozen)"
        );
    }

    /// A quiesce that does not come back (dwarf's estate-box: a host-wide `sync(2)` took
    /// 1 min 53 s under load, and the front said nothing until it returned — the soft stop
    /// was hard-stopped after 15 s) does not hold the stop: it is said at once, and the stop
    /// goes on after its bound, saying the disk was not quiesced in time.
    #[tokio::test]
    async fn a_quiesce_that_hangs_does_not_hold_the_stop_past_its_bound() {
        let t0 = std::time::Instant::now();
        let (line, after) = stop_cleanly(
            async { "SIGTERM" },
            || {
                std::thread::sleep(std::time::Duration::from_secs(3));
                "synced".to_string()
            },
            false,
            std::time::Duration::from_millis(300),
        )
        .await;
        assert!(
            t0.elapsed() < std::time::Duration::from_secs(5),
            "held {:?}",
            t0.elapsed()
        );
        assert!(
            line.contains("SIGTERM") && line.contains("not quiesced within"),
            "{line}"
        );
        assert_eq!(after, appliance_pid1::stop::AfterStop::Return);
    }

    /// **The stop is said BEFORE the disk is touched** (the REAL test2 run 5: the button was
    /// heard, then nothing — the line came after a quiesce that froze /data, and nothing
    /// after the freeze got out). The quiesce finds the stop line already said, and the
    /// wait for it is a plain thread's, not the runtime's timers (a runtime whose workers
    /// block on a frozen disk never fires its timeout).
    #[test]
    fn the_stop_is_said_before_the_quiesce_runs_and_waited_without_the_runtime() {
        use std::sync::{Arc, Mutex};
        use tracing_subscriber::layer::SubscriberExt;
        let said: Arc<Mutex<Vec<String>>> = Arc::default();
        struct Cap(Arc<Mutex<Vec<String>>>);
        struct Msg(String);
        impl tracing::field::Visit for Msg {
            fn record_debug(&mut self, f: &tracing::field::Field, v: &dyn std::fmt::Debug) {
                if f.name() == "message" {
                    self.0 = format!("{v:?}");
                }
            }
        }
        impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Cap {
            fn on_event(
                &self,
                e: &tracing::Event<'_>,
                _: tracing_subscriber::layer::Context<'_, S>,
            ) {
                let mut m = Msg(String::new());
                e.record(&mut m);
                self.0.lock().unwrap().push(m.0);
            }
        }
        let seen = Arc::clone(&said);
        let sub = tracing_subscriber::registry().with(Cap(Arc::clone(&said)));
        // A runtime with NO timer driver: a stop that waits on tokio's timers never ends here.
        let rt = tokio::runtime::Builder::new_current_thread()
            .build()
            .unwrap();
        let (line, _) = tracing::subscriber::with_default(sub, || {
            rt.block_on(stop_cleanly(
                async { "ACPI power button" },
                move || {
                    let before = seen
                        .lock()
                        .unwrap()
                        .iter()
                        .any(|l| l.contains("stopping cleanly on ACPI power button"));
                    format!("said before: {before}")
                },
                true,
                QUIESCE_WITHIN,
            ))
        });
        assert!(
            line.contains("said before: true"),
            "the quiesce ran before the stop was said: {line}"
        );
    }

    /// Only the data disk's filesystem is synced (`syncfs(2)` on it), never the whole host's.
    #[test]
    fn the_quiesce_syncs_the_data_filesystem_only() {
        let d = tempfile::tempdir().unwrap();
        assert!(
            quiesce(d.path(), false).contains("its filesystem synced"),
            "{}",
            quiesce(d.path(), false)
        );
    }
}