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

blocking pool shutting down

Error message

blocking pool shutting down

What it means

SpawnError::ShuttingDown surfaced as an io::Error with kind Other. It is produced when the runtime's blocking thread pool (used by spawn_blocking and internal blocking ops) has already begun shutdown and therefore refuses to schedule a new blocking task. The From<SpawnError> impl converts it into the generic io::Error shown.

Source

Thrown at tokio/src/runtime/blocking/pool.rs:208

        allow(dead_code)
    )]
    Mandatory,
    NonMandatory,
}

pub(crate) enum SpawnError {
    /// Pool is shutting down and the task was not scheduled
    ShuttingDown,
    /// There are no worker threads available to take the task
    /// and the OS failed to spawn a new one
    NoThreads(io::Error),
}

impl From<SpawnError> for io::Error {
    fn from(e: SpawnError) -> Self {
        match e {
            SpawnError::ShuttingDown => {
                io::Error::new(io::ErrorKind::Other, "blocking pool shutting down")
            }
            SpawnError::NoThreads(e) => e,
        }
    }
}

impl Task {
    pub(crate) fn new(task: task::UnownedTask<BlockingSchedule>, mandatory: Mandatory) -> Task {
        Task { task, mandatory }
    }

    pub(super) fn shutdown(self) {
        self.task.shutdown();
    }

    pub(super) fn run(self) {
        self.task.run();
    }

View on GitHub (pinned to 7d0d729d8f)

Solutions

  1. Ensure spawn_blocking calls happen while the runtime is still alive — structure shutdown so blocking tasks drain before Runtime::drop.
  2. Keep a runtime guard (Runtime) alive for the full duration blocking work may be submitted.
  3. Use Handle::is_alive / handle the Err from spawn_blocking to degrade gracefully.
  4. Avoid submitting blocking work from Drop impls that may run during teardown.

Example fix

// before
let handle = runtime.handle().clone();
drop(runtime);
handle.spawn_blocking(|| { /* ... */ }); // fails
// after
let jh = runtime.spawn_blocking(|| { /* ... */ });
jh.await?; // drain before dropping runtime
drop(runtime);
Defensive patterns

Strategy: validation

Validate before calling

// Validate the runtime/driver is alive before spawning blocking work.
if handle.runtime_flavor() == RuntimeFlavor::CurrentThread && /* runtime dropped */ {
    return Err(io::Error::new(io::ErrorKind::Other, "runtime gone"));
}
// Prefer: spawn early and hold the JoinHandle.

Try / catch

match runtime.spawn_blocking(|| expensive()) {
    _ => {} // spawn_blocking itself returns a JoinHandle, not Result; the error surfaces on .await
}
// On .await:
match jh.await {
    Ok(v) => v,
    Err(e) if e.to_string().contains("shutting down") => return,
    Err(e) => return Err(e.into()),
}

Prevention

When it happens

Trigger: Calling spawn_blocking (or any API that schedules onto the blocking pool, e.g. some fs operations after shutdown) after the runtime has been dropped or shut down; spawning blocking work from a task whose runtime handle outlives the pool's shutdown.

Common situations: Storing a Handle and using it after Runtime::drop; spawning blocking work from a Drop impl or a background thread that races runtime teardown; using #[tokio::main] and awaiting work past the function's runtime lifetime.

Related errors


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