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