Skip to content

← Fearless Concurrency: Threads, Channels, Shared State step 3 of 24

Medium Primitives

JoinHandle, panics and thread::Result

Start one thread per input, let some of them panic, and report what happened to each thread in input order.

pub fn survive_panics(inputs: Vec<i64>) -> Vec<String>

The worker body is already written in the starter code and must stay as it is. The worker for an input x does one of three things:

input worker does report entry
x > 0 returns x * 2 "ok:<x * 2>", for example "ok:6" for 3
x < 0 panic!("bad {x}") "err:bad <x>", for example "err:bad -2" for -2
x == 0 panic!("zero") "err:zero"

So [1, -2, 3] produces ["ok:2", "err:bad -2", "ok:6"].

The contract:

  • Return exactly one string per input, at the same position as its input. Threads may finish in any order; the report must still follow input order.
  • A panic in one worker must not stop the other workers, and survive_panics itself must return normally.
  • An empty input returns an empty vector.
  • Positive inputs are at most i64::MAX / 2 (4611686018427387903), so x * 2 cannot overflow. Negative inputs may be any i64, including i64::MIN, because a negative input is only formatted, never doubled.
  • Inputs are short lists (the tests use at most five values), so starting one operating system thread per input is fine here.
  • Numbers are written in plain decimal, with a leading - for negatives and no spaces other than the one after bad.

What happens when a spawned thread panics

A panic is Rust's way of stopping work after a bug or an unrecoverable condition. In the default configuration (described at the end), a panic unwinds: it walks back up that thread's call stack, running destructors (Drop implementations) for the values it passes. When a panic unwinds out of a thread started with std::thread::spawn, only that thread ends. Other threads keep running, and the thread that spawned it is not interrupted.

The value the panic carried, called the payload, is kept for whoever joins the thread. thread::spawn returns a JoinHandle<T>, and

pub fn join(self) -> std::thread::Result<T>

waits for the thread to finish. std::thread::Result<T> is defined as

type Result<T> = std::result::Result<T, Box<dyn Any + Send + 'static>>;

If the thread returned normally, join gives Ok(value). If it panicked, join gives Err(payload); it does not panic again in the joining thread.

The error type is a box holding a value of some unknown type, dyn Any. The type is unknown because a payload can be any Send + 'static value: std::panic::panic_any(42) panics with an i32 payload, for example. So the standard library cannot promise a string.

That is why the starter does not compile. It tries to format the payload with {payload}, which needs the Display trait. Box<T> implements Display only when T does, and dyn Any + Send does not, so the compiler reports:

error[E0277]: `dyn Any + Send` doesn't implement `std::fmt::Display`

The compiler also suggests {:?}. That compiles, but Debug for dyn Any prints Any { .. }, not the panic message.

Getting a message out of the payload

dyn Any has a method downcast_ref::<T>(), which returns Option<&T>: Some if the boxed value really is a T, None otherwise. You can call it directly on the box. The question is which T to ask for, and the answer depends on how the panic was written. With the standard library used here:

panic payload type
panic!("zero"): a plain string literal, nothing to substitute &'static str
panic!("bad {x}"): a message that substitutes a value String

A literal message is already a fixed string, so it is passed along as a &'static str. A message that inserts x has to be built at run time, so it becomes a String. The standard library documents that panic! in the 2021 edition or later always produces one of these two types, but not which form produces which, so check both. Other payload types come from std::panic::panic_any, from std::panic::resume_unwind (which starts unwinding with any boxed value you give it), or from panic!(x) with a non-string x in the 2018 edition and earlier, so keep a fallback for those too. This exercise uses both panic! forms on purpose: a solution that checks only one type produces the fallback instead of the message for the other kind of panic and fails the tests that contain it.

Check &str first and String second (either order works, because a value has exactly one type), then add a fallback branch for any other payload. The reference solution reports such a payload as "err:unknown"; none of the tests produce one, so the fallback text is your choice.

downcast_ref::<&str>() returns Option<&&str>: a reference to the payload, and the payload is itself a &str. Formatting or calling to_string() on a &&str works the same as on a &str.

Panic messages on standard error

When panic! runs, it first calls the panic hook, a process-wide function that reports the panic. The default hook prints a report to standard error naming the thread ('<unnamed>' for threads created without a name), the source location that panicked, and the message. The exact layout varies between Rust versions. So even a correct solution's run prints one such report per panicking worker, and they can appear in any order. They go to standard error, not standard output, so they do not change the JSON result the harness prints.

std::panic::set_hook can replace the hook, but the hook is shared by the whole process: replacing it changes how every panic in every thread is reported, including panics in code you do not own. Do not install a hook in this exercise.

Limits of this technique

Everything above relies on the panic strategy being unwind, which is the default for most targets. A program built with panic = "abort" in its Cargo profile (or -C panic=abort) ends the whole process at the first panic in any thread, so join never gets an Err to inspect. Unwinding also does not help if a destructor panics while a panic is already unwinding; that aborts the process too.

Catching panics is a tool for isolation boundaries, such as a worker pool that should keep serving other jobs when one job hits a bug. It is not the usual way to report expected failures. If a worker can fail in a way callers should handle, have it return a Result and join that, so the error has a real type instead of an Any box.