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
- Understand when and why panics occur in Rust
- Learn about stack unwinding and cleanup
- Use panic hooks for custom panic handling
- Implement panic recovery strategies
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.