Lesson 87 of 94
Panics & Failure Handling
Rust has two failure mechanisms. Recoverable errors are values (Result), covered in the Error Handling section. Panics are for bugs and broken invariants: an index out of bounds, an unwrap on None, an assert! that fails.
Most Rust code never catches a panic, so it's tempting to ignore them. But a senior engineer needs to know exactly what a panic does to a running service: which thread dies, what gets cleaned up, whether locks stay usable, whether a Tokio task's failure is noticed at all, and what changes with panic = "abort". Those answers decide whether a bug causes one failed request or a corrupted cache that serves wrong data for hours.
| Concept | Rust |
|---|---|
| Bugs and broken invariants | panic |
| Recoverable errors | Result<T, E> |
| Cleanup while unwinding | stack unwinding, Drop runs |
| Panic in a spawned thread | ends the thread (the whole process if it's main) |
| Global failure handler | std::panic::set_hook |
| Catching a panic at a boundary | std::panic::catch_unwind |
| Stack traces | RUST_BACKTRACE=1, std::backtrace::Backtrace |
| A lock held during a panic | the lock is released and poisoned |
Panic in Drop during unwinding |
aborts the process |
| No unwinding at all | panic = "abort" |
Part 1: What a panic does
1. A panic unwinds the stack and runs destructors
When code panics, the standard library prints a message (through the panic hook, example 5) and then unwinds: it walks back up the stack, running Drop for every live value. When a panic reaches the top of main, the process exits with status 101.
struct Connection(&'static str);
impl Drop for Connection {
fn drop(&mut self) {
println!("closing {}", self.0);
}
}
fn charge(amount_cents: u64) -> u64 {
let _db = Connection("database");
let fee = 100 / (amount_cents % 3); // panics when amount_cents is a multiple of 3
amount_cents + fee
}
fn main() {
let _cache = Connection("cache");
println!("charged {}", charge(1000));
println!("charged {}", charge(999));
println!("unreachable");
}closing database
charged 1100
closing database
closing cache
thread 'main' panicked at src/main.rs:11:15:
attempt to divide by zeroBoth connections were closed during unwinding, innermost first. The panic message goes to stderr; it's shown last here only because stdout and stderr are captured separately.
2. A panic ends only its own thread
A panic in a spawned thread unwinds and ends that thread. The rest of the process keeps running, and join returns Err with the panic's payload.
use std::thread;
fn parse_all(lines: &[&str]) -> Vec<u32> {
lines.iter().map(|l| l.parse().expect("line must be a number")).collect()
}
fn main() {
let good = thread::spawn(|| parse_all(&["1", "2", "3"]));
let bad = thread::spawn(|| parse_all(&["4", "oops", "6"]));
println!("good: {:?}", good.join());
match bad.join() {
Ok(v) => println!("bad: {v:?}"),
Err(payload) => {
let msg = payload.downcast_ref::<String>().map(String::as_str).or_else(|| payload.downcast_ref::<&str>().copied()).unwrap_or("unknown");
println!("bad thread panicked: {msg}");
}
}
println!("main is still running");
}good: Ok([1, 2, 3])
bad thread panicked: line must be a number: ParseIntError { kind: InvalidDigit }
main is still runningThe payload is a Box<dyn Any + Send>. panic!("literal") produces a &'static str, and a panic with formatting (including expect, unwrap, and assert! failures) produces a String, so code that reports payloads should check both.
3. panic::panic_any: payloads of any type
A panic payload can be any Send + 'static value. Libraries sometimes use this to carry structured information through a panic, and test frameworks use it to recognize their own panics.
use std::panic;
#[derive(Debug)]
struct InvariantViolation {
component: &'static str,
detail: String,
}
fn main() {
panic::set_hook(Box::new(|_| {})); // silence the default message for this demo
let result = panic::catch_unwind(|| {
panic::panic_any(InvariantViolation { component: "ledger", detail: "balance went negative".into() });
});
let _ = panic::take_hook();
match result {
Err(payload) => match payload.downcast::<InvariantViolation>() {
Ok(v) => println!("structured panic from {}: {}", v.component, v.detail),
Err(_) => println!("some other panic"),
},
Ok(()) => println!("no panic"),
}
}structured panic from ledger: balance went negativeDon't use this for control flow; a Result is almost always the right tool. It's useful at framework boundaries that must classify panics.
4. Panics in main end the process, even with other threads running
When main returns or panics, the process ends, and other threads are stopped without unwinding: their destructors don't run. A panic in main does not wait for workers, just as System.exit doesn't.
use std::thread;
use std::time::Duration;
struct Worker(u32);
impl Drop for Worker {
fn drop(&mut self) {
println!("worker {} cleaned up", self.0);
}
}
fn main() {
let finished = thread::spawn(|| {
let _w = Worker(1);
});
finished.join().unwrap();
thread::spawn(|| {
let _w = Worker(2);
thread::sleep(Duration::from_secs(60)); // still running when main returns
});
thread::sleep(Duration::from_millis(50));
println!("main returns");
}worker 1 cleaned up
main returnsWorker 2's cleanup never runs. A service must signal its threads to stop and join them before main returns, if their cleanup matters (flushing a write buffer, releasing a distributed lock).
5. The panic hook: Rust's uncaught-exception handler
Before unwinding, the runtime calls the panic hook, which by default prints the message and location to stderr. Replace it with set_hook to log panics as structured events, send them to your error tracker, or add context. take_hook returns the current hook so you can call it from yours.
use std::panic;
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let reported = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&reported);
let default_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let thread = thread::current().name().unwrap_or("unnamed").to_string();
let location = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default();
let message = info.payload_as_str().unwrap_or("non-string payload");
sink.lock().unwrap().push(format!("{{\"level\":\"fatal\",\"thread\":\"{thread}\",\"at\":\"{location}\",\"msg\":\"{message}\"}}"));
let _ = &default_hook; // a real hook would usually also call default_hook(info)
}));
let h = thread::Builder::new().name("importer".into()).spawn(|| {
let v: Vec<u8> = Vec::new();
v[3]
}).unwrap();
let _ = h.join();
for line in reported.lock().unwrap().iter() {
println!("{line}");
}
}{"level":"fatal","thread":"importer","at":"src/main.rs:20","msg":"index out of bounds: the len is 0 but the index is 3"}The hook runs on the panicking thread, before unwinding, for every panic, including ones that are later caught. It runs even with panic = "abort", which makes it the one reliable place to report a crash. Set it once, early in main. In a service, the tracing-panic crate or your error tracker's SDK (Sentry, for example) installs one for you.
6. Backtraces
The default hook prints a backtrace when the RUST_BACKTRACE environment variable is 1 (short) or full. Release builds need at least debug = "line-tables-only" for file and line numbers. You can also capture a backtrace yourself, for example to attach it to an error value:
use std::backtrace::{Backtrace, BacktraceStatus};
fn load_config() -> Result<String, (String, Backtrace)> {
// `capture` respects RUST_BACKTRACE / RUST_LIB_BACKTRACE; `force_capture` always captures.
Err(("config file missing".to_string(), Backtrace::capture()))
}
fn main() {
let (msg, bt) = load_config().unwrap_err();
println!("error: {msg}");
println!("backtrace captured: {}", bt.status() == BacktraceStatus::Captured);
let forced = Backtrace::force_capture();
println!("forced backtrace captured: {}", forced.status() == BacktraceStatus::Captured);
}error: config file missing
backtrace captured: false
forced backtrace captured: trueCapturing a backtrace walks the stack and costs microseconds, which is why capture is off unless the environment asks for it. anyhow and eyre errors capture one automatically when it's enabled, so a production service can log "where did this error come from" with RUST_LIB_BACKTRACE=1.
Part 2: Catching panics
7. catch_unwind at a boundary
std::panic::catch_unwind runs a closure and returns Err(payload) if it panicked. It's meant for boundaries: a thread pool that must survive a panicking job, a plugin host, a request handler that should turn a bug into a 500 response instead of taking the worker down. It's not a try/catch for normal errors.
use std::panic::{self, AssertUnwindSafe};
fn handle_request(path: &str) -> String {
match path {
"/health" => "ok".to_string(),
"/users/0" => {
let users: Vec<&str> = vec![];
users[0].to_string() // a bug: panics
}
other => format!("200 {other}"),
}
}
fn serve(path: &str) -> String {
match panic::catch_unwind(AssertUnwindSafe(|| handle_request(path))) {
Ok(body) => body,
Err(_) => "500 internal error".to_string(), // the bug was logged by the panic hook
}
}
fn main() {
panic::set_hook(Box::new(|info| println!("[panic hook] {}", info.payload_as_str().unwrap_or("?"))));
for path in ["/health", "/users/0", "/users/7"] {
println!("{path} -> {}", serve(path));
}
}/health -> ok
[panic hook] index out of bounds: the len is 0 but the index is 0
/users/0 -> 500 internal error
/users/7 -> 200 /users/7Web frameworks do this for you: axum can add tower_http::catch_panic::CatchPanicLayer, and Tokio catches panics in every spawned task (example 15). Remember that catch_unwind only works when panics unwind; with panic = "abort", the process ends before it can catch anything.
8. UnwindSafe: why catch_unwind asks for it
catch_unwind requires its closure to be UnwindSafe. The trait is a lint in type form: a closure that captures a &mut reference, or a &RefCell, is not UnwindSafe, because after a panic the data it pointed to may be half-updated, and the caller would keep using it.
use std::panic;
fn main() {
let mut totals = vec![0u64; 3];
let result = panic::catch_unwind(|| {
totals[0] += 10;
totals[5] += 1; // panics after the first update
});
println!("{result:?} {totals:?}");
}error[E0277]: the type `&mut Vec<u64>` may not be safely transferred across an unwind boundary
--> src/main.rs:5:38
|
5 | let result = panic::catch_unwind(|| {
| ------------------- ^-
| | |
| __________________|___________________within this `{closure@src/main.rs:5:38: 5:40}`
| | |
| | required by a bound introduced by this call
6 | | totals[0] += 10;
7 | | totals[5] += 1; // panics after the first update
8 | | });
| |_____^ `&mut Vec<u64>` may not be safely transferred across an unwind boundary
|
= help: within `{closure@src/main.rs:5:38: 5:40}`, the trait `UnwindSafe` is not implemented for `&mut Vec<u64>`
= note: `UnwindSafe` is implemented for `&Vec<u64>`, but not for `&mut Vec<u64>`
note: required because it's used within this closure
--> src/main.rs:5:38
|
5 | let result = panic::catch_unwind(|| {
| ^^
note: required by a bound in `std::panic::catch_unwind`
--> /rustc/<version>/library/std/src/panic.rs:358:0AssertUnwindSafe(closure) tells the compiler "I've thought about it". That's appropriate when the captured state is thrown away after a panic, or when you restore it (next example). It's not a safety issue in the memory-safety sense: the worst outcome is a logic bug, which is why the trait is easy to override.
9. Keeping invariants intact when a panic interrupts an update
This is the Rust version of exception safety. If a panic happens halfway through modifying a structure, and anyone keeps using that structure afterward (because a panic was caught, or a lock was recovered), they see a broken state. Here a transfer panics between the debit and the credit:
use std::panic::{self, AssertUnwindSafe};
#[derive(Debug)]
struct Ledger {
balances: Vec<i64>,
}
impl Ledger {
fn total(&self) -> i64 {
self.balances.iter().sum()
}
// Not panic-safe: the debit happens before the step that can panic.
fn transfer_naive(&mut self, from: usize, to: usize, amount: i64) {
self.balances[from] -= amount;
self.balances[to] += amount; // panics if `to` is out of bounds
}
// Panic-safe: do everything that can fail first, then commit with operations that can't.
fn transfer_safe(&mut self, from: usize, to: usize, amount: i64) {
assert!(from < self.balances.len() && to < self.balances.len(), "unknown account");
self.balances[from] -= amount;
self.balances[to] += amount;
}
}
fn main() {
panic::set_hook(Box::new(|_| {}));
let mut ledger = Ledger { balances: vec![100, 50] };
let _ = panic::catch_unwind(AssertUnwindSafe(|| ledger.transfer_naive(0, 9, 30)));
println!("after naive transfer: {:?}, total {}", ledger.balances, ledger.total());
let mut ledger = Ledger { balances: vec![100, 50] };
let _ = panic::catch_unwind(AssertUnwindSafe(|| ledger.transfer_safe(0, 9, 30)));
println!("after safe transfer: {:?}, total {}", ledger.balances, ledger.total());
}after naive transfer: [70, 50], total 120
after safe transfer: [100, 50], total 150Thirty units vanished in the naive version. The patterns that avoid this are: validate before mutating, compute the new state into a temporary and commit it with operations that can't fail (std::mem::swap, assignment), or use a guard whose Drop rolls back unless it's defused. The standard library's own collections are written this way: a panic in your Ord implementation during sort never loses or duplicates elements.
10. Mutex poisoning
When a thread panics while holding a std::sync::Mutex, the lock is released (the guard's Drop runs during unwinding) and marked poisoned. Every later lock() returns Err(PoisonError), telling the next user that the data may be in a broken state, exactly the situation in the previous example. This is why Rust code writes lock().unwrap(): by default, a panic while holding a lock propagates to every thread that uses it.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let queue = Arc::new(Mutex::new(vec!["job-1", "job-2"]));
let q = Arc::clone(&queue);
let _ = thread::spawn(move || {
let mut jobs = q.lock().unwrap();
jobs.push("job-3");
panic!("worker crashed while holding the lock");
})
.join();
println!("poisoned: {}", queue.is_poisoned());
match queue.lock() {
Ok(jobs) => println!("jobs: {jobs:?}"),
Err(poisoned) => {
// Decide whether the data is still usable. Here, a Vec of jobs is fine.
let jobs = poisoned.into_inner();
println!("recovered after a panic, jobs: {jobs:?}");
}
}
queue.clear_poison(); // we checked the data; let others use the lock normally again
println!("poisoned after clear_poison: {}", queue.is_poisoned());
}poisoned: true
recovered after a panic, jobs: ["job-1", "job-2", "job-3"]
poisoned after clear_poison: falseThe panic message from the worker also appears on stderr. Your policy options:
| Policy | How | When |
|---|---|---|
| Propagate (default) | lock().unwrap() |
the data has invariants a half-finished update could break |
| Recover | lock().unwrap_or_else(PoisonError::into_inner) |
the data can't be left inconsistent (counters, append-only lists, caches you can rebuild) |
| Reset | recover, rebuild the value, then clear_poison() |
you can restore a known-good state |
RwLock poisons the same way. parking_lot::Mutex and tokio::sync::Mutex don't poison at all: they simply release the lock, which is convenient but means nothing warns you about half-finished updates.
11. A panic in Drop during unwinding aborts the process
If a destructor panics while the thread is already unwinding from another panic, Rust can't run two unwinds at once, so it aborts the process immediately. Other destructors don't run, and the exit status is from SIGABRT (134), not 101.
struct FlushOnDrop;
impl Drop for FlushOnDrop {
fn drop(&mut self) {
panic!("flush failed in drop"); // never do this
}
}
fn main() {
let _file = FlushOnDrop;
panic!("first panic");
}thread 'main' (25561) panicked at src/main.rs:11:5:
first panic
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'main' (25561) panicked at src/main.rs:5:9:
flush failed in drop
...
thread 'main' (25561) panicked at library/core/src/panicking.rs:233:5:
panic in a destructor during cleanup
thread caused non-unwinding panic. aborting.
Aborted (exit status 134)Destructors must not panic. When cleanup can fail (flushing a buffered file, committing a transaction), provide an explicit method that returns a Result (BufWriter::flush, File::sync_all, tx.commit()), and let Drop only do best-effort work that ignores errors. That's the pattern from Error Handling Patterns.
12. panic = "abort": no unwinding at all
You can build with panic = "abort". A panic then runs the hook and immediately aborts the process: no unwinding, no destructors, no catch_unwind, no poisoning.
[profile.release]
panic = "abort"The same program behaves differently in the two modes:
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
println!("guard dropped");
}
}
fn main() {
let _g = Guard;
panic!("boom");
}panic = "unwind" (default) |
panic = "abort" |
|
|---|---|---|
| Output | message, then guard dropped |
message only |
| Exit status | 101 | 134 (SIGABRT) |
| Thread panics | only that thread ends | the whole process ends |
catch_unwind, Tokio task isolation, CatchPanicLayer |
work | can't catch anything |
| Binary size and speed | unwinding tables and landing pads | slightly smaller and faster |
Choose abort when a panic should always be fatal and a supervisor (Kubernetes, systemd) restarts the process, or for embedded targets. Keep unwind for servers that rely on isolating a panicking request or task, and for anything that runs untrusted plugins. Tests always unwind: the test harness needs catch_unwind to report a failing test.
Part 3: Panics in real services
13. A supervisor thread that restarts workers
Erlang popularized supervisors. In Rust, a supervisor loop joins the worker and restarts it when it panicked, up to a limit.
use std::panic;
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;
static ATTEMPTS: AtomicU32 = AtomicU32::new(0);
fn consume_queue() -> u32 {
let attempt = ATTEMPTS.fetch_add(1, Ordering::SeqCst) + 1;
if attempt < 3 {
panic!("malformed message on attempt {attempt}");
}
42 // processed messages
}
fn supervise<F: Fn() -> u32 + Send + Sync + Copy + 'static>(name: &str, job: F, max_restarts: u32) -> Option<u32> {
for restart in 0..=max_restarts {
let handle = thread::Builder::new().name(name.into()).spawn(job).unwrap();
match handle.join() {
Ok(result) => return Some(result),
Err(payload) => {
let msg = payload.downcast_ref::<String>().cloned().unwrap_or_default();
println!("{name} crashed ({msg}); restart {} of {max_restarts}", restart + 1);
}
}
}
None
}
fn main() {
panic::set_hook(Box::new(|_| {})); // the supervisor reports instead
match supervise("consumer", consume_queue, 5) {
Some(n) => println!("consumer finished, processed {n}"),
None => println!("consumer gave up"),
}
}consumer crashed (malformed message on attempt 1); restart 1 of 5
consumer crashed (malformed message on attempt 2); restart 2 of 5
consumer finished, processed 42A restart loop must give up eventually (or back off exponentially); otherwise a deterministic bug turns into a hot loop that fills the logs. Also remember that a restarted worker sees whatever shared state the crashed one left behind (example 9).
14. A panic in a spawned thread poisons what it shared
Poisoning (example 10) spreads a crashed worker's failure to everyone else using the lock. In a service that's often what you want: fail loudly rather than serve corrupted data. This example shows the cascade and a health check that reports it.
use std::sync::{Arc, Mutex};
use std::thread;
struct Cache {
entries: Mutex<Vec<(String, u32)>>,
}
impl Cache {
fn healthy(&self) -> bool {
!self.entries.is_poisoned()
}
fn get(&self, key: &str) -> Result<Option<u32>, &'static str> {
let entries = self.entries.lock().map_err(|_| "cache poisoned")?;
Ok(entries.iter().find(|(k, _)| k == key).map(|(_, v)| *v))
}
}
fn main() {
let cache = Arc::new(Cache { entries: Mutex::new(vec![("a".into(), 1)]) });
println!("healthy: {}, get(a) = {:?}", cache.healthy(), cache.get("a"));
let c = Arc::clone(&cache);
let _ = thread::spawn(move || {
let mut e = c.entries.lock().unwrap();
e.push(("b".into(), 2));
e.retain(|(k, _)| k != "a"); // imagine this was supposed to be atomic with a refill...
panic!("refill failed");
})
.join();
println!("healthy: {}, get(a) = {:?}", cache.healthy(), cache.get("a"));
}healthy: true, get(a) = Ok(Some(1))
healthy: false, get(a) = Err("cache poisoned")Exposing healthy() through a readiness endpoint lets the orchestrator replace the instance instead of serving errors forever.
15. Panics in Tokio tasks are caught, and easy to lose
Tokio runs each spawned task inside catch_unwind. A panicking task ends, the runtime and other tasks keep going, and the panic arrives as a JoinError when you await the task's JoinHandle. If you drop the handle without awaiting it, the panic is only visible as a message on stderr.
use std::time::Duration;
#[tokio::main]
async fn main() {
std::panic::set_hook(Box::new(|_| {})); // keep the demo output clean
// 1. Fire and forget: the panic is silently lost (the default hook would print to stderr).
tokio::spawn(async {
panic!("background refresh failed");
});
tokio::time::sleep(Duration::from_millis(20)).await;
println!("nobody noticed the background task died");
// 2. Awaited: the panic becomes a JoinError you can act on.
let handle = tokio::spawn(async {
let ids: Vec<u32> = vec![];
ids[0]
});
match handle.await {
Ok(id) => println!("id {id}"),
Err(e) if e.is_panic() => {
let payload = e.into_panic();
let msg = payload.downcast_ref::<String>().cloned().unwrap_or_default();
println!("task panicked: {msg}");
}
Err(e) => println!("task cancelled: {e}"),
}
}nobody noticed the background task died
task panicked: index out of bounds: the len is 0 but the index is 0In production, never fire and forget long-lived tasks. Keep their handles (in a JoinSet, for example), and have main or a supervisor task await them, so a dead background task becomes an error or a restart instead of a silent outage. Tokio's unstable unhandled_panic setting can also make the runtime shut down on any task panic.
16. Panics and FFI boundaries
Unwinding through code written in another language is undefined behavior unless both sides agree on it. Since Rust 1.81, a panic that tries to unwind out of an extern "C" fn aborts the process instead. If your Rust function is called from C, catch panics at the boundary and turn them into an error code:
use std::panic::{self, AssertUnwindSafe};
/// Called from C: must never unwind.
#[unsafe(no_mangle)]
pub extern "C" fn parse_port(value: i64) -> i32 {
let result = panic::catch_unwind(AssertUnwindSafe(|| {
let port = u16::try_from(value).expect("port out of range");
i32::from(port)
}));
result.unwrap_or(-1) // an error code instead of a crash
}
fn main() {
panic::set_hook(Box::new(|_| {}));
println!("{} {}", parse_port(8080), parse_port(70_000));
}8080 -1extern "C-unwind" exists for the rare case where unwinding across the boundary is intended (for example, C++ code with exceptions on both sides).
17. #[track_caller]: blame the caller, not the helper
A panic inside a helper function reports the helper's line, which is useless when the bug is in how it was called. #[track_caller] makes the panic location point to the caller instead, as unwrap, expect, and indexing do.
use std::panic;
#[track_caller]
fn require_positive(value: i64, what: &str) -> i64 {
if value <= 0 {
panic!("{what} must be positive, got {value}");
}
value
}
fn main() {
panic::set_hook(Box::new(|info| {
let loc = info.location().unwrap();
println!("panic at line {}: {}", loc.line(), info.payload_as_str().unwrap_or("?"));
}));
let _ = panic::catch_unwind(|| {
let qty = require_positive(3, "quantity");
let price = require_positive(-5, "price"); // the reported line is this one
qty * price
});
}panic at line 18: price must be positive, got -5Use it on validation helpers, custom assertion functions, and anything that panics because of its arguments.
18. Lints for panic-free code
Some code must not panic: a long-running server loop, a library used by others, code that runs in a signal handler or across FFI. Clippy can flag every place that can panic, so reviewers see them:
#![warn(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing)]
fn first_word(line: &str) -> Option<&str> {
line.split_whitespace().next() // instead of `.split(' ').collect::<Vec<_>>()[0]`
}
fn parse_level(s: &str) -> Result<u8, String> {
s.parse::<u8>().map_err(|e| format!("bad level {s:?}: {e}")) // instead of `.unwrap()`
}
fn main() {
println!("{:?} {:?}", first_word(" hello world"), first_word(" "));
println!("{:?} {:?}", parse_level("3"), parse_level("x"));
}Some("hello") None
Ok(3) Err("bad level \"x\": invalid digit found in string")| Lint | Flags |
|---|---|
clippy::unwrap_used, clippy::expect_used |
unwrap() and expect() on Option/Result |
clippy::panic, clippy::todo, clippy::unimplemented |
explicit panics |
clippy::indexing_slicing |
v[i] and &s[a..b], which panic when out of range |
clippy::arithmetic_side_effects |
+, -, * that can overflow (they panic in debug builds) |
clippy::missing_panics_doc |
public functions that can panic without a # Panics doc section |
Enable them per module (for example, only in the request-handling core), not across a whole application: expect is still the right choice for startup configuration that must exist.
19. assert!, debug_assert!, and unreachable! in production
| Macro | In release builds | Use for |
|---|---|---|
assert!(cond) |
checked, panics | invariants whose violation must stop the operation (money, security, data integrity) |
debug_assert!(cond) |
removed | expensive checks during development and tests (is this list still sorted?) |
unreachable!() |
panics | branches that really can't happen; documents the reasoning |
std::hint::unreachable_unchecked() |
undefined behavior if reached | never, unless a benchmark proves it matters and unsafe review approves |
fn median(sorted: &[u32]) -> Option<u32> {
debug_assert!(sorted.is_sorted(), "median needs sorted input"); // free in release
match sorted.len() {
0 => None,
n if n % 2 == 1 => Some(sorted[n / 2]),
n => Some((sorted[n / 2 - 1] + sorted[n / 2]) / 2),
}
}
fn main() {
println!("{:?} {:?} {:?}", median(&[]), median(&[1, 3, 9]), median(&[1, 3, 5, 9]));
}None Some(3) Some(4)20. A checklist
| Question | Good answer |
|---|---|
| What happens when a request handler panics? | the framework catches it (Tokio task or CatchPanicLayer) and returns 500; the hook logs it |
| Do we notice a background task panicking? | its JoinHandle is awaited or kept in a JoinSet; a supervisor restarts it or the process exits |
| Where are panics reported? | a panic hook that logs structured events and sends them to the error tracker |
| Can a panic leave shared state half-updated? | updates are validate-then-commit, or poisoning is handled deliberately |
Can any Drop implementation panic? |
no; fallible cleanup has an explicit method |
panic = "abort" or "unwind"? |
chosen on purpose: abort + supervisor restarts, or unwind + isolation |
Can user input reach an unwrap, index, or expect? |
no; lints enabled in the request path |
| Do FFI entry points catch panics? | yes, with catch_unwind and an error code |
Summary
- Panics are for bugs. They unwind the stack, run destructors, and end the current thread; a panic in
mainexits with status 101. - The payload is
&strorStringfor ordinary panics.joinand Tokio'sJoinErrorhand it back. set_hookis the uncaught-exception handler: use it to report every panic, even withpanic = "abort".catch_unwindbelongs at boundaries.UnwindSafereminds you that state touched by a panicking closure may be half-updated; design updates as validate-then-commit.- A panic while holding a
std::sync::Mutexpoisons it. Decide per lock whether to propagate, recover, or reset. - A panic in
Dropduring unwinding aborts. Destructors must not panic. panic = "abort"removes unwinding: smaller and simpler, but no isolation of panicking threads or tasks.- Await or supervise every spawned task; catch panics at FFI boundaries; use
#[track_caller]and Clippy lints to make panics rare and easy to diagnose.
Next: Async Pitfalls in Production: blocking the runtime, locks across .await, cancellation safety, unbounded queues, and oversized futures.
