Skip to main content

graceful_worker/
shutdown.rs

1//! Noticing `SIGTERM`, and stopping without losing work in progress.
2//!
3//! A container platform stops a process by sending `SIGTERM` and then
4//! waiting a fixed grace period before `SIGKILL`. Everything in that window
5//! is the process's own responsibility.
6//!
7//! Shutdown here is **cooperative**: a signal asks loops to stop at their
8//! next opportunity, rather than cancelling whatever is in flight. For a
9//! worker that has taken a message off a queue and not yet acknowledged it,
10//! the difference is whether that message is delivered or lost.
11//!
12//! # Examples
13//!
14//! ```
15//! # #[tokio::main(flavor = "current_thread")]
16//! # async fn main() {
17//! use graceful_worker::Shutdown;
18//!
19//! let shutdown = Shutdown::new();
20//! let watcher = shutdown.watcher();
21//!
22//! assert!(!watcher.is_stopping());
23//!
24//! // Something decided it is time to stop.
25//! shutdown.stop();
26//!
27//! assert!(watcher.is_stopping());
28//! watcher.wait().await; // returns immediately once stopping
29//! # }
30//! ```
31
32use std::time::Duration;
33
34use tokio_util::sync::CancellationToken;
35
36/// The stop signal for a process.
37///
38/// One of these is created at startup; every loop takes a [`Watcher`] from
39/// it.
40///
41/// # Dropping it does not stop anything
42///
43/// Deliberately. A worker's shutdown must be something someone asked for,
44/// not a consequence of where a value happened to go out of scope — the
45/// alternative is a refactor that moves a binding and silently turns a
46/// long-running process into one that exits immediately.
47#[derive(Debug, Clone, Default)]
48pub struct Shutdown {
49    /// The underlying token. Cloneable and cheap.
50    token: CancellationToken,
51}
52
53impl Shutdown {
54    /// A process that is not stopping.
55    #[must_use]
56    pub fn new() -> Self {
57        Self {
58            token: CancellationToken::new(),
59        }
60    }
61
62    /// A handle a loop can watch.
63    #[must_use]
64    pub fn watcher(&self) -> Watcher {
65        Watcher {
66            token: self.token.clone(),
67        }
68    }
69
70    /// Ask every loop to stop at its next opportunity.
71    ///
72    /// Idempotent: calling it twice is the same as calling it once, which
73    /// matters because a `SIGTERM` is often followed by an impatient second
74    /// one.
75    pub fn stop(&self) {
76        if !self.token.is_cancelled() {
77            log_requested();
78        }
79        self.token.cancel();
80    }
81
82    /// Whether a stop has been requested.
83    #[must_use]
84    pub fn is_stopping(&self) -> bool {
85        self.token.is_cancelled()
86    }
87
88    /// Stop when `SIGTERM` or `SIGINT` arrives.
89    ///
90    /// Spawns a task that watches for either and calls [`Shutdown::stop`].
91    /// Returns immediately.
92    ///
93    /// `SIGTERM` is what a container platform sends; `SIGINT` is Ctrl-C on
94    /// a laptop. Both mean the same thing here. On a non-Unix target this
95    /// watches Ctrl-C alone.
96    ///
97    /// # Panics
98    ///
99    /// Does not panic. If the signal handlers cannot be installed — which
100    /// on Unix means the process is in a state where it could not have
101    /// started anyway — the failure is logged and the process runs without
102    /// them rather than aborting.
103    pub fn listen_for_signals(&self) {
104        let shutdown = self.clone();
105        tokio::spawn(async move {
106            wait_for_signal().await;
107            shutdown.stop();
108        });
109    }
110}
111
112/// A loop's view of the stop signal.
113///
114/// Cheap to clone, and safe to hold across an await.
115#[derive(Debug, Clone)]
116pub struct Watcher {
117    /// The underlying token.
118    token: CancellationToken,
119}
120
121impl Watcher {
122    /// Whether a stop has been requested.
123    ///
124    /// The condition a `while` loop tests between units of work.
125    #[must_use]
126    pub fn is_stopping(&self) -> bool {
127        self.token.is_cancelled()
128    }
129
130    /// Whether the loop should keep going.
131    ///
132    /// The inverse of [`Watcher::is_stopping`], spelled the way a loop
133    /// reads best: `while watcher.is_running() { … }`.
134    #[must_use]
135    pub fn is_running(&self) -> bool {
136        !self.token.is_cancelled()
137    }
138
139    /// Wait until a stop is requested.
140    ///
141    /// Returns immediately if one already has been. Use it with `select!`
142    /// to cut a long operation short.
143    pub async fn wait(&self) {
144        self.token.cancelled().await;
145    }
146
147    /// Sleep, unless a stop is requested first.
148    ///
149    /// Returns `true` if the sleep completed, `false` if it was cut short.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// # #[tokio::main(flavor = "current_thread")]
155    /// # async fn main() {
156    /// use std::time::Duration;
157    /// use graceful_worker::Shutdown;
158    ///
159    /// let shutdown = Shutdown::new();
160    /// let watcher = shutdown.watcher();
161    /// shutdown.stop();
162    ///
163    /// // Already stopping, so the sleep is abandoned rather than served.
164    /// assert!(!watcher.sleep(Duration::from_secs(900)).await);
165    /// # }
166    /// ```
167    pub async fn sleep(&self, duration: Duration) -> bool {
168        tokio::select! {
169            () = tokio::time::sleep(duration) => true,
170            () = self.token.cancelled() => false,
171        }
172    }
173}
174
175/// Resolve when `SIGTERM` or `SIGINT` arrives.
176#[cfg(unix)]
177async fn wait_for_signal() {
178    use tokio::signal::unix::{SignalKind, signal};
179
180    let mut terminate = match signal(SignalKind::terminate()) {
181        Ok(stream) => stream,
182        Err(error) => {
183            log_no_handler("SIGTERM", &error);
184            return;
185        }
186    };
187    let mut interrupt = match signal(SignalKind::interrupt()) {
188        Ok(stream) => stream,
189        Err(error) => {
190            log_no_handler("SIGINT", &error);
191            return;
192        }
193    };
194
195    tokio::select! {
196        _ = terminate.recv() => log_signal("SIGTERM"),
197        _ = interrupt.recv() => log_signal("SIGINT"),
198    }
199}
200
201/// Resolve when Ctrl-C arrives.
202#[cfg(not(unix))]
203async fn wait_for_signal() {
204    if let Err(error) = tokio::signal::ctrl_c().await {
205        log_no_handler("Ctrl-C", &error);
206    }
207}
208
209#[cfg(feature = "tracing")]
210fn log_requested() {
211    tracing::info!("shutdown requested");
212}
213#[cfg(not(feature = "tracing"))]
214fn log_requested() {}
215
216#[cfg(feature = "tracing")]
217fn log_signal(name: &str) {
218    tracing::info!("{name} received");
219}
220#[cfg(not(feature = "tracing"))]
221fn log_signal(_name: &str) {}
222
223#[cfg(feature = "tracing")]
224fn log_no_handler(name: &str, error: &std::io::Error) {
225    tracing::error!(%error, "could not listen for {name}");
226}
227#[cfg(not(feature = "tracing"))]
228fn log_no_handler(_name: &str, _error: &std::io::Error) {}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[tokio::test]
235    async fn a_new_shutdown_is_not_stopping() {
236        let shutdown = Shutdown::new();
237        assert!(!shutdown.is_stopping());
238        assert!(shutdown.watcher().is_running());
239    }
240
241    #[tokio::test]
242    async fn stopping_is_visible_to_every_watcher() {
243        let shutdown = Shutdown::new();
244        let first = shutdown.watcher();
245        let second = shutdown.watcher();
246
247        shutdown.stop();
248
249        assert!(first.is_stopping());
250        assert!(second.is_stopping());
251        assert!(!first.is_running());
252    }
253
254    #[tokio::test]
255    async fn stopping_twice_is_harmless() {
256        // A second, impatient SIGTERM must not change anything.
257        let shutdown = Shutdown::new();
258        shutdown.stop();
259        shutdown.stop();
260        assert!(shutdown.is_stopping());
261    }
262
263    #[tokio::test]
264    async fn dropping_a_shutdown_does_not_stop_anything() {
265        // Shutdown must be deliberate, never a scope accident.
266        let watcher = {
267            let shutdown = Shutdown::new();
268            shutdown.watcher()
269        };
270        assert!(watcher.is_running());
271    }
272
273    #[tokio::test(start_paused = true)]
274    async fn a_sleep_runs_to_completion_when_nothing_stops_it() {
275        let shutdown = Shutdown::new();
276        let watcher = shutdown.watcher();
277        assert!(watcher.sleep(Duration::from_secs(900)).await);
278    }
279
280    #[tokio::test(start_paused = true)]
281    async fn a_sleep_is_cut_short_by_a_stop() {
282        let shutdown = Shutdown::new();
283        let watcher = shutdown.watcher();
284
285        let sleeping = tokio::spawn(async move { watcher.sleep(Duration::from_secs(900)).await });
286
287        tokio::task::yield_now().await;
288        shutdown.stop();
289
290        assert!(
291            !sleeping.await.expect("the sleeping task"),
292            "the sleep should report having been cut short"
293        );
294    }
295
296    #[tokio::test]
297    async fn waiting_returns_at_once_when_already_stopping() {
298        let shutdown = Shutdown::new();
299        let watcher = shutdown.watcher();
300        shutdown.stop();
301        watcher.wait().await;
302    }
303
304    #[tokio::test(start_paused = true)]
305    async fn waiting_resolves_when_the_stop_arrives() {
306        let shutdown = Shutdown::new();
307        let watcher = shutdown.watcher();
308
309        let waiting = tokio::spawn(async move { watcher.wait().await });
310        tokio::task::yield_now().await;
311        shutdown.stop();
312
313        waiting.await.expect("the waiting task");
314    }
315
316    #[tokio::test]
317    async fn installing_signal_handlers_does_not_stop_anything_by_itself() {
318        let shutdown = Shutdown::new();
319        shutdown.listen_for_signals();
320        tokio::task::yield_now().await;
321        assert!(!shutdown.is_stopping());
322    }
323}