Exit Codes and Process Control

A program's exit code is its one-byte report to whatever launched it: 0 for success, non-zero for failure. Shell scripts, CI pipelines, and make all branch on it, so a binary that returns the wrong code, or crashes without cleaning up, breaks the tools around it. Rust gives main several return-type options and a few ways to exit early, and they differ in important ways: some run destructors, one doesn't. This tutorial covers main's return types, custom exit codes via ExitCode, and the process::exit gotcha that silently skips your cleanup, building on the error handling and RAII from earlier tutorials.


main Can Return More Than ()

The simplest main returns () and always exits 0. But main can also return Result, which is the idiomatic way to let errors propagate with ? all the way to the top (Error Handling in Practice) instead of hand-writing exit logic:

use std::error::Error;

// returns Result: a returned Err prints the error (via Debug) and exits with code 1
fn main() -> Result<(), Box<dyn Error>> {
    let config = std::fs::read_to_string("config.toml")?;   // ? propagates to main
    println!("{config}");
    Ok(())
}

When main returns Err, Rust prints the error using its Debug representation to stderr and exits with a failure code (1). This is why fn main() -> anyhow::Result<()> (Error Libraries) is so common: you get automatic error reporting and a correct non-zero exit for free, no eprintln! + exit(1) boilerplate. The mechanism behind this is the Termination trait, which (), Result, and ExitCode all implement.


ExitCode: Returning a Specific Code

Result only distinguishes 0 (Ok) from 1 (Err). When you need a specific code, a CLI that returns 2 for "bad usage" and 1 for "runtime error", return std::process::ExitCode from main:

use std::process::ExitCode;

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,               // 0
        Err(Error::BadUsage) => ExitCode::from(2), // custom code 2
        Err(_) => ExitCode::FAILURE,               // 1
    }
}

ExitCode::from(n) builds any code in the u8 range (0255), and SUCCESS/FAILURE are the named 0/1. Returning ExitCode from main (rather than calling process::exit) is the clean way to set a specific code, because returning from main still runs all pending destructors normally. This matters for the distinction in the next section. Many CLI conventions assign meanings to codes (e.g. 2 for usage errors), and ExitCode lets you honor them.


The process::exit Trap: Destructors Don't Run

std::process::exit(code) terminates immediately from anywhere in the program. That immediacy is the danger: it does not unwind the stack, so Drop implementations (Drop, RAII, and Resource Cleanup) never run, buffers aren't flushed, locks aren't released, temp files aren't deleted.

use std::process;

fn main() {
    let _guard = TempFileGuard::new("/tmp/work");   // Drop would delete the file

    if something_wrong() {
        process::exit(1);   // GONE — _guard's Drop never runs, temp file leaks
    }
}

Gotcha: process::exit skips all destructors, which silently breaks RAII cleanup. The most common casualty is a BufWriter (Read and Write Traits) whose buffered output is lost because its drop-flush never happens, producing a truncated file with a "successful" exit. Prefer returning from main (via Result or ExitCode), which unwinds normally and runs every Drop. Reserve process::exit for cases where you genuinely must terminate mid-stack and have no cleanup to lose, or call it only after explicitly flushing/releasing what matters. "Return, don't exit" is the safe default.


Reading the Environment: Args and Variables

Process control also includes inputs from the environment: command-line arguments and environment variables. For anything beyond the trivial, use clap (Building CLIs with clap), but the raw std access is worth knowing:

// command-line arguments (args[0] is the program name)
let args: Vec<String> = std::env::args().collect();

// environment variables — env::var returns Result (Err if unset or not UTF-8)
match std::env::var("LOG_LEVEL") {
    Ok(level) => println!("log level: {level}"),
    Err(_) => println!("LOG_LEVEL not set, using default"),
}

// a common idiom: fall back to a default when unset
let port = std::env::var("PORT").unwrap_or_else(|_| "8080".into());

env::var returning a Result (rather than an Option) is deliberate: it distinguishes "unset" from "set but not valid UTF-8." The unwrap_or_else fallback pattern (Option and Result Combinators) is the idiomatic way to supply a default for an optional variable. env::args yields the program name first, so real arguments start at index 1.


Choosing How to Exit

You wantUse
Success, nothing specialfn main() (implicitly exits 0)
Propagate errors with ?, auto-reportfn main() -> Result<..> (Err → prints, exits 1)
A specific exit codefn main() -> ExitCode + ExitCode::from(n)
Rich error reporting at the topfn main() -> anyhow::Result<()>
Terminate mid-stack (last resort)process::exit(n)skips destructors
Read arguments / env varsstd::env::args / std::env::var

The guiding rule: return from main rather than calling process::exit. Returning lets the Termination trait set the exit code and unwinds the stack so every Drop runs; process::exit gets the code right but abandons cleanup. Use Result when 0/1 suffices and ExitCode when you need a precise code, and keep process::exit for the rare mid-stack bailout where there's nothing to clean up.


Key Takeaways

  • main can return () (always 0), Result (an Err prints via Debug to stderr and exits 1), or ExitCode (any u8), all via the Termination trait. fn main() -> anyhow::Result<()> gives free error reporting plus a correct failure code.
  • Return ExitCode (ExitCode::from(n), SUCCESS, FAILURE) when you need a specific code, e.g. 2 for usage errors, that Result's 0/1 can't express.
  • process::exit(n) terminates immediately and does not run destructors, so RAII cleanup (buffer flushes, lock release, temp-file deletion) is silently skipped. Prefer returning from main, which unwinds and runs every Drop.
  • Read arguments with std::env::args (name at index 0) and env vars with std::env::var (returns Result to distinguish unset from non-UTF-8); unwrap_or_else supplies a default.
  • Default to "return, don't exit": returning sets the code correctly and preserves cleanup; reserve process::exit for a mid-stack bailout with nothing to lose.