bevyengine/bevy · critical

Task thread panicked while executing.

Error message

Task thread panicked while executing.

What it means

TaskPool::drop (task_pool.rs:620-631) closes the shutdown channel, joins every worker thread, and expects each join to succeed (skipped only when the dropping thread is already panicking). A worker's main loop wraps task execution in catch_unwind (task_pool.rs:204), so this panic means an unwind escaped that guard — typically from the on_thread_spawn/on_thread_destroy callbacks, thread-local setup/teardown, or executor machinery during shutdown.

Source

Thrown at crates/bevy_tasks/src/task_pool.rs:624

        Self::LOCAL_EXECUTOR.with(f)
    }
}

impl Default for TaskPool {
    fn default() -> Self {
        Self::new()
    }
}

impl Drop for TaskPool {
    fn drop(&mut self) {
        self.shutdown_tx.close();

        let panicking = thread::panicking();
        for join_handle in self.threads.drain(..) {
            let res = join_handle.join();
            if !panicking {
                res.expect("Task thread panicked while executing.");
            }
        }
    }
}

/// A [`TaskPool`] scope for running one or more non-`'static` futures.
///
/// For more information, see [`TaskPool::scope`].
#[derive(Debug)]
pub struct Scope<'scope, 'env: 'scope, T> {
    executor: &'scope crate::executor::Executor<'scope>,
    external_executor: &'scope ThreadExecutor<'scope>,
    scope_executor: &'scope ThreadExecutor<'scope>,
    spawned: &'scope ConcurrentQueue<FallibleTask<Result<T, Box<dyn core::any::Any + Send>>>>,
    // make `Scope` invariant over 'scope and 'env
    scope: PhantomData<&'scope mut &'scope ()>,
    env: PhantomData<&'env mut &'env ()>,
}

View on GitHub (pinned to 396ca72708)

Solutions

  1. Make on_thread_spawn/on_thread_destroy callbacks panic-free — return errors/log instead
  2. Keep task bodies from panicking (return Result) so unwinds never reach worker teardown
  3. Wrap risky task closures in std::panic::catch_unwind if panics are possible
  4. Update Bevy and report if no custom callback is involved

Example fix

// before — a panic in the spawn callback kills the worker thread
let pool = TaskPoolBuilder::new()
    .on_thread_spawn(|| { panic!("renderer init failed"); })
    .build();

// after — log instead of panicking inside pool-owned threads
let pool = TaskPoolBuilder::new()
    .on_thread_spawn(|| {
        if let Err(e) = init_thread_local_renderer() {
            bevy_log::error!("thread init failed: {e}");
        }
    })
    .build();
Defensive patterns

Strategy: try-catch

Try / catch

// keep panics from ever escaping pool-owned threads
let safe_body = std::panic::AssertUnwindSafe(task_body);
match std::panic::catch_unwind(safe_body) {
    Ok(result) => result,
    Err(payload) => {
        bevy_log::error!("task panicked: {payload:?}");
        None // recover with a default instead of unwinding the worker
    }
}

Prevention

When it happens

Trigger: A panicking callback passed to TaskPoolBuilder::on_thread_spawn or on_thread_destroy; a panic in executor tick/thread-local destructors at shutdown; aborting the pool while a worker is mid-unwind.

Common situations: Custom thread lifecycle callbacks that panic on init failure; custom executors hooked into bevy task pools; shutdown-ordering issues in embedded Bevy hosts.

Related errors


AI-assisted analysis of bevyengine/bevy@396ca72708 (2026-08-20). Data as JSON: /api/errors/49823738f0d7bd78. Report an issue: GitHub.