Tokio Runtime Shutdown Hangs — Blocking Task or Leaked Spawn
Diagnose a Tokio runtime that never returns from shutdown by finding blocking calls on async threads and detached tasks that keep the runtime alive.
Symptoms
- The program never exits after main returns; Ctrl+C is the only way out
- Shutdown hangs only when a certain task runs
- tokio::main with multi_thread hangs while current_thread exits fine
Possible causes
- A blocking call such as std thread sleep or a sync file read runs on the async runtime
- A spawned task loops forever and holds the runtime open
- A dropped JoinHandle detaches the task, which then never completes
Diagnosis steps
Find blocking calls on async threads
tokio-console # or RUST_LOG=tokio=debug cargo runInterpretation
Tokio runtime metrics and tokio-console reveal poll durations and tasks stuck in blocking work.
Reproduce with a short shutdown timeout
RUST_BACKTRACE=1 timeout 15s cargo runInterpretation
A timeout kill with a backtrace shows which task or blocking call prevented exit.
Root cause
The multi-thread runtime keeps running until all workers stop; a task performing blocking work occupies a worker, and a detached looping task keeps the runtime from reaching an idle shutdown state, so the process never exits.
Fix
Move blocking work to spawn_blocking, use tokio::time::sleep instead of std sleep, give long tasks a cancellation signal via CancellationToken, and keep JoinHandles so tasks can be awaited or aborted on shutdown.
Risk note
Aborting tasks can drop data mid-operation; prefer cooperative cancellation with an explicit shutdown signal.
Verify the fix
Run under timeout and confirm the process exits quickly after the shutdown signal; assert no task remains pending via a JoinSet that drains on shutdown.
Symptoms
The error “Tokio Runtime Shutdown Hangs — Blocking Task or Leaked Spawn” points at a specific failure mode. This guide documents the symptoms you can observe, the commands that isolate the cause, the root cause, the fix, and the checks that prove the fix works.
Observed symptoms include:
- The program never exits after main returns; Ctrl+C is the only way out
- Shutdown hangs only when a certain task runs
- tokio::main with multi_thread hangs while current_thread exits fine
Reproduce and Diagnose
Reproduce the failure in isolation before changing any code. Work through the checks below in order; each one narrows the search space.
Step 1: Find blocking calls on async threads
tokio-console # or RUST_LOG=tokio=debug cargo run
Tokio runtime metrics and tokio-console reveal poll durations and tasks stuck in blocking work.
Step 2: Reproduce with a short shutdown timeout
RUST_BACKTRACE=1 timeout 15s cargo run
A timeout kill with a backtrace shows which task or blocking call prevented exit.
Likely contributors, in order of frequency:
- A blocking call such as std thread sleep or a sync file read runs on the async runtime
- A spawned task loops forever and holds the runtime open
- A dropped JoinHandle detaches the task, which then never completes
Root Cause
The multi-thread runtime keeps running until all workers stop; a task performing blocking work occupies a worker, and a detached looping task keeps the runtime from reaching an idle shutdown state, so the process never exits.
The Fix
Move blocking work to spawn_blocking, use tokio::time::sleep instead of std sleep, give long tasks a cancellation signal via CancellationToken, and keep JoinHandles so tasks can be awaited or aborted on shutdown.
Watch out: Aborting tasks can drop data mid-operation; prefer cooperative cancellation with an explicit shutdown signal.
use tokio::task;
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() {
let token = CancellationToken::new();
let handle = task::spawn({
let token = token.clone();
async move {
loop {
tokio::select! {
_ = token.cancelled() => break,
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => { /* work */ }
}
}
}
});
// never use std::thread::sleep or blocking std::fs in async code
let _ = task::spawn_blocking(|| std::thread::sleep(std::time::Duration::from_millis(10))).await;
token.cancel();
let _ = handle.await; // graceful: task observes cancel and ends
}
Verify and Prevent
Run under timeout and confirm the process exits quickly after the shutdown signal; assert no task remains pending via a JoinSet that drains on shutdown.
Once verified, add a regression test that reproduces the original symptom so the failure cannot silently return.
This class of failure is usually environmental rather than pure logic, so capture the exact command, the environment variables, and the working directory in your bug report. Reproduce it in the smallest possible script, then widen the scope only once the minimal case is understood. A short written checklist for this failure mode saves hours the next time it appears.