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.