Don't panic! - Or should you?

By Clemens Tiedt

2023-03-12
#rust
#panic
#error handling


If you came to Rust from a more conventional programming language, you probably had to adjust to its error handling. Many languages use exceptions for error handling and if you are doing a fallible operation, it looks something like this:

try:
    fallible_operation()
except Exception:
    # Handle the error
    # (Or often just ignore it)

Rust, on the other hand takes a page from the functional programming book and makes its errors values. So, the Python example from above would look something like this:

match fallible_operation() {
    Ok(v) => {
        // Do something with the value `v`
    }
    Err(e) => {
        // Handle the error
    }
}

But the point of this article is not to discuss Result and Option. There are already a number of sources you can refer to (including the book). No, the errors we have discussed so far are the "nice" kind that can be handled. Rust has another class of errors that you were probably introduced to as unrecoverable: Panics.

What is a panic?

A panic is a fatal error, one that you cannot catch and handle. Generally, a panic occurring means that something went so wrong that the most reasonable thing to do is to shut down your program. Most functions in the Rust standard library will only have recoverable errors, but you can easily turn a recoverable error into a panic: By unwrapping it. Here is the implementation of Option::unwrap.

pub const fn unwrap(self) -> T {
    match self {
        Some(val) => val,
        None => panic("called `Option::unwrap()` on a `None` value"),
    }
}

As you can see, it will return a T or call the panic macro. This is great if you know that your Option is Some, because then you don't have to handle a None case you know will not happen.

Something we have not talked about yet is that not all panics are equal. Rust supports two modes of panicking: Unwinding and aborting. The latter means just exiting the process and basically leaving the OS to clean up after you. It is in some way the nuclear option, even considering you are already panicking. On the other hand, there is Stack Unwinding. This means that Rust will travel back up the function stack and do its own cleanup. And this is where it gets interesting because as it turns out, we can catch an unwinding panic!

To catch a panic

If you look in the std::panic module, you will find a number of interesting functions. For now, let's start with catch_unwind. If we have a panicking function, we can call it inside catch_unwind and turn the panic into a Result:

fn main() {
    let res = std::panic::catch_unwind(|| {
        panic!("Oh no!");
    });

    match res {
        Ok(()) => println!("Nothing happened"),
        Err(e) => println!("A panic occurred: {e:?}")
    }
}

If you run this code, you will see a panic message, but the process will still exit with a code of zero. The match statement at the end will inform you rather unhelpfully that the error was Any {..}. This is Rust's version of dynamic typing and if we know the specific type it's supposed to be, we can in fact downcast it to get the info back. If we do downcast the error to &str (by calling e.downcast::<&str>().unwrap()), it will get properly printed. Of course, this begs the question: Can we panic with other values? The answer is yes, using the panic_any function!

fn main() {
    let res = std::panic::catch_unwind(|| {
        std::panic::panic_any(42);
    });

    match res {
        Ok(()) => println!("Nothing happened"),
        Err(e) => {
            let e = e.downcast::<i32>().unwrap();
            println!("A panic occurred: {e:?}");
        }
    }
}

This version of our program will tell us that it panicked with an error of 42 - in fact, it doesn't have to be an error, just Any type that is Send (i.e. can be transferred between threads).

The worst slice length function ever

To recap, we now know how to panic with an almost arbitrary value and how to catch this panic and its value. Can we build anything useful with this? Well, "useful" is a difficult word, but we could try to determine a slice's length by trying to index it until the operation panics. Let's start with a skeleton of our function:

fn inefficient_len<T>(data: &[T]) -> usize {
  let mut len = 0;
  loop {
    let _ = data[len];
    len += 1;
  }
  len
}

As you can see, inefficient_len takes a generic slice argument (since we don't care about the specific item type) and returns a usize - no surprises so far. We also have a counting variable and a loop in which we try to index into our slice as far as possible. Right now, this will just cause a panic without returning from our function. So, let's try to catch any panics during the indexing:

fn inefficient_len<T>(data: &[T]) -> usize {
  let mut len = 0;
  loop {
    let res = std::panic::catch_unwind(|| {
      let _ = data[len];
      len += 1;
    });
    if res.is_err() {break;}
  }
  len
}

We have moved our indexing and incrementing operation into a closure which we passed to catch_unwind and are checking the result - if it is an error, we break from the loop and return our count. This looks great, except that it doesn't compile.

error[E0277]: the type `&mut usize` may not be safely transferred across an unwind boundary
 --> src/lib.rs:4:40
  |
4 |       let res = std::panic::catch_unwind(|| {
  |                 ------------------------ ^-
  |                 |                        |
  |  _______________|________________________within this `[closure@src/lib.rs:4:40: 4:42]`
  | |               |
  | |               required by a bound introduced by this call
5 | |       let _ = data[len];
6 | |       len += 1;
7 | |     });
  | |_____^ `&mut usize` may not be safely transferred across an unwind boundary
  |
  = help: within `[closure@src/lib.rs:4:40: 4:42]`, the trait `UnwindSafe` is not implemented for `&mut usize`
  = note: `UnwindSafe` is implemented for `&usize`, but not for `&mut usize`
note: required because it's used within this closure
 --> src/lib.rs:4:40
  |
4 |     let res = std::panic::catch_unwind(|| {
  |                                        ^^
note: required by a bound in `catch_unwind`
 --> /rustc/2c8cc343237b8f7d5a3c3703e3a87f2eb2c54a74/library/std/src/panic.rs:139:1

error[E0277]: the type `T` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
 --> src/lib.rs:4:40
  |
4 |       let res = std::panic::catch_unwind(|| {
  |  _______________------------------------_^
  | |               |
  | |               required by a bound introduced by this call
5 | |       let _ = data[len];
6 | |       len += 1;
7 | |     });
  | |_____^ `T` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
  |
  = note: required because it appears within the type `[T]`
  = note: required for `&[T]` to implement `UnwindSafe`
note: required because it's used within this closure
 --> src/lib.rs:4:40
  |
4 |     let res = std::panic::catch_unwind(|| {
  |                                        ^^
note: required by a bound in `catch_unwind`
 --> /rustc/2c8cc343237b8f7d5a3c3703e3a87f2eb2c54a74/library/std/src/panic.rs:139:1
help: consider restricting type parameter `T`
  |
1 | fn inefficient_len<T: std::panic::RefUnwindSafe>(data: &[T]) -> usize {
  |                     +++++++++++++++++++++++++++

For more information about this error, try `rustc --explain E0277`.
error: could not compile `playground` due to 2 previous errors

This is once again a case of the Rust compiler having spectacular error messages. It is trying to protect us from accidentally violating invariants by transferring a type in an invalid state across a catch_unwind boundary which can happen if we are holding a mutable reference or a type with interior mutability. It also tells us that we can solve this problem by requiring our type T to be safe to transfer across such boundaries with the RefUnwindSafe trait.

However, we know more than the compiler in this case. We know that we will only panic while reading from a &[T] value, so we should not be able to leave our argument in an invalid state. In cases like this one, we can use the AssertUnwindSafe wrapper. By wrapping our closure in this type, we vouch that we are not carrying anything invalid across a catch_unwind boundary.

fn inefficient_len<T>(data: &[T]) -> usize {
  let mut len = 0;
  loop {
    let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
      let _ = data[len];
      len += 1;
    }));
    if res.is_err() {break;}
  }
  len
}

fn main() {
    let data = vec![1, 2, 3, 4];
    let len = inefficient_len(&data);
    println!("Length is {}", len);
}

For convenience, I added a short main function as well. If you run this code, you should see it correctly print that the length is 4. However, we also get the panic message which we might not want to see here. Well, we can get around this with a custom panic hook.

The documentation tells us that "the panic hook is invoked when a thread panics, but before the panic runtime is invoked". The default panic hook is what prints the panic message to the terminal. By replacing it, we can stop this behaviour:

fn inefficient_len<T>(data: &[T]) -> usize {
  std::panic::set_hook(Box::new(|_| {}));
  let mut len = 0;
  loop {
    let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
      let _ = data[len];
      len += 1;
    }));
    if res.is_err() {break;}
  }
  let _ = std::panic::take_hook();
  len
}

fn main() {
    let data = vec![1, 2, 3, 4];
    let len = inefficient_len(&data);
    println!("Length is {}", len);
}

At the beginning of the function, we set an empty hook and at the end we call take_hook to reinstate the default hook. Now, you should only see the message from our main function printed to the console.

It works until it doesn't

There is one major issue with this code: It is absolutely not guaranteed to work and your code can't know if it will work or not. As discussed earlier, there are two panic runtimes: The aborting and the unwinding one. Any code trying to catch a panic will only work with the unwinding runtime. If the program aborts on panic, there is nothing to catch. Furthermore, your code cannot know which panic runtime is used. You can use the experimental always_abort function to guarantee that your program will abort on panic, but since the panic runtime is determined at build time, you can only hope that the unwinding runtime is used.

So, the important takeaway here is: Don't write code that relies on the assumption that it can unwind a panic (if you can avoid it).

You could, however write your own panic runtime. Once again, you should not do this unless you need it but it will give us an interesting view into the inner workings of the Rust runtime. To start, we'll need to go #[no_std] to disable the default panic runtime.

#![no_std]
#![feature(start)]

#[start]
fn main(_argc: isize, _argv: *const *const u8) -> isize {
    panic!("Oh no");
}

We also have to enable the start feature because we can't use the default main function anymore. If we try to compile this code, the Rust compiler will helpfully tell us that we are missing a panic handler - so let's build one! To do this, we will need to implement the panic_impl language item. We will also need the eh_personality language item which we can replace with an empty function.

#![no_std]
#![feature(
    lang_items,
    start,
    core_intrinsics,
    libc,
    rustc_private
)]

extern crate libc;

#[lang = "panic_impl"]
extern "C" fn rust_begin_panic(info: &core::panic::PanicInfo) -> ! {
    core::intrinsics::abort();
}

#[lang = "eh_personality"]
fn eh_personality() {}

#[start]
fn main(_argc: isize, _argv: *const *const u8) -> isize {
    panic!("Oh no");
}

As you can see, our panic handler right now just uses the abort compiler intrinsic to kill the running process. We also needed to import the bundled libc crate because some function in core requires the libc memset function. It's a linker error, so figuring out what exactly went wrong here is not so easy. At this point, we can in fact panic our program, it just won't do anything interesting. How about we extract some information from that PanicInfo struct?

#![no_std]
#![feature(
    lang_items,
    start,
    core_intrinsics,
    libc,
    rustc_private,
    panic_info_message
)]

extern crate libc;

#[lang = "panic_impl"]
extern "C" fn rust_begin_panic(info: &core::panic::PanicInfo) -> ! {
    unsafe { libc::printf("Panicking now\n\0".as_ptr() as *const i8) };
    if let Some(location) = info.location() {
        unsafe {
            libc::printf(
                "Location: %s, %d:%d\n\0".as_ptr() as *const i8,
                location.file().as_ptr() as *const i8,
                location.line(),
                location.column(),
            )
        };
    }
    if let Some(&msg) = info.payload().downcast_ref::<&str>() {
        unsafe { libc::printf("%s\n\0".as_ptr() as *const i8, msg.as_ptr() as *const i8) };
    } else if let Some(msg) = info.message() {
        unsafe {
            libc::printf(
                "%s\n\0".as_ptr() as *const i8,
                msg.as_str().unwrap().as_ptr() as *const i8,
            )
        };
    }
    core::intrinsics::abort();
}

#[lang = "eh_personality"]
fn eh_personality() {}

#[start]
fn main(_argc: isize, _argv: *const *const u8) -> isize {
    panic!("Oh no");
}

Since we're operating in #[no_std] mode, we can't use the print! macro and have to resort to libc::printf instead. If you were doing this the proper way, you should be using CStr instead of how I am butchering my strings (in fact, it took me a moment to figure out I had to manually null-terminate them here), but this is the quickest way to get this working. With these changes, the program should panic, and print the panic message as well as location. This would also be the place to do any more panic handling like delegating to libunwind as the unwinding runtime does.

In practice, you will almost never need to do it this way. If you want to customize the panic message, you can set a hook as shown earlier. The only place I have actually needed to define my own panic handler this way was on a STM32 microprocessor.

Don't panic

One of the issues you may have with panics is that they are "silent" - a function's signature doesn't tell you if it may panic. This is an issue if you have a scenario where your code should never panic, but there are ways around this. For example, you can use David Tolnay's crate no_panic. Let's walk through how to use it and how it works.

use no_panic::no_panic;

#[no_panic]
fn demo(s: &str) -> &str {
    &s[1..]
}

fn main() {
    println!("{}", demo("input string"));
}

This is the example from the crate's documentation. You just add the no_panic attribute to a function and the compiler will have to prove that it can't panic. Well, it's a bit more complicated. You can tell that the demo function could panic if given an empty string or one that contains multibyte characters in the wrong places. But since the only call to the function is made with a well-formed string, the compiler knows it's fine. If you replace the string with one that cannot be sliced at the first character, the program will fail to compile with a scary-looking linker error. How does the crate do this? Well, we can look at the output of cargo expand to see what the proc macro expands to!

#![feature(prelude_import)]
#![feature(type_name_of_val)]  
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;
use no_panic::no_panic;        
#[inline]
fn demo(mut __arg0: &str) -> &str {
    struct __NoPanic;
    extern "C" {
        #[link_name = "\n\nERROR[no-panic]: detected panic in function `demo`\n"]
        fn trigger() -> !;
    }
    impl core::ops::Drop for __NoPanic {
        fn drop(&mut self) {
            unsafe {
                trigger();
            }
        }
    }
    let __guard = __NoPanic;
    let __result = (move || -> &str {
        let s = __arg0;
        &s[1..]
    })();
    core::mem::forget(__guard);
    __result
}
fn main() {
    {
        ::std::io::_print(format_args!("{0}\n", demo("input string")));
    };
}

As you can see, the macro made some major changes to the demo function: Our function itself now lives in a closure and a lot of code has appeared around it. First, the macro defines a new struct __NoPanic. This struct implements Drop and uses this implementation to call a C function trigger when it goes out of scope. It creates an instance of this struct and then calls our original function. This is where it gets interesting. If the function runs through normally, the __guard instance will be forgotten (i.e. destroyed without calling its Drop implementation) and the result returned. But what would happen if the function panicked? Well, the panic runtime would be responsible for cleaning up the __guard value. And this is actually a trap. The trigger function doesn't actually exist. If our code goes down the "happy path", the Drop implementation will never be called and the nonexistant function doesn't matter. But if the compiler knows the panic runtime may have to clean up this value, it will try to link the trigger function, not find it, and give a linker error.

This means that the error won't look very nice (linker errors never do), but it will give you a hint where a panic could happen. Pretty nifty!

Conclusions

I hope you learned something on this tour through the different ways to panic and deal with panics in Rust. In almost all cases, you will be fine treating a panic as an unrecoverable error, but if you need to recover from one, now you know how to do it! As an exercise, try thinking about how you might use the concepts introduced here to implement a testing framework that uses the assert! family of macros like the Rust builtin test harness. If you want to dive deeper, here are some more links that could be interesting:


Read more:

Building a Brainfuck Compiler using Cranelift

After studying computer science at a university level for more than four years at the time of writing this article, many concepts and tasks have become demystified for me. The idea of building a website or app seems pretty simple to me now. Even embedded development or Linux kernel modules don't seem like that big of a task. However, compilers still remain a topic I have a huge amount of respect for....

2023-01-09 #rust #brainfuck #cranelift #compiler

Share this article: Don't panic! - Or should you?