Error Handling

Stack Unwinding and Abort

When a Rust program triggers a panic!, the runtime can terminate execution in one of two ways configured by the build profile: stack unwinding (panic = "unwind", the default) or immediate process abort (panic = "abort").

Unwinding vs Aborting Compared

1. Stack Unwinding (`panic = "unwind"` — Default):
   [Frame 3: panic!] ──> Drops Frame 3 locals ──> Drops Frame 2 locals ──> Drops Frame 1 locals ──> Thread Exits
   (Runs all `Drop` destructors; isolates panic to current thread; catchable at FFI/thread boundaries)

2. Immediate Abort (`panic = "abort"`):
   [Frame 3: panic!] ──> Prints panic message ──> OS `abort()` / `SIGABRT` immediately!
   (Zero destructors run; entire OS process terminates immediately; smaller binary size)
Dimension Stack Unwinding (panic = "unwind") Immediate Abort (panic = "abort")
Default in dev & release? Yes (default on all standard OS targets) No (opt-in via Cargo.toml or -C panic=abort)
Runs Drop destructors on stack? Yes (cleans up open files, locks, and heap allocations in LIFO order) No (OS reclaims all process memory and file descriptors at once on exit)
Effect on other threads Terminates only the panicking thread (other threads keep running!) Terminates the entire OS process and all of its threads immediately
Can be caught with catch_unwind? Yes (std::panic::catch_unwind or thread::JoinHandle::join) No
Binary size & codegen overhead Larger (embeds .eh_frame unwinding tables and landing pads) 10%–20% smaller binary and slightly faster codegen

Observing Stack Unwinding in Action

Under the default unwind strategy, Rust walks up each active function call on the thread's stack from the panic site back to the thread entry point, calling Drop::drop on every initialized local variable in reverse declaration order:

struct ScopeGuard(&'static str);

impl Drop for ScopeGuard {
    fn drop(&mut self) {
        println!("  [Drop] Released {}", self.0);
    }
}

fn inner_step(should_panic: bool) {
    let _inner_res = ScopeGuard("inner_step lock");
    if should_panic {
        println!("  [inner_step] Panicking now!");
        panic!("something went wrong in inner_step");
    }
}

fn outer_step(should_panic: bool) {
    let _outer_res = ScopeGuard("outer_step transaction");
    inner_step(should_panic);
}

fn main() {
    // Run `outer_step(true)` inside `catch_unwind` to observe the unwinding cleanup:
    let result = std::panic::catch_unwind(|| {
        outer_step(true);
    });

    println!("Unwind completed cleanly; caught panic: {}", result.is_err());
}
  [inner_step] Panicking now!
  [Drop] Released inner_step lock
  [Drop] Released outer_step transaction
Unwind completed cleanly; caught panic: true

Catching an Unwind at Boundaries (std::panic::catch_unwind)

The standard library provides std::panic::catch_unwind to intercept an unwinding panic and convert it into a Result<T, Box<dyn Any + Send>>.

Important: catch_unwind is not meant to be used like try / catch for general error handling (always use Result<T, E> for recoverable errors!). Also, catch_unwind only catches unwinding panics—if a crate is compiled with panic = "abort", catch_unwind cannot intercept the abort.

Instead, catch_unwind is designed for three specific boundary scenarios:

  1. Thread pools and web servers: Catching a panic in a single request handler or worker task so one buggy route returns HTTP 500 instead of bringing down the entire worker thread.
  2. Foreign Function Interface (extern "C") boundaries: Unwinding a Rust panic across a C stack frame is Undefined Behavior. Any extern "C" callback invoked by C code must catch panics before returning to C.
  3. Custom test runners: Recording which test cases panicked while continuing to run the remaining tests.

Double Panic Protection (Panic During Unwind)

What happens if a type's Drop::drop implementation itself panics while the runtime is already in the middle of unwinding a previous panic?

Because a thread cannot unwind two simultaneous panics at once, Rust detects the double panic (std::thread::panicking() == true) and immediately aborts the process (thread panicked while processing panic. aborting.). Never call .unwrap() or panic! unconditionally inside a Drop::drop implementation!

Configuring panic = "abort" in Cargo.toml

For CLI tools, embedded systems, WebAssembly binaries, or containerized microservices where you never catch panics and want the smallest possible executable binary, configure panic = "abort" in Cargo.toml:

[profile.release]
panic = "abort"
strip = "symbols"