Panic

#294 Aug 2026

294. AssertUnwindSafe — catch_unwind Won't Touch Your &mut Until You Sign the Waiver

You wrapped the job loop in catch_unwind (bite 291), added a completed-counter — and the build broke: the type `&mut i32` may not be safely transferred across an unwind boundary. AssertUnwindSafe is how you tell the compiler you’ve thought it through.

catch_unwind requires its closure to be UnwindSafe — a marker trait, like Send, that the compiler derives automatically. Capturing a &mut breaks it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
use std::panic;

let mut completed = 0;
for id in 0..4 {
    let result = panic::catch_unwind(|| {
        let v = run_job(id);
        completed += 1; // error[E0277]: the type
        // `&mut i32` may not be safely trans-
        // ferred across an unwind boundary
        v
    });
}

The concern is logical corruption, not memory safety: if the closure panics halfway through updating something it borrowed, you catch the panic and then keep using data that may be half-updated. Types with poisoning like Mutex (bite 290) handle this themselves and stay UnwindSafe; a raw &mut T or RefCell<T> can’t make that promise, so catch_unwind refuses them.

Here the fix is a judgment call, and it’s an easy one — completed += 1 is the last statement, so a panic can’t leave it torn. Wrap the closure in AssertUnwindSafe to vouch for it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
use std::panic::{self, AssertUnwindSafe};

let mut completed = 0;
let mut failed = 0;
for id in 0..4 {
    let result = panic::catch_unwind(
        AssertUnwindSafe(|| {
            let v = run_job(id);
            completed += 1;
            v
        }),
    );
    if result.is_err() {
        failed += 1;
    }
}
assert_eq!(completed, 3);
assert_eq!(failed, 1);

AssertUnwindSafe(x) is a zero-cost wrapper that implements UnwindSafe unconditionally — no unsafe, no runtime check. You can’t cause undefined behavior with it; the worst case is observing state a panic left half-updated, which is exactly what you’re asserting can’t happen (or doesn’t matter).

Rule of thumb: on Err, either discard the state the closure touched or make sure every mutation is panic-proof — then AssertUnwindSafe is a fact, not a wish.

#293 Jul 2026

293. thread::panicking — A Second Panic in Drop Doesn't Unwind, It Aborts

Your Drop impl asserts a cleanup invariant. A test fails, unwinding starts, your destructor runs — and its assert fails too. Two panics at once: the process aborts and the original error message is gone.

Rust can’t unwind twice at the same time. If a destructor panics while the thread is already unwinding from an earlier panic, the runtime gives up and calls abort() — no unwinding, no catch_unwind rescue (bite 291), just a dead process that hides the failure you actually cared about.

The problem:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Guard {
    committed: bool,
}

impl Drop for Guard {
    fn drop(&mut self) {
        // If a panic is already unwinding past this
        // guard, this second panic aborts the process.
        assert!(self.committed, "guard dropped uncommitted");
    }
}

Any panic between creating the guard and committing it now aborts instead of unwinding — your test harness never even gets to print the real failure.

std::thread::panicking() tells you whether the current thread is mid-unwind, so the destructor can stand down:

1
2
3
4
5
6
7
8
impl Drop for Guard {
    fn drop(&mut self) {
        if std::thread::panicking() {
            return; // already unwinding — don't make it worse
        }
        assert!(self.committed, "guard dropped uncommitted");
    }
}

The original panic unwinds cleanly, catch_unwind and your panic hook (bite 292) see it as usual, and the guard still enforces its invariant on every normal exit path.

Rule of thumb: any Drop that can panic — asserts, unwrap, flushing a writer — should either swallow the error or check thread::panicking() first.

#292 Jul 2026

292. panic::set_hook — You Caught the Panic, but It Still Screamed to stderr

catch_unwind gave you a tidy Err — yet the full “thread panicked at …” message hit stderr anyway. std::panic::set_hook owns that output.

Yesterday’s bite kept the worker loop alive with catch_unwind. But run it and stderr is still full of noise, because the default panic hook prints before unwinding even starts:

1
2
3
4
5
6
7
use std::panic;

let _ = panic::catch_unwind(|| run_job(2));
// You handled the Err — but stderr still shows:
// thread 'main' panicked at src/main.rs:4:9:
// job 2 choked
// note: run with `RUST_BACKTRACE=1` ...

The hook and the unwind are separate mechanisms. catch_unwind decides where unwinding stops; the hook decides what gets printed. Replace it and the message is yours — one line, your format, your logger:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::panic;

panic::set_hook(Box::new(|info| {
    let loc = info.location().unwrap();
    let msg = info.payload_as_str().unwrap_or("?");
    eprintln!("[worker] {msg} ({}:{})",
        loc.file(), loc.line());
}));

let _ = panic::catch_unwind(|| run_job(2));
// stderr: [worker] job 2 choked (src/main.rs:4)

info is a PanicHookInfo: location() gives file, line, and column, and payload_as_str() (stable since 1.91) recovers the message when it’s a String or &str — no downcast dance.

Three things to know. The hook is global and process-wide — install it once at startup, not per-job, and note that set_hook swaps the hook for every thread. It runs even under panic = "abort", which makes it the one place to flush logs before the process dies. And take_hook() returns the previous hook as a value, so you can restore the default — or wrap it: grab the old hook, install a closure that logs your line and then calls the old one.

Silencing expected panics in tests is the classic use: swap in a no-op hook, catch_unwind the call you expect to panic, restore with take_hook — assertion output stays clean.

#291 Jul 2026

291. catch_unwind — One Panicking Job Shouldn't Kill the Whole Worker

Job 2 panics and your worker thread dies with it — jobs 3 through 400 never run. std::panic::catch_unwind stops the panic at a boundary you choose.

A panic unwinds until something catches it or the thread dies. In a job loop, “the thread dies” means every queued job after the bad one is silently dropped:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn run_job(id: usize) -> usize {
    if id == 2 {
        panic!("job {id} choked");
    }
    id * 10
}

for id in 0..4 {
    run_job(id); // job 2 kills the loop
}

catch_unwind runs a closure and converts any panic inside it into an Err, so the loop survives:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
use std::panic;

for id in 0..4 {
    match panic::catch_unwind(|| run_job(id)) {
        Ok(v) => println!("job {id} -> {v}"),
        Err(payload) => {
            let msg = payload
                .downcast_ref::<String>()
                .map(|s| s.as_str())
                .unwrap_or("unknown panic");
            eprintln!("job {id} failed: {msg}");
        }
    }
}
// job 0 -> 0
// job 1 -> 10
// job 2 failed: job 2 choked
// job 3 -> 30

The Err carries the panic payload as Box<dyn Any + Send>. A panic! with a format string stores a String; a bare string literal stores a &'static str — downcast to whichever you expect (or try both) to recover the message.

Three things to know before you reach for it. It’s a boundary tool — thread pools, FFI edges, plugin callbacks — not try/catch for control flow; fallible code should return Result. It can’t catch anything if the build uses panic = "abort". And it doesn’t undo side effects: a panic while holding a lock still poisons itcatch_unwind decides where unwinding stops, and this morning’s clear_poison handles what it left behind.

If you only wanted to observe the panic and pass it on — log and rethrow — use panic::resume_unwind(payload): it continues unwinding with the original payload and skips printing a second panic message.

172. #[track_caller] — Point the Panic at the Caller, Not Your Helper

You wrap an assert in a helper to clean up your tests. Now every failure points at the helper’s source line instead of the test that called it. #[track_caller] fixes that with a single line of code.

The problem: panics blame the helper

Say you’ve factored out a custom check used across many tests:

1
2
3
4
5
6
7
8
fn assert_positive(x: i32) {
    assert!(x > 0, "expected positive, got {x}");
}

#[test]
fn it_works() {
    assert_positive(-3); // panics
}

The panic message looks like this:

1
2
thread 'it_works' panicked at src/lib.rs:2:5:
expected positive, got -3

src/lib.rs:2 is the line inside assert_positive. Every test that uses this helper points at the same spot. Useless.

The fix: one attribute

Put #[track_caller] on the helper and the reported location becomes whichever call site invoked it:

1
2
3
4
5
6
7
8
9
#[track_caller]
fn assert_positive(x: i32) {
    assert!(x > 0, "expected positive, got {x}");
}

#[test]
fn it_works() {
    assert_positive(-3); // panics, blames THIS line
}

Now the panic points at the test’s call, exactly like a built-in assert! does. That’s because assert!, unwrap, expect, Vec::index, and friends are all themselves #[track_caller].

How it works

The attribute makes the compiler thread the caller’s Location through the function. You can grab it explicitly with core::panic::Location::caller():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::panic::Location;

#[track_caller]
fn where_am_i() -> &'static Location<'static> {
    Location::caller()
}

fn main() {
    let loc = where_am_i();
    assert_eq!(loc.line(), 11); // the call site, not the fn body
}

The attribute propagates through wrappers — mark every layer between the panic and the public API, otherwise the chain breaks at the first un-annotated function and the location resets to that frame.

When to reach for it

Any time you wrap panic!, assert!, unwrap, or expect behind a helper that callers will treat as a primitive: test assertions, domain-specific unwraps, invariant checks. The cost is zero at runtime in optimized builds — the location is baked in at compile time.