tokio-rs/tokio · error · io::Error

task panicked

Error message

task panicked

What it means

From<JoinError> for io::Error maps Repr::Panic(_) to the message 'task panicked'. It surfaces when a spawned task panics and the JoinHandle error is propagated as io::Error (e.g. via ?). The original panic payload is lost in this conversion; only the generic message survives.

Source

Thrown at tokio/src/runtime/task/error.rs:173

impl fmt::Debug for JoinError {
    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
        match &self.repr {
            Repr::Cancelled => write!(fmt, "JoinError::Cancelled({:?})", self.id),
            Repr::Panic(p) => match panic_payload_as_str(p) {
                Some(panic_str) => {
                    write!(fmt, "JoinError::Panic({:?}, {:?}, ...)", self.id, panic_str)
                }
                None => write!(fmt, "JoinError::Panic({:?}, ...)", self.id),
            },
        }
    }
}

impl std::error::Error for JoinError {}

impl From<JoinError> for io::Error {
    fn from(src: JoinError) -> io::Error {
        io::Error::new(
            io::ErrorKind::Other,
            match src.repr {
                Repr::Cancelled => "task was cancelled",
                Repr::Panic(_) => "task panicked",
            },
        )
    }
}

fn panic_payload_as_str(payload: &SyncWrapper<Box<dyn Any + Send>>) -> Option<&str> {
    // Panic payloads are almost always `String` (if invoked with formatting arguments)
    // or `&'static str` (if invoked with a string literal).
    //
    // Non-string panic payloads have niche use-cases,
    // so we don't really need to worry about those.
    if let Some(s) = payload.downcast_ref_sync::<String>() {
        return Some(s);
    }

View on GitHub (pinned to 625954f365)

Solutions

  1. Match on the JoinError and call into_panic()/resume_unwind to preserve and propagate the real panic.
  2. Remove unwrap/expect from spawned tasks; use proper error returns.
  3. Log the panic source via e.is_panic() before converting.
  4. Separate task error handling from io::Result flows to avoid the lossy From conversion.

Example fix

// before
let v = handle.await?; // 'task panicked' as io::Error
// after
match handle.await {
    Ok(v) => v,
    Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
    Err(e) => return Err(e.into()),
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Before awaiting, anticipate panic risk: design tasks to return Result<T, E>
// rather than panicking.

Type guard

fn is_panic(e: &tokio::task::JoinError) -> bool { e.is_panic() }

Try / catch

match handle.await {
    Ok(v) => v,
    Err(e) if e.is_panic() => {
        std::panic::resume_unwind(e.into_panic());
    }
    Err(e) => return Err(e.into()),
}

Prevention

When it happens

Trigger: Awaiting a JoinHandle of a task that panicked, inside a function returning io::Result, so the JoinError converts to io::Error.

Common situations: unwrap()/expect() inside a spawned task; assertion failures; indexing out of bounds; propagating via ? which triggers From<JoinError>.

Related errors


AI-assisted analysis of tokio-rs/tokio@625954f365 (2026-08-11). Data as JSON: /api/errors/fb7baf571394bafc. Report an issue: GitHub.