crates/appliance-pid1/src/runtime.rs
//! **The PID-1 duties that need a runtime** (feature `runtime`, tokio):
//! signals, the reaper on `SIGCHLD`, and — with `dhcp` — the lease kept alive.
//!
//! Moved AS IS from gunnar-server's `pid1.rs` (gunnar `gunnar-live` 18a44e3,
//! 2026-09-27), where they were written and measured; what was gunnar's by
//! name (`GUNNAR_DHCP_T1_OVERRIDE_SECS`) is the caller's argument now.
//!
//! # Signals, because PID 1 has no default dispositions
//!
//! This is not a nicety, it is a kernel rule. `SIGTERM` to any ordinary process
//! kills it because the *default action* runs; for PID 1 the kernel refuses to
//! apply a default action at all (`kernel/signal.c`, `sig_task_ignored`: a
//! signal with `SIG_DFL` sent to the init of a namespace is **discarded**). So
//! a PID 1 with no explicit handler does not "die on SIGTERM slowly": it never
//! dies at all, and every supervisor above it falls back to `SIGKILL` after its
//! grace period. `podman stop` takes ten seconds and then kills; a QEMU
//! `system_powerdown` never completes. [`shutdown_signal`] is the fix.
#[cfg(feature = "dhcp")]
use crate::net::{apply, publish_resolvers_at, DhcpSession};
// ── 1. signals ──────────────────────────────────────────────────────────────
/// Wait for the first of `SIGTERM`, `SIGINT`, `SIGQUIT` or `SIGPWR`, and name it.
///
/// `SIGPWR` (added 2026-09-30) is the power signal a UPS daemon or a container runtime
/// sends, and what the estate-box sends a process box after `SIGTERM`: with no handler
/// a process is ENDED by it (the default action) and a namespace's PID 1 never hears it.
///
/// Returns the signal's conventional name so the shutdown line says which one
/// arrived: "a supervisor asked" (`SIGTERM`, `podman stop`, a service manager)
/// or "somebody is at the console" (`SIGINT`). A hypervisor's `system_powerdown`
/// is NOT a signal inside the guest — it is the ACPI power button, which only
/// [`shutdown_request`] hears (corrected 2026-09-28: this line used to say the
/// powerdown arrives as `SIGTERM`; on the appliance kernel nothing turns the
/// press into one).
///
/// `SIGTERM` first in the list is not cosmetic: it is the one that was missing,
/// and the one every supervisor sends.
pub async fn shutdown_signal() -> &'static str {
use tokio::signal::unix::{signal, SignalKind};
// A failure to install a handler is not a reason to refuse to serve. It
// is a reason to keep serving with one fewer way to be stopped. Each arm
// that could not be installed simply never fires.
let mut term = signal(SignalKind::terminate()).ok();
let mut int = signal(SignalKind::interrupt()).ok();
let mut quit = signal(SignalKind::quit()).ok();
#[cfg(target_os = "linux")]
let mut pwr = signal(SignalKind::from_raw(libc::SIGPWR)).ok();
#[cfg(not(target_os = "linux"))]
let mut pwr: Option<tokio::signal::unix::Signal> = None;
// `async {}` blocks that never resolve stand in for a stream that could not
// be installed, so the `select!` shape does not have to change.
tokio::select! {
_ = async { match term.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending().await } } => "SIGTERM",
_ = async { match int.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending().await } } => "SIGINT",
_ = async { match quit.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending().await } } => "SIGQUIT",
_ = async { match pwr.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending().await } } => "SIGPWR",
}
}
/// **Wait for the first request to stop: a signal, OR the ACPI power button.**
///
/// [`shutdown_signal`] alone is not enough for an appliance under a cloud: a
/// soft stop is a power-button press, which the kernel reports and never turns
/// into a signal ([`crate::power`]). This waits on both and names the one that
/// came — `"SIGTERM"`, `"SIGINT"`, `"SIGQUIT"` or `"ACPI power button"`.
///
/// The button is heard on a blocking thread ([`crate::power::PowerButton`]).
/// When it cannot be opened (a kernel with no ACPI event family, a container
/// with no netlink) that is logged once and the button arm never fires: the
/// signals still stop the box.
pub async fn shutdown_request() -> &'static str {
let button = async {
let heard = tokio::task::spawn_blocking(|| crate::power::PowerButton::open().and_then(|b| b.wait())).await;
match heard {
Ok(Ok(e)) => {
tracing::info!(device = %e.bus_id, class = %e.device_class, "the ACPI power button was pressed (a soft stop)");
}
Ok(Err(err)) => {
tracing::warn!(error = %err, "the ACPI power button cannot be heard on this kernel; only a signal stops this box");
std::future::pending::<()>().await;
}
Err(err) => {
tracing::warn!(error = %err, "the ACPI power-button listener died; only a signal stops this box");
std::future::pending::<()>().await;
}
}
};
tokio::select! {
sig = shutdown_signal() => sig,
_ = button => "ACPI power button",
}
}
/// **Wait for the first request to stop from [`crate::stop::stop_sources`]** and name
/// it (Gunnar's `pid1::stop_request`): on the appliance [`shutdown_request`] (the
/// signals AND the ACPI power button), elsewhere [`shutdown_signal`].
pub async fn stop_request() -> &'static str {
if crate::stop::stop_sources(crate::stop::on_appliance()).contains(&crate::stop::StopSource::AcpiPowerButton) {
shutdown_request().await
} else {
shutdown_signal().await
}
}
// ── 2. reaping ──────────────────────────────────────────────────────────────
/// Reap on `SIGCHLD`, forever.
///
/// A `SIGCHLD` stream rather than a poll loop: an appliance that never acquires
/// an orphan should cost nothing at all, and a timer that wakes to find no
/// children is exactly the kind of idle cost an appliance is supposed not to
/// have.
///
/// The initial `reap_once()` before the loop is deliberate. Signals coalesce —
/// two children exiting inside one delivery window produce **one** `SIGCHLD` —
/// so the loop body drains, and a child that exited before the stream was
/// installed is picked up by the first pass rather than waiting for the next
/// unrelated death.
pub async fn reaper() {
use tokio::signal::unix::{signal, SignalKind};
let Ok(mut sigchld) = signal(SignalKind::child()) else {
tracing::warn!(
"could not install a SIGCHLD handler; orphans re-parented to this process would \
become zombies. Not fatal."
);
return;
};
for pid in crate::reap_all() {
tracing::info!(pid, "reaped an orphan re-parented to pid 1");
}
while sigchld.recv().await.is_some() {
for pid in crate::reap_all() {
tracing::info!(pid, "reaped an orphan re-parented to pid 1");
}
}
}
// ── 3b. keeping the lease alive ─────────────────────────────────────────────
/// TEST SEAM: a forced T1, in seconds, for proving renewal live.
///
/// QEMU user-net grants 86 400 s leases, so a live boot can never *wait* for
/// a real T1. The variable `var` (gunnar's `GUNNAR_DHCP_T1_OVERRIDE_SECS`) in the boot parameters of a test
/// ISO makes the machine renew that often instead. Read ONLY behind the
/// appliance gate ([`booted_by_korp_start`]): on a developer box or in a
/// container a stray environment variable must not be able to bend the
/// timers. The shipped declaration (`.nornir/build-variants.toml`,
/// `[[build.iso]].boot_params`) carries no override — this exists for test
/// ISO builds and is documented as a seam there too.
#[cfg(feature = "dhcp")]
pub fn dhcp_t1_override(var: &str) -> Option<u32> {
if !crate::net::booted_by_korp_start() {
return None;
}
let secs: u32 = std::env::var(var)
.ok()?
.trim()
.parse()
.ok()?;
tracing::warn!(
t1_override_secs = secs,
t1_override_var = var,
"a DHCP T1 override is set: DHCP T1 is FORCED for testing; \
this is not a configuration for a shipped appliance"
);
Some(secs)
}
/// Drive [`crate::dhcp::renew::Machine`] on the tokio clock, forever.
///
/// Spawned by `serve::run` once the initial lease is applied; aborted at
/// shutdown alongside the reaper. The split of labour is strict:
///
/// * the **machine** owns timers and transitions and is pure (unit-tested
/// over a mock, no sleeping);
/// * this loop owns *real* time (`tokio::time::sleep`, seconds measured from
/// one `Instant`) and runs each `poll` — which may block up to one bounded
/// socket leg (~3 s, or ~12 s for a full re-DISCOVER) — in
/// `spawn_blocking`, keeping the executor threads clean;
/// * a lease that **changes** the address goes through [`apply`] — the same
/// one netlink writer as every other address on this appliance — and is
/// logged loudly, because a mid-life address change on an appliance is a
/// big event; an unchanged renewal (the normal case) re-applies nothing.
#[cfg(feature = "dhcp")]
pub async fn dhcp_renewal(session: DhcpSession, t1_override: Option<u32>) {
use crate::dhcp::renew::{Event, Machine};
let epoch = std::time::Instant::now();
let mut machine = Machine::new(
session.interface.clone(),
session.lease.clone(),
0,
t1_override,
);
tracing::info!(
interface = %session.interface,
renew_in_secs = machine.renew_at(),
rebind_in_secs = machine.rebind_at(),
expire_in_secs = machine.expire_at(),
"DHCP renewal scheduled at T1 (RFC 2131 §4.4.5): unicast REQUEST to \
the lease's server, broadcast after T2, re-DISCOVER at expiry"
);
let mut transport = crate::dhcp::WireTransport {
interface: session.interface.clone(),
mac: session.mac,
};
loop {
let now = epoch.elapsed().as_secs();
let joined = tokio::task::spawn_blocking(move || {
let mut machine = machine;
let mut transport = transport;
let turn = machine.poll(now, &mut transport);
(machine, transport, turn)
})
.await;
let turn = match joined {
Ok((m, t, turn)) => {
machine = m;
transport = t;
turn
}
Err(err) => {
// A panic inside poll. Serving goes on; the lease simply
// stops being maintained, and that is said plainly.
tracing::error!(
interface = %session.interface,
error = %err,
"the DHCP renewal loop died; the lease will NOT be renewed \
from here on"
);
return;
}
};
for event in turn.events {
match event {
Event::Renewed {
via,
changed,
renew_at,
expire_at,
} => {
// The resolvers first, and independently of `changed`: a
// server is free to move its name servers without moving
// anyone's address, and `changed` means "the NETLINK plan
// moved". `resolver::publish` reads the file and decides
// for itself whether there is anything to do, so calling it
// on every ACK costs one `read` per T1 and cannot churn.
if session.owns_default_route {
publish_resolvers_at(&session.interface, &machine.lease().dns, false);
}
match changed {
None => tracing::info!(
interface = %session.interface,
via = via.as_str(),
next_renew_in_secs = renew_at.saturating_sub(epoch.elapsed().as_secs()),
expire_in_secs = expire_at.saturating_sub(epoch.elapsed().as_secs()),
"DHCP lease renewed: ACK refreshed the timers; address \
unchanged, nothing re-applied"
),
Some(plan) => {
tracing::warn!(
interface = %plan.interface,
address = %plan.address,
prefix = plan.prefix,
gateway = ?plan.gateway,
via = via.as_str(),
"DHCP renewal CHANGED THE ADDRESS mid-life — a big \
event on an appliance: every client holding the old \
address must re-resolve. Applying the new plan over \
netlink; the old address stays on the interface \
until reboot (this appliance never deconfigures)"
);
if let Err(err) = apply(&plan) {
tracing::error!(
interface = %plan.interface,
error = %format!("{err:#}"),
"applying the renewed lease's new plan FAILED; \
serving continues on the old address"
);
}
}
}
}
Event::Nak { message } => tracing::warn!(
interface = %session.interface,
message = ?message,
"DHCP server answered NAK during renewal: the lease is dead \
where it stands; re-DISCOVERing from scratch now"
),
Event::ExpiredSquatting { .. } => tracing::warn!(
interface = %session.interface,
"DHCP lease EXPIRED with no replacement. Keeping the expired \
address so this appliance keeps serving (deliberate RFC 2131 \
deviation, see dhcp::renew) — the address is SQUATTED until \
a server grants a lease again"
),
Event::RediscoverFailed { error, squatting } => {
if squatting {
tracing::warn!(
interface = %session.interface,
error = %error,
"DHCP lease is EXPIRED and the address is SQUATTED; \
re-DISCOVER failed, retrying until a server answers"
);
} else {
tracing::warn!(
interface = %session.interface,
error = %error,
"re-DISCOVER after the NAK failed; retrying"
);
}
}
}
}
let now = epoch.elapsed().as_secs();
let sleep_secs = turn.wake_at.saturating_sub(now);
if sleep_secs > 0 {
tokio::time::sleep(std::time::Duration::from_secs(sleep_secs)).await;
}
}
}