Rust - Panic & Unwinding

Overview

Estimated time: 40–55 minutes

Learn how Rust handles catastrophic errors through panics, understand stack unwinding, and master panic recovery techniques. Essential for building robust Rust applications that can handle unexpected failures gracefully.

Learning Objectives

Prerequisites

What is a Panic?

A panic is Rust's way of handling unrecoverable errors. When a panic occurs, the program will print an error message, unwind the stack, and exit:

fn main() {
    println!("About to panic!");
    panic!("Something went terribly wrong!");
    println!("This line will never be reached");
}

Expected output:

About to panic!
thread 'main' panicked at 'Something went terribly wrong!', src/main.rs:3:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Common Causes of Panics

Several operations can cause panics:

fn main() {
    // Array bounds checking
    let arr = [1, 2, 3];
    // let x = arr[10]; // This would panic!
    
    // Division by zero (integers only)
    let x = 10;
    let y = 0;
    // let result = x / y; // This would panic!
    
    // Unwrapping None or Err
    let option: Option = None;
    // let value = option.unwrap(); // This would panic!
    
    let result: Result = Err("error");
    // let value = result.unwrap(); // This would panic!
    
    println!("All dangerous operations commented out!");
}

Expected output:

All dangerous operations commented out!

Stack Unwinding

When a panic occurs, Rust performs stack unwinding by default, calling destructors for all variables:

struct Guard {
    name: String,
}

impl Drop for Guard {
    fn drop(&mut self) {
        println!("Dropping guard: {}", self.name);
    }
}

fn create_guards() {
    let _guard1 = Guard {
        name: "Guard 1".to_string(),
    };
    
    let _guard2 = Guard {
        name: "Guard 2".to_string(),
    };
    
    println!("About to panic in create_guards!");
    panic!("Panic in create_guards!");
}

fn main() {
    let _main_guard = Guard {
        name: "Main Guard".to_string(),
    };
    
    println!("Calling create_guards...");
    create_guards();
    println!("This won't be printed");
}

Expected output:

Calling create_guards...
About to panic in create_guards!
Dropping guard: Guard 2
Dropping guard: Guard 1
Dropping guard: Main Guard
thread 'main' panicked at 'Panic in create_guards!', src/main.rs:20:5

Panic Hooks

You can set custom panic hooks to handle panics:

use std::panic;

fn main() {
    // Set a custom panic hook
    panic::set_hook(Box::new(|panic_info| {
        println!("🚨 CUSTOM PANIC HANDLER 🚨");
        
        if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
            println!("Panic message: {}", s);
        }
        
        if let Some(location) = panic_info.location() {
            println!("Panic occurred at {}:{}", 
                    location.file(), location.line());
        }
        
        println!("Application will now exit gracefully");
    }));
    
    println!("Setting up panic hook...");
    panic!("Test panic with custom handler");
}

Expected output:

Setting up panic hook...
🚨 CUSTOM PANIC HANDLER 🚨
Panic message: Test panic with custom handler
Panic occurred at src/main.rs:18:5
Application will now exit gracefully

Catching Panics

You can catch panics using std::panic::catch_unwind:

use std::panic;

fn might_panic(should_panic: bool) -> i32 {
    if should_panic {
        panic!("I was told to panic!");
    }
    42
}

fn main() {
    // Successful case
    let result = panic::catch_unwind(|| {
        might_panic(false)
    });
    
    match result {
        Ok(value) => println!("Success: {}", value),
        Err(_) => println!("Caught a panic!"),
    }
    
    // Panic case
    let result = panic::catch_unwind(|| {
        might_panic(true)
    });
    
    match result {
        Ok(value) => println!("Success: {}", value),
        Err(_) => println!("Caught a panic!"),
    }
    
    println!("Program continues after catching panic");
}

Expected output:

Success: 42
Caught a panic!
Program continues after catching panic

Panic-Safe Code

When using catch_unwind, ensure your code is panic-safe:

use std::panic;
use std::sync::{Arc, Mutex};

fn main() {
    let data = Arc::new(Mutex::new(vec![1, 2, 3]));
    let data_clone = Arc::clone(&data);
    
    let result = panic::catch_unwind(move || {
        let mut guard = data_clone.lock().unwrap();
        guard.push(4);
        
        // This panic happens after we've modified the data
        if guard.len() > 3 {
            panic!("Too many items!");
        }
    });
    
    match result {
        Ok(_) => println!("Operation succeeded"),
        Err(_) => {
            println!("Operation panicked");
            // Data might be in an inconsistent state
            let guard = data.lock().unwrap();
            println!("Data state: {:?}", *guard);
        }
    }
}

Expected output:

Operation panicked
Data state: [1, 2, 3, 4]

Abort vs Unwind

You can configure Rust to abort instead of unwinding on panic:

// In Cargo.toml:
// [profile.release]
// panic = "abort"

fn main() {
    println!("This example shows the difference between:");
    println!("1. panic = 'unwind' (default) - cleans up and unwinds");
    println!("2. panic = 'abort' - immediately terminates");
    
    // Demonstrate the current panic strategy
    println!("Current panic strategy depends on compilation settings");
    
    // This would abort immediately if compiled with panic = "abort"
    // panic!("Testing panic strategy");
}

Expected output:

This example shows the difference between:
1. panic = 'unwind' (default) - cleans up and unwinds
2. panic = 'abort' - immediately terminates
Current panic strategy depends on compilation settings

Panic in Threads

Panics in threads don't crash the entire program:

use std::thread;
use std::time::Duration;

fn main() {
    println!("Starting main thread");
    
    // Spawn a thread that will panic
    let handle = thread::spawn(|| {
        println!("Worker thread started");
        thread::sleep(Duration::from_millis(100));
        panic!("Worker thread panic!");
    });
    
    // Spawn another thread that won't panic
    let handle2 = thread::spawn(|| {
        println!("Second worker thread started");
        thread::sleep(Duration::from_millis(200));
        println!("Second worker thread completed successfully");
        42
    });
    
    // Wait for the first thread (which will panic)
    match handle.join() {
        Ok(_) => println!("First thread completed successfully"),
        Err(_) => println!("First thread panicked"),
    }
    
    // Wait for the second thread (which won't panic)
    match handle2.join() {
        Ok(value) => println!("Second thread returned: {}", value),
        Err(_) => println!("Second thread panicked"),
    }
    
    println!("Main thread continues after worker panic");
}

Expected output:

Starting main thread
Worker thread started
Second worker thread started
First thread panicked
Second worker thread completed successfully
Second thread returned: 42
Main thread continues after worker panic

Panic with Backtraces

Enable backtraces for better debugging:

fn level_3() {
    panic!("Panic at level 3!");
}

fn level_2() {
    level_3();
}

fn level_1() {
    level_2();
}

fn main() {
    println!("Call stack example");
    println!("Run with RUST_BACKTRACE=1 to see full backtrace");
    println!("Run with RUST_BACKTRACE=full for more detailed backtrace");
    
    level_1();
}

Expected output (with RUST_BACKTRACE=1):

Call stack example
Run with RUST_BACKTRACE=1 to see full backtrace
Run with RUST_BACKTRACE=full for more detailed backtrace
thread 'main' panicked at 'Panic at level 3!', src/main.rs:2:5
stack backtrace:
   0: panic_example::level_3
   1: panic_example::level_2
   2: panic_example::level_1
   3: panic_example::main
   ...

Recovery Strategies

Implement robust error recovery:

use std::panic;

struct Service {
    name: String,
    retry_count: usize,
}

impl Service {
    fn new(name: &str) -> Self {
        Service {
            name: name.to_string(),
            retry_count: 0,
        }
    }
    
    fn risky_operation(&mut self, should_fail: bool) -> Result {
        let result = panic::catch_unwind(|| {
            if should_fail {
                panic!("Operation failed!");
            }
            format!("Success from {}", self.name)
        });
        
        match result {
            Ok(value) => {
                self.retry_count = 0; // Reset on success
                Ok(value)
            }
            Err(_) => {
                self.retry_count += 1;
                Err(format!("Panic caught in {} (attempt {})", 
                           self.name, self.retry_count))
            }
        }
    }
    
    fn safe_execute(&mut self, should_fail: bool) -> String {
        const MAX_RETRIES: usize = 3;
        
        for attempt in 1..=MAX_RETRIES {
            match self.risky_operation(should_fail && attempt < MAX_RETRIES) {
                Ok(result) => return result,
                Err(error) => {
                    println!("Attempt {}: {}", attempt, error);
                    if attempt == MAX_RETRIES {
                        return format!("All attempts failed for {}", self.name);
                    }
                }
            }
        }
        unreachable!()
    }
}

fn main() {
    let mut service = Service::new("TestService");
    
    // This will succeed on the third attempt
    let result = service.safe_execute(true);
    println!("Final result: {}", result);
    
    // This will succeed immediately
    let result2 = service.safe_execute(false);
    println!("Second result: {}", result2);
}

Expected output:

Attempt 1: Panic caught in TestService (attempt 1)
Attempt 2: Panic caught in TestService (attempt 2)
Final result: Success from TestService
Second result: Success from TestService

Best Practices

1. Prefer Result over Panic

// Good: Use Result for recoverable errors
fn divide(a: f64, b: f64) -> Result {
    if b == 0.0 {
        Err("Division by zero".to_string())
    } else {
        Ok(a / b)
    }
}

// Avoid: Using panic for recoverable errors
fn divide_bad(a: f64, b: f64) -> f64 {
    if b == 0.0 {
        panic!("Division by zero"); // Don't do this
    }
    a / b
}

fn main() {
    match divide(10.0, 2.0) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),
    }
    
    match divide(10.0, 0.0) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),
    }
}

2. Use expect() with Meaningful Messages

fn main() {
    let config_path = "config.toml";
    
    // Good: Descriptive expect message
    let _config = std::fs::read_to_string(config_path)
        .expect("Failed to read configuration file - ensure config.toml exists");
    
    // Avoid: Generic unwrap
    // let _config = std::fs::read_to_string(config_path).unwrap();
    
    println!("Configuration loaded successfully");
}

Checks for Understanding

Question 1: What happens to local variables when a panic occurs?

Answer: During stack unwinding, destructors (Drop implementations) are called for all local variables, ensuring proper cleanup. This happens automatically unless you compile with panic = "abort".

Question 2: When should you use catch_unwind?

Answer: Use catch_unwind sparingly, mainly for: FFI boundaries, plugin systems, or when you need to isolate potentially panicking code. Prefer proper error handling with Result in most cases.

Question 3: What's the difference between unwinding and aborting on panic?

Answer: Unwinding (default) cleans up the stack and calls destructors before terminating. Aborting immediately terminates the program without cleanup, but produces smaller binaries and can be faster.