Frontend Labs
⋯

40. Processes, exit codes, and signals

40.1 Exit codes

How the process ends Exit code Destructors run?
main returns () / Ok(()) 0 yes
main returns Err(e) 1, and Error: {e:?} is printed to stderr yes
std::process::exit(n) n no: buffered writers are not flushed
panic (unwinding) 101 yes, for the panicking thread
panic = "abort", or std::process::abort() killed by SIGABRT (shell shows 134) no
killed by signal N shell shows 128 + N no
use std::process::ExitCode;

fn run() -> Result<(), String> {
    let arg = std::env::args().nth(1).ok_or("usage: tool FILE")?;
    std::fs::metadata(&arg).map_err(|e| format!("{arg}: {e}"))?;
    Ok(())
}

fn main() -> ExitCode {
    // Returning ExitCode lets destructors run, unlike process::exit().
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("error: {e}");
            ExitCode::from(2) // match the convention of your tool family (grep: 1 = no match, 2 = error)
        }
    }
}

C §24.3 makes the same point: exit codes are part of your program's API, because shell scripts and Kubernetes probes act on them.

40.2 Graceful SIGTERM handling

When Kubernetes stops a pod, or systemd stops a service, the process gets SIGTERM, then SIGKILL after a grace period. A signal handler may only call async-signal-safe functions (see C §55), so the safe pattern is to set a flag in the handler and do the work in normal code. signal-hook implements exactly that:

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;

fn main() -> std::io::Result<()> {
    let term = Arc::new(AtomicBool::new(false));
    // The handler only stores `true`, which is async-signal-safe.
    signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&term))?;
    signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(&term))?;

    println!("pid {} working; send SIGTERM to stop", std::process::id());
    let mut processed = 0u64;
    while !term.load(Ordering::Relaxed) {
        processed += 1;                               // one unit of work
        std::thread::sleep(Duration::from_millis(100));
        if processed == 20 { break; }                 // demo: stop by itself after 2s
    }
    println!("shutting down cleanly after {processed} units: flushing, closing connections");
    Ok(())
}
cargo run --bin graceful & sleep 0.5; kill -TERM $!; wait $!; echo "exit=$?"   # exit=0

In Tokio, use tokio::signal::unix::signal(SignalKind::terminate()) and .recv().await inside the select! from §31.4.

40.3 PID 1: reaping zombies

In a container, your program may run as PID 1. PID 1 has two special duties (OS Ch 74): the kernel does not apply default signal actions to it, so a SIGTERM with no handler is ignored, and it inherits orphaned processes, which become zombies unless it calls wait. A minimal supervisor:

// linux-only
use nix::sys::signal::{kill, Signal};
use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
use nix::unistd::Pid;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;

fn main() -> std::io::Result<()> {
    // As PID 1 we inherit orphans automatically. When we are NOT PID 1 (in a test, or under
    // a shell), ask the kernel to re-parent our orphaned descendants to us anyway.
    // SAFETY: PR_SET_CHILD_SUBREAPER takes an integer flag and no pointers.
    unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) };

    let term = Arc::new(AtomicBool::new(false));
    signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&term))?;

    // The shell exits after 1s and leaves two `sleep 2` processes orphaned.
    let child = Command::new("sh").args(["-c", "sleep 2 & sleep 2 & sleep 1; echo child done"]).spawn()?;
    let main_pid = Pid::from_raw(child.id() as i32);
    let mut main_exit: Option<i32> = None;
    let mut forwarded = false;

    loop {
        if term.load(Ordering::Relaxed) && !forwarded {
            let _ = kill(main_pid, Signal::SIGTERM);  // pass the shutdown request on to the app
            forwarded = true;
        }
        // Reap ANY exited child, including orphans re-parented to us.
        match waitpid(Pid::from_raw(-1), Some(WaitPidFlag::WNOHANG)) {
            Ok(WaitStatus::Exited(pid, code)) => {
                println!("reaped {pid} exit={code}{}", if pid == main_pid { " (main)" } else { " (orphan)" });
                if pid == main_pid { main_exit = Some(code); }
            }
            Ok(WaitStatus::Signaled(pid, sig, _)) => {
                println!("reaped {pid} killed by {sig:?}");
                if pid == main_pid { main_exit = Some(128 + sig as i32); }
            }
            Ok(_) => std::thread::sleep(Duration::from_millis(50)), // StillAlive: nothing to reap yet
            Err(nix::errno::Errno::ECHILD) => std::process::exit(main_exit.unwrap_or(0)), // all reaped
            Err(e) => return Err(e.into()),
        }
    }
}
child done
reaped 602 exit=0 (main)
reaped 604 exit=0 (orphan)
reaped 603 exit=0 (orphan)

Without the waitpid(-1, ..) loop, the two orphaned sleep processes would stay in the process table as zombies (Z in ps) until the supervisor exits. In a long-running container that runs shell scripts or health checks, zombies accumulate until the PID limit is reached.

In production use tini or dumb-init as PID 1, or Docker's --init. Writing this once shows why they exist.


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36