1use std::future::Future;
8use std::pin::Pin;
9use std::time::{Duration, Instant};
10
11use tokio::task::JoinHandle;
12use tokio_util::sync::CancellationToken;
13
14type ShutdownFuture = Pin<Box<dyn Future<Output = Result<(), String>> + Send>>;
15type ShutdownPhaseFn = dyn FnMut() -> ShutdownFuture + Send;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum TerminationSignal {
20 Interrupt,
22 Terminate,
24}
25
26impl TerminationSignal {
27 #[must_use]
29 pub const fn as_str(self) -> &'static str {
30 match self {
31 Self::Interrupt => "SIGINT",
32 Self::Terminate => "SIGTERM",
33 }
34 }
35}
36
37#[derive(Debug, thiserror::Error)]
39pub enum RuntimeSignalError {
40 #[error("failed to install termination signal handler: {0}")]
42 Install(String),
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum ShutdownPhaseStatus {
48 Succeeded,
50 Failed(String),
52 TimedOut,
54}
55
56impl ShutdownPhaseStatus {
57 #[must_use]
59 pub const fn is_failure(&self) -> bool {
60 !matches!(self, Self::Succeeded)
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct ShutdownPhaseReport {
67 pub name: &'static str,
69 pub status: ShutdownPhaseStatus,
71 pub duration: Duration,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ShutdownReport {
78 pub phases: Vec<ShutdownPhaseReport>,
80}
81
82impl ShutdownReport {
83 #[must_use]
85 pub fn new(phases: Vec<ShutdownPhaseReport>) -> Self {
86 Self { phases }
87 }
88
89 #[must_use]
91 pub fn has_failures(&self) -> bool {
92 self.phases.iter().any(|phase| phase.status.is_failure())
93 }
94}
95
96pub fn log_shutdown_report(report: &ShutdownReport) {
98 if report.has_failures() {
99 tracing::warn!("shutdown completed with one or more failed phases");
100 } else {
101 tracing::info!("shutdown complete");
102 }
103}
104
105struct RegisteredShutdownPhase {
106 name: &'static str,
107 timeout: Option<Duration>,
108 phase: Box<ShutdownPhaseFn>,
109}
110
111#[derive(Default)]
118pub struct ShutdownCoordinator {
119 phases: Vec<RegisteredShutdownPhase>,
120}
121
122impl ShutdownCoordinator {
123 #[must_use]
125 pub fn new() -> Self {
126 Self::default()
127 }
128
129 pub fn phase<F, Fut>(
131 &mut self,
132 name: &'static str,
133 timeout: Option<Duration>,
134 mut phase: F,
135 ) -> &mut Self
136 where
137 F: FnMut() -> Fut + Send + 'static,
138 Fut: Future<Output = Result<(), String>> + Send + 'static,
139 {
140 self.phases.push(RegisteredShutdownPhase {
141 name,
142 timeout,
143 phase: Box::new(move || Box::pin(phase())),
144 });
145 self
146 }
147
148 pub async fn run(&mut self) -> ShutdownReport {
153 let mut reports = Vec::with_capacity(self.phases.len());
154
155 for phase in &mut self.phases {
156 tracing::info!(phase = phase.name, "starting shutdown phase");
157 let started_at = Instant::now();
158 let future = (phase.phase)();
159 let status = match phase.timeout {
160 Some(timeout) => match tokio::time::timeout(timeout, future).await {
161 Ok(Ok(())) => ShutdownPhaseStatus::Succeeded,
162 Ok(Err(error)) => ShutdownPhaseStatus::Failed(error),
163 Err(_) => ShutdownPhaseStatus::TimedOut,
164 },
165 None => match future.await {
166 Ok(()) => ShutdownPhaseStatus::Succeeded,
167 Err(error) => ShutdownPhaseStatus::Failed(error),
168 },
169 };
170 let duration = started_at.elapsed();
171 match &status {
172 ShutdownPhaseStatus::Succeeded => {
173 tracing::info!(phase = phase.name, ?duration, "shutdown phase completed");
174 }
175 ShutdownPhaseStatus::Failed(error) => {
176 tracing::error!(phase = phase.name, ?duration, %error, "shutdown phase failed");
177 }
178 ShutdownPhaseStatus::TimedOut => {
179 tracing::error!(phase = phase.name, ?duration, "shutdown phase timed out");
180 }
181 }
182 reports.push(ShutdownPhaseReport {
183 name: phase.name,
184 status,
185 duration,
186 });
187 }
188
189 ShutdownReport::new(reports)
190 }
191
192 #[must_use]
194 pub fn len(&self) -> usize {
195 self.phases.len()
196 }
197
198 #[must_use]
200 pub fn is_empty(&self) -> bool {
201 self.phases.is_empty()
202 }
203}
204
205pub async fn wait_for_termination_signal() -> Result<TerminationSignal, RuntimeSignalError> {
212 let signal = wait_for_signal_impl().await?;
213 tracing::info!(
214 signal = signal.as_str(),
215 "received termination signal, shutting down gracefully..."
216 );
217 Ok(signal)
218}
219
220pub fn spawn_termination_signal_handler<F, Fut>(
226 shutdown_token: CancellationToken,
227 on_signal: F,
228) -> JoinHandle<()>
229where
230 F: FnOnce() -> Fut + Send + 'static,
231 Fut: Future<Output = ()> + Send + 'static,
232{
233 tokio::spawn(async move {
234 if let Err(error) = wait_for_termination_signal().await {
235 tracing::error!(%error, "shutdown signal listener failed");
236 }
237 shutdown_token.cancel();
238 on_signal().await;
239 })
240}
241
242#[cfg(unix)]
243async fn wait_for_signal_impl() -> Result<TerminationSignal, RuntimeSignalError> {
244 use tokio::signal::unix::{SignalKind, signal};
245
246 let mut sigint = signal(SignalKind::interrupt())
247 .map_err(|error| RuntimeSignalError::Install(error.to_string()))?;
248 let mut sigterm = signal(SignalKind::terminate())
249 .map_err(|error| RuntimeSignalError::Install(error.to_string()))?;
250
251 tokio::select! {
252 _ = sigint.recv() => Ok(TerminationSignal::Interrupt),
253 _ = sigterm.recv() => Ok(TerminationSignal::Terminate),
254 }
255}
256
257#[cfg(not(unix))]
258async fn wait_for_signal_impl() -> Result<TerminationSignal, RuntimeSignalError> {
259 tokio::signal::ctrl_c()
260 .await
261 .map_err(|error| RuntimeSignalError::Install(error.to_string()))?;
262 Ok(TerminationSignal::Interrupt)
263}
264
265#[cfg(test)]
266mod tests {
267 use super::{ShutdownCoordinator, ShutdownPhaseStatus, TerminationSignal};
268 use std::time::Duration;
269
270 #[test]
271 fn termination_signal_reports_stable_labels() {
272 assert_eq!(TerminationSignal::Interrupt.as_str(), "SIGINT");
273 assert_eq!(TerminationSignal::Terminate.as_str(), "SIGTERM");
274 }
275
276 #[tokio::test]
277 async fn shutdown_coordinator_runs_all_phases_in_order() {
278 let mut coordinator = ShutdownCoordinator::new();
279 coordinator
280 .phase("tasks", None, || async { Ok(()) })
281 .phase("audit", None, || async { Err("flush failed".to_string()) })
282 .phase("db", None, || async { Ok(()) });
283
284 let report = coordinator.run().await;
285
286 assert_eq!(coordinator.len(), 3);
287 assert!(report.has_failures());
288 assert_eq!(report.phases[0].name, "tasks");
289 assert_eq!(report.phases[0].status, ShutdownPhaseStatus::Succeeded);
290 assert_eq!(
291 report.phases[1].status,
292 ShutdownPhaseStatus::Failed("flush failed".to_string())
293 );
294 assert_eq!(report.phases[2].status, ShutdownPhaseStatus::Succeeded);
295 }
296
297 #[test]
298 fn shutdown_report_logger_accepts_success_and_failure_reports() {
299 super::log_shutdown_report(&super::ShutdownReport::new(vec![
300 super::ShutdownPhaseReport {
301 name: "tasks",
302 status: ShutdownPhaseStatus::Succeeded,
303 duration: Duration::from_millis(1),
304 },
305 ]));
306
307 super::log_shutdown_report(&super::ShutdownReport::new(vec![
308 super::ShutdownPhaseReport {
309 name: "database",
310 status: ShutdownPhaseStatus::Failed("close failed".to_string()),
311 duration: Duration::from_millis(1),
312 },
313 ]));
314 }
315
316 #[tokio::test]
317 async fn shutdown_coordinator_reports_timeouts() {
318 let mut coordinator = ShutdownCoordinator::new();
319 coordinator.phase("slow", Some(Duration::from_millis(1)), || async {
320 tokio::time::sleep(Duration::from_millis(50)).await;
321 Ok(())
322 });
323
324 let report = coordinator.run().await;
325
326 assert!(report.has_failures());
327 assert_eq!(report.phases[0].status, ShutdownPhaseStatus::TimedOut);
328 }
329
330 #[tokio::test]
331 async fn shutdown_coordinator_supports_consumed_phase_handles() {
332 let mut coordinator = ShutdownCoordinator::new();
333 let mut owned_handle = Some("resource");
334 coordinator.phase("owned", None, move || {
335 let handle = owned_handle.take();
336 async move {
337 if handle == Some("resource") {
338 Ok(())
339 } else {
340 Err("resource already consumed".to_string())
341 }
342 }
343 });
344
345 let report = coordinator.run().await;
346
347 assert!(!report.has_failures());
348 assert_eq!(report.phases[0].status, ShutdownPhaseStatus::Succeeded);
349 }
350}