1use std::future::Future;
8use std::panic::AssertUnwindSafe;
9use std::pin::Pin;
10use std::time::{Duration, Instant};
11
12use futures::FutureExt;
13use tokio::task::JoinHandle;
14use tokio_util::sync::CancellationToken;
15
16type ShutdownFuture = Pin<Box<dyn Future<Output = Result<(), String>> + Send>>;
17type ShutdownPhaseFn = dyn FnMut() -> ShutdownFuture + Send;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum TerminationSignal {
22 Interrupt,
24 Terminate,
26}
27
28impl TerminationSignal {
29 #[must_use]
31 pub const fn as_str(self) -> &'static str {
32 match self {
33 Self::Interrupt => "SIGINT",
34 Self::Terminate => "SIGTERM",
35 }
36 }
37}
38
39#[derive(Debug, thiserror::Error)]
41pub enum RuntimeSignalError {
42 #[error("failed to install termination signal handler: {0}")]
44 Install(String),
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum ShutdownPhaseStatus {
50 Succeeded,
52 Failed(String),
54 TimedOut,
56}
57
58impl ShutdownPhaseStatus {
59 #[must_use]
61 pub const fn is_failure(&self) -> bool {
62 !matches!(self, Self::Succeeded)
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct ShutdownPhaseReport {
69 pub name: &'static str,
71 pub status: ShutdownPhaseStatus,
73 pub duration: Duration,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ShutdownReport {
80 pub phases: Vec<ShutdownPhaseReport>,
82}
83
84impl ShutdownReport {
85 #[must_use]
87 pub fn new(phases: Vec<ShutdownPhaseReport>) -> Self {
88 Self { phases }
89 }
90
91 #[must_use]
93 pub fn has_failures(&self) -> bool {
94 self.phases.iter().any(|phase| phase.status.is_failure())
95 }
96}
97
98pub fn log_shutdown_report(report: &ShutdownReport) {
100 if report.has_failures() {
101 tracing::warn!("shutdown completed with one or more failed phases");
102 } else {
103 tracing::info!("shutdown complete");
104 }
105}
106
107struct RegisteredShutdownPhase {
108 name: &'static str,
109 timeout: Option<Duration>,
110 phase: Box<ShutdownPhaseFn>,
111}
112
113pub(crate) async fn run_shutdown_phase<F, Fut>(
115 timeout: Option<Duration>,
116 phase: F,
117) -> ShutdownPhaseStatus
118where
119 F: FnOnce() -> Fut,
120 Fut: Future<Output = Result<(), String>>,
121{
122 AssertUnwindSafe(async {
126 let future = async { phase().await };
127 match timeout {
128 Some(duration) => match tokio::time::timeout(duration, future).await {
129 Ok(Ok(())) => ShutdownPhaseStatus::Succeeded,
130 Ok(Err(error)) => ShutdownPhaseStatus::Failed(error),
131 Err(_) => ShutdownPhaseStatus::TimedOut,
132 },
133 None => match future.await {
134 Ok(()) => ShutdownPhaseStatus::Succeeded,
135 Err(error) => ShutdownPhaseStatus::Failed(error),
136 },
137 }
138 })
139 .catch_unwind()
140 .await
141 .unwrap_or_else(|_| ShutdownPhaseStatus::Failed("shutdown phase panicked".to_owned()))
142}
143
144#[derive(Default)]
151pub struct ShutdownCoordinator {
152 phases: Vec<RegisteredShutdownPhase>,
153}
154
155impl ShutdownCoordinator {
156 #[must_use]
158 pub fn new() -> Self {
159 Self::default()
160 }
161
162 pub fn phase<F, Fut>(
164 &mut self,
165 name: &'static str,
166 timeout: Option<Duration>,
167 mut phase: F,
168 ) -> &mut Self
169 where
170 F: FnMut() -> Fut + Send + 'static,
171 Fut: Future<Output = Result<(), String>> + Send + 'static,
172 {
173 self.phases.push(RegisteredShutdownPhase {
174 name,
175 timeout,
176 phase: Box::new(move || Box::pin(phase())),
177 });
178 self
179 }
180
181 pub async fn run(&mut self) -> ShutdownReport {
188 let mut reports = Vec::with_capacity(self.phases.len());
189
190 for phase in &mut self.phases {
191 tracing::info!(phase = phase.name, "starting shutdown phase");
192 let started_at = Instant::now();
193 let status = run_shutdown_phase(phase.timeout, &mut phase.phase).await;
194 let duration = started_at.elapsed();
195 match &status {
196 ShutdownPhaseStatus::Succeeded => {
197 tracing::info!(phase = phase.name, ?duration, "shutdown phase completed");
198 }
199 ShutdownPhaseStatus::Failed(error) => {
200 tracing::error!(phase = phase.name, ?duration, %error, "shutdown phase failed");
201 }
202 ShutdownPhaseStatus::TimedOut => {
203 tracing::error!(phase = phase.name, ?duration, "shutdown phase timed out");
204 }
205 }
206 reports.push(ShutdownPhaseReport {
207 name: phase.name,
208 status,
209 duration,
210 });
211 }
212
213 ShutdownReport::new(reports)
214 }
215
216 #[must_use]
218 pub fn len(&self) -> usize {
219 self.phases.len()
220 }
221
222 #[must_use]
224 pub fn is_empty(&self) -> bool {
225 self.phases.is_empty()
226 }
227}
228
229pub async fn wait_for_termination_signal() -> Result<TerminationSignal, RuntimeSignalError> {
236 let signal = wait_for_signal_impl().await?;
237 tracing::info!(
238 signal = signal.as_str(),
239 "received termination signal, shutting down gracefully..."
240 );
241 Ok(signal)
242}
243
244pub fn spawn_termination_signal_handler<F, Fut>(
250 shutdown_token: CancellationToken,
251 on_signal: F,
252) -> JoinHandle<()>
253where
254 F: FnOnce() -> Fut + Send + 'static,
255 Fut: Future<Output = ()> + Send + 'static,
256{
257 tokio::spawn(async move {
258 if let Err(error) = wait_for_termination_signal().await {
259 tracing::error!(%error, "shutdown signal listener failed");
260 }
261 shutdown_token.cancel();
262 on_signal().await;
263 })
264}
265
266#[cfg(unix)]
267async fn wait_for_signal_impl() -> Result<TerminationSignal, RuntimeSignalError> {
268 use tokio::signal::unix::{SignalKind, signal};
269
270 let mut sigint = signal(SignalKind::interrupt())
271 .map_err(|error| RuntimeSignalError::Install(error.to_string()))?;
272 let mut sigterm = signal(SignalKind::terminate())
273 .map_err(|error| RuntimeSignalError::Install(error.to_string()))?;
274
275 tokio::select! {
276 _ = sigint.recv() => Ok(TerminationSignal::Interrupt),
277 _ = sigterm.recv() => Ok(TerminationSignal::Terminate),
278 }
279}
280
281#[cfg(not(unix))]
282async fn wait_for_signal_impl() -> Result<TerminationSignal, RuntimeSignalError> {
283 tokio::signal::ctrl_c()
284 .await
285 .map_err(|error| RuntimeSignalError::Install(error.to_string()))?;
286 Ok(TerminationSignal::Interrupt)
287}
288
289#[cfg(test)]
290mod tests {
291 use super::{
292 ShutdownCoordinator, ShutdownPhaseStatus, ShutdownReport, TerminationSignal,
293 log_shutdown_report,
294 };
295 use std::time::Duration;
296
297 #[test]
298 fn termination_signal_reports_stable_labels() {
299 assert_eq!(TerminationSignal::Interrupt.as_str(), "SIGINT");
300 assert_eq!(TerminationSignal::Terminate.as_str(), "SIGTERM");
301 }
302
303 #[tokio::test]
304 async fn shutdown_coordinator_runs_all_phases_in_order() {
305 let mut coordinator = ShutdownCoordinator::new();
306 coordinator
307 .phase("tasks", None, || async { Ok(()) })
308 .phase("audit", None, || async { Err("flush failed".to_string()) })
309 .phase("db", None, || async { Ok(()) });
310
311 let report = coordinator.run().await;
312
313 assert_eq!(coordinator.len(), 3);
314 assert!(report.has_failures());
315 assert_eq!(report.phases[0].name, "tasks");
316 assert_eq!(report.phases[0].status, ShutdownPhaseStatus::Succeeded);
317 assert_eq!(
318 report.phases[1].status,
319 ShutdownPhaseStatus::Failed("flush failed".to_string())
320 );
321 assert_eq!(report.phases[2].status, ShutdownPhaseStatus::Succeeded);
322 }
323
324 #[test]
325 fn shutdown_report_logger_accepts_success_and_failure_reports() {
326 super::log_shutdown_report(&super::ShutdownReport::new(vec![
327 super::ShutdownPhaseReport {
328 name: "tasks",
329 status: ShutdownPhaseStatus::Succeeded,
330 duration: Duration::from_millis(1),
331 },
332 ]));
333
334 super::log_shutdown_report(&super::ShutdownReport::new(vec![
335 super::ShutdownPhaseReport {
336 name: "database",
337 status: ShutdownPhaseStatus::Failed("close failed".to_string()),
338 duration: Duration::from_millis(1),
339 },
340 ]));
341 }
342
343 #[tokio::test]
344 async fn shutdown_coordinator_reports_timeouts() {
345 let mut coordinator = ShutdownCoordinator::new();
346 coordinator.phase("slow", Some(Duration::from_millis(1)), || async {
347 tokio::time::sleep(Duration::from_millis(50)).await;
348 Ok(())
349 });
350
351 let report = coordinator.run().await;
352
353 assert!(report.has_failures());
354 assert_eq!(report.phases[0].status, ShutdownPhaseStatus::TimedOut);
355 }
356
357 #[tokio::test]
358 async fn shutdown_coordinator_supports_consumed_phase_handles() {
359 let mut coordinator = ShutdownCoordinator::new();
360 let mut owned_handle = Some("resource");
361 coordinator.phase("owned", None, move || {
362 let handle = owned_handle.take();
363 async move {
364 if handle == Some("resource") {
365 Ok(())
366 } else {
367 Err("resource already consumed".to_string())
368 }
369 }
370 });
371
372 let report = coordinator.run().await;
373
374 assert!(!report.has_failures());
375 assert_eq!(report.phases[0].status, ShutdownPhaseStatus::Succeeded);
376 }
377
378 #[test]
379 fn empty_shutdown_report_is_successful_and_empty_coordinator_is_observable() {
380 let coordinator = ShutdownCoordinator::new();
381 assert!(coordinator.is_empty());
382 assert_eq!(coordinator.len(), 0);
383 let report = ShutdownReport::new(Vec::new());
384 assert!(!report.has_failures());
385 log_shutdown_report(&report);
386 }
387}