#295 Aug 2026

295. String::leak — A &'static str From Runtime Data, Without the Box Detour

An API demands &'static str, but your string is built at runtime. The old trick was Box::leak(s.into_boxed_str()) — since 1.72, String::leak says what you mean.

You’ve built a name at startup and some API insists on &'static str:

1
2
3
4
5
6
fn set_worker_name(name: &'static str) { /* … */ }

let name = format!("worker-{id}");
set_worker_name(&name);
// error[E0597]: `name` does not
// live long enough

The value will live for the rest of the program — the borrow checker just can’t know that. The classic workaround was leaking through a Box (bite 67):

1
2
let name: &'static str =
    Box::leak(name.into_boxed_str());

String::leak (stable since 1.72) does it in one step, straight off the format!:

1
2
3
let name: &'static str =
    format!("worker-{id}").leak();
set_worker_name(name);

It consumes the String and hands back a &'static mut str — mutable, exclusive, and alive until the process exits (it coerces to plain &'static str on the spot, as above):

1
2
3
4
let m: &'static mut str =
    String::from("abc").leak();
m.make_ascii_uppercase();
assert_eq!(m, "ABC");

Two things to keep in mind. First, the allocation is never freed — that’s the point. Do this for once-per-process values (config, names, interned keys), never in a loop or per-request path. Second, unlike into_boxed_str, which shrinks the buffer to fit, leak leaks the whole allocation, spare capacity included. A String with 4 KB of capacity holding 10 bytes leaks 4 KB. If that matters, call shrink_to_fit() first.

#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.

#290 Jul 2026

290. Mutex::clear_poison — One Panicked Thread Shouldn't Poison the Lock Forever

A worker panics while holding the lock, and from then on every lock() in your program returns Err. If the data is still consistent, you can undo the poisoning instead of apologizing at every call site.

Poisoning is a Mutex feature, not a bug: a panic mid-update might leave the data half-written, so the lock starts refusing service to make you look. But plenty of panics can’t corrupt anything — the classic case is a worker that panicked after its update, or one that never got to write at all:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use std::sync::Mutex;
use std::thread;

let m = Mutex::new(vec![1, 2, 3]);

thread::scope(|s| {
    let _ = s.spawn(|| {
        let _guard = m.lock().unwrap();
        panic!("worker died");   // guard dropped mid-panic
    }).join();
});

assert!(m.is_poisoned());
assert!(m.lock().is_err());      // ...and now everyone pays

The workaround you usually see is shrugging at every single call site:

1
let data = m.lock().unwrap_or_else(|e| e.into_inner());

That works, but it’s a per-call-site decision repeated forever — and it silently accepts future poisonings too, including the ones that do mean corruption.

Mutex::clear_poison (stable since 1.77) is the one-time version: inspect the data, decide it’s fine, flip the flag back:

1
2
3
4
5
6
7
8
9
{
    // recover once, look at the data, decide
    let data = m.lock().unwrap_or_else(|e| e.into_inner());
    assert_eq!(*data, vec![1, 2, 3]);  // still consistent
}
m.clear_poison();

assert!(!m.is_poisoned());
assert_eq!(m.lock().unwrap().len(), 3);  // Ok again, everywhere

After clear_poison, every lock() in the program returns Ok again — the recovery decision is made once, by the code that actually checked the invariants, not smeared across every caller as a reflexive unwrap_or_else.

The same method exists on RwLock. And if your critical sections genuinely can’t be interrupted mid-invariant — you only ever replace the value wholesale, say — the per-site into_inner() shrug is defensible. But the moment “is the data OK?” has a real answer, clear_poison puts that answer in exactly one place.

#289 Jul 2026

289. JoinHandle::is_finished — Check on a Worker Without Blocking on join()

join() tells you when a thread is done — by blocking until it is. Sometimes you just want to peek: still running, or can I collect the result now?

The DIY version is an Arc<AtomicBool> that the worker sets as its last statement. It has a hole: if the thread panics before that store, the flag stays false forever and your supervisor waits on a corpse.

JoinHandle::is_finished (stable since 1.61) asks the handle directly. It returns immediately, and it reports termination — a worker that panicked counts as finished, because it is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
use std::thread;
use std::time::Duration;

let workers: Vec<_> = (0..4)
    .map(|id| thread::spawn(move || work(id)))
    .collect();

// progress report while they run — no blocking
while workers.iter().any(|w| !w.is_finished()) {
    let done = workers
        .iter()
        .filter(|w| w.is_finished())
        .count();
    println!("{done}/4 workers finished");
    thread::sleep(Duration::from_millis(100));
}

is_finished() doesn’t consume the handle, so you can poll in a loop and still join() afterwards. And you should: is_finished() == true is exactly the guarantee that join() won’t block, and join() is where the result — or the panic — comes out:

1
2
3
4
5
6
for w in workers {
    match w.join() {
        Ok(n) => println!("produced {n}"),
        Err(_) => eprintln!("a worker panicked"),
    }
}

That second half matters. is_finished tells you that a thread ended, never how — a clean return and a panic look identical until you join(). Name your threads with thread::Builder and the panic message at least tells you who died.

One boundary to respect: this is a check-in tool, not a synchronization primitive. A hot while !w.is_finished() {} loop is the same spin-wait sin as the atomic-counter hack from this morning’s Barrier bite. If you need to wait for completion, join() already does that; if you need results as they arrive, use a channel. Reach for is_finished when the answer “not yet” is useful — progress bars, health checks, deciding whether to steal a straggler’s remaining work.

#288 Jul 2026

288. std::sync::Barrier — Make All Threads Start Phase 2 Together

Your workers finish phase 1 at different speeds, and the fast ones charge into phase 2 while the slow ones are still writing. The hand-rolled fix is an atomic counter and a spin loop — std already ships the real thing.

The hack everyone writes first: bump an AtomicUsize when done, then spin until it hits n. It burns CPU while waiting and resetting it for the next round is a race of its own.

std::sync::Barrier (stable since 1.0) is that counter done right. Barrier::new(n) sets the threshold; every thread that calls .wait() blocks until the n-th one arrives, then all of them are released at once:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
use std::sync::Barrier;
use std::thread;

let n = 4;
let barrier = Barrier::new(n);

thread::scope(|s| {
    for id in 0..n {
        let barrier = &barrier;
        s.spawn(move || {
            prepare(id);    // phase 1, each at its own pace
            barrier.wait(); // block until all 4 arrive
            process(id);    // phase 2 starts in lockstep
        });
    }
});

With scoped threads a shadowed &barrier is all the sharing you need — no Arc. (The move is for id; the reference moves with it.) Under plain thread::spawn, wrap the barrier in Arc as usual.

Two details the DIY counter doesn’t give you. First, .wait() returns a BarrierWaitResult, and exactly one thread per round gets is_leader() == true — a built-in election for “someone swap the buffers before the next phase”:

1
2
3
if barrier.wait().is_leader() {
    // exactly one thread runs this per round
}

Second, the barrier resets itself after releasing n threads. Call .wait() in a loop and you get phase-synchronized rounds for free — the classic shape of iterative simulations: compute a step, wait, leader swaps front/back buffers, wait, repeat.

One trap: the count is fixed at construction. If a thread panics before reaching .wait(), the rest block forever — there’s no timeout variant. Keep the work between barriers panic-free, or use channels when workers can drop out.

#287 Jul 2026

287. thread::Builder — Name Your Workers So Panics Tell You Who Died

thread '<unnamed>' panicked — great, which of the eight workers was that? thread::spawn has a configurable sibling, and the two minutes it costs pay off the first time something crashes.

Bite 286 computed how many workers to spawn. Spawn them with thread::spawn and every panic message, debugger view, and profiler trace calls them <unnamed>:

1
2
3
thread::spawn(|| {
    // panics as: thread '<unnamed>' panicked
});

std::thread::Builder (stable since 1.0) is the same spawn with configuration in front:

1
2
3
4
5
6
7
8
9
use std::thread;

let h = thread::Builder::new()
    .name("worker-3".into())
    .spawn(|| {
        // panics as: thread 'worker-3' panicked
        thread::current().name().map(str::to_owned)
    })
    .expect("spawn failed");

Now a crash names the culprit, and the name shows up in gdb/lldb thread lists and profilers too. The code inside can read it back via thread::current().name().

There’s a second difference hiding in that .expect: Builder::spawn returns io::Result<JoinHandle>. Plain thread::spawn panics if the OS can’t create the thread — under fd/memory pressure, exactly when you least want it. With the builder you decide: fall back to fewer workers instead of crashing.

One more knob while you’re there — .stack_size(bytes) for that one worker doing deep recursion, instead of raising RUST_MIN_STACK for the whole program:

1
2
3
4
5
let h = thread::Builder::new()
    .name("deep-parser".into())
    .stack_size(8 * 1024 * 1024)
    .spawn(|| { /* recurse away */ })
    .expect("spawn failed");

Loop it with format!("worker-{i}") and the whole pool from bite 286 is debuggable by name.

#286 Jul 2026

286. available_parallelism — Stop Hardcoding Your Worker Count

That let workers = 8; in your thread pool is wrong on almost every machine but yours. std can tell you how many threads the program can actually run at once — no crate needed.

The channel series (bites 281285) kept spawning workers without ever asking the obvious question: how many? Hardcoding a number over-subscribes small machines and wastes big ones. std::thread::available_parallelism (stable since 1.59) gives the real answer:

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

let workers = thread::available_parallelism()
    .map(|n| n.get())
    .unwrap_or(1);

assert!(workers >= 1);

It returns io::Result<NonZeroUsize> — two deliberate choices in one signature. The Result is because some platforms can’t answer; .unwrap_or(1) degrades gracefully. The NonZeroUsize is a guarantee: on success the count is never zero, so dividing work by it can’t panic:

1
2
3
4
5
6
let data: Vec<u32> = (0..100).collect();

// never a divide-by-zero, workers >= 1
let chunk = data.len().div_ceil(workers);
let batches = data.chunks(chunk).count();
assert!(batches <= workers);

Why not just count cores?

Because the answer isn’t “how many cores does the CPU have” — it’s “how many can this process use,” which can be far smaller:

  • cgroup CPU quotas (Linux): a container capped at 2 CPUs on a 64-core host reports 2, not 64
  • process affinity masks: pinned to 4 cores, you get 4
  • SMT: hyperthreads count, so an 8-core/16-thread CPU typically reports 16

A naive core count in a Kubernetes pod spawns 64 threads to fight over 2 CPUs. available_parallelism reads the quota and sizes the pool right.

Two caveats: the value is a snapshot (quotas can change mid-run), and it’s a hint for CPU-bound work — an I/O-bound pool may justifiably want more threads than cores.