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

nordisk/edda

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