block/buzz · error · anyhow::Error
Shutdown task failed
Error message
Shutdown task failed: {e} What it means
After the TCP server future completes (normal shutdown), serve() awaits the JoinHandle of the spawned shutdown task — the one that runs the jittered drain of live WebSocket connections (ConnectionManager::drain_all_jittered within GRACEFUL_DRAIN_TIMEOUT, 30s) and arms the hard_shutdown_abort. A JoinError here means that task panicked or was cancelled before completing, so the drain bookkeeping did not finish; the message distinguishes it from the server error.
Solutions
- Reproduce the shutdown path: send SIGTERM (`kill -TERM <pid>`), capture the panic backtrace with `RUST_BACKTRACE=1` — the panic location inside the drain task is the real defect.
- Inspect JoinError::is_panic() vs is_cancelled(): panic = code bug in drain; cancelled = runtime dropped during drain (grace period too short under supervisor like `docker stop -t 5`).
- Increase the supervisor's stop grace period above GRACEFUL_DRAIN_TIMEOUT (30s) so the drain task can finish instead of being cancelled.
- If the panic is in drain_all_jittered, fix that call site (no unwrap/expect on the connection registry path) — main.rs already has a TODO(coverage) noting this wiring lacks automated tests; add one.
Defensive patterns
Strategy: try-catch
Try / catch
// separate panic from cancellation so the response differs
let hard_shutdown = match shutdown_handle.await {
Ok(h) => h,
Err(e) if e.is_panic() => {
tracing::error!(panic = ?e.into_panic(), "drain task panicked");
return Err(anyhow::anyhow!("Shutdown task failed: {e}"));
}
Err(e) => {
tracing::warn!("drain task cancelled: {e}");
return Ok(());
}
}; Prevention
- Keep the drain task unwrap/expect-free (repo rule: no new unwrap in production paths).
- Size supervisor stop grace periods above GRACEFUL_DRAIN_TIMEOUT (30s) plus drain jitter.
- Exercise SIGTERM shutdown in CI/staging regularly — the code itself notes a coverage TODO for this wiring.
- Treat any is_panic() JoinError as a code defect to fix, not an operational alarm to silence.
When it happens
Trigger: shutdown_handle.await at crates/buzz-relay/src/main.rs:1375-1377 returns JoinError when the drain task panics — e.g. a bug or unwrap/expect inside drain_all_jittered, a poisoned lock on the connection registry, or cancellation via runtime shutdown while the drain was in flight (JoinError::is_cancelled).
Common situations: A regression in ConnectionManager drain logic that unwinds; a blocking mutex deadlock that turns into cancellation on runtime drop; SIGKILL-adjacent shutdowns where the runtime is torn down before the 30s drain finishes; test harnesses aborting the tokio runtime at grace-period expiry.
Related errors
- install SIGTERM handler
- pool initialization cancelled by shutdown
- ban failed
- ban requires target_pubkey
- bundled model-capabilities.json failed validation
AI-assisted analysis of block/buzz@ef2aa1ae38 (2026-08-20).
Data as JSON: /api/errors/fb5f6ee6a6d906a5.
Report an issue: GitHub.
Appendix: source
Thrown at crates/buzz-relay/src/main.rs:1493
})
.await
.ok();
});
let mut tcp_rx = shutdown_tx.subscribe();
axum::serve(
tcp_listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(async move {
tcp_rx.changed().await.ok();
})
.await
.map_err(|e| anyhow::anyhow!("TCP server error: {e}"))?;
let hard_shutdown = shutdown_handle
.await
.map_err(|e| anyhow::anyhow!("Shutdown task failed: {e}"))?;
uds_handle.abort();
hard_shutdown.abort();
return Ok(());
}
#[cfg(not(unix))]
if config.uds_path.is_some() {
tracing::warn!("BUZZ_UDS_PATH set but UDS not supported on this platform");
}
// TCP-only path.
let mut tcp_rx = shutdown_tx.subscribe();
axum::serve(
tcp_listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(async move {
tcp_rx.changed().await.ok();View on GitHub (pinned to ef2aa1ae38)