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/lib.rs

//! **The PID-1 duties every distroless nordisk appliance payload owes.**
//!
//! A payload that is PID 1 is not just "the first process". The kernel gives it
//! two jobs nobody else can do:
//!
//! 1. **Reaping.** Every orphaned process on the system is re-parented to PID 1,
//!    and PID 1 is the only process that can `wait` for it. A PID 1 that never
//!    waits leaves a `<defunct>` entry in the process table for the appliance's
//!    whole life, per child, forever.
//! 2. **Not exiting.** If PID 1 returns, the kernel panics.
//!
//! The second one is obvious and every payload does it. The first one is easy to
//! miss precisely because it looks like it works: `loop { pause() }` never
//! returns, so the appliance stays up and the zombies are invisible until you go
//! looking in `/proc`.
//!
//! ## Why this crate exists
//!
//! The shape below was written once, for the demo-container appliance's PID 1,
//! and got it right. The RDP-over-Wayland launcher — which spawns *more*
//! children than the demo track does (weston, an operator-configured app, a
//! keepalive) — ended its `main` with a bare `loop { libc::pause() }` and reaped
//! none of them. Two payloads, one duty, and one of them already had the code.
//! It lives here now so the next payload inherits it instead of re-deriving it.
//!
//! ```no_run
//! fn main() {
//!     // … mount, configure, emit the boot contract, start the children …
//!     appliance_pid1::idle_and_reap(); // never returns; never a shell
//! }
//! ```
//!
//! ## What this is NOT
//!
//! Reaping is **hygiene, not supervision**. It stops zombies accumulating; it
//! does not notice that the thing the appliance exists to do has stopped, and it
//! has no restart policy. Deciding what an appliance does when its payload dies
//! — restart with backoff, reboot loudly, halt into a known-safe state, pet or
//! stop petting `/dev/watchdog` — is a policy question with safety consequences,
//! and it is deliberately not answered here.

#![deny(missing_docs)]

/// The kernel pseudo-filesystems a PID 1 mounts for itself — with flags, and
/// with its results read.
pub mod mounts;

/// The network a PID 1 brings up itself: netlink, the net spec, the bring-up.
#[cfg(feature = "net")]
#[allow(missing_docs)]
pub mod net;

/// The boot narration's secret door and the kernel tail, handed to a sink.
#[cfg(feature = "net")]
pub mod narration;

/// The DHCPv4 client and its renewal machine.
#[cfg(feature = "dhcp")]
#[allow(missing_docs)]
pub mod dhcp;

/// `/etc/resolv.conf` from a lease's option 6. Under `net`, not `dhcp` (lane G6): a
/// build with no DHCP client still reads what was published (Gunnar's dashboard).
#[cfg(feature = "net")]
#[allow(missing_docs)]
pub mod resolver;

/// The ACPI power button (a cloud's soft stop) and the power-off after it —
/// libc only, like the reaper.
pub mod power;

/// What ends a serve, and what `main` does after a clean stop.
pub mod stop;

/// The install-mode record korp-installer leaves on the ESP.
pub mod install;

/// The machine's graphics console (`/dev/tty0`), appliance only.
#[cfg(feature = "net")]
pub mod console;

/// A file PID 1 keeps, written so a power cut leaves the old one or the new one.
pub mod durable;

/// How a PID 1 that faulted ends: the code, the record, the ear, restart or power off.
pub mod fault;

/// The last stop, kept on the ESP where it survives the read-only /data.
pub mod stop_record;

/// The supervisor reaper that leaves an exited child a grace for its owner (`GraceReaper`).
pub mod grace;

/// Signals, the SIGCHLD reaper and the lease kept alive, on tokio.
#[cfg(feature = "runtime")]
pub mod runtime;

/// What one non-blocking pass of the reaper found.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reaped {
    /// A child was reaped. Carries its pid and the raw wait status.
    Child {
        /// The pid that was reaped.
        pid: i32,
        /// The raw status word `waitpid` filled in.
        status: i32,
    },
    /// There are children, but none has exited yet. Nothing to do right now.
    StillRunning,
    /// This process has no children at all (`ECHILD`).
    NoChildren,
    /// The call was interrupted by a signal (`EINTR`), or failed for another
    /// reason. The caller should simply try again.
    Interrupted,
    /// The child that exited is CLAIMED ([`claim`]): its owner waits for it, so
    /// the reaper leaves its status alone. The pass ends here; the owner's
    /// `wait` takes it, and the next `SIGCHLD` wakes the reaper again.
    Claimed {
        /// The pid left for its owner.
        pid: i32,
    },
}

// ── claimed children ────────────────────────────────────────────────────────

/// The pids whose exit status belongs to a waiter in this process, not to the
/// reaper. MEASURED on Holger's appliance (lane HOLGER-MEDIA, 2026-10-23): the
/// scan sandbox spawns a container runtime and polls `try_wait`; between two
/// polls the reaper's `waitpid(-1)` took the exit, and the owner read `ECHILD`
/// with no status — a finished scan whose verdict is unknown is refused, so
/// every scan was. A claimed pid is peeked at (`WNOWAIT`) and left.
static CLAIMED: std::sync::Mutex<std::collections::BTreeSet<i32>> = std::sync::Mutex::new(std::collections::BTreeSet::new());

/// **Claim a child's exit status for its owner.** Dropping the guard releases
/// the claim (after the owner reaped it; or, if the owner never does, the
/// reaper takes it on its next pass).
pub fn claim(pid: i32) -> Claim {
    CLAIMED.lock().unwrap_or_else(|e| e.into_inner()).insert(pid);
    Claim(pid)
}

/// A claimed pid ([`claim`]); releases on drop.
#[derive(Debug)]
pub struct Claim(i32);

impl Claim {
    /// The claimed pid.
    pub fn pid(&self) -> i32 {
        self.0
    }
}

impl Drop for Claim {
    fn drop(&mut self) {
        CLAIMED.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.0);
    }
}

/// Is `pid` claimed?
pub fn is_claimed(pid: i32) -> bool {
    CLAIMED.lock().unwrap_or_else(|e| e.into_inner()).contains(&pid)
}

/// **Reap at most one exited child, without blocking.**
///
/// This is the testable half of the duty: it reports what it found instead of
/// looping, so a test can spawn a real process, let it exit, and prove the
/// zombie is gone — no VM, no boot.
///
/// `waitpid(-1, …, WNOHANG)` asks about *any* child, which is what PID 1 needs:
/// re-parented orphans are children it never spawned and whose pids it does not
/// know.
pub fn reap_once() -> Reaped {
    // 1. PEEK (`WNOWAIT`): which child exited, without taking its status.
    // SAFETY: `info` is a zeroed `siginfo_t` on this frame, uniquely borrowed;
    // `waitid(2)` fills it and keeps no reference. `P_ALL` with id 0 means "any
    // child"; `WNOHANG` makes the call non-blocking; `WNOWAIT` leaves the child
    // waitable, so an owner's `wait` still gets it.
    let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
    let rc = unsafe { libc::waitid(libc::P_ALL, 0, &mut info, libc::WEXITED | libc::WNOHANG | libc::WNOWAIT) };
    if rc < 0 {
        // Distinguish "no children" from "try again", because the caller
        // sleeps on the first and retries on the second.
        return match std::io::Error::last_os_error().raw_os_error() {
            Some(libc::ECHILD) => Reaped::NoChildren,
            _ => Reaped::Interrupted,
        };
    }
    // SAFETY: `si_pid` reads the union field `waitid` fills for `CLD_*` codes;
    // with `WNOHANG` and nothing exited it is 0 (the struct was zeroed).
    let pid = unsafe { info.si_pid() };
    if pid == 0 {
        // WNOHANG: children exist, none has changed state.
        return Reaped::StillRunning;
    }
    if is_claimed(pid) {
        return Reaped::Claimed { pid };
    }
    // 2. REAP exactly that pid.
    let mut status: libc::c_int = 0;
    // SAFETY: the only pointer is `&mut status`, a live, uniquely borrowed
    // `c_int` on this frame, and `waitpid(2)` writes at most one `int` through
    // it and keeps no reference. `WNOHANG` makes the call non-blocking.
    let rc = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
    if rc == pid {
        return Reaped::Child { pid, status };
    }
    if rc == 0 {
        return Reaped::StillRunning;
    }
    match std::io::Error::last_os_error().raw_os_error() {
        Some(libc::ECHILD) => Reaped::NoChildren,
        _ => Reaped::Interrupted,
    }
}

/// **Reap every child that has already exited, and report which** — the
/// drain [`reap_once`] is the unit of, as the pids (gunnar-server's
/// `pid1::reap_once`, now this crate's libc reaper under it: one reaper).
///
/// Returns the raw pids, **not** a count and not a bool: a reaper that returns
/// `true` cannot be told from one that returns `true` unconditionally. Never
/// blocks: `WNOHANG` throughout, so on a process with no children it costs
/// one `waitpid` returning `ECHILD`.
pub fn reap_all() -> Vec<i32> {
    let mut reaped = Vec::new();
    // `Interrupted` is also "any other error": retried a few times, never spun on.
    let mut interrupted = 0;
    loop {
        match reap_once() {
            Reaped::Child { pid, .. } => reaped.push(pid),
            Reaped::Interrupted if interrupted < 8 => interrupted += 1,
            Reaped::Interrupted => return reaped,
            // Its owner waits for it; the next SIGCHLD brings the reaper back.
            Reaped::Claimed { .. } => return reaped,
            Reaped::StillRunning | Reaped::NoChildren => return reaped,
        }
    }
}

/// **The PID-1 duty: reap forever, and never return.**
///
/// Drains every child that has already exited, then blocks in `pause()` until a
/// signal arrives — `SIGCHLD` from the next child to die is one of them, which
/// is exactly the wake-up this loop wants. It never busy-spins and it never
/// starts a shell.
///
/// Diverges: an appliance's `main` ends here.
pub fn idle_and_reap() -> ! {
    loop {
        // Drain: one SIGCHLD can stand for several dead children.
        while let Reaped::Child { .. } = reap_once() {}
        // Nothing left to reap right now — sleep until a signal, rather than
        // spinning a core for the life of the appliance.
        // SAFETY: `pause(2)` takes no arguments and touches no memory. It blocks
        // until a signal arrives (SIGCHLD from the next dead child is one) and
        // always returns `-1`/`EINTR`, so the loop is what makes this diverge.
        unsafe { libc::pause() };
    }
}

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

    /// The whole point, proven against a real process rather than a mock: a
    /// child whose handle nobody holds becomes a **zombie** — `/proc/<pid>/stat`
    /// says `Z` — and stays one until PID 1 waits for it.
    ///
    /// One test function does the whole sequence on purpose. `waitpid(-1, …)`
    /// asks about *any* child of this process, so two tests reaping concurrently
    /// in the same test binary would steal each other's children.
    #[test]
    fn a_forgotten_child_is_a_zombie_until_it_is_reaped() {
        // `true` is not available in a distroless image, but this test runs on a
        // developer host; the crate under test spawns nothing itself.
        let child = std::process::Command::new("/bin/sh")
            .args(["-c", "exit 7"])
            .spawn()
            .expect("spawn a short-lived child");
        let pid = child.id() as i32;
        // Drop the handle WITHOUT waiting — this is precisely what
        // `Ok(_child) => { … }` does at a spawn site, and `Child::drop` does not
        // wait either. Nothing but a reaper will ever collect it.
        std::mem::forget(child);

        // Wait for it to actually die and become a zombie.
        let mut sawstate = None;
        for _ in 0..400 {
            match proc_state(pid) {
                Some('Z') => {
                    sawstate = Some('Z');
                    break;
                }
                other => sawstate = other,
            }
            std::thread::sleep(std::time::Duration::from_millis(5));
        }
        assert_eq!(
            sawstate,
            Some('Z'),
            "the child never became a zombie, so this test proves nothing about reaping"
        );

        // Now do PID 1's job.
        let reaped = reap_once();
        match reaped {
            Reaped::Child { pid: got, status } => {
                assert_eq!(got, pid, "reaped the wrong child");
                assert!(libc::WIFEXITED(status), "status word: {status}");
                assert_eq!(libc::WEXITSTATUS(status), 7, "the exit code survives");
            }
            other => panic!("expected to reap {pid}, got {other:?}"),
        }

        // And it is gone from the process table.
        assert_eq!(
            proc_state(pid),
            None,
            "pid {pid} is still in /proc after being reaped"
        );

        // A CLAIMED child (same sequence, same reason: one reaper per test
        // binary): the reaper peeks, names it, and leaves its status to the
        // owner, whose `wait` still gets the exit code.
        let mut owned = std::process::Command::new("/bin/sh")
            .args(["-c", "exit 3"])
            .spawn()
            .expect("spawn an owned child");
        let owned_pid = owned.id() as i32;
        let guard = claim(owned_pid);
        assert!(is_claimed(owned_pid));
        for _ in 0..400 {
            if proc_state(owned_pid) == Some('Z') {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(5));
        }
        assert_eq!(proc_state(owned_pid), Some('Z'), "the owned child never exited");
        assert_eq!(reap_once(), Reaped::Claimed { pid: owned_pid }, "the reaper leaves a claimed child");
        assert_eq!(reap_all(), Vec::<i32>::new(), "a pass ends at a claimed child");
        assert_eq!(proc_state(owned_pid), Some('Z'), "still waitable by its owner");
        let st = owned.wait().expect("the owner's wait gets the status");
        assert_eq!(st.code(), Some(3));
        drop(guard);
        assert!(!is_claimed(owned_pid));
    }

    /// With no children at all the reaper says so, so `idle_and_reap` sleeps
    /// instead of spinning. This is the state a healthy appliance sits in.
    #[test]
    fn no_children_is_reported_as_such_and_is_not_an_error() {
        // Deliberately not asserted on its own — the reaping test above may have
        // a child in flight — but the variant must exist and be distinguishable.
        let r = reap_once();
        assert!(
            matches!(
                r,
                Reaped::NoChildren | Reaped::StillRunning | Reaped::Child { .. }
            ),
            "unexpected {r:?}"
        );
    }

    /// The single-character state field of `/proc/<pid>/stat`, or `None` when the
    /// pid is gone. Parsed from the LAST `)` because the comm field can contain
    /// spaces and parentheses.
    fn proc_state(pid: i32) -> Option<char> {
        let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
        let after_comm = &stat[stat.rfind(')')? + 1..];
        after_comm.split_whitespace().next()?.chars().next()
    }
}