use std::fmt::{self, Display, Formatter};
use std::future::Future;
use std::time::Duration;
use anyhow::Context;
pub use tokio_util::sync::CancellationToken;
use crate::tracing::{error, info};
pub const GRACE_PERIOD: Duration = Duration::from_secs(10);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ShutdownSignal {
Interrupt,
Terminate,
}
impl Display for ShutdownSignal {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::Interrupt => f.write_str("SIGINT"),
Self::Terminate => f.write_str("SIGTERM"),
}
}
}
pub async fn run_with_shutdown<F, Fut>(service_name: &'static str, run: F) -> anyhow::Result<()>
where
F: FnOnce(CancellationToken) -> Fut,
Fut: Future<Output = anyhow::Result<()>>,
{
run_with_shutdown_signal(service_name, run, shutdown_signal()).await
}
async fn run_with_shutdown_signal<F, Fut, Signal>(
service_name: &'static str,
run: F,
signal: Signal,
) -> anyhow::Result<()>
where
F: FnOnce(CancellationToken) -> Fut,
Fut: Future<Output = anyhow::Result<()>>,
Signal: Future<Output = anyhow::Result<ShutdownSignal>>,
{
let token = CancellationToken::new();
let service = run(token.clone());
tokio::pin!(service);
tokio::pin!(signal);
tokio::select! {
result = &mut service => result,
result = &mut signal => {
let signal = result?;
info!(
"Shutdown requested",
service.name = service_name,
shutdown.signal = signal.to_string()
);
token.cancel();
let Ok(result) = tokio::time::timeout(GRACE_PERIOD, &mut service).await else {
error!(
anyhow::anyhow!("graceful shutdown timed out"),
"Graceful shutdown timed out; exiting process",
service.name = service_name,
shutdown.grace_period_ms = GRACE_PERIOD.as_millis() as u64
);
std::process::exit(1);
};
result?;
info!("Shutdown complete", service.name = service_name);
Ok(())
},
}
}
pub async fn shutdown_signal() -> anyhow::Result<ShutdownSignal> {
#[cfg(unix)]
{
let mut terminate =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("failed to install SIGTERM handler")?;
tokio::select! {
_ = terminate.recv() => Ok(ShutdownSignal::Terminate),
result = tokio::signal::ctrl_c() => {
result
.context("failed to install Ctrl-C handler")
.map(|()| ShutdownSignal::Interrupt)
},
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c()
.await
.context("failed to install Ctrl-C handler")
.map(|()| ShutdownSignal::Interrupt)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use super::*;
#[tokio::test]
async fn signal_cancels_and_waits_for_service() {
let cancelled = Arc::new(AtomicBool::new(false));
let service_cancelled = Arc::clone(&cancelled);
run_with_shutdown_signal(
"test-service",
move |shutdown| async move {
shutdown.cancelled().await;
service_cancelled.store(true, Ordering::Relaxed);
Ok(())
},
std::future::ready(Ok(ShutdownSignal::Interrupt)),
)
.await
.expect("clean shutdown should succeed");
assert!(cancelled.load(Ordering::Relaxed));
}
#[tokio::test]
async fn signal_handler_error_is_propagated() {
let err = run_with_shutdown_signal(
"test-service",
|shutdown| async move {
shutdown.cancelled().await;
Ok(())
},
std::future::ready(Err(anyhow::anyhow!("signal handler failed"))),
)
.await
.expect_err("signal error should be returned");
assert_eq!(err.to_string(), "signal handler failed");
}
}