Learn Rust Series (#51) - Panics, Unwinding, abort, and catch_unwind

Words
2945
Reading
14 min
2M
Listen to this post
~20 min

Learn Rust Series (#51) - Panics, Unwinding, abort, and catch_unwind

rust-banner.png

What will I learn

  • You will learn what a panic is and how it differs from a recoverable Result error;
  • what happens during unwinding, and why destructors still run as the stack is torn down;
  • when unwrap and expect are appropriate, and when they are a code smell;
  • the difference between the unwind and abort panic strategies, and how to pick one;
  • how catch_unwind and thread join let you contain a panic at a boundary.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Rust toolchain (via rustup, from rustup.rs);
  • The previous fifty episodes, especially error handling with Result and the ? operator;
  • The ambition to learn systems programming from the ground up.

Difficulty

  • Intermediate

Curriculum (of the Learn Rust Series):

Learn Rust Series (#51) - Panics, Unwinding, abort, and catch_unwind

The last five episodes lived entirely in the world of Result -- the recoverable kind of failure, the kind a caller is expected to look at and handle. We built custom error types (episode 48), let thiserror generate the boilerplate (episode 49), and collapsed everything into anyhow for application code (episode 50). All of that is Rust's answer to expected failure: a file that might be missing, a number that might not parse, a network call that might time out. Perfectly ordinary things a well-written program plans for.

This episode is about the other kind of failure -- the kind you did not plan for, because it should never happen at all. Indexing off the end of a slice. Dividing by zero. An assertion that is supposed to be true every single time and suddenly is not. Rust's response to that class of problem is the panic, and it behaves very differently from a Result. Understanding exactly what a panic does -- what unwinding is, why your destructors still run, when to reach for unwrap, and how to contain a panic at a boundary so it does not take the whole process down with it -- is one of those topics that quietly separates a robust program from a fragile one. No hand-waving, as always; we prove every claim with code you can run ;-)

Solutions to Episode 50 Exercises

Episode 50 was anyhow. Because anyhow is an external crate (and this series only ships code that compiles locally with plain std), here are the three exercises worked out in the standard-library shapes that anyhow polishes for you.

Exercise 1 asked you to write a main() -> anyhow::Result<()> that reads a file and parses a number from it, attaching context at each step. In std, context is a map_err that stacks a readable message on top of the raw error, and ? propagates it. Notice the message names the file at both stages, so a failure report reads like a sentence:

use std::error::Error;
use std::fs;

fn load_number(path: &str) -> Result<i32, Box<dyn Error>> {
    let text = fs::read_to_string(path)
        .map_err(|e| format!("failed to read config from {path}: {e}"))?;
    let n: i32 = text.trim().parse()
        .map_err(|e| format!("failed to parse a number from {path}: {e}"))?;
    Ok(n)
}

fn main() {
    match load_number("/definitely/missing") {
        Ok(n) => println!("got {n}"),
        Err(e) => println!("error: {e}"), // error: failed to read config from ...: No such file or directory
    }
}

The format!(...) produces a String, and ? converts that String straight into a Box<dyn Error> because std provides From<String> for the boxed trait object. That conversion is the entire trick .context() automates.

Exercise 2 wanted bail! and ensure! used to check that a parsed value lands inside a range. Both macros are just early returns, so writing them by hand shows precisely what anyhow saves you -- a whole return Err(...) becomes one readable line:

fn checked_port(s: &str) -> Result<u16, String> {
    let port: u16 = s.parse().map_err(|_| "not a number".to_string())?;
    if !(1024..=65535).contains(&port) {
        return Err(format!("port {port} is out of range")); // this is bail! by hand
    }
    Ok(port)
}

fn main() {
    println!("{:?}", checked_port("8080")); // Ok(8080)
    println!("{:?}", checked_port("80"));   // Err("port 80 is out of range")
}

The multi-line {:#} "caused by" report the exercise asked you to compare is an anyhow formatting feature; in std you would forward source() yourself, exactly as we did back in episode 48.

Exercise 3 was the healthy shape mature projects use: a library defines a precise, matchable error, and the application on top collapses everything into one flexible type with added context -- and the library never mentions the application's error crate. In std that is a concrete enum in the "library" layer and Box<dyn Error> in the "application" layer:

use std::error::Error;
use std::fmt;

// library layer: a precise, matchable error, no application types leaking in
#[derive(Debug)]
enum LibError { Empty, NotANumber, TooBig(u32) }

impl fmt::Display for LibError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            LibError::Empty => write!(f, "input was empty"),
            LibError::NotANumber => write!(f, "input was not a number"),
            LibError::TooBig(n) => write!(f, "value {n} exceeds the limit of 1000"),
        }
    }
}
impl Error for LibError {}

fn lib_check(s: &str) -> Result<u32, LibError> {
    if s.is_empty() { return Err(LibError::Empty); }
    let n: u32 = s.parse().map_err(|_| LibError::NotANumber)?;
    if n > 1000 { return Err(LibError::TooBig(n)); }
    Ok(n)
}

// application layer: unify any error, add context, report
fn app(s: &str) -> Result<u32, Box<dyn Error>> {
    let n = lib_check(s).map_err(|e| format!("validating '{s}': {e}"))?;
    Ok(n * 2)
}

fn main() {
    println!("{:?}", app("21"));           // Ok(42)
    println!("{}", app("9999").is_err());  // true
}

The arrow points one way: precise errors flow outward from the library, flexible errors are collected inward by the application. Right, homework cleared. Now, panics.

What a panic actually does

A panic is an abrupt but controlled shutdown of the current thread. It is triggered explicitly by the panic! macro, or implicitly by any std operation that hits a state it cannot make sense of -- the classic being an out-of-bounds index:

fn main() {
    let data = vec![10, 20, 30];
    let i = 5;
    // this line panics at runtime: "index out of bounds: the len is 3 but the index is 5"
    println!("{}", data[i]);
}

Here is the mental model that matters: a panic is not the same thing as a crash in C, where the program simply keels over and leaves everything in whatever half-finished state it was in. The interesting part happens after the message prints. By default, Rust unwinds the stack. It walks back up through every function call that was in progress, and for every local value it passes on the way out, it runs that value's destructor -- the Drop implementation we studied in episode 20. Files get closed, Mutex locks get released, heap allocations get freed, buffers get flushed. A panic is not a resource leak; it is an orderly retreat back to some point that can cope, cleaning up as it goes.

That is a genuinely rare guarantee. In a language without destructors, an unexpected failure mid-function leaves you leaking whatever you had acquired. Rust's RAII means the unwinding path is as tidy as the normal path -- which, as we will see near the end, is precisely what makes it safe to catch a panic at all.

unwrap and expect: panics you write on purpose

Most panics you meet in your own code are ones you asked for, via unwrap and expect. Both take an Option or a Result and either hand you the inner value or panic if it is None/Err. We have used them casually since the early episodes; now we can be precise about when they are correct.

They are correct when the absence genuinely cannot happen, and would represent a bug in your own logic if it did. In that situation a panic is the right response -- you do not want to limp along past a broken invariant, you want to stop loudly. And expect beats unwrap every time, because its message documents why you believed the value was there, which is exactly what a future reader (often you, at 2am) needs:

fn main() {
    let maybe: Option<i32> = "7".parse().ok();
    let value = maybe.expect("the literal '7' always parses"); // documented invariant
    println!("{value}"); // 7

    // for input that really can fail, recover in stead of panicking:
    let bad: Result<i32, _> = "x".parse::<i32>();
    println!("recovered: {}", bad.unwrap_or(-1)); // recovered: -1
}

The rule of thumb is worth memorising: unwrap/expect for invariants you are certain of, Result and the ? operator for anything a user, a file, or the network controls. Reaching for unwrap on external input is the single most common way a Rust program panics in production -- the input will eventually be something you did not expect, and then your "impossible" branch fires for real. If a value comes from outside your program, it deserves a Result, full stop.

unwind or abort: the two panic strategies

Unwinding is powerful, but it is not free. To run destructors on the way out, the compiler has to emit extra "landing pad" code around every function -- tables describing what to clean up if a panic passes through. That is bytes in your binary and a little complexity in the generated code. For most programs it is a trade well worth making. But for a tiny binary, or an embedded target with no room to spare, Rust offers a second strategy: abort. With abort, a panic does not unwind at all -- it immediately terminates the entire process, right there, no cleanup, no recovery. You choose it per build profile in Cargo.toml:

[profile.release]
panic = "abort"

The trade is stark. abort gives you smaller binaries and slightly leaner code, at the cost of losing all cleanup and losing catch_unwind entirely (a panic is now instantly fatal, so there is nothing to catch). unwind gives you recoverability and tidy teardown at the cost of that landing-pad overhead. There is no universally right answer -- it depends on the program.

The one firm rule here is for library authors: never write code that assumes one strategy or the other. Your library might be linked into a binary that chose abort, or one that chose unwind, and you do not get to decide. In particular, do not rely on a destructor running after a panic to maintain safety, because under abort it will not run at all.

Catching a panic with catch_unwind

Sometimes a panic simply must not be allowed to travel any further. The textbook case is a trust boundary: an extern "C" callback handed to a C library (unwinding across an FFI boundary is undefined behaviour), a thread-pool worker that should not kill the pool, a plugin host that must survive a misbehaving plugin. For these, std gives you std::panic::catch_unwind, which runs a closure and turns any panic inside it into an Err you can inspect:

use std::panic;

fn main() {
    let outcome = panic::catch_unwind(|| {
        let v: Vec<i32> = Vec::new();
        v[0] // panics: index out of bounds
    });
    println!("did it panic? {}", outcome.is_err()); // did it panic? true
    println!("main keeps going");
}

I want to be very clear about what this is not: it is not exceptions, and it is not a control-flow tool. Do not reach for catch_unwind to implement ordinary error handling -- that is what Result is for, and using panics as flow control is both slower and completely against the grain of the language. catch_unwind exists to contain a panic exactly at the point where letting it propagate would be catastrophic. Everywhere else, model your failures with Result.

The value you get back on the error path is a Box<dyn Any + Send> -- the panic payload, which is usually the string you passed to panic!. You can downcast it to recover the message:

use std::panic;

fn main() {
    let result = panic::catch_unwind(|| {
        panic!("something specific went wrong");
    });
    if let Err(payload) = result {
        // the payload is Box; try to read it back as a &str
        if let Some(msg) = payload.downcast_ref::<&str>() {
            println!("caught panic with message: {msg}");
        } else {
            println!("caught a panic with a non-string payload");
        }
    }
}

That dyn Any is the same type-erasure machinery we will lean on elsewhere; here it lets the runtime carry an arbitrary panic value across the unwinding boundary without the catcher having to know its type in advance.

Panics across thread boundaries

A crucial property: a panic only ever unwinds its own thread. It does not leap across threads. So if a worker thread you spawned (as we did in episode 13) panics, the rest of your program keeps running -- and the panic is captured for you in the value that join returns:

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        panic!("the worker hit an unrecoverable state");
    });
    match handle.join() {
        Ok(_) => println!("worker finished cleanly"),
        Err(_) => println!("worker panicked, but main survived"), // this arm runs
    }
}

join hands back a Result<T, Box<dyn Any + Send>> -- the very same payload type catch_unwind produces, which is no coincidence, since a thread boundary is an unwinding boundary. This is the foundation every thread pool is built on: a robust pool joins its workers (or wraps their bodies in catch_unwind), notices the ones that died, logs the payload, and spins up a replacement -- rather than letting one bad task bring down the whole system. The parent gets to decide how to react to a dead child, which is exactly the control you want.

A custom panic hook

Before any unwinding begins, Rust calls the panic hook -- a global function whose default implementation prints the panic message and, when RUST_BACKTRACE is set, a backtrace. You can replace that hook to route panics wherever you need them: a log file, a crash reporter, a metrics counter, a Sentry-style service:

use std::panic;

fn main() {
    panic::set_hook(Box::new(|info| {
        // a real program would write this to a log file or a crash reporter
        if let Some(loc) = info.location() {
            eprintln!("PANIC at {}:{}: {}", loc.file(), loc.line(), info);
        } else {
            eprintln!("PANIC (unknown location): {info}");
        }
    }));
    let _ = panic::catch_unwind(|| panic!("boom"));
    println!("hook ran, program continues");
}

Note how the hook and catch_unwind compose: the hook runs first (giving you a logged record of every panic, with its source location via info.location()), and then the unwinding happens, which catch_unwind contains. You get both observability and containment out of the two mechanisms together -- log everything, crash nothing.

Destructors still run while unwinding

Let me make the "orderly retreat" completely concrete, because it is the claim the whole episode rests on. Here is a guard type whose Drop prints as it runs. The closure panics after acquiring the guard -- and the cleanup still happens, on the way out, before control returns:

use std::panic;

struct Guard(&'static str);
impl Drop for Guard {
    fn drop(&mut self) { println!("cleaning up {}", self.0); }
}

fn main() {
    let _ = panic::catch_unwind(|| {
        let _g = Guard("database connection");
        panic!("failure after acquiring the resource");
    });
    // "cleaning up database connection" was printed during unwinding, before this line
    println!("the resource was released even though we panicked");
}

The output is cleaning up database connection and then the resource was released even though we panicked. That is RAII surviving a panic -- the destructor fired during the unwind, in the reverse order of construction we studied in episode 42, exactly as it would on a normal return. This is why a Mutex does not stay locked forever when a thread holding it panics, and why a file handle does not leak. (There is one sharp edge worth knowing: if a destructor itself panics while the stack is already unwinding from an earlier panic, Rust gives up and aborts the process -- a "double panic" is unrecoverable. So keep your Drop impls simple and panic-free.)

How Python and Go see this

A glance sideways sharpens the picture, since many of you came from the Learn Python Series. In Python, there is no real distinction between our two categories: everything is an exception. A missing key, a failed parse, and a genuine bug all raise, and all can be caught with the same try/except. That is flexible, but it means "expected" and "unexpected" failures look identical, and catching too broadly (except Exception:) silently swallows real bugs. Rust deliberately splits the two: Result for the failures you handle, panic for the bugs you should not.

Go is closer to Rust in spirit. It uses explicit error values for ordinary failures (its Result), and reserves panic/recover for the truly exceptional:

func worker() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered from:", r) // Go's catch_unwind
        }
    }()
    panic("something broke")
}

Go's recover() (only meaningful inside a defer) is the analogue of catch_unwind, and its defer is a looser cousin of our RAII destructors. The philosophy is the same one Rust enforces more strictly: error values for the expected, panic for the exceptional, and recovery reserved for a boundary. If that split feels familiar, good -- it is the same precise-errors-for-libraries, flexible-handling-for-applications instinct we have been building all arc, applied now to the line between recoverable and unrecoverable itself.

Wrapping up

So here is the whole picture. A Result is for failure you expect and a caller can handle. A panic is for a bug -- a broken invariant your program cannot sensibly continue past. By default a panic unwinds, running every destructor on the way out so resources are released cleanly, though a binary may choose abort to skip all of that. unwrap/expect are panics you write deliberately, and they belong only where the value truly cannot be absent. And when a panic absolutely must not escape -- across an FFI boundary, out of a pool worker -- catch_unwind and thread join let you contain it and inspect the payload.

Having said that, the golden rule stands: design so that panics are rare and reserved for genuine bugs. If a caller could reasonably recover, give them a Result. Panics are the seatbelt, not the steering wheel. In the next episode we start putting all of this under a microscope -- writing code that proves it behaves, including the panics that are supposed to happen ;-)

Exercises

  1. Write a function that returns a Result in stead of panicking on bad input, then call it and handle both the Ok and Err arms with a match. Then write a second version that expects the value and document, in the message, the invariant that makes the expect safe.
  2. Use catch_unwind around a closure that indexes past the end of a slice, print whether it panicked, and then downcast_ref the payload to recover the panic message as a &str.
  3. Spawn three threads where exactly one of them panics, join all three, and count how many finished successfully versus how many panicked -- proving the two survivors were unaffected.

Thanks for reading, and I will catch you in the next one -- de groeten! ;-)

scipio@scipio

Learn Rust Series (#51) - Panics, Unwinding, abort, and catch_unwin... | Ecency