Dont panic!()

The general error handling story with Rust feels very ergonomic and more importantly very obvious and plausible to a programmer. There are exactly two kind of errors:

  1. Recoverable errors, represented by the Result<T,E> type, and
  2. unrecoverable errors, where the program panics.

This post is about the latter: What happens exactly if we call panic!() in a Rust program, which options are there to modify and influence this behaviour, and what are the caveats? This write-up is non-exhaustive, but highlights the noteworthy points that helped me understand it better. Platform specific nuances are not considered, if they are not necessary to understand.

Panics are commonly used to enforce an invariant of a program, which must not be broken and is completely unexpected by the application's logic. For further considerations when to use panics or not, see this section in the Rust book.

Let's consider a simple panic1:

#![allow(unused)]
fn main() {
panic!("boom");
println!("Nobody prints me :-(");
}

The argument passed to the panic! macro is printed, and further execution is stopped. In the following, we will trace what just happened.

The panic! macro

The panic! macro is defined in std:

macro_rules! panic {
    () => ({ $crate::panic!("explicit panic") });
    ($msg:expr $(,)?) => ({ $crate::rt::begin_panic($msg) });
    ($fmt:expr, $($arg:tt)+) => ({
        $crate::rt::begin_panic_fmt(&$crate::format_args!($fmt, $($arg)+))
    });
}

So it either accepts a string literal with formatting args (like format!), an object, or no argument at all (fallback to a default string literal). The object needs to fulfill the Any + Send trait bounds to be able to call begin_panic:

Update for Rust 1.51.0: Starting with 1.51.0, the panic! macro only takes String or &str payload. If you want to pass any object, you should use std::panic::panic_any instead, which will directly call begin_panic (see below):

pub fn begin_panic<M: Any + Send>(msg: M) -> ! {
    if cfg!(feature = "panic_immediate_abort") {
        intrinsics::abort()
    }

    let loc = Location::caller();
    return crate::sys_common::backtrace::__rust_end_short_backtrace(move || {
        rust_panic_with_hook(&mut PanicPayload::new(msg), None, loc)
    });

Interesting, with the panic_immediate_abort feature set, the program immediately aborts without any further actions. This is not to be confused with the panic = "abort" setting in Cargo.toml, which sets the panic_abort runtime. This is used in order to get really tiny binaries. You need to compile Rust's std library yourself with said feature flag set in order to use it.

Panic hook

rust_panic_with_hook will do some checking for recursive panics (it will terminate immediately in this case), and execute the panic hook, before dispatching to the panic runtime to unwind (or abort). Users can set a custom panic hook, which is executed for every panic, with std::panic::set_hook. This is a global resource, so last writer wins.

#![allow(unused)]
fn main() {
std::panic::set_hook(Box::new(|_panic_info| {
  println!("Hello Panic!");
}));
panic!();
}

The PanicInfo type propagates meta information of the panic (location, payload or message). Any custom set panic hooks can be unset by std::panic::take_hook() which will leave Rust's default panic hook in place.

Panic runtime

After control flow returned from the panic hook, a FFI function2 is called:

fn __rust_start_panic(payload: *mut &mut dyn BoxMeUp) -> u32;

This is a native interface, because rustc will make sure that user chosen panic runtime is linked against after the compilation step.

Rust provides two panic runtimes: panic_abort and panic_unwind. panic_abort just aborts the program (via a platform specific syscall, e.g. libc::abort()) and can be enabled in Cargo.toml:

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

The default is panic_unwind, which implements platform specific stack unwinding: In general, an unwinder walks the stack from top to bottom, where for each frame a so-called "personality routine" is called (those are provided by this runtime). This routine determines for the individual stack frame, which actions to take to handle an exception. If a handler frame has been found (so basically a catch block), the cleanup phase begins, where for each stack frame a "landing pad" is generated, which runs destructors, frees memory etc. Once the stack has been unwound, control is transferred to the handler frame. If no handler frame was found, the process is terminated. This is not unique to Rust, but behaviour of the LLVM backend.

Catching a panic

With the panic_unwind runtime, a panic triggers the unwinding of the stack. After the stack has been unwound, and no handler frame (catch block, see above) has been found, the process is terminated. The Rust standard library provides a mechanism to "catch" a panic after the stack has been unwound: std::panic::catch_unwind, which takes a closure that might potentially panic, and returns a Result<T, Box<dyn Any + Send + 'static>>. This eventually ends up in the compiler intrinsics, where LLVM instructions for a try-catch block are fabricated (for GNU, see here). With that, std::panic::catch_unwind is able to catch a panic, after the stack has been safely unwound.

But why even try to catch a panic at all, given that it's considered a unrecoverable error? There are a couple of potential motivations: supervision of third party (library) code, robustness concerns (think safety critical systems), or managing of thread pools (for example in async runtimes). The canonical example of this (one where the motivation of Rust's creators is well visible) is std::thread: Any panics encountered in a spawned thread are unwound, after which the (OS) thread exits. The JoinHandle returns from the thread's spawn method will carry the panic, if any happened. tokio::task::spawn took a similar approach (sans the OS thread exit, of course).

std::panic::resume_unwind provides a way to trigger a panic without invoking the panic hook. If invoked with a panic caught by std::panic::catch_unwind, the panic hook has been already executed for said panic, and the stack unwound. The stack will be unwound again with resume_unwind, but this time without any catch blocks (if not nested with another catch_unwind).

Note that all types passed into catch_unwind need to be UnwindSafe. When used only from within safe Rust code, problematic is only shared mutable state, which could violate application level invariants. More considerations apply if you interface with foreign code.

Reacting to panics in application level code

Writing applications in Rust, I think it's advisable to stick to the recommendations of the Rust core team regarding error handling3. Panics should be used as a last resort for non-recoverable errors. The logical conclusion from that is to always use the panic_abort runtime, so that any panic in any thread will bring down the whole process. However, it's good to have some sort of shutdown procedure (close database connections, flush to disk, etc.) to orderly bring down your application (if needed). This can be implemented by placing a custom panic hook with std::panic::set_hook4. As long as the closure passed to the hook finishes all work synchronously, we can stick with the panic_abort runtime; if we can only signal the shutdown to other threads and then yield, we obviously have to use the panic_unwind runtime -- otherwise the program would immediately exit after the panic hook returns.


1

This is a runnable snippet, so you can click on the play button above.

2

If you're curious about the type of payload, check out this explanation.

3

The Rust Book has a very well written chapter on error handling, which is worth a read.

4

For some inspiration to parse PanicInfo, check out the default hook in std.